diff --git a/.config/nextest.toml b/.config/nextest.toml index 66fe1043..92451dd5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,7 +11,7 @@ not ( (package(solidcraft-engine) & ( test(=tests::hostile_params_never_panic) | test(/^tests::robustness::/) - | test(/^tests::robustness_goldens::/) + | test(/^tests::robustness_goldens::seed_/) | test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen) | test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs) | test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..5e7b7fa2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Questions and chat + url: https://solidcraft.io/discord + about: Ask questions and talk with users and contributors on the SolidCraft Discord. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..b617d6db --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - self-hosted-fast diff --git a/.github/release-notes.md b/.github/release-notes.md index 3a5920b6..05f7fd0f 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -1,9 +1,9 @@ SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a -parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/) -· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) · +parameter to watch the timeline rebuild. [Documentation](https://docs.solidcraft.io/) +· [try it in the browser](https://solidcraft.io/app) · [contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) · [issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP: -`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)). +`solidcraft-cli mcp` ([how](https://docs.solidcraft.io/guide/mcp.html)). **What's new** in SolidCraft {version} is listed below, and the [ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists @@ -41,16 +41,20 @@ installing SolidCraft; keep both remotes in the same user installation: ```sh flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo -flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo +flatpak remote-add --user --if-not-exists solidcraft https://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo flatpak install --user solidcraft ai.storyteller.solidcraft flatpak run ai.storyteller.solidcraft ``` +A remote added from the old `bherbruck.github.io` address no longer finds updates: run +`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft`, then +`flatpak update --user`. + Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user` offers to replace it with `ai.storyteller.solidcraft`, and settings move along. Single-file `.flatpak` bundles include the runtime repository address; missing runtimes -still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). +still need an internet connection. [Linux installation details](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak). The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose **Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b523826..0a9215b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,24 @@ # vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do # pushes to main, the nightly run and Run workflow. # Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are -# skipped on pull requests and run on pushes to main and nightly. +# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders +# and app startup) run on pull requests only when what they test changes (the GPU crate, the app, +# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and +# nightly always run them. Only main saves the build caches; pull requests restore them. # Cross-compilation is opt-in through Run workflow; release builds remain in release.yml. +# Pushes to main and merge-queue groups use both pools: Clippy, the doc tests and test shards +# 1-2 run on the self-hosted runner (`self-hosted-fast`, warm build directories via +# scripts/ci/warm-target.sh), everything else on GitHub's runners. The test archive is built on +# GitHub's runners: the self-hosted machine has a newer glibc, so its binaries wouldn't start +# on ubuntu-24.04, while the archive runs there unchanged (same /home/runner/work checkout +# path). Pull requests, the nightly run and Run workflow use GitHub's runners only; the +# self-hosted runner refuses any job that isn't a push to main or a merge-queue group. name: CI on: pull_request: + # The merge queue: each queued group runs the full suite before it lands on main. + merge_group: push: branches: [main] schedule: @@ -55,6 +67,7 @@ jobs: outputs: native: ${{ steps.paths.outputs.native }} kernel: ${{ steps.paths.outputs.kernel }} + windows: ${{ steps.paths.outputs.windows }} full: ${{ steps.paths.outputs.full }} packages: ${{ steps.paths.outputs.packages }} steps: @@ -70,15 +83,23 @@ jobs: run: | native=false kernel=false + windows=false full=true packages= if [ "$EVENT_NAME" != pull_request ]; then native=true + windows=true else # Compare both old and new paths of renames; unknown paths run native checks. git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths" while IFS= read -r -d '' path; do case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac + # What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and + # its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts. + case "$path" in + crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;; + tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;; + esac case "$path" in .github/workflows/ci.yml) native=true ;; *.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;; @@ -86,6 +107,12 @@ jobs: *) native=true ;; esac done < "$RUNNER_TEMP/ci-paths" + # A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows). + if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \ + && git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \ + | grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then + windows=true + fi # Pull requests build and test the touched packages and their dependents. python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected" cat "$RUNNER_TEMP/ci-affected" @@ -97,6 +124,7 @@ jobs: { echo "native=$native" echo "kernel=$kernel" + echo "windows=$windows" echo "full=$full" echo "packages=$packages" } >> "$GITHUB_OUTPUT" @@ -113,10 +141,13 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: repo-checks + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Format run: cargo fmt --all -- --check - name: Asset attribution run: cargo xtask assets + - name: UI icon standards and review tool + run: cargo xtask icons - name: Licences run: cargo xtask licences --check - name: Dependency layers @@ -132,16 +163,22 @@ jobs: name: Native Linux Clippy needs: changes if: needs.changes.outputs.native == 'true' - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }} timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Warm build directory (self-hosted runner) + if: runner.environment == 'self-hosted' + run: scripts/ci/warm-target.sh clippy - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + # The self-hosted runner keeps its build directory and Cargo home instead. + - if: runner.environment == 'github-hosted' + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: native-clippy + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Clippy env: FULL: ${{ needs.changes.outputs.full }} @@ -169,6 +206,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: native-tests + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -198,7 +236,7 @@ jobs: tests: name: Native Linux tests (${{ matrix.shard }}/4) needs: [changes, build-tests] - runs-on: ubuntu-24.04 + runs-on: ${{ ((github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && matrix.shard <= 2) && 'self-hosted-fast' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false @@ -213,6 +251,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install a software Vulkan driver (lavapipe) + # Preinstalled on the self-hosted runner, which has no sudo. + if: runner.environment == 'github-hosted' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 @@ -239,15 +279,15 @@ jobs: cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ --profile ci --partition "slice:$SHARD/4" --no-tests=pass "${all[@]}" - name: Robustness goldens sample (pull requests that touch the kernel) - # Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and - # the nightly run every seed in the step above. + # Every 8th seed of tests/robustness/goldens.json (each seed is a test), a quarter per + # shard; pushes to main and the nightly run every seed in the step above. if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true' env: SHARD: ${{ matrix.shard }} run: | cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ - --profile ci --ignore-default-filter \ - -E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))" + --profile ci --ignore-default-filter --partition "slice:$SHARD/4" --no-tests=pass \ + -E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::seed_(00|08|16|24|32|40|48|56|64|72|80|88)$/)' - name: Upload test timings if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -352,7 +392,7 @@ jobs: name: Doc tests needs: changes if: needs.changes.outputs.native == 'true' - runs-on: ubuntu-24.04 + runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }} timeout-minutes: 45 env: CARGO_INCREMENTAL: "0" @@ -361,10 +401,16 @@ jobs: SCCACHE_IGNORE_SERVER_IO_ERROR: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Warm build directory (self-hosted runner) + if: runner.environment == 'self-hosted' + run: scripts/ci/warm-target.sh doc-tests - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + # The self-hosted runner keeps its build directory and Cargo home instead. + - if: runner.environment == 'github-hosted' + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: doc-tests + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -389,6 +435,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: oracle + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Set up compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: @@ -412,7 +459,7 @@ jobs: windows-fxc: name: Windows D3D12 FXC shaders needs: changes - if: needs.changes.outputs.native == 'true' + if: needs.changes.outputs.windows == 'true' runs-on: windows-latest timeout-minutes: 45 env: @@ -427,6 +474,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-fxc + save-if: ${{ github.ref == 'refs/heads/main' }} # One test at a time: WARP's shader JIT crashes now and then (a null read in # d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute # pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not @@ -439,7 +487,7 @@ jobs: windows-startup: name: Windows app startup needs: changes - if: needs.changes.outputs.native == 'true' + if: needs.changes.outputs.windows == 'true' runs-on: windows-latest timeout-minutes: 60 steps: @@ -448,6 +496,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-startup + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Backend order and probing on Windows run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests - name: Build the app @@ -470,6 +519,7 @@ jobs: CHANGES: ${{ needs.changes.result }} CHECKS: ${{ needs.checks.result }} NATIVE: ${{ needs.changes.outputs.native }} + WINDOWS: ${{ needs.changes.outputs.windows }} CLIPPY: ${{ needs.clippy.result }} BUILD: ${{ needs.build-tests.result }} TESTS: ${{ needs.tests.result }} @@ -486,6 +536,8 @@ jobs: test "$TESTS" = success test "$DOCS" = success test "$ORACLE" = success + fi + if [ "$WINDOWS" = true ]; then test "$WINDOWS_FXC" = success test "$WINDOWS_STARTUP" = success fi @@ -503,6 +555,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: wasm + save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo xtask wasm riscv64-gates: @@ -524,6 +577,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: riscv64 + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install cross-compilation dependencies run: | # Ubuntu's regular archive serves amd64; RISC-V packages live in ports. diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml index 20951305..23a39297 100644 --- a/.github/workflows/generated.yml +++ b/.github/workflows/generated.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main + # main goes through the merge queue; this deploy key may push past it. + ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 604c3fef..d4c9a426 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,5 @@ -# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app -# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the +# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/ +# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the # repository is private. name: Pages @@ -56,7 +56,7 @@ jobs: run: cargo xtask book - name: Build the web app working-directory: apps/solidcraft-web - run: trunk build --release --public-url /solidcraft/app/ + run: trunk build --release --public-url ./ - name: Test the web app offline and across an update (headless Chrome) run: cargo xtask web-offline-test --site dist/web - name: Assemble the site diff --git a/AGENTS.md b/AGENTS.md index 82492953..dbdea882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,11 @@ People trust SolidCraft with their designs; a crash loses their work. **This out nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend operations are our own. - **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or - artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code - (`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** + artwork from commercial CAD products — ever.** Command icons are the original SVG masters in + `assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`. + **Before any icon, icon assignment or media edit, read `assets/AGENTS.md`, + `assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical + bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** (`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed. Breaking this rule is the most serious mistake you can make in this repo. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` @@ -85,7 +88,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps` (orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll camera input (a test rejects it). -- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is +- **Application code is Rust only.** Units are millimetres and radians internally; Z is up. + Local asset authoring, lint and review tools use Python 3.11+ as documented in + `assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`. Keep them minimal; `cargo xtask web-offline-test` covers the service worker. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 97dca7e5..2d66adb1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,25 +17,15 @@ Editable drawing code, native projects and shipped exports are collected under ` | examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 | | examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 | | crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 | | assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 | +| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 | | assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | -| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 | +| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 | +| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 | | docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 | @@ -87,16 +77,19 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 | | assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 | | assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 | -| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | +| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | +| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | +| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | +| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 | | assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | @@ -110,9 +103,294 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 | | assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | +| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 | | assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 | | crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 | +| assets/icons/ui/design/aliases.toml | SolidCraft contributors | icon ids that share a master, original SolidCraft data | MIT OR Apache-2.0 | +| assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/home.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/lookat.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/moon.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/nav_settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/orbit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/pan.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/perspective.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/zoom.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/accessibility.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/activate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/align.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/appearance.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/arc3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/arc_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/arc_slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/arc_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/as_built.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/auto_constrain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/bendtable.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/blend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/blend_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/boss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/boundary.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/boundary_fill.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/box.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/break.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_coincident.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_collinear.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_concentric.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_equal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_fix.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_hv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_midpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_parallel.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_perpendicular.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_smooth.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_symmetry.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/c_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/camera.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/canvas.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/capture.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/center_of_mass.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/centerline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/chain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/checkpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/circle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/circle2.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/circle3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/configuration_table.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/conic.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/constrainer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/construction.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/convert_sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/copy.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/curvature_comb.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/curvature_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/cylinder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/decal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/delete.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/dimension.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/dots.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/draft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/draft_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/drive.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/dxf.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/edge_continuity.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/edit.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/ellipse.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/emboss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/environment_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/explode.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/eye.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/finish.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/fit_section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/flange.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/flat.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/fold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/folder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/graph.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/hem.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/hole.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/import.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/include.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/inspect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/interference.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/intersect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/intersection_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/iso_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/iso_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/joint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/joint_origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/library.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/line.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/line_mid.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/lip.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/list.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/loft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/material.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/measure.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/min_radius.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/motion_link.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/move.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/new.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/offset.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/offset_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/offset_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/open.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/params.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/paste.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/patch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern_circ.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern_path.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plastic_assign.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plastic_rule.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/play.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/presspull.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/project.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/project_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rect3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rect_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rect_rounded.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/refold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rename.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/render_image.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/render_scene.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/reorder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rest.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/restore.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/reverse_normal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/revert.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/revolve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rib.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rigid_group.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rollback.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/ruled.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/scale.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/select.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sheet_rules.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/shell.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch_chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch_fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch_mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/snap.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sphere.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/spline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/spline_cv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/split_body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/split_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/spun.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/star.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/suppress.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/text.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/thicken.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/thread.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/torus.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/trim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/unfold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/unstitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/untrim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/update.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/zebra.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/as_built.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/component.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/curvature_comb.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/interference.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/iso_analysis.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/joint.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/pattern.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/small/section.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/workspace/ws_animate.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/polygon_circumscribed.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/polygon_edge.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/slot_overall.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/arc_slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/gear.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/rack_pinion.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/screw.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/cam.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/trace.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/agent_chat.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/normal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/infinite.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern_flip.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/pattern_center.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/reach.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/cut.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/compare.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/web.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/replace_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/bounding_solid.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/delete_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/snap_fit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/contact_sets.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/contact_all.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/contact_off.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/contact_new.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis_through_cylinder.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis_normal_to_face.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis_two_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis_two_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/axis_edge.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_vertex.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_three_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_center.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_edge_plane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/point_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_midplane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_three_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_tangent.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/plane_perpendicular.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/workspace/ws_design.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/workspace/ws_drawing.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/workspace/ws_render.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/references.toml | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/author/bases.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/author/new_icon.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/review/index.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/review/guide.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/review/review.css | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/review/review.js | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/icon_palette.py | SolidCraft contributors | Original shared semantic icon palette resolver for repository tools | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/icon_status.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | +| assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 | +| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | | docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 | @@ -190,3 +468,5 @@ and shares that effective count with the surface attachments and CAD callbacks. Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling. A frame whose meshes the GPU refuses to stage (device lost or out of memory) is skipped with a warning instead of panicking (#1469). +Paint callbacks are prepared only after a drawable is acquired, so a skipped +surface frame cannot strand cached meshes without their GPU initialization. diff --git a/CLAUDE.md b/CLAUDE.md index 5ed78acd..82492953 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend operations are our own. - **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or - artwork from commercial CAD products — ever.** Every icon is drawn in code - (`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** + artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code + (`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md** (`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed. Breaking this rule is the most serious mistake you can make in this repo. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` @@ -81,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)`. Colours come from `theme::Tokens`. +- **One set of camera controls**: every page or canvas with a camera (viewport, Render + preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps` + (orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll + camera input (a test rejects it). - **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`. @@ -96,6 +100,29 @@ People trust SolidCraft with their designs; a crash loses their work. **This out - **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`). +## Showing and following through on changes + +- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior, + issues and PRs must include actual descriptive screenshots or a short recording from a + small reproduction. This includes kernel, geometry, assembly and backend changes whose + effects can be shown in the app. Coding agents should run the app and capture this evidence + themselves; do not ask a human for evidence the agent can produce. Show the actual failure + or result, preferably before and after, with brief reproduction steps, expected/actual + behavior and annotations that point out the relevant detail. +- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible + behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate + the point. Changes with no useful observable behavior do not need screenshot or video evidence. +- Use a small public-safe example and check for private designs, paths, account details or + other sensitive content before sharing. Attach media to the issue/PR or link to an accessible + recording; do not commit generated screenshots or videos just to provide review evidence. +- Throughout the work and before handoff, check current upstream commits and the relevant + PR's merge status. Keep your branch current with upstream merges, resolve conflicts without + discarding others' work, and re-run validation when integration changes the result. Use + `git pull --rebase origin main` before pushing as above; never force-push. Report the checked + upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open, + keep monitoring it while the task is active and make its pending status clear at handoff; + do not claim it merged without checking. + ## Claim = a draft PR - Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`. - If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b835ed41..39e63887 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,13 +10,16 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page build it, how the code is laid out, the few rules that aren't negotiable, and where help is needed most. +Questions, ideas or want to say what you're working on? Join the [Discord](https://solidcraft.io/discord). + ## Get it running (about 10 minutes on a fast machine) You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web -build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev). +build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk) +([documentation](https://trunk-rs.github.io/trunk/)). ```sh -git clone https://github.com/bherbruck/solidcraft && cd solidcraft +git clone https://github.com/tangency-labs/solidcraft && cd solidcraft cargo run --release -p solidcraft -- --sample # desktop app with a sample part cargo xtask test # unit, engine and UI-scenario tests cargo xtask ci # everything a PR must pass (see below) @@ -177,7 +180,7 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev `.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md): - `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and - `tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part), + the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th), `pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`, `pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`, `cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests @@ -245,11 +248,11 @@ and a fresh CI run: ## Sending a change -1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled +1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft PR** before you start, so two people don't build the same thing: - - Check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`. + - Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`. - Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and open a **draft** PR titled ` (#N)`, e.g. `Fillet: variable radius along a chain (#123)`. - **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help @@ -297,10 +300,10 @@ long as the change meets the rules above. ## Where help is wanted most -**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://bherbruck.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/bherbruck/solidcraft/issues/1597). +**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597). -See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and -[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels. +See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and +[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels. In order of how much they'd help users: 1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation). diff --git a/Cargo.lock b/Cargo.lock index 1fa23852..cea02e9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,15 +197,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -232,6 +223,12 @@ version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "220a2c618ab466efe41d0eace94dfeff1c35e3aa47891bdb95e1c0fefffd3c99" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" @@ -1067,17 +1064,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "derive_arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - [[package]] name = "derive_more" version = "0.99.20" @@ -2535,6 +2521,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -4051,7 +4049,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "block2 0.6.2", "eframe", @@ -4072,6 +4070,7 @@ dependencies = [ "rustls", "serde_json", "solidcraft-engine", + "solidcraft-space-mouse", "solidcraft-ui-egui", "ureq", "windows-sys 0.61.2", @@ -4080,7 +4079,7 @@ dependencies = [ [[package]] name = "solidcraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "serde_json", @@ -4091,7 +4090,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.14.0" +version = "0.15.0" dependencies = [ "proc-macro2", "quote", @@ -4100,7 +4099,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "serde", @@ -4117,7 +4116,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "serde", @@ -4129,11 +4128,12 @@ dependencies = [ "solidcraft-render", "solidcraft-sketch", "thiserror 2.0.21", + "web-time", ] [[package]] name = "solidcraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde", "serde_json", @@ -4142,7 +4142,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "naga", @@ -4159,13 +4159,14 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.14.0" +version = "0.15.0" dependencies = [ "cadmpeg-core", "cadmpeg-ir", "cadmpeg-registry", "gltf", "quick-xml", + "rustix 1.1.5", "serde", "serde_json", "sha2 0.10.9", @@ -4173,12 +4174,12 @@ dependencies = [ "solidcraft-geom", "solidcraft-kernel", "thiserror 2.0.21", - "zip 3.0.0", + "zip 9.0.4", ] [[package]] name = "solidcraft-kernel" -version = "0.14.0" +version = "0.15.0" dependencies = [ "log", "proptest", @@ -4193,11 +4194,12 @@ dependencies = [ "truck-shapeops", "truck-stepio", "truck-topology", + "web-time", ] [[package]] name = "solidcraft-mcp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde_json", "solidcraft-engine", @@ -4206,7 +4208,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.14.0" +version = "0.15.0" dependencies = [ "image", "moxcms", @@ -4215,11 +4217,12 @@ dependencies = [ "serde_json", "sha2 0.10.9", "solidcraft-geom", + "weezl", ] [[package]] name = "solidcraft-sketch" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde", "serde_json", @@ -4228,9 +4231,13 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "solidcraft-space-mouse" +version = "0.15.0" + [[package]] name = "solidcraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "egui", "egui-wgpu", @@ -4244,14 +4251,17 @@ dependencies = [ "sha2 0.10.9", "solidcraft-engine", "solidcraft-gpu", + "tiny-skia", + "toml", "ttf-parser", "web-time", - "zip 3.0.0", + "winreg", + "zip 9.0.4", ] [[package]] name = "solidcraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -4298,6 +4308,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "subtle" version = "2.6.1" @@ -4423,6 +4439,31 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5750,7 +5791,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde_json", "sha2 0.10.9", @@ -5801,7 +5842,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.8", "serde", @@ -6041,20 +6082,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "zip" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap", - "memchr", - "zopfli", -] - [[package]] name = "zip" version = "8.6.0" @@ -6069,6 +6096,21 @@ dependencies = [ "zstd 0.13.3", ] +[[package]] +name = "zip" +version = "9.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "nix 0.31.3", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index d48e8a61..30d7c7df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -23,6 +23,7 @@ solidcraft-doc = { path = "crates/doc" } solidcraft-io = { path = "crates/io" } solidcraft-engine = { path = "crates/engine" } solidcraft-ui-egui = { path = "crates/ui-egui" } +solidcraft-space-mouse = { path = "crates/space-mouse" } solidcraft-mcp = { path = "crates/mcp" } solidcraft-derive = { path = "crates/derive" } @@ -51,7 +52,7 @@ spade = { version = "2.15", default-features = false, features = ["std"] } rayon = "1.12" # packages (3MF) -zip = { version = "3", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } quick-xml = "0.41" # glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are # read by crates/io itself, so no image decoding or file access comes with it. @@ -71,6 +72,12 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] } # images image = { version = "0.25", default-features = false, features = ["png", "hdr"] } png = "0.18.1" +# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff). +weezl = "0.1.12" +# rasterises the UI icon SVGs (BSD-3-Clause, pure Rust, builds for wasm) +tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] } +# build scripts: reads the icon palette (MIT OR Apache-2.0) +toml = "1" # ui egui = "0.36" diff --git a/README.md b/README.md index f24a498d..6920fde5 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -# SolidCraft +

+ SolidCraft +

-**An open-source parametric 3D CAD application built in pure Rust.** +

SolidCraft

-Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension -and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents -model through the built-in MCP server. +

+ An open-source parametric 3D CAD application built in pure Rust.
+ Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension + and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents + model through the built-in MCP server. +

-![SolidCraft 0.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.gif) - -*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).* - -## Download +
[![Download for Windows][badge-windows]][windows-msi] [![Download for macOS][badge-macos]][macos-dmg] @@ -18,6 +19,33 @@ model through the built-in MCP server. [![Get it from the Snap Store][badge-snap]][snap-store] [![Try it in your browser][badge-web]][web-app] +
+ +

+ Website · + Download · + Try it in the browser · + Documentation · + Discord · + SolidCore +

+ +

+ CI + Latest release + Downloads + Licence + Discord + Listed on Remade + Stars +

+ +![SolidCraft 0.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.gif) + +*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).* + +## Download + - **Windows:** download and run the [installer][windows-msi]. Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a [portable zip][windows-portable] that runs without installing. @@ -27,7 +55,7 @@ model through the built-in MCP server. (Apple silicon and Intel). - **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it. Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora, - openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). + openSUSE), or use [Flatpak](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak). With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]). For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm]. - **Web:** [try it in your browser][web-app], no install. @@ -35,39 +63,41 @@ model through the built-in MCP server. The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then **Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**. -Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases). +Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases). Install details, upgrading from earlier builds and building from source are in -[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html). +[Getting started](https://docs.solidcraft.io/guide/getting-started.html). [badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B [badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white [badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white [badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white [badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D -[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi -[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi -[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi -[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip -[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg -[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage -[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb -[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm -[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage -[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb -[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm -[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap +[windows-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi +[windows-arm64-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi +[windows-x86-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x86.msi +[windows-portable]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip +[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg +[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage +[linux-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb +[linux-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm +[linux-arm64-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage +[linux-arm64-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb +[linux-arm64-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm +[linux-snap]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap [snap-store]: https://snapcraft.io/solidcraft -[web-app]: https://bherbruck.github.io/solidcraft/app/ +[web-app]: https://solidcraft.io/app -**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide, +**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide, developer guide and command reference; sources in [docs/book](docs/book)). +**Community:** chat with users and contributors on [Discord](https://solidcraft.io/discord). + **Status: early but usable for simple parts.** 75 of 76 public reference parts match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box ([docs/oracle.md](docs/oracle.md)); the other is a tracked bug. These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md). -Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), +Open work and known bugs are in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). ## Built with SolidCraft @@ -77,7 +107,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). *SELENE, a scientific rover built entirely through SolidCraft's MCP server by [@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that rebuild with no errors, replayed here at 2× speed. -[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4) +[Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4) · [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).* ## What works @@ -176,15 +206,21 @@ cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out br cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png cargo run --release -p solidcraft -- part.step # open a STEP file as a new design cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file -cargo xtask ci # fmt, clippy, tests, assets, layers, wasm +cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm ``` +UI artwork changes follow the [icon specification](assets/icons/ui/STYLE_GUIDE.md). +The [included review tool](assets/icons/ui/README.md) discovers new icons and keeps +unaccepted artwork Open; run `cargo xtask icons` to check the collection. + Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`. -On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its -multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. +On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis +controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that +declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation -page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads +page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an +invert switch and a speed for the 3D mouse. The web app reads it too, through the browser; Firefox needs a 3DxWare setting first (see [Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)). @@ -192,23 +228,37 @@ With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there 3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that 3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts. -On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the -device. A udev rule that adds the `uaccess` tag does that, for example -`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id: +On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket +`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead +zones) applies as it does in other programs, and no udev rule is needed. With nothing set there, +the motion is the same as without the daemon. Without `spacenavd`, SolidCraft reads `/dev/hidrawN` +directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess` +tag does that, for example +`/etc/udev/rules.d/70-3d-mouse.rules` for USB devices with 3Dconnexion's vendor id, and for its +Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead): ``` SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess" +SUBSYSTEM=="hidraw", KERNELS=="0005:256F:*", TAG+="uaccess" ``` -Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`) -instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak -sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now. -Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their -report descriptor, so they work as well. +Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see +`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own +vendor and product id. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then +replug the device. The Flatpak sandbox does not expose hidraw devices, so the 3D mouse only works +with native builds there for now. + +SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device +lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six +in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits. +Each axis is scaled from the range the descriptor declares. In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). +Agents can control the document open in a browser tab through `window.solidcraft.execute(...)` +and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md). + ## Scripts A script is a JSON list of commands, the same ones the UI runs: diff --git a/ROADMAP.md b/ROADMAP.md index 1887bc9c..22ff428c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,11 +53,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | +| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — | +| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | | M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | @@ -71,11 +68,23 @@ retain their saved two-finger scroll preference; camera motion is available thro M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits -partly hidden edges and includes curved silhouettes. Curves are approximated; drawing sheets, +partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle +hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers. +Curves are approximated; drawing sheets, annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md). +Desktop view DXF export runs in the file-operation worker with five-stage progress and +cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside +the hidden-edge setting. Cancelled exports preserve the model and the previous destination; +completed drawings publish atomically. The browser still exports synchronously. + ## Interactive modelling +- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and + pitch selectors expose the existing metric designation parameter; custom designations stay + editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and + full/partial-length options remain available. This does not add tolerance classes or + left-hand threads. - New Body extrusion and face-offset previews show completed intermediate geometry while a drag continues, then catch up to the exact final value. Changes to the selected geometry, operation or feature still discard obsolete results. Distance arrows and their value boxes @@ -321,7 +330,9 @@ annotations and exact projected curves remain deferred. See [view DXF](docs/view bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303); whole smooth loops of a planar face, picked whole or by one edge (tangent chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, - plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of + plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight + blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal + checked within 0.1% of analytic offset slices), runs of neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square; with the edge between them too, the corner becomes a sphere octant), rounded corners rounded again (sphere octants, tori at concave corners); every edge of a @@ -374,6 +385,9 @@ annotations and exact projected curves remain deferred. See [view DXF](docs/view A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled walls through denser stations where the path has corners or the profile has holes. + Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail + turns the profile and scales it (or stretches it across to the rail) against the start; two + rails carry it between them. Rails along a path with sharp corners are refused for now. - Extend intersects exact ellipses and rational/high-degree splines without moving the line or circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance certificate and refine within bounded node limits while retaining existing IDs. diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index a702b820..653870ec 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 545 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 549 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -21,9 +21,9 @@ android-activity 0.6.1 (MIT OR Apache-2.0) android-properties 0.2.2 (MIT) android_system_properties 0.1.6 (MIT OR Apache-2.0) approx 0.4.0 (Apache-2.0) -arbitrary 1.5.0 (MIT OR Apache-2.0) arboard 3.6.1 (MIT OR Apache-2.0) array-macro 2.1.8 (MIT OR Apache-2.0) +arrayref 0.3.9 (BSD-2-Clause) arrayvec 0.7.8 (MIT OR Apache-2.0) as-raw-xcb-connection 1.0.1 (MIT OR Apache-2.0) ash 0.38.0+1.3.281 (MIT OR Apache-2.0) @@ -102,7 +102,6 @@ crypto-common 0.2.2 (MIT OR Apache-2.0) cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib) dbus 0.9.12 (Apache-2.0/MIT) dbus-secret-service 4.1.0 (MIT OR Apache-2.0) -derive_arbitrary 1.5.0 (MIT OR Apache-2.0) derive_more 0.99.20 (MIT) digest 0.10.7 (MIT OR Apache-2.0) digest 0.11.3 (MIT OR Apache-2.0) @@ -237,6 +236,7 @@ ndk 0.9.0 (MIT OR Apache-2.0) ndk-context 0.1.1 (MIT OR Apache-2.0) ndk-sys 0.6.0+11769913 (MIT OR Apache-2.0) nix 0.29.0 (MIT) +nix 0.31.3 (MIT) nohash-hasher 0.2.0 (Apache-2.0 OR MIT) num 0.4.3 (MIT OR Apache-2.0) num-bigint 0.4.8 (MIT OR Apache-2.0) @@ -382,6 +382,7 @@ spade 2.15.1 (MIT OR Apache-2.0) spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0) stable_deref_trait 1.2.1 (MIT OR Apache-2.0) static_assertions 1.1.0 (MIT OR Apache-2.0) +strict-num 0.1.1 (MIT) subtle 2.6.1 (BSD-3-Clause) syn 1.0.109 (MIT OR Apache-2.0) syn 2.0.119 (MIT OR Apache-2.0) @@ -394,6 +395,8 @@ thiserror 2.0.21 (MIT OR Apache-2.0) thiserror-impl 1.0.69 (MIT OR Apache-2.0) thiserror-impl 2.0.21 (MIT OR Apache-2.0) tiff 0.11.3 (MIT) +tiny-skia 0.12.0 (BSD-3-Clause) +tiny-skia-path 0.12.0 (BSD-3-Clause) tinystr 0.8.4 (Unicode-3.0) tokio 1.53.2 (MIT) toml 1.1.8+spec-1.1.0 (MIT OR Apache-2.0) @@ -501,6 +504,7 @@ windows_x86_64_msvc 0.52.6 (MIT OR Apache-2.0) windows_x86_64_msvc 0.53.1 (MIT OR Apache-2.0) winit 0.30.13 (Apache-2.0) winnow 1.0.4 (MIT) +winreg 0.56.0 (MIT) wit-bindgen 0.57.1 (Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT) writeable 0.6.4 (Unicode-3.0) x11-dl 2.21.0 (MIT) @@ -531,8 +535,8 @@ zeroize_derive 1.5.0 (Apache-2.0 OR MIT) zerotrie 0.2.5 (Unicode-3.0) zerovec 0.11.8 (Unicode-3.0) zerovec-derive 0.11.6 (Unicode-3.0) -zip 3.0.0 (MIT) zip 8.6.0 (MIT) +zip 9.0.4 (MIT) zlib-rs 0.6.8 (Zlib) zmij 1.0.23 (MIT) zopfli 0.8.3 (Apache-2.0) @@ -1086,30 +1090,29 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-fs -2.2.0, async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal -0.2.14, async-task 4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, -blocking 1.7.0, bumpalo 3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation -0.10.1, core-foundation 0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types -0.1.3, core_detect 1.0.0, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, -dbus-secret-service 4.1.0, derive_arbitrary 1.5.0, displaydoc 0.2.7, either 1.19.0, equivalent -1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand -2.5.0, flate2 1.1.10, form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, -gltf 1.4.1, gltf-derive 1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi -0.5.3, httparse 1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools -0.15.0, js-sys 0.3.106, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys -0.4.15, lock_api 0.4.14, log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer -0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe -0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, -percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, -pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, -roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46, -rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0, -security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0, -signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait -1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width -0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, -wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, +ahash 0.8.12, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0, async-io +2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task +4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo +3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation +0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, core_detect +1.0.0, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service +4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14, +event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded +1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, gltf 1.4.1, gltf-derive 1.4.1, gltf-json +1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0, +idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl +6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, +num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46, num-rational +0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0, parking +2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain +0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, +renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, +rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, +secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0, security-framework-sys +2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, +stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation +1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 +1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1 ================================================================================ @@ -2024,38 +2027,6 @@ Apache License See the License for the specific language governing permissions and limitations under the License. -================================================================================ -arbitrary 1.5.0, derive_arbitrary 1.5.0 -================================================================================ - -MIT License - -Copyright (c) 2019 Manish Goregaokar - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - ================================================================================ arboard 3.6.1 ================================================================================ @@ -2082,6 +2053,37 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +arrayref 0.3.9 +================================================================================ + +Copyright (c) 2015 David Roundy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ================================================================================ arrayvec 0.7.8 ================================================================================ @@ -9419,7 +9421,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -nix 0.29.0 +nix 0.29.0, nix 0.31.3 ================================================================================ The MIT License (MIT) @@ -12693,6 +12695,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +strict-num 0.1.1 +================================================================================ + +Copyright (c) 2022 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ subtle 2.6.1 ================================================================================ @@ -12795,6 +12821,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +tiny-skia 0.12.0, tiny-skia-path 0.12.0 +================================================================================ + +Copyright (c) 2011 Google Inc. All rights reserved. +Copyright (c) 2020 Yevhenii Reizner All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ================================================================================ tokio 1.53.2 ================================================================================ @@ -14757,6 +14818,30 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +winreg 0.56.0 +================================================================================ + +Copyright (c) 2015 Igor Shaula + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ x11rb 0.13.2, x11rb-protocol 0.13.2 ================================================================================ @@ -15472,36 +15557,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -zip 3.0.0 -================================================================================ - -The MIT License (MIT) - -Copyright (c) 2014 Mathijs van de Nes - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Some files in the "tests/data" subdirectory of this repository are under other -licences; see files named LICENSE.*.txt for details. - -================================================================================ -zip 8.6.0 +zip 8.6.0, zip 9.0.4 ================================================================================ The MIT License (MIT) diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index b078297a..8aca7563 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! SolidCraft headless command line. //! //! ```text -//! solidcraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors] +//! solidcraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors] //! solidcraft-cli eval [--json-out FILE] (alias: inspect) measurements as JSON //! solidcraft-cli script [--out script.json] the design's history as a command script //! solidcraft-cli bench-open [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines @@ -37,9 +37,11 @@ use solidcraft_engine::Session; use solidcraft_engine::view::Guides; const USAGE: &str = "usage: - solidcraft-cli run [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors] + solidcraft-cli run [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors] solidcraft-cli run --in [--out FILE]... (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway) + (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too; + --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share) solidcraft-cli eval [--json-out FILE] (alias: inspect; or --in FILE) (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout, as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark) @@ -180,7 +182,11 @@ fn positional(args: &[String]) -> Option<&String> { let mut i = 0; while let Some(a) = args.get(i) { if a.starts_with("--") { - i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 }; + i += if matches!(a.as_str(), "--quiet" | "--results" | "--timings" | "--profile" | "--allow-errors" | "--no-grid" | "--no-axes") { + 1 + } else { + 2 + }; continue; } return Some(a); @@ -224,15 +230,28 @@ fn cmd_run(args: &[String]) -> Result<(), String> { if path.is_none() && base.is_none() { return Err(USAGE.to_string()); } - let (mut s, results) = if args.iter().any(|a| a == "--results") { - // Collect every command's result (scripts only), then process requested files too. + let want_results = args.iter().any(|a| a == "--results"); + // `--profile` times each command and splits out its kernel time; it implies `--timings`. + let profile = args.iter().any(|a| a == "--profile"); + let timings = profile || args.iter().any(|a| a == "--timings"); + let (mut s, results) = if want_results || timings { + // Collect every command's result or time (scripts only), then process requested files too. let mut s = match base { Some(b) => load(b)?, None => Session::default(), }; - let v = read_json(path.ok_or("--results needs a script")?)?; - let r = s.run_script(&v).map_err(|e| e.to_string())?; - (s, Some(Value::Array(r))) + let needs = if want_results { "--results needs a script" } else { "--timings needs a script" }; + let v = read_json(path.ok_or(needs)?)?; + let out = if timings { + let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?; + if want_results && let Some(o) = t.as_object_mut() { + o.insert("results".into(), Value::Array(r)); + } + t + } else { + Value::Array(s.run_script(&v).map_err(|e| e.to_string())?) + }; + (s, Some(out)) } else { (open(base, path)?, None) }; diff --git a/apps/solidcraft-cli/tests/action_matrix.rs b/apps/solidcraft-cli/tests/action_matrix.rs index ac025a44..23781cdc 100644 --- a/apps/solidcraft-cli/tests/action_matrix.rs +++ b/apps/solidcraft-cli/tests/action_matrix.rs @@ -1519,7 +1519,6 @@ fn sketches_on_loft_and_sweep_faces() { } #[test] -#[ignore = "fails until #562 is fixed"] fn press_pull_on_the_pi_case_floor() { reproduces( Some("tests/action_matrix/designs/pi-case.solidcraft"), @@ -1560,7 +1559,6 @@ fn a_sketch_on_the_rim_a_shell_leaves() { } #[test] -#[ignore = "fails until #565 is fixed"] fn sketches_after_split_body_and_intersect() { reproduces( None, diff --git a/apps/solidcraft-cli/tests/mcp_connect.rs b/apps/solidcraft-cli/tests/mcp_connect.rs index e384d660..e96ebd7d 100644 --- a/apps/solidcraft-cli/tests/mcp_connect.rs +++ b/apps/solidcraft-cli/tests/mcp_connect.rs @@ -9,8 +9,16 @@ use serde_json::{Value, json}; #[test] fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() { - // A port that was free a moment ago: nothing listens on it. - let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + // This test needs the port closed until its second phase. A port obtained + // with bind(:0) and released can immediately be reused by another parallel + // test's mock server. Stay below the usual ephemeral ranges, + // with a process-specific starting point, and skip any occupied ports. + let port = (0..512u32) + .find_map(|offset| { + let port = 16000 + (std::process::id() + offset) % 16000; + TcpListener::bind(("127.0.0.1", port as u16)).ok().map(|listener| listener.local_addr().unwrap().port()) + }) + .expect("an unused non-ephemeral test port"); let addr = format!("127.0.0.1:{port}"); let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id())); std::fs::create_dir_all(&control_dir).unwrap(); diff --git a/apps/solidcraft-cli/tests/run_results.rs b/apps/solidcraft-cli/tests/run_results.rs index 1da815e9..c5709904 100644 --- a/apps/solidcraft-cli/tests/run_results.rs +++ b/apps/solidcraft-cli/tests/run_results.rs @@ -179,3 +179,33 @@ fn unknown_command_suggests_the_closest_id() { assert_eq!(output.status.code(), Some(1)); assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr)); } + +/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds +/// the kernel's share, `--results` puts the results beside them, and files are still written. +#[test] +fn timings_and_profile_report_each_command() { + let dir = Scratch::new(); + let saved = dir.0.join("timed.solidcraft"); + let t = json_output(cli().arg("run").arg(dir.script()).arg("--timings").arg("--save").arg(&saved).output().unwrap()); + let rows = t["timings"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0]["index"].as_u64(), rows[0]["command"].as_str()), (Some(0), Some("solid.box"))); + assert!(rows[0]["ms"].as_f64().is_some() && t["total_ms"].as_f64().is_some(), "{t}"); + assert!(rows[0].get("profile").is_none() && t.get("results").is_none(), "{t}"); + assert_eq!(body_count(&saved), 1); + + // --profile alone implies --timings; a flag before the script doesn't eat it. + let t = json_output(cli().arg("run").arg("--profile").arg(dir.script()).arg("--results").output().unwrap()); + let p = &t["timings"][0]["profile"]; + for k in ["boolean_ms", "tessellate_ms", "other_ms"] { + assert!(p[k].as_f64().is_some_and(|ms| ms >= 0.0), "{k}: {t}"); + } + assert_eq!(t["results"].as_array().map(Vec::len), Some(1), "{t}"); + + // --results alone stays a plain array. + assert!(json_output(cli().arg("run").arg(dir.script()).arg("--results").output().unwrap()).is_array()); + + let output = cli().arg("run").arg("--in").arg(&saved).arg("--timings").output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script")); +} diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index 4c615fb4..668fe086 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", - "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", + "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance", ] } diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html index aa66df9a..c1068d5b 100644 --- a/apps/solidcraft-web/index.html +++ b/apps/solidcraft-web/index.html @@ -9,20 +9,21 @@ - - + + - + + @@ -35,7 +36,7 @@ -
Loading SolidCraft…
+
Loading SolidCraft…
diff --git a/assets/icons/ui/design/tools/review/review.css b/assets/icons/ui/design/tools/review/review.css new file mode 100644 index 00000000..f6aa119e --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.css @@ -0,0 +1,62 @@ +:root{--bg:#f4f6f9;--fg:#1d2733;--card:#fff;--line:#d5dbe3;--mut:#6b7a8a;--sz:48px} +body.dark{--bg:#1e2530;--fg:#dbe4ee;--card:#262f3c;--line:#3a4657;--mut:#8a9db0} +body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)} +header{position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--line);padding:6px 12px;z-index:5;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center} +h1{font-size:14px;margin:0}.sum{color:var(--mut)} +button,input,select{font:inherit;color:inherit;padding:2px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);cursor:pointer} +button.on{background:var(--review-accent);color:var(--review-accent-ink);border-color:var(--review-accent)} +.grp{display:flex;gap:3px;align-items:center}.grp small{color:var(--mut)} +main{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;align-items:stretch} +.gm{flex:0 0 11px;writing-mode:vertical-rl;transform:rotate(180deg);font:600 8px/11px system-ui;letter-spacing:.04em;color:var(--mut);background:var(--line);border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-transform:uppercase;font-style:normal;max-height:120px;align-self:stretch} +.gm.tab{background:var(--review-accent);color:var(--review-accent-ink)} +.card{position:relative;flex:0 0 118px;background:var(--card);border:1px solid var(--line);border-radius:7px} +.art{display:flex;justify-content:center;align-items:center;padding:8px 0 2px;min-height:calc(var(--sz) + 10px)} +.art svg{width:var(--sz);height:var(--sz)} +.art .ad,.art .sd,.art .sl{display:none} +body.dark .art .al{display:none}body.dark .art .ad{display:inline} +body.small .art.hs .al,body.small .art.hs .ad{display:none} +body.small:not(.dark) .art.hs .sl{display:inline}body.small.dark .art.hs .sd{display:inline} +.meta{padding:2px 6px 5px;line-height:1.35;word-break:break-all}.meta small{color:var(--mut);font-size:10px} +.src,.acc,.st{font-size:10px;color:var(--mut);font-style:normal}.src{color:var(--mut)}.st{color:var(--review-error)} +.ck,.nt{position:absolute;top:3px;width:20px;height:20px;padding:0;line-height:18px;border-radius:50%;font-size:12px;color:var(--mut);opacity:.55} +.ck{right:3px}.nt{left:3px;border-radius:5px} +.card:hover .ck,.card:hover .nt{opacity:1} +.card.final{border-color:var(--review-success)}.card.final .art,.card.final .meta{opacity:.6} +.card.final .ck{background:var(--review-success);color:var(--review-accent-ink);border-color:var(--review-success);opacity:1} +.card.final .nt{display:none} +.card.hasnote{border-color:var(--review-warning)}.card.hasnote .nt{background:var(--review-warning);color:var(--review-accent-ink);border-color:var(--review-warning);opacity:1} +.pop{position:absolute;left:0;top:100%;z-index:9;width:250px;background:var(--card);border:1px solid var(--review-accent);border-radius:7px;padding:6px;box-shadow:0 6px 18px #0004;display:none} +.card.open .pop{display:block}.card.open{z-index:9} +.pop textarea{width:100%;box-sizing:border-box;height:90px;font:12px system-ui;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:5px} +#msg{color:var(--review-error)} +[hidden]{display:none!important} +header .detail{width:100%;display:flex;gap:14px;align-items:center;font-size:11px} +header a{color:var(--fg);text-decoration:underline;text-underline-offset:2px} +.help{color:var(--mut);font-size:11px;margin:8px 12px 0} +.warning:empty{display:none}.warning,.error{color:var(--review-error)} +.warning{width:100%;margin:0;font-size:11px} +#msg{color:var(--mut)}#msg.error{color:var(--review-error)} +button:disabled{cursor:not-allowed;opacity:.3} +button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--review-accent);outline-offset:2px} +#empty{padding:30px;color:var(--mut)} + +.shared{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--mut);font-size:10px;word-break:normal} + +.vision{display:none}body[data-vision="off"] .vision-off,body[data-vision="red-green"] .vision-red-green,body[data-vision="blue-yellow"] .vision-blue-yellow{display:contents} + +.acc{color:var(--icon-accent,var(--mut))} + +/* Temporary palette tuning controls; keep them separate from review/acceptance. */ +#palette-editor{flex-basis:100%;border-bottom:1px solid var(--line);padding:2px 0 8px} +.palette-heading{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-bottom:6px} +#palette-status{color:var(--mut);font-size:11px}#palette-status.error{color:var(--review-error)} +#palette-tokens{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:3px 12px;max-height:180px;overflow:auto} +.palette-token{display:flex;align-items:center;gap:5px;min-width:0;line-height:20px} +.palette-token span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.palette-token input[type=color]{appearance:none;-webkit-appearance:none;box-sizing:border-box;width:19px;height:19px;border:1px solid var(--line);border-radius:50%;padding:0;background:transparent;flex-shrink:0;overflow:hidden} +.palette-token input[type=color]::-webkit-color-swatch-wrapper{padding:0} +.palette-token input[type=color]::-webkit-color-swatch{border:0;border-radius:50%} +.palette-token input[type=color]::-moz-color-swatch{border:0;border-radius:50%} +.palette-token input:disabled{cursor:default;opacity:1} +.palette-knockout{box-sizing:border-box;width:19px;height:19px;flex-shrink:0;border:1px solid var(--line);border-radius:50%;background:repeating-conic-gradient(#8993a0 0% 25%,transparent 0% 50%) 0 0/8px 8px} +body.palette-preview .card.final .art{opacity:1} diff --git a/assets/icons/ui/design/tools/review/review.js b/assets/icons/ui/design/tools/review/review.js new file mode 100644 index 00000000..d71ae4b0 --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.js @@ -0,0 +1,285 @@ +// No framework, build step, external resources or model integration. +const S = @@STATE@@; +const fin = new Set(S.final); +const notes = {...S.notes}; +let base = {final: [...S.final].sort(), notes: {...notes}}; +let filter = 'open', tab = '', dirty = false, timer; +let saves = Promise.resolve(); +const query = document.getElementById('q'); +const message = document.getElementById('msg'); +const cards = [...document.querySelectorAll('.card')]; +const markers = [...document.querySelectorAll('.gm')]; +const staticKey = 'icon-review:' + S.rev; +const prefsKey = 'icon-review-preferences'; +const reloadKey = 'icon-review-reload-view'; + +function state() { + return {final: [...fin].sort(), notes: Object.fromEntries( + Object.entries(notes).filter(([, value]) => value.trim()))}; +} +function payload(snapshot) { return JSON.stringify({base, ...snapshot, rev: S.rev}); } +function showMessage(text, error = false) { + message.textContent = text; + message.classList.toggle('error', error); +} +function run() { + let finals = 0, noted = 0, visible = 0; + for (const card of cards) { + const id = card.dataset.id; + const accepted = fin.has(id), hasNote = Boolean((notes[id] || '').trim()); + card.classList.toggle('final', accepted); + card.classList.toggle('hasnote', hasNote && !accepted); + card.querySelector('.ck').title = accepted ? 'Reopen for review' : 'Accept as final'; + card.querySelector('.ck').setAttribute('aria-label', (accepted ? 'Reopen ' : 'Accept ') + id + (accepted ? '' : ' as final')); + if (accepted) finals++; + if (hasNote && !accepted) noted++; + const matches = (filter === 'all' || (filter === 'open' && !accepted) || + (filter === 'notes' && hasNote && !accepted) || (filter === 'final' && accepted)) && + (!tab || card.dataset.tab === tab) && card.dataset.k.includes(query.value.toLowerCase()); + card.hidden = !matches; + if (matches) visible++; + } + for (const marker of markers) { + let next = marker.nextElementSibling, any = false; + while (next && !next.classList.contains('gm')) { + if (!next.hidden) any = true; + next = next.nextElementSibling; + } + marker.hidden = !any; + } + document.getElementById('cnt').textContent = `${finals} final · ${cards.length - finals} open · ${noted} with notes`; + document.getElementById('empty').hidden = visible > 0; +} +function scheduleSave() { + dirty = true; + clearTimeout(timer); + timer = setTimeout(flush, 250); +} +async function flush() { + clearTimeout(timer); + const snapshot = state(); + saves = saves.catch(() => {}).then(async () => { + if (!S.serving) { + localStorage.setItem(staticKey, JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Static preview: Export downloads the review file. Use --serve to save to the repository.'); + return; + } + const response = await fetch('/api/review', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Save failed'); + base = JSON.parse(JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Saved'); + }); + try { await saves; return true; } + catch (error) { showMessage(error.message, true); return false; } +} +function view() { + return {filter, tab, q: query.value, y: scrollY}; +} +function preferences() { + return {dark: document.body.classList.contains('dark'), vision: document.body.dataset.vision, size: document.documentElement.style.getPropertyValue('--sz')}; +} +function savePreferences() { try { localStorage.setItem(prefsKey, JSON.stringify(preferences())); } catch {} } +function setSize(size) { + document.documentElement.style.setProperty('--sz', size + 'px'); + document.body.classList.toggle('small', size <= 24); + document.querySelectorAll('.sz').forEach(button => button.classList.toggle('on', +button.dataset.s === size)); +} +function selectFilter(value) { + filter = value; + document.querySelectorAll('[data-f]').forEach(button => button.classList.toggle('on', button.dataset.f === value)); +} +function selectTab(value) { + tab = value; + document.querySelectorAll('.chip').forEach(button => button.classList.toggle('on', (button.dataset.tab || '') === value)); +} + +// Temporary palette editor: CSS variables recolour every rendered theme, size +// and vision variant without touching SVGs, notes or acceptance hashes. +const paletteDraftKey = 'icon-review-palette-draft'; +const paletteStatus = document.getElementById('palette-status'); +const paletteApply = document.getElementById('palette-apply'); +const paletteReset = document.getElementById('palette-reset'); +const palettePaints = Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout) + .map(token => [token.id, {light: token.light, dark: token.dark}])); +const paletteInputs = []; +let paletteDirty = false, paletteSaving = false; +function paletteVariable(id, theme) { return '--icon-palette-' + id.replaceAll('.', '-') + '-' + theme; } +function paletteChanges() { + return Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout && + ['light', 'dark'].some(theme => palettePaints[token.id][theme] !== token[theme])) + .map(token => [token.id, {...palettePaints[token.id]}])); +} +function updatePalette() { + for (const [id, paints] of Object.entries(palettePaints)) { + for (const theme of ['light', 'dark']) document.documentElement.style.setProperty(paletteVariable(id, theme), paints[theme]); + } + for (const {input, token, theme} of paletteInputs) { + input.value = palettePaints[token.linked || token.id][theme]; + input.title = `${token.name} · ${theme}: ${input.value}${token.linked ? ' (follows construction stroke)' : ''}`; + } + paletteDirty = Object.keys(paletteChanges()).length > 0; + paletteApply.disabled = !paletteDirty || paletteSaving; + paletteReset.disabled = !paletteDirty || paletteSaving; + try { + if (paletteDirty) sessionStorage.setItem(paletteDraftKey, JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})); + else sessionStorage.removeItem(paletteDraftKey); + } catch {} +} +for (const token of S.palette.tokens) { + const row = document.createElement('div'); row.className = 'palette-token'; + for (const theme of ['light', 'dark']) { + if (token.knockout) { + const swatch = document.createElement('span'); swatch.className = 'palette-knockout'; + swatch.title = token.name + ' · ' + theme + ': transparent cutout'; + swatch.setAttribute('aria-label', swatch.title); row.appendChild(swatch); continue; + } + const input = document.createElement('input'); input.type = 'color'; input.value = token[theme]; + input.disabled = Boolean(token.linked); + input.setAttribute('aria-label', token.name + ' ' + theme + ' colour'); + input.oninput = () => { + palettePaints[token.id][theme] = input.value; + updatePalette(); paletteStatus.textContent = 'Preview only · Apply saves both themes to palette.toml'; + paletteStatus.classList.remove('error'); + }; + paletteInputs.push({input, token, theme}); row.appendChild(input); + } + const label = document.createElement('span'); label.textContent = token.name + (token.linked ? ' ↗' : ''); + row.appendChild(label); document.getElementById('palette-tokens').appendChild(row); +} +document.body.classList.add('palette-preview'); +document.getElementById('palette-all').onclick = () => { selectFilter('all'); selectTab(''); query.value = ''; run(); }; +paletteReset.onclick = () => { + for (const token of S.palette.tokens.filter(token => !token.linked && !token.knockout)) palettePaints[token.id] = {light: token.light, dark: token.dark}; + updatePalette(); paletteStatus.textContent = 'Preview reset · saved palette unchanged'; paletteStatus.classList.remove('error'); +}; +paletteApply.onclick = async () => { + if (!paletteDirty || paletteSaving) return; + if (dirty && !await flush()) return; + if (!S.serving) { + paletteStatus.textContent = 'Preview only · run the review server to apply colours to palette.toml'; return; + } + paletteSaving = true; updatePalette(); + try { + const response = await fetch('/api/palette', {method: 'POST', body: JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Palette save failed'); + S.palette = {revision: result.revision, tokens: result.tokens}; + paletteStatus.textContent = 'Saved to palette.toml · rebuild the app to use these colours'; + paletteStatus.classList.remove('error'); + } catch (error) { paletteStatus.textContent = error.message; paletteStatus.classList.add('error'); } + finally { paletteSaving = false; updatePalette(); } +}; +try { + const draft = JSON.parse(sessionStorage.getItem(paletteDraftKey) || 'null'); + if (draft?.revision === S.palette.revision) { + for (const [id, paints] of Object.entries(draft.changes)) { + if (palettePaints[id] && ['light','dark'].every(theme => /^#[0-9a-f]{6}$/i.test(paints[theme] || ''))) palettePaints[id] = {...paints}; + } + paletteStatus.textContent = 'Unsaved palette preview restored'; + } else if (draft) { paletteStatus.textContent = 'Saved palette changed · older preview was not restored'; } +} catch {} +updatePalette(); +for (const card of cards) { + const id = card.dataset.id; + const pop = document.createElement('div'); + pop.className = 'pop'; + const textarea = document.createElement('textarea'); + textarea.placeholder = 'What should change in this icon?'; + textarea.setAttribute('aria-label', 'Notes for ' + id); + textarea.value = notes[id] || ''; + pop.appendChild(textarea); card.appendChild(pop); + textarea.oninput = () => { notes[id] = textarea.value; run(); scheduleSave(); }; + card.querySelector('.ck').onclick = () => { + if (card.dataset.missing === 'true') return; + fin.has(id) ? fin.delete(id) : fin.add(id); + card.classList.remove('open'); run(); scheduleSave(); + }; + card.querySelector('.nt').onclick = event => { + const open = !card.classList.contains('open'); + cards.forEach(other => other.classList.remove('open')); + card.classList.toggle('open', open); + if (open) textarea.focus(); + event.stopPropagation(); + }; + pop.onclick = event => event.stopPropagation(); +} +document.addEventListener('click', () => cards.forEach(card => card.classList.remove('open'))); +document.addEventListener('keydown', event => { + if (event.key === 'Escape') cards.forEach(card => card.classList.remove('open')); +}); +document.querySelectorAll('[data-f]').forEach(button => button.onclick = () => { selectFilter(button.dataset.f); run(); }); +document.querySelectorAll('.chip').forEach(button => button.onclick = () => { selectTab(button.dataset.tab || ''); run(); }); +document.querySelectorAll('.sz').forEach(button => button.onclick = () => { setSize(+button.dataset.s); savePreferences(); }); +document.getElementById('dk').onclick = function () { + document.body.classList.toggle('dark'); this.classList.toggle('on'); savePreferences(); +}; +document.getElementById('vision').onchange = event => { + document.body.dataset.vision = event.target.value; savePreferences(); +}; +query.oninput = run; +document.getElementById('rescan').onclick = async () => { + if (!dirty || await flush()) location.reload(); +}; +document.getElementById('exp').onclick = async () => { + if (dirty && !await flush()) return; + if (!S.serving) { + const snapshot = state(); + const accepted = Object.fromEntries(snapshot.final.map(id => [id, S.hashes[id]])); + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([JSON.stringify({version: 1, final: accepted, notes: snapshot.notes}, null, 1)], {type: 'application/json'})); + link.download = 'review.json'; link.click(); URL.revokeObjectURL(link.href); + return; + } + try { + await saves; + const snapshot = state(); + const response = await fetch('/api/export', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Export failed'); + base = JSON.parse(JSON.stringify(snapshot)); + showMessage(result.sent ? `Queued ${result.sent} note${result.sent === 1 ? '' : 's'} in the local review inbox` : 'No open notes to export'); + } catch (error) { showMessage(error.message, true); } +}; +try { + if (!S.serving) { + const saved = JSON.parse(localStorage.getItem(staticKey) || 'null'); + if (saved) { fin.clear(); saved.final.forEach(id => fin.add(id)); Object.assign(notes, saved.notes); } + } + const prefs = JSON.parse(localStorage.getItem(prefsKey) || 'null'); + if (prefs) { + if (prefs.vision && ['off', 'red-green', 'blue-yellow'].includes(prefs.vision)) { document.body.dataset.vision = prefs.vision; document.getElementById('vision').value = prefs.vision; } + if (prefs.dark) document.getElementById('dk').click(); + const size = parseInt(prefs.size); + if ([16,24,48,64].includes(size)) setSize(size); + } + // Only an automatic artwork reload restores filters. A normal opening always + // starts at Open / Everything, so yesterday's filter cannot hide new artwork. + const restored = JSON.parse(sessionStorage.getItem(reloadKey) || 'null'); + sessionStorage.removeItem(reloadKey); + if (restored) { + selectFilter(restored.filter); selectTab(restored.tab); query.value = restored.q; + requestAnimationFrame(() => scrollTo(0, restored.y)); + } +} catch {} +run(); +if (S.serving) setInterval(async () => { + if (dirty || paletteDirty || paletteSaving || document.activeElement?.tagName === 'TEXTAREA') return; + try { + const response = await fetch('/api/review'); + const current = await response.json(); + if (current.rev && current.rev !== S.rev) { + sessionStorage.setItem(reloadKey, JSON.stringify(view())); + location.reload(); return; + } + if (JSON.stringify([current.final, current.notes]) !== JSON.stringify([[...fin].sort(), notes])) { + fin.clear(); current.final.forEach(id => fin.add(id)); + Object.keys(notes).forEach(id => delete notes[id]); Object.assign(notes, current.notes); + base = {final: [...current.final].sort(), notes: {...notes}}; + cards.forEach(card => { card.querySelector('textarea').value = notes[card.dataset.id] || ''; }); + run(); + } + } catch { showMessage('Review server unavailable; reconnect before saving.', true); } +}, 3000); diff --git a/assets/icons/ui/design/tools/review_state.py b/assets/icons/ui/design/tools/review_state.py new file mode 100644 index 00000000..2b78ae2a --- /dev/null +++ b/assets/icons/ui/design/tools/review_state.py @@ -0,0 +1,76 @@ +"""Shared icon review state: read and write design/review.json (standard library only). + + {"version": 1, "final": {"": ""}, "notes": {"": "text"}} + +Legacy acceptance lists migrate once when the review server starts; existing hashes are never refreshed implicitly. +""" +import os, re, json, glob, hashlib, tomllib, threading, uuid + +LOCK = threading.RLock() + +D = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # .../assets/icons/ui/design +REVIEW = D + '/review.json' + +def aliases(): + return tomllib.load(open(D + '/aliases.toml', 'rb')).get('alias', {}) +def master_of(icon_id, al=None): + v = (aliases() if al is None else al).get(icon_id) + return (v['master'] if isinstance(v, dict) else v) if v else icon_id +def master_hash(master): + h = hashlib.sha256() + for f in sorted(glob.glob(D + '/svg/*/' + master + '.svg')): + h.update(os.path.relpath(f, D).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read()) + return h.hexdigest() +def load(): + try: r = json.load(open(REVIEW, encoding='utf-8')) + except FileNotFoundError: r = {} + fin = r.get('final', {}) + if isinstance(fin, list): fin = {i: '' for i in fin} + return {'final': dict(fin), 'notes': {k: v for k, v in r.get('notes', {}).items() if isinstance(v, str) and v.strip()}} +def valid_final(state=None): + """Acceptance belongs to a particular artwork revision, not just an id.""" + state = load() if state is None else state + al = aliases() + return {i: h for i, h in state['final'].items() + if h and glob.glob(D + '/svg/*/' + master_of(i, al) + '.svg') + and h == master_hash(master_of(i, al))} +def save(final, notes): + """final: {id: hash or ''}; a missing hash is filled in from the master as it is now.""" + ok = lambda i: re.fullmatch(r'[a-z][a-z0-9_]*', i) + al = aliases() + fin = {i: (h or master_hash(master_of(i, al))) for i, h in final.items() if ok(i)} + nts = {k: v for k, v in notes.items() if ok(k) and isinstance(v, str) and v.strip()} + temporary = REVIEW + '.' + uuid.uuid4().hex + '.tmp' + with LOCK: + try: + with open(temporary, 'w', encoding='utf-8') as f: + json.dump({'version': 1, 'final': dict(sorted(fin.items())), 'notes': dict(sorted(nts.items()))}, f, indent=1); f.write('\n') + os.replace(temporary, REVIEW) + finally: + if os.path.exists(temporary): os.unlink(temporary) +def resolve(masters_or_ids): + """The icons were redrawn: drop their notes, and the notes on aliases of the same masters. Returns the ids cleared.""" + r = load(); al = aliases(); done = set(masters_or_ids) + cleared = sorted(i for i in r['notes'] if i in done or master_of(i, al) in done) + for i in cleared: del r['notes'][i] + if cleared: save(r['final'], r['notes']) + return cleared + + +def resolve_export(request, written): + """Clear only still-matching exported notes on masters actually redrawn. + + Call after applying a snapshot's artwork feedback. Newer notes and final + aliases are retained; this helper does not move requests or draw anything. + """ + with LOCK: + state = load() + protected = {master_of(i) for i in valid_final(state)} + cleared = [] + for icon, expected in request.get('notes', {}).items(): + master = master_of(icon) + if master in written and master not in protected and state['notes'].get(icon) == expected: + del state['notes'][icon] + cleared.append(icon) + if cleared: save(state['final'], state['notes']) + return sorted(cleared) diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs deleted file mode 100644 index 2f342a8c..00000000 --- a/assets/icons/ui/source/icons.rs +++ /dev/null @@ -1,1607 +0,0 @@ -//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a -//! 24 × 24 grid and scaled to the target rectangle. - -use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2}; - -/// `n + 1` points along the cubic Bézier curve with control points `c`. -fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> { - (0..=n) - .map(|i| i as f32 / n as f32) - .map(|t| { - let u = 1.0 - t; - let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; - (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) - }) - .collect() -} - -/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre -/// first). -fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> { - (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect() -} - -/// A gear's toothed outline about (x, y): `n` teeth from the root radius `root` out to `tip`, -/// turned by `phase` radians. -fn teeth(x: f32, y: f32, root: f32, tip: f32, n: usize, phase: f32) -> Vec<(f32, f32)> { - let step = std::f32::consts::TAU / n as f32; - let at = |a: f32, r: f32| (x + r * a.cos(), y - r * a.sin()); - (0..n) - .flat_map(|k| { - let a = phase + step * k as f32; - [at(a - 0.3 * step, root), at(a - 0.15 * step, tip), at(a + 0.15 * step, tip), at(a + 0.3 * step, root)] - }) - .collect() -} - -struct Pen<'a> { - p: &'a Painter, - r: Rect, - ink: Color32, - fill: Color32, - accent: Color32, - w: f32, -} - -impl Pen<'_> { - fn at(&self, x: f32, y: f32) -> Pos2 { - pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height()) - } - fn s(&self) -> f32 { - self.r.width() / 24.0 - } - fn line(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::line(v, Stroke::new(self.w, c))); - } - fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c))); - } - fn closed(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::closed_line(v, Stroke::new(self.w, c))); - } - fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) { - self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c)); - } - fn dot(&self, x: f32, y: f32, c: Color32) { - self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c); - } - fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) { - let n = 24; - let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect(); - self.line(&pts, c); - } - fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) { - self.line(&[from, to], c); - let d = vec2(to.0 - from.0, to.1 - from.1).normalized(); - let n = vec2(-d.y, d.x); - let tip = vec2(to.0, to.1); - let a = tip - d * 3.5 + n * 2.2; - let b = tip - d * 3.5 - n * 2.2; - self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c); - } - /// Isometric box from a 2D footprint height. - fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) { - let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)]; - let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)]; - let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)]; - self.poly(&front, self.fill.gamma_multiply(0.85), self.ink); - self.poly(&side, self.fill.gamma_multiply(0.7), self.ink); - self.poly(&top, self.fill, self.ink); - } - /// A cubic Bézier curve through control points `c`. - fn curve(&self, c: [(f32, f32); 4], col: Color32) { - self.line(&bezier(c, 16), col); - } - fn dashed(&self, pts: &[(f32, f32)], c: Color32) { - let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect(); - self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s())); - } - /// Fill the strip between two polylines that run the same way, such as the front and back - /// edges of a curved sheet. Unlike `poly`, the strip need not be convex. - fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) { - let mut m = Mesh::default(); - for (p, q) in a.iter().zip(b) { - m.colored_vertex(self.at(p.0, p.1), fill); - m.colored_vertex(self.at(q.0, q.1), fill); - } - let n = a.len().min(b.len()) as u32; - for i in 1..n { - let k = 2 * i; - m.add_triangle(k - 2, k - 1, k); - m.add_triangle(k - 1, k + 1, k); - } - self.p.add(Shape::mesh(m)); - } -} - -/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than -/// the generic icon weight (`Pen::w`). It is sized from whichever side of `r` is smaller. A -/// short, wide button and a square toolbar slot both get a stroke their gaps can survive next -/// to. -fn line_type_stroke(r: Rect) -> f32 { - (r.width().min(r.height()) / 24.0 * 2.0).max(1.6) -} - -/// The Centerline icon's dot radius. It is sized from `r`. The stroke width plays no part in it. -/// It stays a round mark even next to a heavy stroke. -fn line_type_dot_radius(r: Rect) -> f32 { - (r.width().min(r.height()) * 0.16).max(2.4) -} - -/// Paint the icon `name` into `r`. `accent` highlights the "action" part. -pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) { - paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons); -} - -/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`). -pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) { - let ic = *ic; - let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) }; - let a = pen.accent; - match name { - "sketch" => { - pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink); - pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a); - } - "finish" => { - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent); - pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a); - } - "extrude" => { - pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0); - pen.arrow((12.0, 9.0), (12.0, 2.5), a); - } - "revolve" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink); - pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a); - pen.arrow((15.0, 20.4), (18.5, 19.0), a); - } - "box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0), - "cylinder" => { - pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT); - pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink); - pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink); - let e = |y: f32, fillc: Color32| { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink))); - }; - pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink); - e(6.0, pen.fill); - } - "sphere" => { - pen.circle(12.0, 12.0, 8.5, pen.fill, ink); - pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5)); - p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint); - } - "torus" => { - let pts: Vec = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink))); - let pts: Vec = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink))); - } - "fillet" => { - pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT); - pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink); - pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink); - pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink); - pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); - } - "chamfer" => { - pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink); - pen.line(&[(12.0, 4.0), (20.0, 12.0)], a); - } - "combine" => { - pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink); - pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a); - } - "pattern_rect" => { - for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] { - pen.poly( - &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)], - if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT }, - if x == 4.0 && y == 4.0 { ink } else { a }, - ); - } - } - "pattern_circ" => { - for i in 0..6 { - let t = i as f32 / 6.0 * std::f32::consts::TAU; - pen.circle( - 12.0 + 7.5 * t.cos(), - 12.0 + 7.5 * t.sin(), - 2.4, - if i == 0 { pen.fill } else { Color32::TRANSPARENT }, - if i == 0 { ink } else { a }, - ); - } - } - "hole" => { - pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink); - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a))); - pen.line(&[(7.0, 8.0), (7.0, 16.0)], a); - pen.line(&[(17.0, 8.0), (17.0, 16.0)], a); - } - "sweep" => { - pen.arc(20.0, 20.0, 14.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, ink); - pen.circle(6.0, 20.0, 3.0, pen.fill, a); - pen.circle(20.0, 6.0, 3.0, pen.fill, a); - } - "loft" => { - pen.poly(&[(3.0, 20.0), (15.0, 20.0), (21.0, 15.0), (9.0, 15.0)], pen.fill, ink); - pen.circle(12.0, 6.0, 3.5, Color32::TRANSPARENT, a); - pen.line(&[(3.0, 20.0), (8.5, 6.0)], a); - pen.line(&[(21.0, 15.0), (15.5, 6.0)], a); - } - "shell" => { - pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0); - pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a); - } - "draft" => { - pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink); - pen.line(&[(20.0, 20.0), (20.0, 5.0)], a); - } - "split" => { - pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); - pen.line(&[(8.0, 2.0), (16.0, 22.0)], a); - } - "mirror" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink); - pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a); - } - "lookat" => { - pen.poly(&[(4.0, 6.0), (16.0, 6.0), (16.0, 18.0), (4.0, 18.0)], pen.fill, ink); - pen.arrow((22.0, 12.0), (13.0, 12.0), a); - } - "style" => { - pen.poly(&[(12.0, 3.0), (20.0, 7.5), (12.0, 12.0), (4.0, 7.5)], pen.fill, ink); - pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink); - pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink); - } - // Original bug glyph: rounded body, antennae and three pairs of legs. - "bug_report" => { - pen.circle(12.0, 13.5, 5.0, Color32::TRANSPARENT, ink); - pen.line(&[(12.0, 9.0), (12.0, 18.0)], ink); - pen.line(&[(9.5, 8.5), (8.0, 5.0)], ink); - pen.line(&[(14.5, 8.5), (16.0, 5.0)], ink); - for y in [10.0, 13.5, 17.0] { - pen.line(&[(3.0, y), (7.0, y)], ink); - pen.line(&[(17.0, y), (21.0, y)], ink); - } - } - // Update: an arrow down onto a tray (a newer release can be downloaded). - "update" => { - pen.line(&[(12.0, 3.5), (12.0, 14.5)], ink); - pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink); - pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink); - } - // Agent chat: a speech bubble with three dots. - "agent_chat" => { - pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink); - for x in [8.5, 12.0, 15.5] { - pen.dot(x, 10.5, ink); - } - } - // Help: a question mark in a ring. - "help" => { - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - pen.arc(12.0, 9.5, 3.2, -0.6, 3.4, ink); - pen.line(&[(13.9, 12.0), (12.0, 13.6), (12.0, 15.0)], ink); - pen.dot(12.0, 17.8, ink); - } - "sun" => { - pen.circle(12.0, 12.0, 4.5, a, ink); - for i in 0..8 { - let t = i as f32 / 8.0 * std::f32::consts::TAU; - pen.line(&[(12.0 + 7.0 * t.cos(), 12.0 + 7.0 * t.sin()), (12.0 + 9.5 * t.cos(), 12.0 + 9.5 * t.sin())], ink); - } - } - "moon" => { - let outer: Vec = - (0..=24).map(|i| -0.6 + i as f32 / 24.0 * 4.4).map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 + 8.0 * t.sin())).collect(); - let inner: Vec = - (0..=24).rev().map(|i| -0.9 + i as f32 / 24.0 * 3.9).map(|t| pen.at(15.5 + 6.5 * t.cos(), 9.5 + 6.5 * t.sin())).collect(); - let mut pts = outer; - pts.extend(inner); - p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink))); - } - "section" => { - pen.poly(&[(4.0, 8.0), (14.0, 4.0), (20.0, 8.0), (10.0, 12.0)], pen.fill, ink); - pen.poly(&[(4.0, 8.0), (10.0, 12.0), (10.0, 20.0), (4.0, 16.0)], pen.fill, ink); - pen.poly(&[(10.0, 12.0), (20.0, 8.0), (20.0, 16.0), (10.0, 20.0)], a, ink); - } - "presspull" => { - pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink); - pen.arrow((12.0, 15.0), (12.0, 3.0), a); - } - "move" => { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { - pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a); - } - } - "configuration_table" => { - pen.poly(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink); - pen.line(&[(3.0, 9.0), (21.0, 9.0)], a); - pen.line(&[(3.0, 14.0), (21.0, 14.0)], ink); - pen.line(&[(9.0, 4.0), (9.0, 20.0)], ink); - } - "params" => { - pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink); - pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink); - pen.line(&[(14.0, 13.0), (20.0, 20.0)], a); - pen.line(&[(20.0, 13.0), (14.0, 20.0)], a); - } - "compute" => { - pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink); - pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a); - pen.arrow((5.0, 9.0), (4.6, 13.0), ink); - pen.arrow((19.0, 15.0), (19.4, 11.0), a); - } - "delete" => { - pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent); - pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent); - } - "measure" => { - pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink); - for i in 0..5 { - let t = 4.0 + i as f32 * 2.6; - pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink); - } - } - "line" => { - pen.line(&[(4.0, 19.0), (20.0, 5.0)], a); - pen.dot(4.0, 19.0, ink); - pen.dot(20.0, 5.0, ink); - } - "rect" | "rect3" | "rect_center" => { - pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a); - match name { - "rect" => { - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - "rect3" => { - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - _ => { - pen.dot(12.0, 12.0, ink); - pen.dot(20.0, 6.0, ink); - } - } - } - "rect_rounded" => { - // Straight sides between quarter arcs of radius 3 at the corners. - pen.line(&[(7.0, 6.0), (17.0, 6.0)], a); - pen.line(&[(20.0, 9.0), (20.0, 15.0)], a); - pen.line(&[(17.0, 18.0), (7.0, 18.0)], a); - pen.line(&[(4.0, 15.0), (4.0, 9.0)], a); - let q = std::f32::consts::FRAC_PI_2; - pen.arc(17.0, 9.0, 3.0, 0.0, q, a); - pen.arc(7.0, 9.0, 3.0, q, 2.0 * q, a); - pen.arc(7.0, 15.0, 3.0, 2.0 * q, 3.0 * q, a); - pen.arc(17.0, 15.0, 3.0, 3.0 * q, 4.0 * q, a); - pen.dot(4.0, 18.0, ink); - pen.dot(20.0, 6.0, ink); - } - "circle" | "circle2" | "circle3" => { - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a); - match name { - "circle" => { - pen.dot(12.0, 12.0, ink); - pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink); - } - "circle2" => { - pen.dot(4.0, 12.0, ink); - pen.dot(20.0, 12.0, ink); - } - _ => { - pen.dot(4.0, 12.0, ink); - pen.dot(12.0, 4.0, ink); - pen.dot(20.0, 12.0, ink); - } - } - } - "arc3" | "arc_center" => { - pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a); - pen.dot(3.0, 16.0, ink); - pen.dot(21.0, 16.0, ink); - if name == "arc3" { - pen.dot(12.0, 7.0, ink); - } else { - pen.dot(12.0, 16.0, ink); - } - } - "polygon" => { - let pts: Vec<(f32, f32)> = - (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - pen.closed(&pts, a); - pen.dot(12.0, 12.0, ink); - } - "slot" => { - pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a); - pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a); - pen.line(&[(8.0, 7.0), (16.0, 7.0)], a); - pen.line(&[(8.0, 17.0), (16.0, 17.0)], a); - pen.dot(8.0, 12.0, ink); - pen.dot(16.0, 12.0, ink); - } - "point" => { - pen.dot(12.0, 12.0, a); - pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink); - } - "dimension" => { - pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink); - pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink); - pen.arrow((12.0, 11.0), (4.5, 11.0), a); - pen.arrow((12.0, 11.0), (19.5, 11.0), a); - pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink); - } - // The Sketch Palette's line type selector (normal, construction, centerline) paints all - // three on the same diagonal, from (3, 19) to (19, 3). Only the dash pattern changes - // between them. Normal draws in `ink` and ignores `accent`. It is a plain line. It is - // never construction geometry. Its colour must stay fixed even if a caller passes the - // construction orange for it. Construction and centerline draw only in `accent` and - // ignore `ink`. The caller decides the colour. The palette and the toolbar both pass - // the theme's construction orange. - // - // These three are drawn small. The Sketch Palette draws them down to about 18 px - // square. At that size a repeating dash pattern loses its gaps. It reads as a solid - // line. Two patterns that only differ in dash phase read as the same texture too. - // Normal, Construction and Centerline instead get three different silhouettes. One is - // an unbroken line. One is a line broken by a single gap. One is a line broken by a - // single gap with a bold dot sitting in it. A viewer can name the shape on sight. No - // rhythm needs measuring. The stroke width and the dot radius both come from the rect - // actually passed in. That keeps the shapes legible in the palette, the toolbar and the - // command palette, at every size any of them uses. - "normal" => { - let w = line_type_stroke(r); - p.add(Shape::line(vec![pen.at(3.0, 19.0), pen.at(19.0, 3.0)], Stroke::new(w, ink))); - } - "construction" => { - // One gap, no dot. It is an unmistakably broken line. Nothing sits in the break. - let w = line_type_stroke(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.35), (0.65, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - } - "centerline" => { - // The same gap as Construction, with a bold dot centred in it. The silhouette is - // line, dot, line. The dot's radius comes from the rect. It reads as a distinct - // round mark on its own, never as a thicker stretch of the stroke. The dashes stop - // well short of it on both sides. The dot never touches either stroke. It always - // reads as a separate shape. - let w = line_type_stroke(r); - let dot_r = line_type_dot_radius(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.26), (0.74, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - p.circle_filled(at(0.5), dot_r, a); - } - "infinite" => { - // An infinite line (#1359): Construction's broken line, with an open arrowhead at - // each end saying it runs on. Accent only, like Construction and Centerline. - let w = line_type_stroke(r); - let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t); - for (t0, t1) in [(0.0, 0.38), (0.62, 1.0)] { - p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a))); - } - // Each head: two short strokes back from the tip, either side of the line. - let tip_len = 0.3; - for (tip, back) in [(0.0f32, tip_len), (1.0, 1.0 - tip_len)] { - let (t, b) = (at(tip), at(back)); - let along = b - t; - let side = egui::vec2(-along.y, along.x) * 0.55; - p.add(Shape::line(vec![b + side, t, b - side], Stroke::new(w, a))); - } - } - "c_hv" => { - pen.line(&[(4.0, 18.0), (20.0, 18.0)], a); - pen.line(&[(6.0, 4.0), (6.0, 14.0)], a); - } - "c_coincident" => { - pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink); - pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink); - pen.circle(12.0, 12.0, 2.6, ic.highlight, a); - } - "c_tangent" => { - pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink); - pen.line(&[(3.0, 8.0), (21.0, 8.0)], a); - } - "c_equal" => { - pen.line(&[(5.0, 9.0), (19.0, 9.0)], a); - pen.line(&[(5.0, 15.0), (19.0, 15.0)], a); - } - "c_parallel" => { - pen.line(&[(5.0, 19.0), (13.0, 5.0)], a); - pen.line(&[(11.0, 19.0), (19.0, 5.0)], a); - } - "c_perpendicular" => { - pen.line(&[(4.0, 19.0), (20.0, 19.0)], a); - pen.line(&[(12.0, 19.0), (12.0, 5.0)], a); - pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink); - } - "c_fix" => { - pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a); - pen.line(&[(12.0, 13.0), (12.0, 21.0)], a); - pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink); - } - "c_midpoint" => { - pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink); - pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a); - } - "c_concentric" => { - pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a); - pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a); - } - "c_collinear" => { - pen.line(&[(3.0, 19.0), (10.0, 12.0)], a); - pen.line(&[(14.0, 8.0), (21.0, 1.0)], a); - } - "c_symmetry" => { - pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); - pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a); - pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a); - } - "undo" | "redo" => { - let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) }; - pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink); - pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink); - } - "new" => { - pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink); - pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink); - } - "open" => { - pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink); - } - "save" => { - pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink); - pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink); - } - "export" => { - pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink); - pen.arrow((10.0, 14.0), (20.0, 4.0), a); - } - "import" => { - pen.iso_box(4.0, 12.0, 12.0, 7.0, 7.0); - pen.arrow((20.0, 3.0), (12.0, 11.0), a); - } - "home" => { - pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink); - pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink); - } - "fit" => { - for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] { - pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink); - } - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink); - } - "orbit" => { - pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink); - pen.arrow((17.0, 17.5), (19.6, 14.5), a); - pen.dot(12.0, 12.0, ink); - } - "spin" => { - // A turntable: an ellipse with an arrow running round it, a post in the middle. - let pts: Vec = (0..=28).map(|i| 0.2 + i as f32 / 28.0 * 5.6).map(|t| pen.at(12.0 + 9.0 * t.cos(), 16.0 + 3.5 * t.sin())).collect(); - p.add(Shape::line(pts, Stroke::new(pen.w, ink))); - pen.arrow((17.0, 19.0), (20.5, 17.0), a); - pen.line(&[(12.0, 15.5), (12.0, 4.0)], ink); - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 4.0), (9.0, 4.0)], Color32::TRANSPARENT, ink); - } - "pan" => { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { - pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink); - } - } - "zoom" => { - pen.circle(10.0, 10.0, 6.0, ic.highlight, ink); - pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink); - } - "perspective" => { - pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink); - pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink); - } - "eye" => { - pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink); - pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink); - pen.circle(12.0, 12.0, 2.6, ink, ink); - } - "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0), - // Component: a box with a smaller one stacked on it. - "component" => { - pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0); - pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0); - } - "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink), - "origin" => { - pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]); - pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]); - pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]); - } - "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink), - "axis" => { - pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink); - pen.circle(4.0, 20.0, 2.0, ink, ink); - } - "settings" => { - // A gear: a solid ring with six chunky, square-ish teeth and a see-through centre - // hole (#290; thin rays read as a sun). - let (teeth, root, tip, hole) = (6usize, 7.0f32, 10.5f32, 3.2f32); - let pitch = std::f32::consts::TAU / teeth as f32; - // Half-widths (radians) of a tooth at its root and at its tip: nearly parallel sides. - let (half_root, half_tip) = (pitch * 0.3, pitch * 0.22); - let polar = |a: f32, r: f32| (12.0 + r * a.cos(), 12.0 + r * a.sin()); - let start = -std::f32::consts::FRAC_PI_2; - let mut outline = Vec::new(); - for i in 0..teeth { - let c = start + i as f32 * pitch; - // The tooth, filled; it reaches into the ring so no seam shows. - let quad = [polar(c - half_root, root - 1.0), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root - 1.0)]; - let v: Vec = quad.iter().map(|(x, y)| pen.at(*x, *y)).collect(); - p.add(Shape::convex_polygon(v, pen.fill, Stroke::NONE)); - outline.extend([polar(c - half_root, root), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root)]); - // The root circle up to the next tooth. - for k in 1..4 { - let a = c + half_root + (pitch - 2.0 * half_root) * k as f32 / 4.0; - outline.push(polar(a, root)); - } - } - // The ring: a thick stroke, so the hole stays transparent on any background. - p.circle_stroke(pen.at(12.0, 12.0), (root + hole) * 0.5 * pen.s(), Stroke::new((root - hole) * pen.s(), pen.fill)); - pen.closed(&outline, ink); - pen.circle(12.0, 12.0, hole, Color32::TRANSPARENT, ink); - } - // Sketch tools (sketch-style: thin outlines, accent for what the tool makes). - "canvas" | "decal" => { - if name == "canvas" { - pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 19.0), (3.0, 19.0)], pen.fill, ink); - } else { - pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 13.0), (15.0, 19.0), (3.0, 19.0)], pen.fill, ink); - pen.line(&[(21.0, 13.0), (15.0, 13.0), (15.0, 19.0)], ink); - } - pen.line(&[(5.0, 17.0), (9.5, 10.5), (12.5, 14.5), (14.5, 12.0), (18.0, 16.5)], a); - pen.circle(16.5, 8.5, 1.6, a, a); - } - "dxf" | "svg" => { - pen.poly(&[(5.0, 2.5), (15.0, 2.5), (19.5, 7.0), (19.5, 21.5), (5.0, 21.5)], pen.fill, ink); - pen.line(&[(15.0, 2.5), (15.0, 7.0), (19.5, 7.0)], ink); - let label = if name == "dxf" { "DXF" } else { "SVG" }; - p.text(pen.at(12.2, 15.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(6.4 * pen.s()), a); - } - "c_smooth" => { - let pts: Vec<(f32, f32)> = - (0..=24).map(|i| i as f32 / 24.0).map(|t| (3.0 + 18.0 * t, 12.0 - 7.0 * (t * std::f32::consts::TAU).sin())).collect(); - let (l, r) = pts.split_at(13); - pen.line(l, ink); - let mut r2 = vec![pts[12]]; - r2.extend_from_slice(r); - pen.line(&r2, a); - pen.dot(12.0, 12.0, ink); - } - "c_polygon" => { - let pts: Vec<(f32, f32)> = - (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU).map(|t| (12.0 + 8.5 * t.cos(), 12.0 + 8.5 * t.sin())).collect(); - pen.closed(&pts, a); - pen.line(&[(12.0, 12.0), (20.5, 12.0)], ink); - pen.dot(12.0, 12.0, ink); - } - "auto_constrain" | "constrainer" => { - pen.closed(&[(3.0, 8.0), (15.0, 8.0), (15.0, 20.0), (3.0, 20.0)], ink); - if name == "auto_constrain" { - for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] { - let k = if dx * dy == 0.0 { 4.0 } else { 2.5 }; - pen.line(&[(18.0, 6.0), (18.0 + k * dx, 6.0 + k * dy)], a); - } - } else { - pen.line(&[(13.0, 6.0), (16.0, 9.0), (21.5, 2.5)], a); - } - pen.line(&[(6.0, 17.0), (12.0, 17.0)], a); - pen.line(&[(6.0, 11.0), (6.0, 14.0)], a); - } - "project" | "project_surface" => { - if name == "project" { - pen.poly(&[(2.0, 18.0), (12.0, 14.0), (22.0, 18.0), (12.0, 22.0)], pen.fill, ink); - pen.closed(&[(6.0, 18.0), (12.0, 15.6), (18.0, 18.0), (12.0, 20.4)], a); - } else { - pen.line(&[(2.0, 19.0), (7.0, 16.0), (12.0, 17.5), (17.0, 15.5), (22.0, 18.0)], ink); - pen.line(&[(6.0, 19.5), (12.0, 20.5), (18.0, 18.5)], a); - } - pen.iso_box(7.0, 5.0, 5.0, 5.0, 4.0); - pen.arrow((12.0, 10.5), (12.0, 15.0), a); - } - "intersect" | "intersection_curve" => { - pen.iso_box(6.0, 6.0, 7.0, 5.0, 11.0); - if name == "intersect" { - pen.line(&[(2.0, 13.0), (14.0, 8.0), (22.0, 12.0)], ink.gamma_multiply(0.6)); - pen.line(&[(6.0, 13.0), (11.0, 15.5), (18.0, 12.0)], a); - } else { - pen.arc(12.0, 26.0, 13.0, 0.6, 2.55, ink.gamma_multiply(0.6)); - pen.line(&[(6.0, 14.5), (11.0, 16.5), (18.0, 13.5)], a); - } - } - "include" => { - pen.iso_box(4.0, 9.0, 10.0, 7.0, 9.0); - pen.line(&[(4.0, 9.0), (11.0, 12.5), (21.0, 7.5)], a); - pen.dot(11.0, 12.5, a); - } - "spun" => { - pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink); - pen.line(&[(14.0, 5.0), (18.0, 9.0), (18.0, 15.0), (14.0, 19.0)], ink); - pen.line(&[(10.0, 5.0), (6.0, 9.0), (6.0, 15.0), (10.0, 19.0)], a); - pen.arc(12.0, 12.0, 6.0, 0.3, 2.8, ink.gamma_multiply(0.6)); - } - "iso_curve" => { - pen.poly(&[(3.0, 16.0), (10.0, 6.0), (21.0, 8.0), (14.0, 19.0)], pen.fill, ink); - pen.line(&[(6.5, 11.0), (17.5, 13.5)], ink.gamma_multiply(0.5)); - pen.line(&[(7.7, 17.3), (14.8, 6.8)], a); - } - "fit_section" => { - pen.closed(&[(3.0, 18.0), (8.0, 6.0), (14.0, 15.0), (19.0, 5.0), (21.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(8.0, 6.0), (3.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(14.0, 15.0), (3.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(14.0, 15.0), (21.0, 18.0)], ink.gamma_multiply(0.6)); - pen.line(&[(2.0, 12.0), (7.0, 10.5), (12.0, 12.5), (17.0, 10.0), (22.0, 11.5)], a); - } - "sketch_mirror" => { - p.add(Shape::dashed_line(&[pen.at(12.0, 2.0), pen.at(12.0, 22.0)], Stroke::new(pen.w, ink), 2.5 * pen.s(), 1.8 * pen.s())); - pen.closed(&[(3.0, 6.0), (9.0, 10.0), (9.0, 18.0), (3.0, 18.0)], ink); - pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a); - } - "sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a), - "pattern_flip" => { - // Opposing arrows show reversal, with a short reference tick between them. - pen.arrow((4.0, 7.0), (20.0, 7.0), a); - pen.arrow((20.0, 17.0), (4.0, 17.0), ink); - pen.line(&[(12.0, 10.0), (12.0, 14.0)], ink); - } - "pattern_center" => { - // An original on a reference axis, with one equally spaced copy on either side. - pen.line(&[(12.0, 3.0), (12.0, 21.0)], a); - pen.line(&[(4.0, 12.0), (20.0, 12.0)], ink); - for x in [4.0, 12.0, 20.0] { - pen.circle(x, 12.0, 2.4, fill, if x == 12.0 { a } else { ink }); - } - } - "sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a), - "line_mid" => { - pen.line(&[(3.0, 19.0), (21.0, 5.0)], a); - pen.dot(12.0, 12.0, ink); - pen.dot(21.0, 5.0, ink); - pen.circle(12.0, 12.0, 3.0, Color32::TRANSPARENT, ink); - } - "arc_tangent" => { - pen.line(&[(2.0, 18.0), (11.0, 18.0)], ink); - pen.arc(11.0, 10.0, 8.0, -std::f32::consts::FRAC_PI_2, 0.6, a); - pen.dot(11.0, 18.0, ink); - } - "circle_tt" | "circle_ttt" => { - if name == "circle_ttt" { - pen.closed(&[(3.0, 20.0), (21.0, 20.0), (12.0, 4.0)], ink); - } else { - pen.line(&[(21.0, 20.0), (3.0, 20.0), (12.0, 4.0)], ink); - } - pen.circle(12.0, 14.74, 5.26, Color32::TRANSPARENT, a); - } - "arc_slot" => { - let (t0, t1) = (0.75_f32, 2.4_f32); - pen.arc(12.0, 21.0, 13.0, t0, t1, a); - pen.arc(12.0, 21.0, 7.0, t0, t1, a); - pen.arc(12.0 + 10.0 * t0.cos(), 21.0 - 10.0 * t0.sin(), 3.0, t0, t0 - std::f32::consts::PI, a); - pen.arc(12.0 + 10.0 * t1.cos(), 21.0 - 10.0 * t1.sin(), 3.0, t1, t1 + std::f32::consts::PI, a); - pen.dot(12.0, 21.0, ink); - } - "ellipse" => { - let pts: Vec<(f32, f32)> = - (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| (12.0 + 9.5 * t.cos(), 12.0 + 5.5 * t.sin())).collect(); - pen.closed(&pts, a); - pen.dot(12.0, 12.0, ink); - pen.line(&[(12.0, 12.0), (21.5, 12.0)], ink); - } - "spline" | "spline_cv" => { - let c = [(3.0_f32, 18.0_f32), (8.0, 3.0), (16.0, 21.0), (21.0, 6.0)]; - let pts: Vec<(f32, f32)> = (0..=24) - .map(|i| i as f32 / 24.0) - .map(|t| { - let u = 1.0 - t; - let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; - (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum()) - }) - .collect(); - if name == "spline_cv" { - pen.line(&c, ink.gamma_multiply(0.6)); - for q in c { - pen.dot(q.0, q.1, ink); - } - } else { - for i in [0, 8, 16, 24] { - if let Some(q) = pts.get(i) { - pen.dot(q.0, q.1, ink); - } - } - } - pen.line(&pts, a); - } - "conic" => { - pen.line(&[(3.0, 20.0), (12.0, 3.0), (21.0, 20.0)], ink.gamma_multiply(0.6)); - let pts: Vec<(f32, f32)> = - (0..=20).map(|i| i as f32 / 20.0).map(|t| (3.0 + 18.0 * t, 20.0 - 4.0 * 0.78 * 17.0 * t * (1.0 - t))).collect(); - pen.line(&pts, a); - pen.dot(3.0, 20.0, ink); - pen.dot(21.0, 20.0, ink); - } - "text" => { - p.text(pen.at(12.0, 11.5), egui::Align2::CENTER_CENTER, "A", egui::FontId::proportional(17.0 * pen.s()), a); - pen.line(&[(4.0, 20.5), (20.0, 20.5)], ink); - } - "curvature_comb" => { - let f = |x: f32| 16.0 - 6.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin(); - let mut env = Vec::new(); - for i in 0..=8 { - let x = 3.0 + 18.0 * i as f32 / 8.0; - let k = 1.0 + 5.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin(); - pen.line(&[(x, f(x)), (x, f(x) - k)], a.gamma_multiply(0.7)); - env.push((x, f(x) - k)); - } - pen.line(&env, a); - let pts: Vec<(f32, f32)> = (0..=24).map(|i| 3.0 + 18.0 * i as f32 / 24.0).map(|x| (x, f(x))).collect(); - pen.line(&pts, ink); - } - "min_radius" => { - pen.line(&[(3.0, 20.0), (7.0, 9.0), (12.0, 5.5), (17.0, 9.0), (21.0, 20.0)], ink); - pen.circle(12.0, 10.5, 5.0, Color32::TRANSPARENT, a); - pen.arrow((12.0, 10.5), (15.5, 7.0), a); - pen.dot(12.0, 10.5, ink); - } - "iso_analysis" => { - pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (15.0, 19.0)], pen.fill, ink); - for k in 1..4 { - let t = k as f32 / 4.0; - pen.line(&[(3.0 + 6.0 * t, 17.0 - 12.0 * t), (15.0 + 6.0 * t, 19.0 - 12.0 * t)], a); - pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a); - } - } - "accessibility" => { - pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink); - pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT); - pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink); - pen.arrow((4.0, 2.0), (4.0, 13.0), a); - pen.arrow((20.0, 2.0), (20.0, 13.0), a); - } - "environment_map" => { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink))); - pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT); - pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight); - p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight); - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - } - "zebra" | "draft_analysis" | "curvature_map" => { - let pts: Vec = - (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect(); - match name { - "zebra" => { - p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink))); - for k in 0..3 { - let y = 6.5 + k as f32 * 4.5; - let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt(); - pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT); - } - pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink); - } - "draft_analysis" => { - pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink); - pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT); - pen.arrow((12.0, 16.0), (12.0, 1.5), a); - } - _ => { - p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink))); - pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT); - pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT); - } - } - } - "center_of_mass" => { - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); - let q = |a0: f32| { - let pts: Vec = std::iter::once(pen.at(12.0, 12.0)) - .chain( - (0..=8) - .map(|i| a0 + i as f32 / 8.0 * std::f32::consts::FRAC_PI_2) - .map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 - 8.0 * t.sin())), - ) - .collect(); - p.add(Shape::convex_polygon(pts, a, Stroke::NONE)); - }; - q(0.0); - q(std::f32::consts::PI); - pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink); - } - "sketch_fillet" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.arc(12.0, 12.0, 8.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 12.0), (12.0, 4.0)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - // The three chamfer types, for the Chamfer Type row of the corner tool window. Each - // draws the cut it makes: equal legs with a tick on each, two unequal legs, and one leg - // with the angle the cut leaves it at. - "sketch_chamfer_equal" => { - pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink); - pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 12.0), (12.0, 4.0)], a); - pen.line(&[(2.5, 9.5), (5.5, 6.5)], a); - pen.line(&[(9.5, 2.5), (6.5, 5.5)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer_two" => { - pen.line(&[(4.0, 21.0), (4.0, 10.0)], ink); - pen.line(&[(18.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 10.0), (18.0, 4.0)], a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 10.0), pen.at(4.0, 4.0), pen.at(18.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "sketch_chamfer_angle" => { - pen.line(&[(4.0, 21.0), (4.0, 13.0)], ink); - pen.line(&[(14.0, 4.0), (21.0, 4.0)], ink); - pen.line(&[(4.0, 13.0), (14.0, 4.0)], a); - pen.arc(4.0, 13.0, 6.0, -std::f32::consts::FRAC_PI_2, 0.73, a); - p.add(Shape::dashed_line( - &[pen.at(4.0, 13.0), pen.at(4.0, 4.0), pen.at(14.0, 4.0)], - Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)), - 2.0 * pen.s(), - 1.5 * pen.s(), - )); - } - "offset" => { - pen.closed(&[(8.0, 9.0), (16.0, 9.0), (16.0, 15.0), (8.0, 15.0)], ink); - pen.line(&[(8.0, 4.0), (16.0, 4.0)], a); - pen.line(&[(21.0, 9.0), (21.0, 15.0)], a); - pen.line(&[(16.0, 20.0), (8.0, 20.0)], a); - pen.line(&[(3.0, 15.0), (3.0, 9.0)], a); - pen.arc(16.0, 9.0, 5.0, 0.0, std::f32::consts::FRAC_PI_2, a); - pen.arc(16.0, 15.0, 5.0, -std::f32::consts::FRAC_PI_2, 0.0, a); - pen.arc(8.0, 15.0, 5.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); - pen.arc(8.0, 9.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a); - } - // Offset mode icons, drawn with the house `dashed` helper. The source is the solid - // line and each result a dashed one. One Side has a single result; Two Sides has one - // each way at two different gaps; Symmetric has one each way at the same gap. - "offset_one_side" => { - pen.line(&[(8.0, 4.0), (8.0, 20.0)], ink); - pen.dashed(&[(18.0, 4.0), (18.0, 20.0)], a); - } - "offset_two_sides" => { - pen.line(&[(13.0, 4.0), (13.0, 20.0)], ink); - pen.dashed(&[(3.0, 4.0), (3.0, 20.0)], a); - pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); - } - "offset_symmetric" => { - pen.line(&[(12.0, 4.0), (12.0, 20.0)], ink); - pen.dashed(&[(4.0, 4.0), (4.0, 20.0)], a); - pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); - } - "trim" => { - pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); - pen.line(&[(3.0, 12.0), (12.0, 12.0)], ink); - p.add(Shape::dashed_line(&[pen.at(12.0, 12.0), pen.at(21.0, 12.0)], Stroke::new(pen.w, a), 2.0 * pen.s(), 1.6 * pen.s())); - pen.line(&[(15.0, 15.0), (19.0, 19.0)], a); - pen.line(&[(19.0, 15.0), (15.0, 19.0)], a); - } - "patch" => { - // A wire loop with a surface spanned across it. - pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink); - pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a); - } - "stitch" => { - // Two surface patches sewn along their shared edge. - pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink); - pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink); - for y in [6.0, 9.0, 12.0] { - pen.line(&[(10.0, y), (14.0, y + 1.5)], a); - } - } - "thicken" => { - // A wavy sheet (accent edges) grown downward into a slab, shaded like `iso_box`. - let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)]; - let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)]; - let d = f.map(|(x, y)| (x, y + 6.0)); - pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet); - pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85)); - pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink); - pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink); - pen.line(&[(17.0, 12.5), (17.0, 18.5)], ink); - pen.line(&[(21.4, 8.9), (21.4, 14.9)], ink); - pen.curve(d, ink); - pen.line(&[(6.4, 8.9), (2.0, 12.5)], ink); - pen.line(&[(21.4, 8.9), (17.0, 12.5)], a); - pen.curve(b, a); - pen.curve(f, a); - } - "extend" => { - pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink); - pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink); - pen.dot(10.0, 12.0, ink); - pen.arrow((10.0, 12.0), (19.0, 12.0), a); - } - "break" => { - pen.line(&[(3.0, 17.0), (10.5, 11.0)], ink); - pen.line(&[(13.5, 10.0), (21.0, 5.0)], a); - pen.dot(12.0, 10.5, a); - pen.line(&[(9.0, 5.0), (15.0, 16.0)], ink.gamma_multiply(0.5)); - } - "scale" => { - pen.closed(&[(3.0, 13.0), (11.0, 13.0), (11.0, 21.0), (3.0, 21.0)], ink); - pen.closed(&[(3.0, 3.0), (21.0, 3.0), (21.0, 21.0), (3.0, 21.0)], a); - pen.dot(3.0, 21.0, ink); - pen.arrow((11.0, 13.0), (18.0, 6.0), a); - } - "blend" => { - pen.line(&[(2.0, 19.0), (8.0, 15.0)], ink); - pen.line(&[(16.0, 9.0), (22.0, 5.0)], ink); - pen.curve([(8.0, 15.0), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)], a); - pen.dot(8.0, 15.0, ink); - pen.dot(16.0, 9.0, ink); - } - // Surface modelling: open sheets (no caps) in the sheet colours, the input or the result - // in the accent. - "surface_extrude" => { - // An open profile (accent) pulled up into a sheet. - let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)]; - let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)]; - pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet); - pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink); - pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink); - pen.curve(top, ink); - pen.curve(bottom, a); - pen.arrow((12.0, 8.5), (12.0, 2.5), a); - } - "surface_revolve" => { - // An open profile (accent) turned about the axis into a hollow band. - let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)]; - let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)]; - pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet); - let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16); - pen.poly(&front, ic.sheet, Color32::TRANSPARENT); - // A closed fill takes each point once: a repeated end point spikes `poly`'s edge. - let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32); - opening.pop(); - pen.poly(&opening, ic.sheet_light, ink); - pen.line(&front, ink); - pen.curve(left, ink); - pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink); - pen.line(&[(12.0, 20.2), (12.0, 22.5)], ink); - pen.curve(right, a); - } - "surface_sweep" => { - // A straight profile (accent) carried along a curved path: a ribbon. - let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)]; - let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)]; - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet); - pen.curve(near, ink); - pen.curve(far, ink); - pen.line(&[(4.0, 13.0), (4.0, 20.0)], a); - pen.line(&[(20.0, 9.0), (20.0, 16.0)], ink); - } - "surface_loft" => { - // Two open sections (accent) joined by a sheet. - let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)]; - let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)]; - pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet); - pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink); - pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink); - pen.curve(top, a); - pen.curve(bottom, a); - } - "surface_offset" => { - // A wavy sheet and its offset copy (dashed), the distance shown by the arrow. - let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)]; - let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)]; - pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet); - pen.curve(back, ink); - pen.curve(front, ink); - pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink); - pen.line(&[(21.0, 14.0), (18.0, 20.0)], ink); - pen.dashed(&bezier(back.map(|(x, y)| (x, y - 9.0)), 16), a); - pen.dashed(&bezier(front.map(|(x, y)| (x, y - 9.0)), 16), a); - pen.dashed(&[(6.0, 5.0), (3.0, 11.0)], a); - pen.dashed(&[(21.0, 5.0), (18.0, 11.0)], a); - pen.arrow((12.0, 17.0), (12.0, 8.5), a); - } - "ruled" => { - // A strip hanging off a block's edge (accent), ruled by straight lines. - pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink); - pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink); - pen.line(&[(15.0, 9.3), (15.0, 19.3)], a); - pen.line(&[(18.0, 6.7), (18.0, 16.7)], a); - pen.line(&[(12.0, 12.0), (12.0, 22.0)], a); - } - "boundary" => { - // Four boundary curves (accent) meeting at corners, the patch between them a Coons - // patch with its iso lines. - let (c00, c10, c11, c01) = ((4.0, 17.0), (10.0, 5.0), (20.0, 7.0), (17.0, 20.0)); - let left = [c00, (5.0, 12.0), (8.0, 8.0), c10]; - let top = [c10, (13.0, 4.0), (17.0, 4.0), c11]; - let right = [c01, (19.0, 16.0), (20.0, 11.0), c11]; - let bottom = [c00, (8.0, 20.0), (13.0, 21.0), c01]; - let n = 12; - let lerp = |p: (f32, f32), q: (f32, f32), s: f32| (p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s); - let (b, t) = (bezier(bottom, n), bezier(top, n)); - // Each row runs across the patch (u) at one height (v) up the left and right sides. - let rows: Vec> = bezier(left, n) - .into_iter() - .zip(bezier(right, n)) - .enumerate() - .map(|(j, (lv, rv))| { - let v = j as f32 / n as f32; - b.iter() - .zip(&t) - .enumerate() - .map(|(i, (bu, tu))| { - let u = i as f32 / n as f32; - let corners = lerp(lerp(c00, c01, u), lerp(c10, c11, u), v); - let (ruled_v, ruled_u) = (lerp(*bu, *tu, v), lerp(lv, rv, u)); - (ruled_v.0 + ruled_u.0 - corners.0, ruled_v.1 + ruled_u.1 - corners.1) - }) - .collect() - }) - .collect(); - for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) { - pen.band(lo, hi, ic.sheet); - } - for j in [n / 3, 2 * n / 3] { - if let Some(r) = rows.get(j) { - pen.line(r, ic.sheet_line); - } - pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::>(), ic.sheet_line); - } - for c in [left, top, right, bottom] { - pen.curve(c, a); - } - for (x, y) in [c00, c10, c11, c01] { - pen.dot(x, y, ink); - } - } - "surface_blend" => { - // Two separate sheets at different heights bridged by a smooth strip (accent) that - // leaves both edges tangent. - let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)]; - let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); - pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink); - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light); - pen.curve(far, a); - pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink); - pen.curve(near, a); - } - "untrim" => { - // A trimmed sheet whose hole (dashed, accent) is filled back in. - pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink); - let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32); - pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT); - pen.dashed(&hole, a); - } - "unstitch" => { - // Two sheets pulled apart from their shared edge. - pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink); - pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink); - pen.arrow((10.8, 11.0), (7.0, 11.0), a); - pen.arrow((13.2, 11.0), (17.0, 11.0), a); - } - "reverse_normal" => { - // A sheet with its old normal (faded) and the flipped one (accent). - pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink); - pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5)); - pen.arrow((12.0, 14.0), (12.0, 22.5), a); - } - "surface_fillet" => { - // A wall sheet and a floor sheet joined by a round strip (accent edges). - let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)]; - let far = near.map(|(x, y)| (x + 4.4, y - 3.6)); - pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink); - pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink); - pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light); - pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line); - pen.curve(far, a); - pen.curve(near, a); - } - "continuity" => { - // A straight curve joining a curved one (accent) at a point, with the curved one's - // curvature comb: teeth that grow as the curve tightens. - pen.line(&[(2.0, 17.0), (12.0, 13.0)], ink); - let c = [(12.0, 13.0), (16.5, 11.2), (20.0, 8.0), (21.0, 3.0)]; - let pts = bezier(c, 10); - let mut tips = Vec::new(); - for (i, ((x, y), (x1, y1))) in pts.iter().zip(pts.iter().skip(1)).enumerate() { - let len = (x1 - x).hypot(y1 - y).max(1e-6); - let h = 0.8 + 4.2 * i as f32 / 10.0; - let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h); - if i % 2 == 0 { - pen.line(&[(*x, *y), tip], ic.sheet_line); - } - tips.push(tip); - } - pen.line(&tips, ic.sheet_line); - pen.curve(c, a); - pen.dot(12.0, 13.0, a); - } - // Assembly: joints (accent: the joint itself). - "joint" => { - pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0); - pen.iso_box(12.0, 4.0, 7.0, 5.0, 5.0); - pen.line(&[(9.0, 15.0), (15.0, 9.0)], a); - pen.circle(12.0, 12.0, 2.6, pen.fill, a); - } - "as_built" => { - pen.iso_box(1.0, 10.0, 8.0, 5.0, 8.0); - pen.iso_box(10.0, 10.0, 8.0, 5.0, 8.0); - pen.circle(13.0, 15.0, 2.6, pen.fill, a); - } - "joint_origin" => { - pen.arrow((8.0, 17.0), (20.0, 17.0), ink); - pen.arrow((8.0, 17.0), (14.0, 11.0), ink); - pen.arrow((8.0, 17.0), (8.0, 3.0), a); - pen.circle(8.0, 17.0, 2.4, pen.fill, a); - } - "rigid_group" => { - pen.iso_box(3.0, 13.0, 5.0, 3.0, 4.0); - pen.iso_box(13.0, 13.0, 5.0, 3.0, 4.0); - pen.iso_box(8.0, 4.0, 5.0, 3.0, 4.0); - pen.closed(&[(2.0, 3.0), (22.0, 3.0), (22.0, 21.0), (2.0, 21.0)], a); - } - "drive" => { - pen.circle(12.0, 12.0, 4.0, pen.fill, ink); - pen.dot(12.0, 12.0, ink); - pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a); - pen.arrow((11.0, 20.4), (14.5, 20.3), a); - } - // A crank on its pivot and the closed path its far end draws (accent). - "trace" => { - pen.curve([(15.0, 13.0), (22.0, 13.0), (23.0, 3.0), (15.0, 3.0)], a); - pen.curve([(15.0, 3.0), (8.0, 3.0), (9.0, 13.0), (15.0, 13.0)], a); - pen.line(&[(5.0, 20.0), (15.0, 13.0)], ink); - pen.circle(5.0, 20.0, 2.4, pen.fill, ink); - pen.dot(15.0, 13.0, a); - } - "motion_link" => { - pen.circle(7.0, 14.0, 4.5, pen.fill, ink); - pen.circle(17.5, 9.0, 3.0, pen.fill, ink); - pen.line(&[(7.0, 14.0), (17.5, 9.0)], a); - pen.dot(7.0, 14.0, a); - pen.dot(17.5, 9.0, a); - } - // Couplings: the driven part in the accent. - "gear" => { - pen.circle(9.0, 14.0, 5.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(9.0, 14.0, 5.2, 6.8, 9, 0.0), ink); - pen.dot(9.0, 14.0, ink); - pen.circle(17.6, 7.4, 3.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(17.6, 7.4, 3.2, 4.6, 6, 0.3), a); - pen.dot(17.6, 7.4, a); - } - "rack_pinion" => { - pen.circle(12.0, 7.4, 4.2, pen.fill, Color32::TRANSPARENT); - pen.closed(&teeth(12.0, 7.4, 4.2, 5.8, 8, 0.2), ink); - pen.dot(12.0, 7.4, ink); - let mut rack = vec![(2.0, 18.0)]; - for k in 0..5 { - let x = 2.5 + 4.0 * k as f32; - rack.extend([(x, 14.3), (x + 1.0, 12.8), (x + 2.2, 12.8), (x + 3.2, 14.3)]); - } - rack.extend([(22.0, 14.3), (22.0, 18.0)]); - pen.closed(&rack, a); - pen.arrow((7.0, 21.5), (17.5, 21.5), a); - } - "screw" => { - pen.poly(&[(9.5, 2.0), (14.5, 2.0), (14.5, 22.0), (9.5, 22.0)], pen.fill, ink); - for y in [5.0, 8.0, 17.0, 20.0] { - pen.line(&[(9.5, y), (14.5, y - 2.0)], ink); - } - pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a); - pen.arrow((21.0, 17.0), (21.0, 6.0), a); - } - "interference" => { - pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink); - pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference); - pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink); - } - // Sheet metal: thin plates (fill), the made or changed part in the accent. - "flange" => { - pen.poly(&[(2.0, 17.0), (12.0, 13.0), (18.0, 16.0), (8.0, 20.0)], pen.fill, ink); - pen.poly(&[(12.0, 13.0), (12.0, 5.0), (18.0, 8.0), (18.0, 16.0)], pen.fill.gamma_multiply(0.8), a); - pen.arrow((20.5, 15.0), (20.5, 4.0), a); - } - "hem" => { - pen.line(&[(2.0, 16.0), (16.0, 16.0)], ink); - pen.line(&[(2.0, 13.0), (16.0, 13.0)], ink); - pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a); - pen.line(&[(16.0, 7.0), (8.0, 7.0)], a); - } - // Fastener: a socket head screw seen from the side. - "fastener" => { - pen.poly(&[(6.0, 3.0), (18.0, 3.0), (18.0, 9.0), (6.0, 9.0)], pen.fill, ink); - pen.line(&[(10.0, 3.0), (10.0, 6.0), (14.0, 6.0), (14.0, 3.0)], a); - pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 21.0), (9.0, 21.0)], pen.fill.gamma_multiply(0.85), ink); - for y in [12.0, 15.0, 18.0] { - pen.line(&[(9.0, y), (15.0, y + 1.5)], ink); - } - } - "fold" => { - pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink); - pen.line(&[(12.0, 18.0), (19.0, 6.0)], a); - pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold); - pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a); - pen.arrow((18.0, 16.0), (17.0, 12.5), a); - } - "unfold" => { - pen.line(&[(3.0, 18.0), (12.0, 18.0), (12.0, 7.0)], ink); - pen.line(&[(12.0, 18.0), (22.0, 18.0)], a); - pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); - pen.arrow((17.5, 14.0), (19.0, 16.0), a); - } - "refold" => { - pen.line(&[(3.0, 18.0), (12.0, 18.0), (21.0, 18.0)], ink); - pen.line(&[(12.0, 18.0), (12.0, 6.0)], a); - pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a); - pen.arrow((15.0, 11.5), (13.5, 10.5), a); - } - "flat" => { - pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); - for x in [9.0, 15.0] { - for k in 0..3 { - let y = 7.5 + k as f32 * 4.0; - pen.line(&[(x, y), (x, y + 2.2)], a); - } - } - } - "convert_sheet" => { - pen.iso_box(2.0, 9.0, 6.0, 4.0, 6.0); - pen.arrow((11.0, 12.0), (15.0, 12.0), a); - pen.poly(&[(15.0, 15.0), (21.0, 12.0), (23.0, 13.0), (17.0, 16.0)], pen.fill, a); - } - "sheet_rules" => { - pen.poly(&[(2.0, 15.0), (11.0, 11.0), (17.0, 14.0), (8.0, 18.0)], pen.fill, ink); - for y in [5.0, 9.0, 13.0] { - pen.line(&[(14.0, y), (22.0, y)], a); - } - } - // Plastic: a wall or plate (fill) with the feature in the accent. - "boss" => { - pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); - pen.poly(&[(9.0, 7.0), (15.0, 7.0), (15.0, 16.0), (9.0, 16.0)], pen.fill.gamma_multiply(0.85), a); - pen.circle(12.0, 7.0, 3.0, pen.fill, a); - pen.circle(12.0, 7.0, 1.2, ink, ink); - } - "lip" => { - pen.poly(&[(4.0, 21.0), (4.0, 9.0), (10.0, 9.0), (10.0, 21.0)], pen.fill, ink); - pen.poly(&[(4.0, 9.0), (4.0, 4.0), (7.0, 4.0), (7.0, 9.0)], pen.fill, a); - pen.poly(&[(14.0, 21.0), (14.0, 6.0), (20.0, 6.0), (20.0, 21.0)], pen.fill, ink); - pen.closed(&[(17.0, 6.0), (17.0, 11.0), (20.0, 11.0), (20.0, 6.0)], a); - } - "snap" => { - pen.poly(&[(2.0, 20.0), (2.0, 16.0), (22.0, 16.0), (22.0, 20.0)], pen.fill, ink); - pen.poly(&[(9.0, 16.0), (9.0, 4.0), (12.0, 4.0), (12.0, 16.0)], pen.fill, a); - pen.poly(&[(12.0, 4.0), (16.0, 8.0), (12.0, 8.0)], a, a); - } - "rest" => { - pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink); - pen.poly(&[(7.0, 14.0), (13.0, 11.5), (17.0, 13.5), (11.0, 16.0)], a, a); - pen.line(&[(7.0, 14.0), (7.0, 11.5), (13.0, 9.0), (17.0, 11.0), (17.0, 13.5)], a); - } - "plastic_rule" => { - pen.circle(7.0, 8.0, 3.2, pen.fill, a); - pen.circle(11.0, 13.0, 3.2, pen.fill, a); - pen.circle(5.5, 16.0, 3.2, pen.fill, a); - for y in [6.0, 11.0, 16.0] { - pen.line(&[(15.0, y), (22.0, y)], ink); - } - } - "plastic_assign" => { - pen.iso_box(2.0, 12.0, 7.0, 5.0, 6.0); - pen.arrow((19.0, 6.0), (13.0, 11.0), a); - pen.circle(19.0, 6.0, 3.0, pen.fill, a); - } - // Appearance: a shaded ball with three paint dabs. - "appearance" => { - pen.circle(10.0, 13.0, 7.5, pen.fill, ink); - pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight); - pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink); - pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink); - pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink); - } - "warning" => { - pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink); - pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink); - pen.dot(12.0, 17.0, ink); - } - // A failed feature: a disc with a cross, a different shape from the warning's triangle, so - // it reads without colour (#972). Drawn in the caller's colours only. - "error" => { - pen.circle(12.0, 12.0, 10.0, pen.fill, ink); - pen.line(&[(8.0, 8.0), (16.0, 16.0)], ink); - pen.line(&[(16.0, 8.0), (8.0, 16.0)], ink); - } - _ => { - pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A debug dump of everything `paint` draws for `name`, for comparing two colour choices - /// without depending on epaint's internal shape fields. - fn drawn(name: &str, ink: Color32, fill: Color32, accent: Color32) -> String { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)); - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let out = ctx.run_ui(input, |ui| { - let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); - paint(ui.painter(), r, name, ink, fill, accent); - }); - format!("{:?}", out.shapes) - } - - /// Command icons not drawn yet (the fallback square) or drawn past their square, from before - /// this check. Remove a name when its icon is fixed; never add one. - const KNOWN_GAPS: &[&str] = - &["cylinder", "pipe", "offset_face", "thread", "boundary_fill", "coil", "rib", "emboss", "capture", "revert", "camera", "material"]; - - /// What is wrong with the icon `name`: not drawn (the fallback square), or drawn past its - /// square by more than a stroke width. - fn icon_problem(name: &str) -> Option { - if drawn(name, Color32::BLACK, Color32::WHITE, Color32::BLUE) == drawn("", Color32::BLACK, Color32::WHITE, Color32::BLUE) { - return Some("no icon drawn".into()); - } - let side = 48.0; - let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side)); - let ctx = egui::Context::default(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() }; - let out = ctx.run_ui(input, |ui| paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::BLUE)); - let used = out.shapes.iter().fold(Rect::NOTHING, |b, s| b.union(s.shape.visual_bounding_rect())); - (!r.expand(side / 24.0 * 1.4).contains_rect(used)).then(|| format!("drawn over {used:?}")) - } - - /// Every command's icon is drawn and stays inside its square. - #[test] - fn every_command_icon_is_drawn_inside_its_square() { - let bad: Vec = solidcraft_engine::command_specs() - .into_iter() - .filter(|c| !c.icon.is_empty() && !KNOWN_GAPS.contains(&c.icon)) - .filter_map(|c| icon_problem(c.icon).map(|why| format!("{} ({}): {why}", c.id, c.icon))) - .collect(); - assert!(bad.is_empty(), "{}", bad.join("\n")); - for gap in KNOWN_GAPS { - assert!(icon_problem(gap).is_some(), "`{gap}` is fixed now: take it off KNOWN_GAPS"); - } - } - - /// The Sketch Palette and the toolbar both rely on this contract. Construction and - /// centerline must track whatever accent colour the caller passes (both callers pass the - /// theme's construction orange) and must not change with `ink`. - #[test] - fn construction_and_centerline_icons_follow_the_callers_accent_only() { - for name in ["construction", "centerline", "infinite"] { - let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3)); - let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6)); - assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour"); - let other_ink = drawn(name, Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3)); - assert_eq!(base, other_ink, "{name} icon has no ink-coloured parts: ink alone must not change it"); - } - } - - /// Normal is a plain line type. It is never construction geometry. Its icon must never pick - /// up the accent colour. It can never render construction orange even if a caller passes it - /// by mistake. - #[test] - fn normal_icon_never_uses_the_accent_colour() { - let one_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3)); - let other_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6)); - assert_eq!(one_accent, other_accent, "the Normal icon must not change with the accent colour"); - let other_ink = drawn("normal", Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3)); - assert_ne!(one_accent, other_ink, "the Normal icon must change with ink"); - } - - /// The dashes (as their first and last point) and the dots (as centre and radius) `paint` - /// actually drew for `name`, at `side` pixels square: the Sketch Palette draws these three - /// icons down to about 18 px. - fn dashes_and_dots(name: &str, side: f32) -> (Vec<(Pos2, Pos2)>, Vec<(Pos2, f32)>) { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)); - let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; - let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side)); - let out = ctx.run_ui(input, |ui| { - paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(232, 140, 48)); - }); - let dashes = out - .shapes - .iter() - .filter_map(|s| match &s.shape { - Shape::Path(p) => Some((*p.points.first()?, *p.points.last()?)), - _ => None, - }) - .collect(); - let dots = out.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect(); - (dashes, dots) - } - - /// Pins the dash count and the gap. A future tidy-up might add dashes back in. It might - /// narrow the gap to look more faithful to the ISO convention. Either change would flatten - /// the icon back into a near-solid line at the size the Sketch Palette actually draws it, - /// about 18 px square. That is the exact defect this fix addressed. - #[test] - fn construction_and_centerline_read_as_two_dashes_with_a_gap_wider_than_the_stroke() { - for (name, want_dots) in [("construction", 0), ("centerline", 1)] { - for side in [18.0, 26.0] { - let (dashes, dots) = dashes_and_dots(name, side); - assert_eq!(dashes.len(), 2, "{name} at {side}px must be exactly two dashes"); - assert_eq!(dots.len(), want_dots, "{name} at {side}px has the wrong number of dots"); - let (_, gap_start) = dashes[0]; - let (gap_end, _) = dashes[1]; - let gap = gap_start.distance(gap_end); - let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side))); - assert!( - gap > stroke * 2.0, - "{name} at {side}px: the gap of {gap:.1}px must exceed the {stroke:.1}px stroke by a wide margin, or the dashes read as one solid line" - ); - } - } - } - - /// Pins the shape of the Centerline dot. It must be large enough to read as a round mark - /// beside the stroke. It must sit clear of both dashes. The silhouette must stay line, dot, - /// line. - #[test] - fn centerline_dot_is_a_bold_mark_clear_of_both_dashes() { - for side in [18.0, 26.0] { - let (dashes, dots) = dashes_and_dots("centerline", side); - let (center, radius) = dots[0]; - let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side))); - assert!(radius > stroke * 1.2, "centerline dot at {side}px: radius {radius:.1}px must exceed the {stroke:.1}px stroke by a wide margin"); - for (a, b) in &dashes { - assert!(center.distance(*a) > radius, "centerline dot at {side}px must not touch a dash end"); - assert!(center.distance(*b) > radius, "centerline dot at {side}px must not touch a dash end"); - } - } - } -} diff --git a/assets/locales/cs.json b/assets/locales/cs.json new file mode 100644 index 00000000..619f6194 --- /dev/null +++ b/assets/locales/cs.json @@ -0,0 +1,1480 @@ +{ + "0 turns it off": "0 vypíná", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Otevřete níže koncept na GitHubu a vložte hlášení zkopírované do schránky.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Otevřete níže koncept na GitHubu. Název a hlášení se vyplní automaticky.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Přiložte snímek obrazovky: zkopírujte a vložte jej, nebo uložte a nahrajte PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Hlášení odešlete na GitHubu tlačítkem Submit new issue.", + "A new configuration, a copy of the active one": "Nová konfigurace, kopie aktivní", + "AI": "AI", + "About SolidCraft": "O aplikaci SolidCraft", + "Active": "Aktivní", + "Active rule": "Aktivní pravidlo", + "Add": "Přidat", + "Add Key": "Přidat klíč", + "Add Mid Radius": "Přidat střední poloměr", + "Add Row": "Přidat řádek", + "Add condition": "Přidat podmínku", + "Alignment": "Zarovnání", + "All Sketch Points": "Všechny body náčrtu", + "All open designs": "Všechny otevřené návrhy", + "Allowed expressions, separated by semicolons": "Povolené výrazy oddělené středníkem", + "Also deletes": "Smaže také", + "Angle 2": "Úhel 2", + "Animate": "Animovat", + "Appearance name": "Název vzhledu", + "Applied Radius": "Použitý poloměr", + "Apply To": "Použít na", + "Apply to selection": "Použít na výběr", + "Arc length": "Délka oblouku", + "Area": "Plocha", + "Attachment": "Připojení", + "BROWSER": "PROHLÍŽEČ", + "Bend Line Position": "Poloha ohybové čáry", + "Bend Position": "Poloha ohybu", + "Bend Radius": "Poloměr ohybu", + "Bends": "Ohyby", + "Body": "Těleso", + "Boundary": "Hranice", + "Bounding box": "Ohraničující kvádr", + "CSV or JSON: updates existing parameters, adds new ones": "CSV nebo JSON: aktualizuje existující parametry, přidá nové", + "Capture All Positions": "Zachytit všechny polohy", + "Catch Direction": "Směr záchytu", + "Cells": "Buňky", + "Center X": "Střed X", + "Center Y": "Střed Y", + "Center of mass": "Těžiště", + "Centre to centre": "Střed–střed", + "Chamfer Type": "Typ sražení", + "Check collisions": "Kontrolovat kolize", + "Checked": "Zaškrtnuto", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Zvolte novou rovinu. Geometrie a kóty náčrtu zůstanou ve svých dvourozměrných souřadnicích beze změny.", + "Circumference": "Obvod", + "Close draft": "Zavřít koncept", + "Collisions": "Kolize", + "Colour": "Barva", + "Component1": "Komponenta1", + "Configuration": "Konfigurace", + "Configuration rules": "Pravidla konfigurace", + "Construction": "Konstrukční", + "Contact": "Kontakt", + "Continuity": "Spojitost", + "Copy Error Message": "Kopírovat chybovou zprávu", + "Copy Warning Message": "Kopírovat varování", + "Copy all licences": "Kopírovat všechny licence", + "Copy diagnostic information": "Kopírovat diagnostické informace", + "Copy report": "Kopírovat hlášení", + "Copy screenshot": "Kopírovat snímek", + "Corner Relief": "Odlehčení rohu", + "Corner Relief Shape": "Tvar odlehčení rohu", + "Counterbore depth": "Hloubka zahloubení", + "Countersink angle": "Úhel kuželového zahloubení", + "Create Issue — review draft": "Vytvořit hlášení — kontrola konceptu", + "Cube": "Krychle", + "Current orthographic view (1:1 mm)": "Aktuální pravoúhlý pohled (1:1 mm)", + "Current perspective view": "Aktuální perspektivní pohled", + "Curvature (G2)": "Křivost (G2)", + "Customize Toolbar": "Přizpůsobit panel nástrojů", + "Delete all": "Smazat vše", + "Delete rule": "Smazat pravidlo", + "Delete the row": "Smazat řádek", + "Delete the set": "Smazat sadu", + "Delete this parameter": "Smazat tento parametr", + "Density": "Hustota", + "Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.", + "Discard": "Zahodit", + "Do not export hidden edges": "Neexportovat skryté hrany", + "Double-click to rename": "Dvojklikem přejmenujete", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dvojklik prostředním tlačítkem v pohledu: Domů (výchozí pohled se zobrazením všeho), Přizpůsobit (zobrazí vše při zachování natočení) nebo nic", + "Drive Joint": "Pohánět kloub", + "Edges": "Hrany", + "Edit Profile Sketch": "Upravit náčrt profilu", + "Enable rule": "Povolit pravidlo", + "Enabled": "Povoleno", + "Explode": "Rozložit", + "Export Animation…": "Exportovat animaci…", + "Export Current View as DXF": "Exportovat aktuální pohled do DXF", + "Export Current View as DXF…": "Exportovat aktuální pohled do DXF…", + "Export DXF…": "Exportovat DXF…", + "Export hidden edges as dashed lines": "Exportovat skryté hrany čárkovaně", + "Extents": "Rozsahy", + "Face": "Plocha", + "Face attachment lost": "Připojení k ploše ztraceno", + "Faces": "Plochy", + "Faces / edges / vertices": "Plochy / hrany / vrcholy", + "Favourite": "Oblíbené", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Soubory z mnoha CAD programů mají osu Y nahoru, proto se zde otevřou ležící na zádech (Z nahoru).", + "Filter": "Filtr", + "Find in Browser": "Najít v prohlížeči", + "Flat": "Plochý", + "Flat size": "Rozměr rozvinu", + "Follow camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)", + "Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)", + "Font": "Písmo", + "Full Length": "Celá délka", + "Gap": "Mezera", + "Grid": "Mřížka", + "Height": "Výška", + "Hidden edges": "Skryté hrany", + "Holes": "Otvory", + "Holes (edges on them)": "Otvory (jejich hrany)", + "Home view": "Výchozí pohled", + "In This Design": "V tomto návrhu", + "In place (surfaces)": "Na místě (plochy)", + "Inside": "Uvnitř", + "Interferes": "Koliduje", + "Issue title": "Název hlášení", + "Joint Origin1": "Počátek kloubu1", + "Keep Tools": "Ponechat nástroje", + "Later": "Později", + "Leave it": "Nechat být", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Starší typ připojení k ploše. Novým navázáním na ověřenou plochu vznikne přesné připojení.", + "Length": "Délka", + "Length units": "Jednotky délky", + "Letter Spacing": "Mezery mezi písmeny", + "Library": "Knihovna", + "Line Spacing": "Řádkování", + "Loop": "Smyčka", + "Margin": "Okraj", + "Mass": "Hmotnost", + "Material": "Materiál", + "Max": "Max", + "Mid Radius": "Střední poloměr", + "Middle double-click": "Dvojklik prostředním tlačítkem", + "Min": "Min", + "Mitre": "Pokos", + "Modeled": "Modelovaný", + "More toolbar panels": "Další panely nástrojů", + "Motion": "Pohyb", + "Move Type": "Typ posunu", + "Move earlier": "Posunout dříve", + "Move later": "Posunout později", + "Name": "Název", + "No crash reports.": "Žádná hlášení o pádech.", + "No matching appearances": "Žádné odpovídající vzhledy", + "No visible bodies to export.": "Žádná viditelná tělesa k exportu.", + "None": "Žádné", + "Not available yet": "Zatím není k dispozici", + "Notch": "Zářez", + "Opacity": "Neprůhlednost", + "Open GitHub draft": "Otevřít koncept na GitHubu", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametrický 3D CAD v jazyce Rust. Licence MIT OR Apache-2.0.", + "Other side": "Druhá strana", + "Outside": "Vně", + "Overall length": "Celková délka", + "Parameter values": "Hodnoty parametrů", + "Pass through": "Procházet skrz", + "Path": "Cesta", + "Per Edge": "Pro každou hranu", + "Pick the tool bodies": "Vyberte nástrojová tělesa", + "Picked": "Vybráno", + "Place at the origin instead": "Místo toho umístit do počátku", + "Playback": "Přehrávání", + "Position": "Poloha", + "Position (0 to 1)": "Poloha (0 až 1)", + "Position (G0)": "Poloha (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Poloha podél hrany, 0 na počátečním konci, 1 na druhém", + "Preview Row": "Náhled řádku", + "Preview leaves the current design unchanged.": "Náhled aktuální návrh nemění.", + "Profiles/Curves": "Profily/křivky", + "Projection": "Promítání", + "RECENT": "NEDÁVNÉ", + "RULED SURFACE": "PŘÍMKOVÁ PLOCHA", + "Radius Fit": "Proložení poloměrem", + "Ratio": "Poměr", + "Reattach selected face": "Znovu připojit k vybrané ploše", + "Recover": "Obnovit", + "Recover unsaved design?": "Obnovit neuložený návrh?", + "Rectangular": "Obdélníkový", + "Reference face": "Referenční plocha", + "Remove Tools": "Odstranit nástroje", + "Remove from list": "Odebrat ze seznamu", + "Remove override": "Odebrat přepsání", + "Remove private design details or paths before sharing.": "Před sdílením odstraňte soukromé údaje o návrhu a cesty k souborům.", + "Remove screenshot": "Odebrat snímek", + "Remove the column": "Odebrat sloupec", + "Remove the track": "Odebrat stopu", + "Remove this point": "Odebrat tento bod", + "Report a bug — review a screenshot and open a GitHub draft": "Nahlásit chybu — zkontrolovat snímek a otevřít koncept na GitHubu", + "Reset Toolbar": "Obnovit panel nástrojů", + "Restore outer boundary": "Obnovit vnější hranici", + "Restore the inherited component or physical material appearance": "Obnovit zděděný vzhled komponenty nebo fyzického materiálu", + "Result": "Výsledek", + "Retake screenshot": "Pořídit snímek znovu", + "Reverse": "Obrátit", + "Reverse Direction": "Obrátit směr", + "Revert": "Vrátit", + "Revert All Positions": "Vrátit všechny polohy", + "Revert Position": "Vrátit polohu", + "Review the report and screenshot before opening GitHub.": "Před otevřením GitHubu zkontrolujte hlášení a snímek.", + "Rim Edge": "Okrajová hrana", + "Roll History Marker Here": "Přesunout značku historie sem", + "Round": "Zaoblený", + "Rule": "Pravidlo", + "Save Rule": "Uložit pravidlo", + "Save the study as an animated GIF, seen as the viewport shows it": "Uložit studii jako animovaný GIF tak, jak ji zobrazuje pohled", + "Saved preview. Geometry is being checked before editing.": "Uložený náhled. Geometrie se před úpravou kontroluje.", + "Scale factor": "Měřítko", + "Search": "Hledat", + "Search commands": "Hledat příkazy", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Vybrat jiné: podržte levé tlačítko na modelu (nebo pravé tlačítko › Vybrat jiné…) a zobrazí se vše pod kurzorem; dalším kliknutím na stejné místo vyberete další prvek. Dvojklikem prostředním tlačítkem přejdete na výchozí pohled (Předvolby › Navigace).", + "Select bodies (a face picks its body)": "Vyberte tělesa (plocha vybere své těleso)", + "Select bodies or faces instead": "Vyberte místo toho tělesa nebo plochy", + "Selection filter": "Filtr výběru", + "Semantic faces share their appearance across instances.": "Sémantické plochy sdílejí vzhled napříč instancemi.", + "Send your report on GitHub": "Odeslat hlášení na GitHub", + "Shape": "Tvar", + "Show Agent Cursor": "Zobrazit kurzor agenta", + "Show in folder": "Zobrazit ve složce", + "Show the pointer an AI agent moves (#35)": "Zobrazit ukazatel, kterým pohybuje AI agent (#35)", + "Signed axes": "Osy se znaménkem", + "Size": "Velikost", + "Sketch constraints": "Vazby náčrtu", + "Sketch curves": "Křivky náčrtu", + "Sketch dimensions": "Kóty náčrtu", + "Sketch geometry and profiles": "Geometrie a profily náčrtu", + "Sketch plane has no body-face attachment.": "Rovina náčrtu není připojena k ploše tělesa.", + "Sketch points": "Body náčrtu", + "Slot width": "Šířka drážky", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.", + "Speed": "Rychlost", + "Stand it up": "Postavit", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postavit: otočení o čtvrt otáčky kolem osy X jako jeden prvek Posunutí, který lze vrátit.", + "Stand this part up?": "Postavit tento díl?", + "Step": "Krok", + "Steps": "Kroky", + "Style": "Styl", + "Suppressed": "Potlačeno", + "Swap Ends": "Prohodit konce", + "Tangent (G1)": "Tečně (G1)", + "Tangent Chain": "Tečný řetězec", + "Tear": "Roztržení", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Čárkovaný náčrt je uložené vodítko roviny. Pro opravu připojení vyberte novou plochu.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Uložený výskyt autora chybí. Před novým navázáním vyberte platné umístění autora.", + "Then": "Pak", + "These features will be deleted:": "Tyto prvky budou smazány:", + "These features will fail:": "Tyto prvky selžou:", + "Thickness Outside": "Tloušťka vně", + "Thin": "Tenkostěnné", + "Thin Extrude": "Tenkostěnné vysunutí", + "This child is shared. Deleting it removes it from every copy of its parent.": "Tento potomek je sdílený. Smazáním jej odeberete ze všech kopií jeho rodiče.", + "This child is shared. Position changes apply to every copy of its parent.": "Tento potomek je sdílený. Změny polohy se projeví ve všech kopiích jeho rodiče.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tato plocha nemůže poskytnout trvalé připojení. Zvolte jinou plochu nebo výslovně použijte pevnou rovinu.", + "To Offset": "Odsazení k objektu", + "Tolerance": "Tolerance", + "Turn off section analysis before exporting the view.": "Před exportem pohledu vypněte analýzu řezu.", + "Type": "Typ", + "UNTRIM": "ZRUŠIT OŘEZ", + "Updating the preview": "Aktualizace náhledu", + "Use active author placement": "Použít aktivní umístění autora", + "Use fixed plane": "Použít pevnou rovinu", + "Use saved fixed plane": "Použít uloženou pevnou rovinu", + "Use selected face as fixed plane": "Použít vybranou plochu jako pevnou rovinu", + "VIEW": "POHLED", + "Value": "Hodnota", + "Vertices": "Vrcholy", + "View navigator": "Navigátor pohledu", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Pouze viditelná tělesa. Křivky jsou nahrazeny úsečkami (tolerance 0,01 mm).", + "Volume": "Objem", + "Wall": "Stěna", + "Wall Location": "Umístění stěny", + "Wall Thickness": "Tloušťka stěny", + "When": "Když", + "Width": "Šířka", + "Will fail": "Selže", + "angle, height": "úhel, výška", + "appearance.dielectric_ior": "Index lomu dielektrika", + "appearance.metallic": "Kovovost", + "appearance.pbr_help": "Tento povrch zobrazíte v renderu nebo v produktovém PNG. Bez lomu a průchodu světla.", + "appearance.pbr_opaque": "Neprůhledné PBR", + "appearance.roughness": "Drsnost", + "appearance.surface_response": "Odezva povrchu", + "browser.body_name": "Těleso{number}", + "browser.component_name": "Komponenta{number}", + "browser.parameters": "Parametry ({count})", + "browser.precision": "Přesnost: {precision}", + "browser.sketch_name": "Náčrt{number}", + "browser.units": "Jednotky: {units}", + "cam.flat": "Ploché čelo", + "cam.follower": "Zdvihátko", + "cam.knife": "Břit", + "cam.needs_joints": "vyžaduje jeden otočný kloub a jeden posuvný nebo kývavý", + "cam.roller": "Kladka", + "cam.roller_pick": "Kladka (volitelné)", + "cam.tip_pick": "Hrot (volitelné)", + "cmd.appearance_assign": "Vzhled", + "cmd.appearance_library": "Knihovna vzhledů", + "cmd.appearance_list": "Seznam vzhledů", + "cmd.body_rename": "Přejmenovat těleso", + "cmd.browser_group": "Nová skupina", + "cmd.browser_move": "Přesunout položky prohlížeče", + "cmd.browser_order": "Seřadit položky prohlížeče", + "cmd.browser_rename_group": "Přejmenovat skupinu", + "cmd.browser_ungroup": "Zrušit skupinu", + "cmd.browser_visibility": "Zobrazit/skrýt", + "cmd.canvas_calibrate": "Kalibrovat plátno", + "cmd.canvas_decal": "Obtisk", + "cmd.canvas_delete": "Smazat plátno", + "cmd.canvas_edit": "Upravit plátno", + "cmd.canvas_insert": "Plátno", + "cmd.component_activate": "Aktivovat komponentu", + "cmd.component_break_link": "Zrušit odkaz", + "cmd.component_capture_position": "Zachytit polohu", + "cmd.component_create": "Nová komponenta", + "cmd.component_delete": "Smazat komponentu", + "cmd.component_from_bodies": "Vytvořit komponenty z těles", + "cmd.component_insert_design": "Vložit návrh", + "cmd.component_links": "Seznam odkazů", + "cmd.component_list": "Seznam komponent", + "cmd.component_move_bodies": "Přesunout tělesa do komponenty", + "cmd.component_move_sketches": "Přesunout náčrty do komponenty", + "cmd.component_paste_new": "Vložit jako nové", + "cmd.component_properties": "Fyzikální vlastnosti komponenty", + "cmd.component_rename": "Přejmenovat komponentu", + "cmd.component_revert_position": "Vrátit polohu", + "cmd.component_unlock": "Odemknout obsah", + "cmd.component_update_links": "Aktualizovat odkazy", + "cmd.config_activate": "Aktivovat konfiguraci", + "cmd.config_column": "Sloupec konfigurace", + "cmd.config_configure": "Konfigurovat", + "cmd.config_preview": "Náhled konfigurace", + "cmd.config_row": "Řádek konfigurace", + "cmd.config_rule": "Pravidlo konfigurace", + "cmd.config_rules": "Pravidla konfigurace", + "cmd.config_table": "Zobrazit tabulku konfigurací", + "cmd.construct_axis_edge": "Osa hranou", + "cmd.construct_axis_normal_to_face": "Osa kolmá k ploše", + "cmd.construct_axis_through_cylinder": "Osa válce/kužele/anuloidu", + "cmd.construct_axis_two_planes": "Osa průsečíkem dvou rovin", + "cmd.construct_axis_two_points": "Osa dvěma body", + "cmd.construct_plane_along_path": "Rovina podél cesty", + "cmd.construct_plane_midplane": "Střední rovina", + "cmd.construct_plane_perpendicular": "Kolmá rovina", + "cmd.construct_plane_tangent": "Tečná rovina", + "cmd.construct_plane_three_points": "Rovina třemi body", + "cmd.construct_plane_two_edges": "Rovina dvěma hranami", + "cmd.construct_point_along_path": "Bod podél cesty", + "cmd.construct_point_center": "Bod ve středu kružnice/koule/anuloidu", + "cmd.construct_point_edge_plane": "Bod v průsečíku hrany a roviny", + "cmd.construct_point_three_planes": "Bod průsečíkem tří rovin", + "cmd.construct_point_two_edges": "Bod průsečíkem dvou hran", + "cmd.construct_point_vertex": "Bod ve vrcholu", + "cmd.contact_check": "Kontrola kontaktu", + "cmd.contact_create": "Nová sada kontaktů", + "cmd.contact_disable_all": "Vypnout kontakt", + "cmd.contact_edit": "Upravit sadu kontaktů", + "cmd.contact_enable_all": "Zapnout všechny kontakty", + "cmd.contact_enable_sets": "Zapnout sady kontaktů", + "cmd.contact_list": "Seznam sad kontaktů", + "cmd.diagnostics_crash_report": "Hlášení o pádu", + "cmd.diagnostics_crash_reports": "Hlášení o pádech", + "cmd.diagnostics_crash_reports_clear": "Smazat hlášení o pádech", + "cmd.diagnostics_crash_test": "Test pádu", + "cmd.doc_recover": "Obnovit návrh", + "cmd.doc_recovery_discard": "Zahodit obnovený návrh", + "cmd.doc_recovery_list": "Obnovitelné návrhy", + "cmd.document_inspect": "Prozkoumat návrh", + "cmd.document_metadata": "Vlastnosti návrhu", + "cmd.document_metadata_remove": "Odebrat vlastnost návrhu", + "cmd.document_metadata_set": "Změnit vlastnosti návrhu", + "cmd.document_units": "Změnit jednotky návrhu", + "cmd.edit_checkpoint": "Kontrolní bod", + "cmd.edit_restore_checkpoint": "Obnovit kontrolní bod", + "cmd.engine_commands": "Seznam příkazů", + "cmd.explode_create": "Rozložený pohled", + "cmd.explode_delete": "Smazat rozložený pohled", + "cmd.explode_list": "Seznam rozložených pohledů", + "cmd.explode_show": "Zobrazit rozložený pohled", + "cmd.expr_evaluate": "Vyhodnotit výraz", + "cmd.face_delete": "Smazat plochu", + "cmd.file_export": "Exportovat", + "cmd.file_insert_mesh": "Vložit síť", + "cmd.file_insert_step": "Vložit STEP", + "cmd.file_save_mesh": "Uložit jako síť", + "cmd.file_slicers": "Nainstalované slicery", + "cmd.inspect_accessibility": "Analýza přístupnosti", + "cmd.inspect_center_of_mass": "Těžiště", + "cmd.inspect_compare": "Porovnat tělesa", + "cmd.inspect_continuity": "Analýza spojitosti", + "cmd.inspect_curvature_comb": "Analýza hřebenu křivosti", + "cmd.inspect_curvature_map": "Mapa křivosti", + "cmd.inspect_draft": "Analýza úkosu", + "cmd.inspect_environment_map": "Analýza mapou prostředí", + "cmd.inspect_interference": "Kolize", + "cmd.inspect_isocurve": "Analýza izokřivek", + "cmd.inspect_minimum_radius": "Analýza minimálního poloměru", + "cmd.inspect_zebra": "Zebra analýza", + "cmd.joint_as_built": "Kloub podle stavu", + "cmd.joint_cam": "Vačka", + "cmd.joint_create": "Kloub", + "cmd.joint_delete": "Smazat kloub", + "cmd.joint_drive": "Pohánět klouby", + "cmd.joint_edit": "Upravit kloub", + "cmd.joint_gear": "Ozubený převod", + "cmd.joint_limits": "Limity kloubu", + "cmd.joint_list": "Seznam kloubů", + "cmd.joint_motion_link": "Vazba pohybu", + "cmd.joint_origin": "Počátek kloubu", + "cmd.joint_rack_pinion": "Hřeben a pastorek", + "cmd.joint_reach": "Dosáhnout bodu", + "cmd.joint_rigid_group": "Pevná skupina", + "cmd.joint_screw": "Šroubový pohyb", + "cmd.joint_solve": "Vyřešit klouby", + "cmd.library_apply": "Použít položku místní knihovny", + "cmd.library_export": "Exportovat knihovnu návrhu", + "cmd.library_inspect": "Prozkoumat místní knihovnu", + "cmd.material_assign": "Fyzický materiál", + "cmd.material_create": "Nový fyzický materiál", + "cmd.material_delete": "Smazat fyzický materiál", + "cmd.material_edit": "Upravit fyzický materiál", + "cmd.material_list": "Seznam materiálů", + "cmd.material_properties_directional": "Směrový modul pružnosti", + "cmd.material_properties_validate": "Ověřit data fyzického materiálu", + "cmd.mesh_convert": "Převést síť", + "cmd.model_edges": "Seznam hran", + "cmd.model_faces": "Seznam ploch", + "cmd.model_threads": "Seznam závitů", + "cmd.motion_delete": "Smazat studii pohybu", + "cmd.motion_export": "Exportovat studii pohybu", + "cmd.motion_export_animation": "Exportovat animaci pohybu", + "cmd.motion_list": "Seznam studií pohybu", + "cmd.motion_play": "Přehrát studii pohybu", + "cmd.motion_study": "Studie pohybu", + "cmd.motion_trace": "Stopa pohybu", + "cmd.motion_trace_keep": "Ponechat stopu pohybu", + "cmd.occurrence_copy": "Vložit (instance)", + "cmd.occurrence_ground": "Ukotvit", + "cmd.occurrence_move": "Přesunout výskyt", + "cmd.occurrence_properties": "Fyzikální vlastnosti výskytu", + "cmd.parameters_add": "Přidat uživatelský parametr", + "cmd.parameters_change": "Změnit parametry", + "cmd.parameters_comment": "Komentář parametru", + "cmd.parameters_complete": "Doplnit výraz", + "cmd.parameters_delete": "Smazat parametr", + "cmd.parameters_export": "Exportovat parametry", + "cmd.parameters_favorite": "Oblíbený parametr", + "cmd.parameters_graph": "Graf parametrů", + "cmd.parameters_import": "Importovat parametry", + "cmd.parameters_list": "Seznam parametrů", + "cmd.parameters_move": "Přesunout parametr", + "cmd.parameters_rename": "Přejmenovat parametr", + "cmd.parameters_users": "Kde se parametr používá", + "cmd.parts_fastener": "Vložit spojovací prvek", + "cmd.parts_insert": "Vložit díl", + "cmd.parts_library": "Normalizované díly", + "cmd.parts_resize": "Změnit velikost dílu", + "cmd.plastic_assign_rule": "Přiřadit pravidlo pro plasty", + "cmd.plastic_boss": "Nálitek", + "cmd.plastic_lip": "Osazení (lem)", + "cmd.plastic_manage_rules": "Spravovat pravidla pro plasty", + "cmd.plastic_rest": "Opěrka", + "cmd.plastic_rules": "Seznam pravidel pro plasty", + "cmd.plastic_snap_fit": "Zacvakávací spoj", + "cmd.render_backends": "Renderovací jádra", + "cmd.render_cancel": "Zrušit render", + "cmd.render_export": "Exportovat obrázek renderu", + "cmd.render_preflight": "Zkontrolovat požadavek na render", + "cmd.render_scene": "Prozkoumat scénu renderu", + "cmd.render_settings_apply": "Použít kameru renderu", + "cmd.render_settings_get": "Nastavení renderu", + "cmd.render_settings_set": "Uložit nastavení renderu", + "cmd.render_start": "Renderovat obrázek", + "cmd.render_status": "Průběh renderu", + "cmd.select_clear": "Zrušit výběr", + "cmd.sheet_convert": "Převést na plech", + "cmd.sheet_export_bend_table": "Exportovat tabulku ohybů", + "cmd.sheet_export_dxf": "Exportovat rozvin do DXF", + "cmd.sheet_flange": "Příruba", + "cmd.sheet_flat_pattern": "Vytvořit rozvin", + "cmd.sheet_fold": "Ohnout", + "cmd.sheet_hem": "Lem", + "cmd.sheet_manage_rules": "Pravidla pro plech", + "cmd.sheet_perforate": "Perforovat", + "cmd.sheet_refold": "Znovu ohnout", + "cmd.sheet_rules": "Seznam pravidel pro plech", + "cmd.sheet_unfold": "Rozvinout", + "cmd.sketch_arc_center_point": "Oblouk ze středu", + "cmd.sketch_arc_tangent": "Tečný oblouk", + "cmd.sketch_arc_three_point": "Oblouk ze 3 bodů", + "cmd.sketch_auto_constrain": "Automatické vazby", + "cmd.sketch_auto_constrain_from_datum": "Automatické vazby od reference", + "cmd.sketch_auto_project": "Automaticky promítat hrany na referenci", + "cmd.sketch_blend_curve": "Přechodová křivka", + "cmd.sketch_chain": "Řetězec", + "cmd.sketch_chamfer_distance_angle": "Sražení vzdáleností a úhlem", + "cmd.sketch_chamfer_equal_distance": "Sražení stejnou vzdáleností", + "cmd.sketch_chamfer_two_distance": "Sražení dvěma vzdálenostmi", + "cmd.sketch_circle_center": "Kružnice ze středu", + "cmd.sketch_circle_three_point": "Kružnice ze 3 bodů", + "cmd.sketch_circle_three_tangent": "Kružnice tečná ke 3 prvkům", + "cmd.sketch_circle_two_point": "Kružnice ze 2 bodů", + "cmd.sketch_circle_two_tangent": "Kružnice tečná ke 2 prvkům", + "cmd.sketch_constrainer": "Zavazbení náčrtu", + "cmd.sketch_constraint_fix": "Zafixovat/uvolnit", + "cmd.sketch_constraint_midpoint": "Střed", + "cmd.sketch_construction": "Běžná/konstrukční", + "cmd.sketch_copy": "Kopírovat prvky náčrtu", + "cmd.sketch_cut": "Vyjmout prvky náčrtu", + "cmd.sketch_delete": "Smazat prvky náčrtu", + "cmd.sketch_dimension_text": "Přesunout text kóty", + "cmd.sketch_drag": "Táhnout geometrii náčrtu", + "cmd.sketch_drag_end": "Ukončit tažení náčrtu", + "cmd.sketch_draw_dimensioned": "Kreslit okótovanou geometrii náčrtu", + "cmd.sketch_edit": "Upravit náčrt", + "cmd.sketch_edit_text": "Upravit text", + "cmd.sketch_export_dxf": "Uložit jako DXF", + "cmd.sketch_finish_auto_constrain": "Dokončit s automatickými vazbami", + "cmd.sketch_fit_curves_to_section": "Proložit křivky řezem sítě", + "cmd.sketch_glyphs": "Symboly vazeb", + "cmd.sketch_include_3d": "Zahrnout 3D geometrii", + "cmd.sketch_infinite": "Nekonečná přímka", + "cmd.sketch_insert_dxf": "Vložit DXF", + "cmd.sketch_insert_svg": "Vložit SVG", + "cmd.sketch_inspect": "Prozkoumat náčrt", + "cmd.sketch_intersection_curve": "Průsečná křivka", + "cmd.sketch_isoparametric_curve": "Izoparametrická křivka", + "cmd.sketch_line3d": "3D úsečka", + "cmd.sketch_line_midpoint": "Úsečka od středu", + "cmd.sketch_move": "Přesunout/kopírovat prvky náčrtu", + "cmd.sketch_move3d": "Přesunout 3D bod", + "cmd.sketch_move_point": "Táhnout bod náčrtu", + "cmd.sketch_offset_break": "Zrušit vazbu odsazení", + "cmd.sketch_options": "Možnosti palety náčrtu", + "cmd.sketch_paste": "Vložit prvky náčrtu", + "cmd.sketch_point3d": "3D bod", + "cmd.sketch_polygon_circumscribed": "Opsaný mnohoúhelník", + "cmd.sketch_polygon_edge": "Mnohoúhelník hranou", + "cmd.sketch_polygon_inscribed": "Vepsaný mnohoúhelník", + "cmd.sketch_project_to_surface": "Promítnout na plochu", + "cmd.sketch_rectangle_center": "Obdélník ze středu", + "cmd.sketch_rectangle_rounded": "Zaoblený obdélník", + "cmd.sketch_rectangle_three_point": "Obdélník ze 3 bodů", + "cmd.sketch_rectangle_two_point": "Obdélník ze 2 bodů", + "cmd.sketch_redefine": "Změnit rovinu náčrtu", + "cmd.sketch_slot_arc_center": "Oblouková drážka ze středu", + "cmd.sketch_slot_arc_three_point": "Oblouková drážka ze 3 bodů", + "cmd.sketch_slot_center_point": "Drážka ze středu", + "cmd.sketch_slot_center_to_center": "Drážka střed–střed", + "cmd.sketch_slot_overall": "Drážka celkovou délkou", + "cmd.sketch_snap": "Přichytávání", + "cmd.sketch_solve": "Vyřešit náčrt", + "cmd.sketch_spline3d": "3D splajn", + "cmd.sketch_spline_control_point": "Splajn řídicími body", + "cmd.sketch_spline_control_point_5": "Splajn řídicími body (stupeň 5)", + "cmd.sketch_spline_fit_point": "Splajn procházející body", + "cmd.sketch_spun_profile": "Rotační obrys", + "cmd.sketch_toggle_driven": "Přepnout řízenou kótu", + "cmd.solid_align": "Zarovnat", + "cmd.solid_boundary_fill": "Vyplnění hranicemi", + "cmd.solid_bounding_solid": "Obalové těleso", + "cmd.solid_emboss": "Reliéf", + "cmd.solid_move": "Posunout/kopírovat", + "cmd.solid_offset_face": "Odsadit plochu", + "cmd.solid_press_pull": "Tlačit/táhnout", + "cmd.solid_remove": "Odstranit", + "cmd.solid_replace_face": "Nahradit plochu", + "cmd.solid_rib": "Žebro", + "cmd.solid_split_face": "Rozdělit plochu", + "cmd.solid_web": "Výztuha", + "cmd.surface_blend": "Přechodová plocha", + "cmd.surface_boundary": "Plocha z hranic", + "cmd.surface_extrude": "Vysunutí plochy", + "cmd.surface_fair": "Vyhladit plochu", + "cmd.surface_fillet": "Zaoblení ploch", + "cmd.surface_loft": "Plocha spojením profilů", + "cmd.surface_match": "Napojit plochu", + "cmd.surface_offset": "Odsadit povrch", + "cmd.surface_patch": "Záplata", + "cmd.surface_reverse_normal": "Obrátit normálu", + "cmd.surface_revolve": "Rotace plochy", + "cmd.surface_ruled": "Přímková plocha", + "cmd.surface_stitch": "Sešít", + "cmd.surface_sweep": "Tažení plochy", + "cmd.surface_thicken": "Zesílit", + "cmd.surface_unstitch": "Rozešít", + "cmd.surface_untrim": "Zrušit oříznutí", + "cmd.timeline_add": "Přidat zaznamenaný prvek", + "cmd.timeline_compute_all": "Přepočítat vše", + "cmd.timeline_copy": "Kopírovat prvky", + "cmd.timeline_dependents": "Závislé prvky", + "cmd.timeline_export_script": "Exportovat skript příkazů", + "cmd.timeline_inspect": "Prozkoumat prvek", + "cmd.timeline_paste": "Vložit prvky", + "cmd.timeline_redefine": "Upravit prvek", + "cmd.timeline_rename": "Přejmenovat", + "cmd.timeline_reorder": "Změnit pořadí prvku", + "cmd.timeline_roll_to": "Přesunout značku historie", + "cmd.timeline_rollback": "Přesunout značku časové osy", + "cmd.timeline_suppress": "Potlačit prvek", + "cmd.view_apply": "Obnovit pojmenovaný pohled", + "cmd.view_delete": "Smazat pojmenovaný pohled", + "cmd.view_list": "Seznam pojmenovaných pohledů", + "cmd.view_navigate": "Navigovat pohled", + "cmd.view_rename": "Přejmenovat pojmenovaný pohled", + "cmd.view_save": "Nový pojmenovaný pohled", + "command name": "název příkazu", + "coupling.gear_1": "Kolo 1", + "coupling.gear_1_pick": "Kolo 1 (volitelné)", + "coupling.gear_2": "Kolo 2", + "coupling.gear_2_pick": "Kolo 2 (volitelné)", + "coupling.internal": "Vnitřní ozubení", + "coupling.needs_turn_and_slide": "vyžaduje jeden otočný a jeden posuvný kloub", + "coupling.needs_turns": "vyžaduje dva otočné klouby", + "coupling.not_round": "není kruhová plocha", + "coupling.nut": "Matice", + "coupling.picked": "vybráno", + "coupling.pinion": "Pastorek", + "coupling.pinion_pick": "Pastorek (volitelné)", + "coupling.pitch": "Stoupání", + "coupling.rack": "Hřeben", + "coupling.radius_1": "Poloměr 1", + "coupling.radius_2": "Poloměr 2", + "dialog.computing": "Výpočet… {seconds} s", + "dialog.custom_axis": "↖ Vlastní osa", + "dialog.edit_title": "UPRAVIT {title}", + "dialog.select": "↖ Vybrat", + "dialog.selected_count": "↖ Vybráno: {count}", + "dialog.selection_guidance": "Vstup změníte jeho výběrem. Kliknutím na vybraný objekt jej odeberete; Ctrl/Cmd+klik výběr také přepíná. Zkontrolujte náhled a stiskněte OK.", + "dialog.selection_step": "{step} z {total}: Vyberte {input}", + "dialog.step": "Krok", + "document.save_changes": "Uložit změny v {name}?", + "document.unsaved_changes": "{name} obsahuje neuložené změny. Pokud je neuložíte, budou ztraceny.", + "drive.stopped_by_contact": "Zastaveno na {value}: {a} by prošlo skrz {b}", + "drive.stopped_by_loop": "Zastaveno na {value}: smyčku přes {joint} už nelze dále uzavřít", + "drive.unconnected": "Nic se nepohne: {part} není spojeno s ničím ukotveným", + "dxf.cancel_wait": "Čekání na zastavení exportu…", + "dxf.cancelled": "Export DXF zrušen", + "dxf.cancelling": "Rušení exportu DXF", + "dxf.exporting": "Export do DXF", + "dxf.invalid_tolerance": "Tolerance křivek musí být mezi 0,0001 a 1 mm.", + "dxf.progress.index": "Příprava ploch (2/5)", + "dxf.progress.meshes": "Příprava sítí (1/5)", + "dxf.progress.occlusion": "Řešení skrytých čar (4/5)", + "dxf.progress.projection": "Promítání geometrie (3/5)", + "dxf.progress.write": "Zápis DXF (5/5)", + "dxf.tolerance": "Tolerance křivek", + "dxf.tolerance_help": "Pouze viditelná tělesa. Křivky jsou nahrazeny úsečkami. Menší tolerance dává jemnější křivky, ale zabere více času a paměti.", + "expression, e.g. 25 mm": "výraz, např. 25 mm", + "fits the face, e.g. M8": "odpovídá ploše, např. M8", + "from the rule": "z pravidla", + "home.documentation": "Dokumentace", + "home.in_this_browser": "v tomto prohlížeči", + "home.missing": "chybí", + "home.open": "Otevřít…", + "home.opened_days_ago": "Otevřeno před {count} dny", + "home.opened_hours_ago": "Otevřeno před {count} h", + "home.opened_just_now": "Otevřeno právě teď", + "home.opened_minutes_ago": "Otevřeno před {count} min", + "home.opening": "otevírání…", + "home.recent": "Nedávné", + "home.report_issue": "Nahlásit problém", + "home.samples": "Ukázky", + "home.whats_new": "Co je nového", + "install.button_tip": "Nainstalovat SolidCraft jako aplikaci: otevře se ve vlastním okně a funguje i offline", + "install.dock_1": "Zvolte Soubor › Přidat do Docku.", + "install.dock_2": "Klikněte na Přidat.", + "install.dock_after": "SolidCraft se pak otevírá z Docku ve vlastním okně a funguje i offline.", + "install.done": "Hotovo", + "install.home_screen_1": "Klepněte na Sdílet (v nabídce …, pokud není na liště).", + "install.home_screen_2": "Zvolte Přidat na plochu.", + "install.home_screen_3": "Klepněte na Přidat.", + "install.home_screen_after": "SolidCraft se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.", + "install.menu": "Nainstalovat aplikaci…", + "install.steps_intro": "Safari instaluje webové aplikace z vlastních nabídek:", + "install.title": "Instalace SolidCraftu", + "joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.", + "joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.", + "joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.", + "nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.", + "nav.touchpad.label": "Touchpad", + "nav.touchpad.orbit": "Otáčení (podržte Alt/Option a pohybujte; bez tlačítka myši)", + "nav.touchpad.pan": "Posun (podržte Shift a pohybujte; bez tlačítka myši)", + "nav.touchpad.scroll": "Touchpad přibližuje svislým posouváním. Vaše nastavení posunu dvěma prsty zůstává pro ostatní předvolby.", + "nav.touchpad.zoom": "Přiblížení (svislé posouvání nebo sevření prstů)", + "needs two joints": "vyžaduje dva klouby", + "new configuration": "nová konfigurace", + "new name": "nový název", + "new parameter": "nový parametr", + "no interference": "bez kolize", + "no joint that moves yet": "zatím žádný pohyblivý kloub", + "no picture": "bez obrázku", + "pattern.alignment_2": "Zarovnání 2", + "pattern.alignment_hint": "Počty zahrnují originál. Na střed rozloží kopie rovnoměrně; Převrátit volí stranu navíc u sudých počtů.", + "pattern.direction_1": "Směr 1", + "pattern.direction_2": "Směr 2", + "pattern.flip_direction": "Převrátit směr", + "pick a hole to seat it, or OK places it at the origin": "vyberte otvor pro usazení, nebo OK umístí do počátku", + "pick one or two vertices, edges or faces": "vyberte jeden nebo dva vrcholy, hrany či plochy", + "render.background": "Pozadí scény", + "render.fit": "Přizpůsobit produktu", + "render.image_cancel": "Zrušit render", + "render.image_cancelled": "Render zrušen", + "render.image_cancelling": "Rušení", + "render.image_check": "Zkontrolovat požadavek na render", + "render.image_completed": "Obrázek připraven k exportu", + "render.image_encoding": "Kódování PNG", + "render.image_export": "Exportovat PNG", + "render.image_failed": "Render selhal", + "render.image_height": "Výška", + "render.image_help": "Náhledový obrázek z CPU · PNG · přibližná průhlednost", + "render.image_overwrite": "Nahradit existující soubor", + "render.image_preparing": "Příprava obrázku", + "render.image_queued": "Čeká na render", + "render.image_reduce": "Zkuste menší obrázek nebo jednodušší scénu.", + "render.image_running": "Renderování obrázku", + "render.image_stale": "Tento obrázek používá scénu zachycenou před pozdějšími změnami.", + "render.image_start": "Spustit render", + "render.image_supported": "Požadavek na render je podporován; fronta a paměť se znovu ověří při spuštění.", + "render.image_title": "Renderovat obrázek", + "render.image_too_large": "Překročen limit obrázku: 4096 pixelů na stranu a celkem 4 194 304 pixelů.", + "render.image_transparent": "Průhledné pozadí", + "render.image_unknown": "Stav renderu není k dispozici", + "render.image_use_largest": "Použít největší velikost v tomto poměru stran:", + "render.image_width": "Šířka", + "render.navigation_help": "Kamera se ovládá stejně jako v návrhu (Předvolby › Navigace). Uložit nastavení zaznamená aktuální kameru.", + "render.opaque_image_summary": "Náhledové PNG · neprůhledné PBR · smíšená průhlednost", + "render.opaque_preview_help": "Neprůhledné PBR a jednoduché barvy; smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.", + "render.perspective": "Perspektivní kamera", + "render.preview": "Integrovaný náhled", + "render.preview_help": "Rastrové stínování a smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.", + "render.reload_settings": "Znovu načíst uložené nastavení", + "render.restore_camera": "Obnovit uloženou kameru", + "render.return_design": "Zpět k návrhu", + "render.save_setup": "Uložit nastavení", + "render.setup_name": "Název nastavení", + "render.unavailable_preview": "Toto nastavení vyžaduje nedostupný profil náhledu.", + "render.unsaved": "Neuložené změny náhledu", + "render.use_preview": "Použít integrovaný náhled", + "sample.bracket.about": "Základní příruba s okrajovými přírubami a lemem podle pravidla 2 mm", + "sample.bracket.name": "Plechový držák", + "sample.enclosure.about": "Skořepinová krabička s osazením, nálitky pro šrouby, opěrkou a zacvakávacím spojem", + "sample.enclosure.name": "Plastová krabička", + "sample.flange.about": "Náboj a příruba s kruhovým polem řízeným parametrem", + "sample.flange.name": "Šroubovaná příruba", + "sample.hinge.about": "Dvě komponenty a otočný kloub natočený na 60°", + "sample.hinge.name": "Sestava pantu", + "sample.plate.about": "Parametrická deska se zaobleními, otvory a vyvrtaným nálitkem", + "sample.plate.name": "Ukázková deska", + "sample.vents.about": "Drážky v obdélníkovém poli: řádky, sloupce a rozteč jsou parametry", + "sample.vents.name": "Větrací deska", + "settings.display_quality_note": "Jemnější úrovně kreslí zakřivené plochy a hrany hladčeji, ale síťování trvá déle. Mění se jen zobrazení, nikdy návrh ani exporty.", + "settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.", + "settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z", + "settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.", + "settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu", + "settings.msaa_reset": "{setting}: {requested}× toto grafické zařízení nepodporuje; nyní je nastaveno {reset}× (podporováno: {supported}).", + "settings.msaa_unsupported": "Toto grafické zařízení nepodporuje", + "settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění SolidCraftu, proto byla změněna:", + "settings.reset_title": "Nastavení byla změněna", + "settings.supersampling_note": "Vykresluje pohled ve vyšším rozlišení a poté jej zmenšuje: hladší hrany, čáry a mřížka na obrazovkách pod 4K. Stojí výkon GPU: asi 2,25× více pixelů při 1,5× a 4× při 2×.", + "sketch.degrees_of_freedom": "Stupně volnosti: {count}", + "sketch.draw_curves": "Kreslit jako {linetype}", + "sketch.make_selection": "Převést výběr na {linetype}", + "sketch_text.align_center": "Na střed", + "sketch_text.align_left": "Vlevo", + "sketch_text.align_right": "Vpravo", + "sketch_text.bold": "Tučné", + "sketch_text.builtin_font": "{family} (vestavěné)", + "sketch_text.font_search": "Hledat písma", + "sketch_text.italic": "Kurzíva", + "sketch_text.loading_fonts": "Hledání nainstalovaných písem…", + "sketch_text.on_path": "Text sleduje svou cestu: umístění určuje křivka", + "sketch_text.path_clear": "Odpojit text od křivky", + "sketch_text.path_hint": "Křivka náčrtu, kterou text sleduje: klikněte sem a poté na křivku", + "sketch_text.use_font_file": "Použít soubor písma {path}", + "the parts library is not available": "knihovna dílů není k dispozici", + "timeline.group": "Nová složka prvků", + "timeline.move_many": "Přesunout položky časové osy", + "timeline.move_to_group": "Přesunout prvky do složky", + "timeline.rename_group": "Přejmenovat složku prvků", + "timeline.reorder_many": "Změnit pořadí prvků", + "timeline.ungroup": "Odebrat složky prvků", + "to the next face": "k další ploše", + "toolbar.image": "OBRÁZEK", + "toolbar.render": "RENDER", + "toolbar.render_scene": "SCÉNA", + "trace.points": "Bodů podél cesty: {count}", + "trace.stopped_by_contact": "Končí ve vzorku {sample}: {a} by prošlo skrz {b}", + "trace.stopped_by_loop": "Končí ve vzorku {sample}: smyčku přes {joint} už nelze dále uzavřít", + "ui.1_degree_of_freedom": "1 stupeň volnosti", + "ui.3d_mouse_speed": "Rychlost 3D myši", + "ui.3d_mouse_windows": "3D myš", + "ui.3d_sketch": "3D náčrt", + "ui.about": "O aplikaci", + "ui.activate": "Aktivovat", + "ui.active_component": "Aktivní komponenta", + "ui.agent_chat": "Chat s agentem", + "ui.agent_chat_allow": "Povolit", + "ui.agent_chat_allowed": "Povoleno", + "ui.agent_chat_answered": "Odpověď: {option}", + "ui.agent_chat_approval": "Povolit agentovi tuto akci?", + "ui.agent_chat_attach_view": "Přiložit pohled", + "ui.agent_chat_attach_view_tip": "Odeslat se zprávou obrázek aktuálního pohledu", + "ui.agent_chat_auth_agent": "Přihlásit se přes {name}", + "ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte SolidCraft.", + "ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; SolidCraft vaše heslo ani klíče nikdy nevidí.", + "ui.agent_chat_auth_none": "Agent zde nenabízí žádný způsob přihlášení: přihlaste se podle jeho vlastní dokumentace.", + "ui.agent_chat_auth_retry": "Přihlásil(a) jsem se: připojit znovu", + "ui.agent_chat_auth_terminal": "Spusťte toto v terminálu a pak znovu stiskněte Připojit:", + "ui.agent_chat_auth_title": "Přihlásit se k agentovi", + "ui.agent_chat_cancel": "Zrušit", + "ui.agent_chat_changes": "Změny: {n}, jeden krok zpět", + "ui.agent_chat_changes_of": "Změny: {n}/{limit}", + "ui.agent_chat_close": "Zavřít", + "ui.agent_chat_command": "Příkaz agenta", + "ui.agent_chat_connect": "Připojit", + "ui.agent_chat_copy": "Kopírovat", + "ui.agent_chat_custom_hint": "Jakýkoli program, který komunikuje protokolem Agent Client Protocol přes stdin a stdout. Nespouští se přes shell.", + "ui.agent_chat_denied": "Zamítnuto", + "ui.agent_chat_deny": "Zamítnout", + "ui.agent_chat_desktop_only": "Chat s agentem vyžaduje desktopovou aplikaci.", + "ui.agent_chat_detecting": "Hledání agentů…", + "ui.agent_chat_dock_left": "Ukotvit vlevo", + "ui.agent_chat_dock_right": "Ukotvit vpravo", + "ui.agent_chat_edit": "Upravit", + "ui.agent_chat_exited": "Agent se zastavil.", + "ui.agent_chat_failed_start": "Agenta se nepodařilo spustit: {error}", + "ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.", + "ui.agent_chat_key_in_env": "klíč nastaven", + "ui.agent_chat_left": "Vlevo", + "ui.agent_chat_limit": "Agent dosáhl limitu {limit} změn na jednu zprávu. Pošlete další zprávu, aby mohl pokračovat.", + "ui.agent_chat_minimize": "Minimalizovat do pruhu", + "ui.agent_chat_model_tip": "Model, který agent používá. Vaše volba se pro tohoto agenta zapamatuje.", + "ui.agent_chat_new": "Nový chat", + "ui.agent_chat_new_confirm": "Začít nový chat? Aktuální bude vymazán.", + "ui.agent_chat_new_yes": "Nový chat", + "ui.agent_chat_no_cli": "Vedle aplikace nebyl nalezen solidcraft-cli, takže agent nemá přístup k návrhu.", + "ui.agent_chat_no_control": "Endpoint chatu s agentem se nespustil, takže agent nemá přístup k návrhu.", + "ui.agent_chat_no_images": "Tento agent nepřijímá obrázky", + "ui.agent_chat_none_found": "Nebyl nalezen žádný kódovací agent. Nainstalujte některého z těchto a stiskněte Obnovit:", + "ui.agent_chat_none_found_short": "Agent nenalezen", + "ui.agent_chat_not_installed": "Není nainstalován", + "ui.agent_chat_not_signed_in": "nepřihlášen", + "ui.agent_chat_permission": "Agent žádá o spuštění: {title}", + "ui.agent_chat_pick": "Agent", + "ui.agent_chat_placeholder": "Požádejte agenta o změnu návrhu…", + "ui.agent_chat_placeholder_busy": "Zařadit zprávu, až agent skončí (Ctrl+Enter: odeslat hned)", + "ui.agent_chat_privacy": "Nic se nikam neodesílá, jen agentovi, kterého spouštíte pod vlastním účtem.", + "ui.agent_chat_privacy_note": "SolidCraft nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. SolidCraft nikdy nevidí ani neukládá klíč či heslo.", + "ui.agent_chat_queue": "Zařadit", + "ui.agent_chat_queue_full": "Čeká příliš mnoho zpráv: některou odeberte nebo počkejte na agenta.", + "ui.agent_chat_queued": "Ve frontě", + "ui.agent_chat_queued_stopped": "Ve frontě (zastaveno, neodesláno)", + "ui.agent_chat_refresh": "Obnovit", + "ui.agent_chat_refused": "Pro agenta nepovoleno: {reason}", + "ui.agent_chat_remove": "Odebrat z fronty", + "ui.agent_chat_right": "Vpravo", + "ui.agent_chat_save": "Uložit", + "ui.agent_chat_send": "Odeslat", + "ui.agent_chat_send_now": "Odeslat hned", + "ui.agent_chat_send_now_tip": "Zastavit aktuální krok agenta a poslat tuto zprávu jako další (Ctrl+Enter)", + "ui.agent_chat_side": "Strana chatu s agentem", + "ui.agent_chat_signed_in": "přihlášen", + "ui.agent_chat_state_auth": "Vyžadováno přihlášení", + "ui.agent_chat_state_busy": "Pracuje…", + "ui.agent_chat_state_exited": "Agent zastaven", + "ui.agent_chat_state_off": "Nepřipojeno", + "ui.agent_chat_state_ready": "Připraveno", + "ui.agent_chat_state_starting": "Spouštění…", + "ui.agent_chat_stop": "Zastavit", + "ui.agent_chat_stop_tip": "Zastavit agenta a vše, co spustil", + "ui.agent_chat_stop_turn_tip": "Zastavit agenta (Esc). Zprávy ve frontě zůstanou.", + "ui.agent_chat_turn_limit": "Změn na zprávu", + "ui.agent_chat_undo_changes": "Vrátit tyto změny", + "ui.agent_chat_undo_label": "Chat s agentem: {message}", + "ui.agent_chat_undone": "(vráceno)", + "ui.agent_chat_view_attached": "Pohled přiložen", + "ui.agent_cursor": "Kurzor agenta", + "ui.agent_cursor_speed": "Rychlost kurzoru agenta", + "ui.ai_add": "Přidat", + "ui.ai_add_agent": "Přidat agenta…", + "ui.ai_add_agent_title": "Přidat agenta", + "ui.ai_add_api": "API kompatibilní s OpenAI", + "ui.ai_add_custom": "Vlastní příkaz", + "ui.ai_add_installed": "Nainstalovaný agent", + "ui.ai_add_installed_note": "Vlastní položka pro nainstalovaného agenta, například s jinými argumenty. Nainstalovaní agenti se v seznamu zobrazují sami.", + "ui.ai_added": "přidáno", + "ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje SolidCraftu se řídí oprávněními v Předvolby > AI.", + "ui.ai_agents": "Agenti", + "ui.ai_always_allow": "Vždy povolit", + "ui.ai_always_allow_tip": "Od teď povolit {tool} tomuto agentovi bez ptaní", + "ui.ai_always_allowed": "Vždy povoleno", + "ui.ai_always_allowed_none": "Zatím není nic trvale povoleno. Zde se zobrazí odpovědi „Vždy povolit“ zadané v chatu.", + "ui.ai_auto_allowed": "Automaticky povoleno: {title} ({why})", + "ui.ai_auto_mode": "vaše nastavení oprávnění", + "ui.ai_auto_ours": "SolidCraft své nástroje kontroluje sám", + "ui.ai_auto_remembered": "zvolili jste Vždy povolit", + "ui.ai_bad_url": "Zadejte URL začínající http:// nebo https://", + "ui.ai_base_url": "Základní URL", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "kopie", + "ui.ai_custom_url": "Vlastní URL", + "ui.ai_default": "Výchozí", + "ui.ai_http_busy": "endpoint je zaneprázdněný nebo překročil limit; zkuste to později", + "ui.ai_http_key": "endpoint odmítl klíč: zkontrolujte jej v Předvolby > AI", + "ui.ai_http_not_found": "takový endpoint nebo model neexistuje: zkontrolujte základní URL a název modelu", + "ui.ai_key": "API klíč", + "ui.ai_key_hint": "volitelné; uloženo v systémové klíčence", + "ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.", + "ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení SolidCraftu. Pro místní server nechte prázdné.", + "ui.ai_keychain_error": "Systémová klíčenka odmítla: {error}", + "ui.ai_mode_all": "Povolit vše", + "ui.ai_mode_all_note": "Nic se nejdřív neptá. Hlášení o pádech zůstávají nepřístupná a limity složek stále platí.", + "ui.ai_mode_all_warning": "Agent může bez ptaní ukládat, přepisovat, exportovat a nahradit váš návrh. Používejte to jen s agentem, kterému důvěřujete, a na uložených návrzích.", + "ui.ai_mode_ask": "Ptát se pokaždé", + "ui.ai_mode_ask_note": "Každá změna návrhu a každá operace se soubory čeká na vaše Povolit.", + "ui.ai_mode_edits": "Automaticky povolit úpravy modelu", + "ui.ai_mode_edits_note": "Modelovací příkazy běží bez ptaní; ukládání, otevírání, export a další souborové či destruktivní akce se stále ptají.", + "ui.ai_mode_global": "Podle nastavení výše", + "ui.ai_model": "Model", + "ui.ai_modes_scope": "Tato nastavení se týkají nástrojů SolidCraftu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.", + "ui.ai_name": "Název", + "ui.ai_need_command": "Zadejte příkaz, který spouští agenta", + "ui.ai_need_model": "Zadejte název modelu", + "ui.ai_no_answer": "Agent neodpověděl včas", + "ui.ai_no_http": "Tato verze se nemůže připojit k API endpointům", + "ui.ai_no_keychain": "Systémová klíčenka není k dispozici, klíč tedy nelze uložit. Místní server bez klíče funguje dál.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Oprávnění", + "ui.ai_preset": "Předvolba", + "ui.ai_remove": "Odebrat", + "ui.ai_request_limit": "zastaveno po 24 požadavcích na jednu zprávu; pro pokračování pošlete další zprávu", + "ui.ai_reset": "Obnovit", + "ui.ai_settings": "Nastavení AI…", + "ui.ai_show_features": "Zobrazit funkce AI", + "ui.ai_show_features_note": "Chat s agentem: panel, ve kterém na otevřeném návrhu pracuje kódovací agent, kterého již používáte, nebo model na zvoleném endpointu. Vypnuto: žádné tlačítko AI, zkratka, položka nabídky ani panel a nic se nehledá ani nespouští.", + "ui.ai_switch_tip": "Změnit agenta pro tuto konverzaci", + "ui.ai_test": "Test", + "ui.ai_test_acp_ok": "Odpovídá: {agent}", + "ui.ai_test_acp_tip": "Krátce spustí agenta, požádá jej o relaci a zase jej ukončí", + "ui.ai_test_api_tip": "Požádá endpoint o seznam modelů", + "ui.ai_test_no_model": "Spojeno: modelů {count}, ale chybí {model}", + "ui.ai_test_ok": "Spojeno: modelů {count}", + "ui.ai_testing": "Testování…", + "ui.ai_tool_done": "hotovo", + "ui.ai_tool_failed": "selhalo", + "ui.ai_tool_running": "běží", + "ui.ai_tool_unfold": "Kliknutím zobrazíte, co vrátil", + "ui.ai_tool_waiting": "čeká", + "ui.ai_toolbar_button": "Zobrazit tlačítko na panelu nástrojů", + "ui.ai_toolbar_button_note": "Tlačítko AI na pravém konci panelu nástrojů. Ctrl+Shift+A a nabídka Zobrazit panel otevřou i tak.", + "ui.ai_undo_tip": "Vrátit jedním krokem vše, co agent u této zprávy změnil", + "ui.all": "Vše", + "ui.analysis": "Analýza", + "ui.angle": "Úhel", + "ui.angle_decimals": "Desetinná místa úhlu", + "ui.animateJoint": "Animovat kloub", + "ui.animate_view_changes": "Animovat změny pohledu", + "ui.animation": "Animace", + "ui.animation_speed": "Rychlost animace", + "ui.anti_aliasing": "Vyhlazování hran", + "ui.apply": "Použít", + "ui.arc": "Oblouk", + "ui.assemble": "Sestavit", + "ui.autosave_every": "Automaticky ukládat každých", + "ui.axes": "Osy", + "ui.axis": "Osa", + "ui.base_point": "Základní bod", + "ui.blocked": "Blokováno", + "ui.bodies": "Tělesa", + "ui.box": "Kvádr", + "ui.break": "Přerušit", + "ui.calibrate": "Kalibrovat", + "ui.cancel": "Zrušit", + "ui.canvases": "Plátna", + "ui.center": "Střed", + "ui.centerline": "Osa", + "ui.chain": "Řetězec", + "ui.chamfer": "Sražení", + "ui.chamfer_type": "Typ sražení", + "ui.change_workspace": "Změnit pracovní prostředí", + "ui.chord_length": "Délka tětivy", + "ui.circle": "Kružnice", + "ui.circular_pattern": "Kruhové pole", + "ui.clear": "Vymazat", + "ui.clear_recent": "Vymazat nedávné", + "ui.clear_the_selection": "Zrušit výběr", + "ui.click_selects_whole_bodies": "Kliknutí vybírá celá tělesa", + "ui.click_then_press_key": "Klikněte a stiskněte novou klávesu", + "ui.close": "Zavřít", + "ui.coil": "Šroubovice", + "ui.coincident": "Totožnost", + "ui.collapse": "Sbalit", + "ui.collinear": "Kolinearita", + "ui.colour_vision": "Barevné vidění", + "ui.colour_vision_deutan": "Deuteranopie", + "ui.colour_vision_deutan_tip": "Pro oslabené nebo chybějící vnímání zelené", + "ui.colour_vision_failed": "Selhalo", + "ui.colour_vision_fixed": "Pevné", + "ui.colour_vision_mode": "Režim", + "ui.colour_vision_note": "Mění barvy, které nesou význam: stavy náčrtu, osy, ohyby, analýzy a ikony. Pokud používáte barevné filtry Windows, nechte vypnuto.", + "ui.colour_vision_off": "Vypnuto", + "ui.colour_vision_off_tip": "Vlastní barvy motivu", + "ui.colour_vision_projected": "Promítnuté", + "ui.colour_vision_protan": "Protanopie", + "ui.colour_vision_protan_tip": "Pro oslabené nebo chybějící vnímání červené", + "ui.colour_vision_sample": "Ukázka", + "ui.colour_vision_tritan": "Tritanopie", + "ui.colour_vision_tritan_tip": "Pro oslabené nebo chybějící vnímání modré", + "ui.combine": "Kombinovat", + "ui.coming_later": "připravujeme", + "ui.command_done": "{command} hotovo", + "ui.command_palette_hint": "Zadejte příkaz, např. {example} — nebo stiskněte S pro hledání", + "ui.concentric": "Soustřednost", + "ui.configurations": "Konfigurace", + "ui.conic_curve": "Kuželosečka", + "ui.constant": "Konstantní", + "ui.constraints_conflict": "Konflikt vazeb", + "ui.construct": "Konstrukce", + "ui.construction": "Konstrukční", + "ui.context_menu": "Místní nabídka", + "ui.copy": "Kopírovat", + "ui.corner": "Roh", + "ui.corners": "Rohy", + "ui.counterbore": "Válcové zahloubení", + "ui.countersink": "Kuželové zahloubení", + "ui.create": "Vytvořit", + "ui.create_copy": "Vytvořit kopii", + "ui.create_sketch": "Vytvořit náčrt", + "ui.current": "aktuální", + "ui.curvature": "Křivost", + "ui.curvature_g2": "Křivost (G2)", + "ui.curves": "Křivky", + "ui.custom_program": "Vlastní…", + "ui.custom_program_tip": "Vyberte slicer, který se přidá do seznamu", + "ui.customizeToolbar": "Přizpůsobit panel nástrojů…", + "ui.cut": "Vyjmout", + "ui.cylinder": "Válec", + "ui.dark": "Tmavý", + "ui.default_modelling_orientation": "Výchozí orientace modelování", + "ui.degrees_of_freedom": "stupňů volnosti", + "ui.delete": "Smazat", + "ui.depth": "Hloubka", + "ui.design": "Návrh", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Popis", + "ui.design_properties.exports": "Exporty STEP, IGES a 3MF tyto údaje přenášejí, pokud pro ně formát má místo.", + "ui.design_properties.license": "Licence", + "ui.design_properties.not_text": "Uloženo jinak než jako text; psaním zde hodnotu nahradíte.", + "ui.design_properties.organization": "Organizace", + "ui.design_properties.others": "Data jiných nástrojů", + "ui.design_properties.others_note": "Uchovávají se v návrhu beze změny a nikdy se neexportují.", + "ui.design_properties.part_number": "Číslo dílu", + "ui.design_properties.project": "Projekt", + "ui.design_properties.revision": "Revize", + "ui.diameter": "Průměr", + "ui.dimension": "Kóta", + "ui.direction": "Směr", + "ui.display_quality": "Kvalita zobrazení", + "ui.display_quality_coarse": "Hrubá", + "ui.display_quality_fine": "Jemná", + "ui.display_quality_normal": "Normální", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Vzdálenost", + "ui.distance_1": "Vzdálenost 1", + "ui.distance_2": "Vzdálenost 2", + "ui.document_settings": "Nastavení dokumentu", + "ui.don_t_save": "Neukládat", + "ui.draft": "Úkos", + "ui.drawing": "Výkres", + "ui.drill_point": "Špička vrtáku", + "ui.edit": "Upravit", + "ui.editJointLimits": "Upravit limity kloubu", + "ui.edit_section_analysis": "Upravit analýzu řezu", + "ui.ellipse": "Elipsa", + "ui.end_radius": "Koncový poloměr", + "ui.end_scale": "Koncové měřítko", + "ui.entities": "Prvky", + "ui.equal": "Rovnost", + "ui.equivalent_radius": "Ekvivalentní poloměr", + "ui.expand": "Rozbalit", + "ui.exportDxf": "Exportovat DXF…", + "ui.export_ellipsis": "Exportovat...", + "ui.export_ellipsis_keymap": "Exportovat…", + "ui.export_keymap_tip": "Klávesy, oblíbené z vyhledávání S a panel nástrojů jako soubor", + "ui.extend": "Prodloužit", + "ui.extent_type": "Typ rozsahu", + "ui.extrude": "Vysunutí", + "ui.factor": "Činitel", + "ui.feature_history": "Historie prvků", + "ui.feature_options": "Možnosti prvku", + "ui.features": "Prvky", + "ui.file": "Soubor", + "ui.fillet": "Zaoblení", + "ui.filter_commands_or_keys": "Filtrovat příkazy nebo klávesy", + "ui.findInTimeline": "Najít na časové ose", + "ui.findInWindow": "Najít v okně", + "ui.finish_sketch": "Dokončit náčrt", + "ui.fit": "Přizpůsobit", + "ui.fix_unfix": "Zafixovat/uvolnit", + "ui.flat": "Plochý", + "ui.flip": "Převrátit", + "ui.format": "Formát", + "ui.frame_rate": "Snímková frekvence", + "ui.front": "Zepředu", + "ui.fully_constrained": "Plně zavazbeno", + "ui.general": "Obecné", + "ui.graphics": "Grafika", + "ui.ground_shadow": "Stín na podložce", + "ui.groupSelected": "Seskupit vybrané", + "ui.heading_add_ins": "DOPLŇKY", + "ui.heading_assemble": "SESTAVA", + "ui.heading_box": "KVÁDR", + "ui.heading_chamfer": "SRAŽENÍ", + "ui.heading_circular_pattern": "KRUHOVÉ POLE", + "ui.heading_coil": "ŠROUBOVICE", + "ui.heading_combine": "KOMBINOVAT", + "ui.heading_configure": "KONFIGURACE", + "ui.heading_constraints": "VAZBY", + "ui.heading_construct": "KONSTRUKCE", + "ui.heading_create": "VYTVOŘIT", + "ui.heading_create_sketch": "VYTVOŘIT NÁČRT", + "ui.heading_cylinder": "VÁLEC", + "ui.heading_design": "NÁVRH", + "ui.heading_draft": "ÚKOS", + "ui.heading_edit": "UPRAVIT", + "ui.heading_extrude": "VYSUNUTÍ", + "ui.heading_fillet": "ZAOBLENÍ", + "ui.heading_finish_sketch": "DOKONČIT NÁČRT", + "ui.heading_front": "ZEPŘEDU", + "ui.heading_hole": "OTVOR", + "ui.heading_home": "DOMŮ", + "ui.heading_insert": "VLOŽIT", + "ui.heading_inspect": "ANALÝZA", + "ui.heading_loft": "SPOJENÍ PROFILŮ", + "ui.heading_make": "VÝROBA", + "ui.heading_measure": "MĚŘENÍ", + "ui.heading_mesh": "SÍŤ", + "ui.heading_mirror": "ZRCADLIT", + "ui.heading_modify": "UPRAVIT", + "ui.heading_move": "POSUNOUT", + "ui.heading_move_copy": "POSUNOUT/KOPÍROVAT", + "ui.heading_offset": "ODSAZENÍ", + "ui.heading_offset_plane": "ODSAZENÁ ROVINA", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARAMETRY", + "ui.heading_pattern": "POLE", + "ui.heading_pattern_on_path": "POLE PO CESTĚ", + "ui.heading_pipe": "TRUBKA", + "ui.heading_plane_at_angle": "ROVINA POD ÚHLEM", + "ui.heading_plastic": "PLASTY", + "ui.heading_position": "POLOHA", + "ui.heading_prepare": "PŘÍPRAVA", + "ui.heading_rectangular_pattern": "OBDÉLNÍKOVÉ POLE", + "ui.heading_revolve": "ROTACE", + "ui.heading_right": "ZPRAVA", + "ui.heading_save_mesh": "ULOŽIT JAKO SÍŤ", + "ui.heading_scale": "MĚŘÍTKO", + "ui.heading_section_analysis": "ANALÝZA ŘEZU", + "ui.heading_select": "VÝBĚR", + "ui.heading_setup": "NASTAVENÍ", + "ui.heading_sheet_metal": "PLECH", + "ui.heading_shell": "SKOŘEPINA", + "ui.heading_sketch": "NÁČRT", + "ui.heading_sketch_palette": "PALETA NÁČRTU", + "ui.heading_sketch_scale": "MĚŘÍTKO NÁČRTU", + "ui.heading_solid": "TĚLESO", + "ui.heading_sphere": "KOULE", + "ui.heading_split_body": "ROZDĚLIT TĚLESO", + "ui.heading_surface": "PLOCHY", + "ui.heading_sweep": "TAŽENÍ", + "ui.heading_thread": "ZÁVIT", + "ui.heading_top": "SHORA", + "ui.heading_torus": "ANULOID", + "ui.heading_utilities": "NÁSTROJE", + "ui.heading_utility": "NÁSTROJE", + "ui.help": "Nápověda", + "ui.hide": "Skrýt", + "ui.hideAxes": "Skrýt osy", + "ui.hideDimension": "Skrýt kótu", + "ui.hideOrigin": "Skrýt počátek", + "ui.hidePlanes": "Skrýt roviny", + "ui.hideProfile": "Skrýt profil", + "ui.hole": "Otvor", + "ui.hole_tap_type": "Typ závitu otvoru", + "ui.hole_type": "Typ otvoru", + "ui.home": "Domů", + "ui.homeView": "Výchozí pohled", + "ui.horizontal_vertical": "Vodorovně/svisle", + "ui.import_ellipsis": "Importovat…", + "ui.infinite": "Nekonečná", + "ui.insert": "Vložit", + "ui.inspect": "Analýza", + "ui.intersect": "Průnik", + "ui.isolate": "Izolovat", + "ui.join": "Sjednotit", + "ui.joints": "Klouby", + "ui.keyboard_shortcuts": "Klávesové zkratky", + "ui.language": "Jazyk", + "ui.length_decimals": "Desetinná místa délky", + "ui.light": "Světlý", + "ui.line": "Úsečka", + "ui.linetype": "Typ čáry", + "ui.lock": "Zamknout", + "ui.loft": "Spojení profilů", + "ui.look_at": "Pohled kolmo", + "ui.look_at_anim_desc": "Pohled podél osy, pohled kolmo, výchozí pohled a přizpůsobení", + "ui.manufacture": "Výroba", + "ui.material_review.axes": "Lokální osy materiálu; orientace tělesa není přiřazena.", + "ui.material_review.claims": "Zdroje a licence jsou neověřená tvrzení. Chybějící hodnoty zůstávají neznámé.", + "ui.material_review.compressive_strength": "Pevnost v tlaku", + "ui.material_review.conditions": "Uvedené podmínky", + "ui.material_review.density": "Hustota", + "ui.material_review.derived": "Odvozené izotropní moduly", + "ui.material_review.elasticity": "Pružnost", + "ui.material_review.input": "Vložte materiálovou kartu podle schématu 1 (JSON)", + "ui.material_review.limit": "Karta překračuje vstupní limit 256 KiB.", + "ui.material_review.moisture_unit": "kg vody / kg suchého materiálu", + "ui.material_review.properties": "Vlastnosti", + "ui.material_review.review": "Zkontrolovat kartu", + "ui.material_review.scope": "Pouze kandidát: nepřiřazeno ani neuloženo. Numerické ověření není inženýrská certifikace.", + "ui.material_review.shear_strength": "Pevnost ve smyku", + "ui.material_review.source": "Zdroj", + "ui.material_review.specific_heat": "Měrná tepelná kapacita", + "ui.material_review.tensile_strength": "Pevnost v tahu", + "ui.material_review.thermal_conductivity": "Tepelná vodivost", + "ui.material_review.thermal_expansion": "Tepelná roztažnost", + "ui.material_review.title": "Kontrola dat fyzického materiálu", + "ui.material_review.unknown": "Neznámé", + "ui.material_review.yield_strength": "Mez kluzu", + "ui.measure": "Měřit", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF se značkami ploch", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Střed", + "ui.mirror": "Zrcadlit", + "ui.mirror_line": "Osa zrcadlení", + "ui.modify": "Upravit", + "ui.move": "Posunout", + "ui.multiple": "Více", + "ui.named_views": "Pojmenované pohledy", + "ui.navigation": "Navigace", + "ui.needs_design": "{command}: nejprve otevřete návrh", + "ui.newSketchGroup": "Nová skupina náčrtů", + "ui.new_body": "Nové těleso", + "ui.new_design": "Nový návrh", + "ui.no_slicer_found": "Nebyl nalezen žádný slicer", + "ui.normal": "Běžná", + "ui.normalConstruction": "Běžná / konstrukční", + "ui.not_a_program": "{path} není spustitelný program", + "ui.object_type": "Typ objektu", + "ui.objects": "Objekty", + "ui.offer_to_stand_up_y_up_files": "Nabízet postavení souborů s osou Y nahoru", + "ui.offset": "Odsazení", + "ui.offset_plane": "Odsazená rovina", + "ui.one_side": "Jedna strana", + "ui.open": "Otevřít", + "ui.open_ellipsis": "Otevřít...", + "ui.open_preferences": "Otevřít předvolby", + "ui.open_recent": "Otevřít nedávné", + "ui.operation": "Operace", + "ui.options": "Možnosti", + "ui.orbit": "Otáčení", + "ui.orbit_around": "Otáčet kolem", + "ui.orientation": "Orientace", + "ui.origin": "Počátek", + "ui.palette_points": "Body", + "ui.palette_profile": "Profil", + "ui.pan": "Posun", + "ui.pan_zoom_orbit": "Posun, přiblížení, otáčení", + "ui.parallel": "Rovnoběžnost", + "ui.parameters": "Parametry", + "ui.paste": "Vložit", + "ui.path": "Cesta", + "ui.pattern_on_path": "Pole po cestě", + "ui.perpendicular": "Kolmost", + "ui.perspective_view": "Perspektivní pohled", + "ui.pinToShortcuts": "Připnout ke zkratkám", + "ui.pinToToolbar": "Připnout na panel nástrojů", + "ui.pipe": "Trubka", + "ui.placement": "Umístění", + "ui.plane": "Rovina", + "ui.plane_at_angle": "Rovina pod úhlem", + "ui.point": "Bod", + "ui.point_under_the_cursor": "Bod pod kurzorem", + "ui.points": "Body", + "ui.polygon": "Mnohoúhelník", + "ui.preferences": "Předvolby", + "ui.press_a_key": "stiskněte klávesu…", + "ui.profile": "Profil", + "ui.profile_plane": "Rovina profilu", + "ui.profiles": "Profily", + "ui.project": "Promítnout", + "ui.project_face_edges_sketch": "Promítat hrany plochy do nových náčrtů", + "ui.properties": "Vlastnosti", + "ui.pull_direction": "Směr vyjímání", + "ui.quantity": "Počet", + "ui.quantity_2": "Počet 2", + "ui.quit": "Ukončit", + "ui.radial_context_menu": "Kruhová místní nabídka", + "ui.radius": "Poloměr", + "ui.radius_type": "Typ poloměru", + "ui.reachable": "Dosažitelné", + "ui.rectangle": "Obdélník", + "ui.rectangular_pattern": "Obdélníkové pole", + "ui.redo": "Znovu", + "ui.removeFromToolbar": "Odebrat z panelu nástrojů", + "ui.removeSection": "Odebrat řez", + "ui.render": "Render", + "ui.renderer": "Vykreslovač", + "ui.repeat": "Opakovat", + "ui.report_a_bug": "Nahlásit chybu", + "ui.reset": "Obnovit", + "ui.reset_all": "Obnovit vše", + "ui.reset_the_settings_on_this_page": "Obnovit nastavení na této stránce", + "ui.restore": "Obnovit", + "ui.restore_defaults": "Obnovit výchozí", + "ui.reverse_zoom_direction": "Obrátit směr přiblížení", + "ui.revolve": "Rotace", + "ui.right": "Zprava", + "ui.save": "Uložit", + "ui.save_as": "Uložit jako", + "ui.save_as_ellipsis": "Uložit jako...", + "ui.save_mesh_no_slicer": "Nebyl nalezen žádný slicer: zvolte Vlastní… a vyberte svůj slicer, nebo vypněte Odeslat do sliceru", + "ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení", + "ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}", + "ui.saved_to": "Uloženo {path}", + "ui.scale": "Měřítko", + "ui.section_analysis": "Analýza řezu", + "ui.select": "Vybrat", + "ui.selectOther": "Vybrat jiné…", + "ui.selectTangentChain": "Vybrat tečný řetězec", + "ui.select_other_hint": "Vybrat jiné: podržte levé tlačítko na modelu (nebo pravé tlačítko › Vybrat jiné…) a zobrazí se vše pod kurzorem; dalším kliknutím na stejné místo vyberete další prvek.", + "ui.selection": "Výběr", + "ui.send": "Odeslat", + "ui.send_to_slicer": "Odeslat do sliceru", + "ui.sent_to_slicer": "Odesláno do {slicer}", + "ui.shell": "Skořepina", + "ui.show": "Zobrazit", + "ui.showAll": "Zobrazit vše", + "ui.showAllBodies": "Zobrazit všechna tělesa", + "ui.showAxes": "Zobrazit osy", + "ui.showDimension": "Zobrazit kótu", + "ui.showOrigin": "Zobrazit počátek", + "ui.showPlanes": "Zobrazit roviny", + "ui.show_constraints": "Vazby", + "ui.show_construction_geometries": "Konstrukční geometrie", + "ui.show_dimensions": "Kóty", + "ui.show_points": "Zobrazit body", + "ui.show_profile": "Zobrazit profil", + "ui.show_projected_geometries": "Promítnuté geometrie", + "ui.simple": "Jednoduchý", + "ui.simulation": "Simulace", + "ui.single": "Jeden", + "ui.sketch": "Náčrt", + "ui.sketch_dimension": "Kóta náčrtu", + "ui.sketch_grid": "Mřížka náčrtu", + "ui.sketch_scale": "Měřítko náčrtu", + "ui.sketch_status": "Stav náčrtu", + "ui.sketches": "Náčrty", + "ui.slice": "Řez", + "ui.slicer": "Slicer", + "ui.slicer_delete_tip": "{program}\nKlávesou Delete jej odeberete ze seznamu", + "ui.slot": "Drážka", + "ui.smooth": "Hladký", + "ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem", + "ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání", + "ui.snap": "Přichytávání", + "ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.", + "ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse", + "ui.spacing": "Rozteč", + "ui.spacing_2": "Rozteč 2", + "ui.sphere": "Koule", + "ui.spline": "Splajn", + "ui.split_body": "Rozdělit těleso", + "ui.stand_up_tip": "Když se soubor STEP nebo IGES otevře s osou Z nahoru, nabídnout otočení o čtvrt otáčky kolem X", + "ui.start": "Start", + "ui.start_radius": "Počáteční poloměr", + "ui.supersampling": "Supersampling (SSAA)", + "ui.supersampling_adaptive": "Jen když je pohled v klidu", + "ui.supersampling_off": "Vypnuto (1×)", + "ui.suppress": "Potlačit", + "ui.sweep": "Tažení", + "ui.sweep_follow_direction": "Sledovat směr", + "ui.sweep_keep_as_drawn": "Zachovat jak nakresleno", + "ui.sweep_turn_with_path": "Natáčet podle cesty", + "ui.symmetric": "Symetricky", + "ui.symmetry": "Symetrie", + "ui.system": "Systém", + "ui.tangent": "Tečnost", + "ui.taper_angle": "Úhel zúžení", + "ui.tapped": "Se závitem", + "ui.text": "Text", + "ui.theme": "Motiv", + "ui.thickness": "Tloušťka", + "ui.thread": "Závit", + "ui.timeline_bottom": "Časová osa dole", + "ui.timeline_left_note": "Rozložení vlevo zobrazuje lištu historie nad prohlížečem dílů a scény.", + "ui.timeline_left_sidebar": "Levý postranní panel", + "ui.tip_angle": "Úhel špičky", + "ui.to_object": "K objektu", + "ui.tool_direction": "Směr nástroje", + "ui.top": "Shora", + "ui.torus": "Anuloid", + "ui.total_angle": "Celkový úhel", + "ui.trackpad_pan_tip": "Gesta na trackpadu posouvají (vypnuto: přibližují jako kolečko); kolečko myši přibližuje, palcové kolečko posouvá do stran, sevření prstů vždy přibližuje", + "ui.trim": "Oříznout", + "ui.twist": "Zkroucení", + "ui.two_finger_scroll_pans": "Posouvání dvěma prsty posouvá pohled", + "ui.two_sides": "Dvě strany", + "ui.ui_scale": "Měřítko rozhraní", + "ui.undo": "Zpět", + "ui.unground": "Zrušit ukotvení", + "ui.unisolate": "Zrušit izolaci", + "ui.units": "Jednotky", + "ui.units_of_new_designs": "Jednotky nových návrhů", + "ui.unlock": "Odemknout", + "ui.unsuppress": "Zrušit potlačení", + "ui.untitled": "Bez názvu", + "ui.up_axis_note": "Která osa v pohledech míří nahoru (pohled podél osy, výchozí pohled, pohled kolmo). Návrhy se nemění.", + "ui.updateToCurrentView": "Aktualizovat na aktuální pohled", + "ui.variable": "Proměnný", + "ui.view_centre": "Střed pohledu", + "ui.visual_shaded_hidden_edges": "Stínovaný se skrytými hranami", + "ui.visual_shaded_with_edges": "Stínovaný s hranami", + "ui.visual_style": "Vizuální styl", + "ui.visual_wireframe": "Drátový model", + "ui.workspace": "pracovní prostředí", + "ui.x_distance": "Vzdálenost X", + "ui.y_distance": "Vzdálenost Y", + "ui.y_up": "Y nahoru", + "ui.z_up": "Z nahoru", + "ui.zoom": "Přiblížení", + "ui.zoom_to_cursor_tip": "Kolečko přibližuje k bodu pod kurzorem (vypnuto: ke středu pohledu)", + "ui.zoom_to_the_cursor": "Přibližovat ke kurzoru", + "update.available": "Je k dispozici SolidCraft {version}", + "update.button": "Aktualizovat", + "update.button_tip": "Je k dispozici SolidCraft {version}", + "update.button_version": "Aktualizovat na {version}", + "update.check_automatically": "Automaticky kontrolovat aktualizace", + "update.check_now": "Zkontrolovat nyní", + "update.checking": "Kontrola…", + "update.copy": "Kopírovat", + "update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte SolidCraft do Aplikací místo staré kopie a poté SolidCraft restartujte.", + "update.download": "Stáhnout", + "update.download_open": "Stáhnout a otevřít", + "update.downloading": "Stahování a ověřování… {done} z {total} MB", + "update.failed": "Kontrola se nezdařila: {error}", + "update.file": "Stáhne se {file}.", + "update.install_failed": "Aktualizace se nenainstalovala: {error}. Nic se nezměnilo; tlačítkem Stáhnout získáte balíček.", + "update.install_msi": "Nainstalovat (zavře SolidCraft)", + "update.installed": "SolidCraft {version} je nainstalován. Pro použití restartujte.", + "update.installer_started": "Instalátor se spustí po zavření SolidCraftu.", + "update.later": "Připomenout později", + "update.no_notes": "Tato verze nemá poznámky.", + "update.package_hint": "Tato kopie pochází z {kind}. Stáhněte nový balíček a nainstalujte jej pomocí:", + "update.prefs_dev": "Toto je vývojová verze ({version}): aktualizace nekontroluje.", + "update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. SolidCraft nekontroluje.", + "update.prefs_label": "Aktualizace", + "update.prefs_note": "Toto je SolidCraft {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.", + "update.release_page": "Otevřít stránku vydání", + "update.restart": "Restartovat nyní", + "update.restart_later": "Později", + "update.show": "Zobrazit aktualizaci na {version}", + "update.skip": "Přeskočit tuto verzi", + "update.system_hint": "Tato kopie je {kind}. Aktualizujte ji pomocí:", + "update.title": "Je k dispozici aktualizace", + "update.up_to_date": "SolidCraft je aktuální.", + "update.update_now": "Aktualizovat nyní", + "update.you_have": "Máte {version}." +} diff --git a/assets/locales/de.json b/assets/locales/de.json index c74bd78c..58467f62 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -4,15 +4,21 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.", "3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.", "AI": "KI", + "Acceleration": "Beschleunigung", + "Against": "Gegen", "Alignment": "Ausrichtung", + "All components": "Alle Komponenten", "Arc length": "Bogenlänge", "Area": "Fläche", "BROWSER": "BROWSER", "Bends": "Biegungen", "Boundary": "Begrenzung", "Centre to centre": "Mitte zu Mitte", + "Check collisions": "Kollisionen prüfen", "Circumference": "Umfang", + "Collisions": "Kollisionen", "Construction": "Konstruktionslinie", + "Contact sets": "Kontaktsätze", "Continuity": "Stetigkeit", "Copy Error Message": "Fehlermeldung kopieren", "Copy Warning Message": "Warnmeldung kopieren", @@ -21,9 +27,13 @@ "Current perspective view": "Aktuelle perspektivische Ansicht", "Curvature (G2)": "Krümmung (G2)", "Do not export hidden edges": "Verdeckte Kanten nicht exportieren", + "Don't ask again": "Nicht mehr fragen", "Drive Joint": "Gelenk antreiben", + "Duration": "Dauer", "Edges": "Kanten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", + "Explode Text": "Text auflösen", + "Export Animation…": "Animation exportieren…", "Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren", "Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…", "Export DXF…": "DXF exportieren…", @@ -32,6 +42,7 @@ "Faces": "Flächen", "Find in Browser": "Im Browser suchen", "Flat": "Flach", + "Gap": "Spalt", "Hidden edges": "Verdeckte Kanten", "Holes": "Löcher", "Holes (edges on them)": "Löcher (ihre Kanten)", @@ -41,6 +52,7 @@ "Max": "Max", "Min": "Min", "Mitre": "Gehrung", + "New study": "Neue Untersuchung", "No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.", "None": "Keine", "Notch": "Ausklinkung", @@ -48,9 +60,13 @@ "Overall length": "Gesamtlänge", "Pass through": "Durch Punkte", "Picked": "Ausgewählte", + "Picked point": "Gewählter Punkt", + "Plot": "Diagramm", "Position (G0)": "Position (G0)", + "Preferences › Design turns the offer back on": "Unter Einstellungen › Design lässt sich das Angebot wieder einschalten", "Profiles/Curves": "Profile/Kurven", "Projection": "Projektion", + "Quantity": "Größe", "RULED SURFACE": "REGELFLÄCHE", "Rectangular": "Rechteckig", "Reference face": "Referenzfläche", @@ -59,6 +75,7 @@ "Restore outer boundary": "Äußere Begrenzung wiederherstellen", "Revert Position": "Position zurücksetzen", "Round": "Rund", + "Save the study as an animated GIF, seen as the viewport shows it": "Die Studie als animiertes GIF speichern, so wie der Ansichtsbereich sie zeigt", "Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)", "Selection filter": "Auswahlfilter", "Send your report on GitHub": "Bericht auf GitHub senden", @@ -68,6 +85,7 @@ "Sketch geometry and profiles": "Skizzengeometrie und Profile", "Sketch points": "Skizzenpunkte", "Slot width": "Langlochbreite", + "Study": "Untersuchung", "Tangent (G1)": "Tangente (G1)", "Tear": "Reißen", "Thickness Outside": "Dicke außen", @@ -76,6 +94,7 @@ "Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.", "Type": "Typ", "UNTRIM": "TRIMMEN AUFHEBEN", + "Velocity": "Geschwindigkeit", "Vertices": "Scheitelpunkte", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Nur sichtbare Körper. Kurven werden durch Liniensegmente angenähert (Toleranz: 0,01 mm).", "Wall Location": "Wandlage", @@ -92,6 +111,13 @@ "browser.precision": "Genauigkeit: {precision}", "browser.sketch_name": "Skizze {number}", "browser.units": "Einheiten: {units}", + "cam.flat": "Flachstößel", + "cam.follower": "Stößel", + "cam.knife": "Schneide", + "cam.needs_joints": "braucht ein Gelenk, das sich dreht, und eines, das gleitet oder schwenkt", + "cam.roller": "Rolle", + "cam.roller_pick": "Rolle (optional)", + "cam.tip_pick": "Spitze (optional)", "cmd.appearance_assign": "Erscheinungsbild", "cmd.appearance_library": "Erscheinungsbild-Bibliothek", "cmd.appearance_list": "Erscheinungsbilder auflisten", @@ -195,6 +221,7 @@ "cmd.inspect_minimum_radius": "Mindestradiusanalyse", "cmd.inspect_zebra": "Zebra-Analyse", "cmd.joint_as_built": "Fertigungsgelenk", + "cmd.joint_cam": "Nocken", "cmd.joint_create": "Gelenk", "cmd.joint_delete": "Gelenk löschen", "cmd.joint_drive": "Gelenke antreiben", @@ -225,8 +252,11 @@ "cmd.model_threads": "Gewinde auflisten", "cmd.motion_delete": "Bewegungsuntersuchung löschen", "cmd.motion_export": "Bewegungsuntersuchung exportieren", + "cmd.motion_export_animation": "Bewegungsanimation exportieren", + "cmd.motion_interference": "Kollisionen der Bewegungsuntersuchung", "cmd.motion_list": "Bewegungsuntersuchungen auflisten", "cmd.motion_play": "Bewegungsuntersuchung abspielen", + "cmd.motion_plot": "Diagramm der Bewegungsuntersuchung", "cmd.motion_study": "Bewegungsuntersuchung", "cmd.motion_trace": "Bewegungsbahn", "cmd.motion_trace_keep": "Bewegungsbahn behalten", @@ -428,6 +458,18 @@ "drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen", "drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen", "drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden", + "dxf.cancel_wait": "Warten, bis der Export angehalten wurde…", + "dxf.cancelled": "DXF-Export abgebrochen", + "dxf.cancelling": "DXF-Export wird abgebrochen", + "dxf.exporting": "DXF wird exportiert", + "dxf.invalid_tolerance": "Die Kurventoleranz muss zwischen 0,0001 und 1 mm liegen.", + "dxf.progress.index": "Flächen vorbereiten (2/5)", + "dxf.progress.meshes": "Netze vorbereiten (1/5)", + "dxf.progress.occlusion": "Verdeckte Kanten ermitteln (4/5)", + "dxf.progress.projection": "Geometrie projizieren (3/5)", + "dxf.progress.write": "DXF schreiben (5/5)", + "dxf.tolerance": "Kurventoleranz", + "dxf.tolerance_help": "Nur sichtbare Körper. Kurven werden durch gerade Segmente angenähert. Eine kleinere Toleranz liefert feinere Kurven und benötigt mehr Zeit und Speicher.", "home.documentation": "Dokumentation", "home.in_this_browser": "in diesem Browser", "home.missing": "fehlt", @@ -456,6 +498,10 @@ "joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.", "joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.", "joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.", + "motion.checked_to": "Nur bis Schritt {step} geprüft: der Prüfaufwand ist erschöpft", + "motion.collision": "{a} · {b}: Schritte {steps}", + "motion.duration_hint": "Sekunden (leer: pro Schritt)", + "motion.no_collisions": "Keine Kollisionen in der Untersuchung", "nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)", @@ -767,6 +813,9 @@ "ui.constraints_conflict": "Bedingungen konfliktreich", "ui.construct": "Konstruktion", "ui.construction": "Konstruktionslinie", + "ui.continuity_g0": "G0 Knick", + "ui.continuity_g1": "G1 Tangente", + "ui.continuity_g2": "G2 Krümmung", "ui.copy": "Kopieren", "ui.corner": "Ecke", "ui.corners": "Ecken", @@ -1035,6 +1084,8 @@ "ui.quit": "Beenden", "ui.radius": "Radius", "ui.radius_type": "Radius-Typ", + "ui.rail": "Leitkurve", + "ui.rail_fit": "Anpassung an Leitkurve", "ui.reachable": "Erreichbar", "ui.rectangle": "Rechteck", "ui.rectangular_pattern": "Rechteckmuster", @@ -1058,6 +1109,7 @@ "ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}", "ui.saved_to": "Gespeichert: {path}", "ui.scale": "Skalieren", + "ui.second_rail": "Zweite Leitkurve", "ui.section_analysis": "Querschnittsanalyse", "ui.select": "Auswählen", "ui.selectOther": "Andere auswählen…", @@ -1102,6 +1154,7 @@ "ui.split_body": "Körper trennen", "ui.start": "Start", "ui.start_radius": "Startradius", + "ui.stretch": "Strecken", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Nur bei ruhender Ansicht", "ui.supersampling_off": "Aus (1×)", diff --git a/assets/locales/en.json b/assets/locales/en.json index ffbe52a3..18130fac 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "A new configuration, a copy of the active one", "AI": "AI", "About SolidCraft": "About SolidCraft", + "Acceleration": "Acceleration", "Active": "Active", "Active rule": "Active rule", "Add": "Add", @@ -14,8 +15,10 @@ "Add Mid Radius": "Add Mid Radius", "Add Row": "Add Row", "Add condition": "Add condition", + "Against": "Against", "Alignment": "Alignment", "All Sketch Points": "All Sketch Points", + "All components": "All components", "All open designs": "All open designs", "Allowed expressions, separated by semicolons": "Allowed expressions, separated by semicolons", "Also deletes": "Also deletes", @@ -57,6 +60,7 @@ "Configuration rules": "Configuration rules", "Construction": "Construction", "Contact": "Contact", + "Contact sets": "Contact sets", "Continuity": "Continuity", "Copy Error Message": "Copy Error Message", "Copy Warning Message": "Copy Warning Message", @@ -84,14 +88,18 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.", "Discard": "Discard", "Do not export hidden edges": "Do not export hidden edges", + "Don't ask again": "Don't ask again", "Double-click to rename": "Double-click to rename", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing", "Drive Joint": "Drive Joint", + "Duration": "Duration", "Edges": "Edges", "Edit Profile Sketch": "Edit Profile Sketch", "Enable rule": "Enable rule", "Enabled": "Enabled", "Explode": "Explode", + "Explode Text": "Explode Text", + "Export Animation…": "Export Animation…", "Export Current View as DXF": "Export Current View as DXF", "Export Current View as DXF…": "Export Current View as DXF…", "Export DXF…": "Export DXF…", @@ -149,6 +157,8 @@ "Move earlier": "Move earlier", "Move later": "Move later", "Name": "Name", + "Nearest": "Nearest", + "New study": "New study", "No crash reports.": "No crash reports.", "No matching appearances": "No matching appearances", "No visible bodies to export.": "No visible bodies to export.", @@ -167,16 +177,20 @@ "Per Edge": "Per Edge", "Pick the tool bodies": "Pick the tool bodies", "Picked": "Picked", + "Picked point": "Picked point", "Place at the origin instead": "Place at the origin instead", "Playback": "Playback", + "Plot": "Plot", "Position": "Position", "Position (0 to 1)": "Position (0 to 1)", "Position (G0)": "Position (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Position along the edge, 0 at the start end, 1 at the other", + "Preferences › Design turns the offer back on": "Preferences › Design turns the offer back on", "Preview Row": "Preview Row", "Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.", "Profiles/Curves": "Profiles/Curves", "Projection": "Projection", + "Quantity": "Quantity", "RECENT": "RECENT", "RULED SURFACE": "RULED SURFACE", "Radius Fit": "Radius Fit", @@ -211,6 +225,7 @@ "Round": "Round", "Rule": "Rule", "Save Rule": "Save Rule", + "Save the study as an animated GIF, seen as the viewport shows it": "Save the study as an animated GIF, seen as the viewport shows it", "Saved preview. Geometry is being checked before editing.": "Saved preview. Geometry is being checked before editing.", "Scale factor": "Scale factor", "Search": "Search", @@ -241,6 +256,7 @@ "Stand this part up?": "Stand this part up?", "Step": "Step", "Steps": "Steps", + "Study": "Study", "Style": "Style", "Suppressed": "Suppressed", "Swap Ends": "Swap Ends", @@ -260,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.", "To Offset": "To Offset", "Tolerance": "Tolerance", + "Too close": "Too close", "Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.", "Type": "Type", "UNTRIM": "UNTRIM", @@ -270,6 +287,7 @@ "Use selected face as fixed plane": "Use selected face as fixed plane", "VIEW": "VIEW", "Value": "Value", + "Velocity": "Velocity", "Vertices": "Vertices", "View navigator": "View navigator", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Visible bodies only. Curves use line segments (0.01 mm tolerance).", @@ -293,6 +311,13 @@ "browser.precision": "Precision: {precision}", "browser.sketch_name": "Sketch{number}", "browser.units": "Units: {units}", + "cam.flat": "Flat face", + "cam.follower": "Follower", + "cam.knife": "Knife edge", + "cam.needs_joints": "needs a joint that turns and one that slides or swings", + "cam.roller": "Roller", + "cam.roller_pick": "Roller (optional)", + "cam.tip_pick": "Tip (optional)", "cmd.appearance_assign": "Appearance", "cmd.appearance_library": "Appearance Library", "cmd.appearance_list": "List Appearances", @@ -385,6 +410,7 @@ "cmd.file_slicers": "Installed Slicers", "cmd.inspect_accessibility": "Accessibility Analysis", "cmd.inspect_center_of_mass": "Center of Mass", + "cmd.inspect_clearance": "Clearance", "cmd.inspect_compare": "Compare Bodies", "cmd.inspect_continuity": "Continuity Analysis", "cmd.inspect_curvature_comb": "Curvature Comb Analysis", @@ -394,8 +420,10 @@ "cmd.inspect_interference": "Interference", "cmd.inspect_isocurve": "Isocurve Analysis", "cmd.inspect_minimum_radius": "Minimum Radius Analysis", + "cmd.inspect_silhouette": "Silhouette", "cmd.inspect_zebra": "Zebra Analysis", "cmd.joint_as_built": "As-Built Joint", + "cmd.joint_cam": "Cam", "cmd.joint_create": "Joint", "cmd.joint_delete": "Delete Joint", "cmd.joint_drive": "Drive Joints", @@ -426,8 +454,11 @@ "cmd.model_threads": "List Threads", "cmd.motion_delete": "Delete Motion Study", "cmd.motion_export": "Export Motion Study", + "cmd.motion_export_animation": "Export Motion Animation", + "cmd.motion_interference": "Motion Interference", "cmd.motion_list": "List Motion Studies", "cmd.motion_play": "Play Motion Study", + "cmd.motion_plot": "Plot Motion Study", "cmd.motion_study": "Motion Study", "cmd.motion_trace": "Motion Trace", "cmd.motion_trace_keep": "Keep Motion Trace", @@ -631,6 +662,18 @@ "drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}", "drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further", "drive.unconnected": "Moves nothing: {part} is not connected to anything grounded", + "dxf.cancel_wait": "Waiting for the export to stop…", + "dxf.cancelled": "DXF export cancelled", + "dxf.cancelling": "Cancelling DXF export", + "dxf.exporting": "Exporting DXF", + "dxf.invalid_tolerance": "Curve tolerance must be between 0.0001 and 1 mm.", + "dxf.progress.index": "Preparing surfaces (2/5)", + "dxf.progress.meshes": "Preparing meshes (1/5)", + "dxf.progress.occlusion": "Resolving hidden lines (4/5)", + "dxf.progress.projection": "Projecting geometry (3/5)", + "dxf.progress.write": "Writing DXF (5/5)", + "dxf.tolerance": "Curve tolerance", + "dxf.tolerance_help": "Visible bodies only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.", "expression, e.g. 25 mm": "expression, e.g. 25 mm", "fits the face, e.g. M8": "fits the face, e.g. M8", "from the rule": "from the rule", @@ -662,6 +705,10 @@ "joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.", "joint.one_body": "Both picks are on {body}. Pick the second one on another part.", "joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.", + "motion.checked_to": "Checked to step {step} only: the check ran out of its work budget", + "motion.collision": "{a} · {b}: steps {steps}", + "motion.duration_hint": "seconds (empty: per step)", + "motion.no_collisions": "No collisions over the study", "nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)", @@ -760,6 +807,10 @@ "sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve", "sketch_text.use_font_file": "Use the font file {path}", "the parts library is not available": "the parts library is not available", + "thread.automatic_size": "Automatic size", + "thread.designation": "Designation", + "thread.iso_metric": "ISO metric", + "thread.select_size": "Select size", "timeline.group": "New Feature Folder", "timeline.move_many": "Move Timeline Items", "timeline.move_to_group": "Move Features to Folder", @@ -1005,6 +1056,9 @@ "ui.construct": "Construct", "ui.construction": "Construction", "ui.context_menu": "Context menu", + "ui.continuity_g0": "G0 crease", + "ui.continuity_g1": "G1 tangent", + "ui.continuity_g2": "G2 curvature", "ui.copy": "Copy", "ui.corner": "Corner", "ui.corners": "Corners", @@ -1092,6 +1146,8 @@ "ui.fully_constrained": "Fully constrained", "ui.general": "General", "ui.graphics": "Graphics", + "ui.grid_follows_view": "Grid follows the view", + "ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground", "ui.ground_shadow": "Ground shadow", "ui.groupSelected": "Group Selected", "ui.heading_add_ins": "ADD-INS", @@ -1286,6 +1342,8 @@ "ui.radial_context_menu": "Radial context menu", "ui.radius": "Radius", "ui.radius_type": "Radius Type", + "ui.rail": "Rail", + "ui.rail_fit": "Rail Fit", "ui.reachable": "Reachable", "ui.rectangle": "Rectangle", "ui.rectangular_pattern": "Rectangular Pattern", @@ -1312,6 +1370,7 @@ "ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}", "ui.saved_to": "Saved {path}", "ui.scale": "Scale", + "ui.second_rail": "Second Rail", "ui.section_analysis": "Section Analysis", "ui.select": "Select", "ui.selectOther": "Select Other…", @@ -1362,6 +1421,7 @@ "ui.stand_up_tip": "After a STEP or IGES file opens with Z up, offer a quarter turn about X", "ui.start": "Start", "ui.start_radius": "Start Radius", + "ui.stretch": "Stretch", "ui.supersampling": "Supersampling (SSAA)", "ui.supersampling_adaptive": "Only when the view is still", "ui.supersampling_off": "Off (1×)", diff --git a/assets/locales/es.json b/assets/locales/es.json new file mode 100644 index 00000000..e312d1f2 --- /dev/null +++ b/assets/locales/es.json @@ -0,0 +1,1457 @@ +{ + "0 turns it off": "0 lo desactiva", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Abre el borrador de GitHub de abajo y pega el informe copiado al portapapeles.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Abre el borrador de GitHub de abajo. El título y el informe se rellenarán automáticamente.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Adjunta la captura de pantalla: cópiala y pégala, o guarda y sube el PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Haz clic en «Submit new issue» en GitHub para enviar el informe.", + "A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa", + "AI": "AI", + "About SolidCraft": "Acerca de SolidCraft", + "Active": "Activo", + "Active rule": "Regla activa", + "Add": "Añadir", + "Add Key": "Añadir clave", + "Add Mid Radius": "Añadir radio intermedio", + "Add Row": "Añadir fila", + "Add condition": "Añadir condición", + "Alignment": "Alineación", + "All Sketch Points": "Todos los puntos del boceto", + "All open designs": "Todos los diseños abiertos", + "Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma", + "Also deletes": "También elimina", + "Angle 2": "Ángulo 2", + "Animate": "Animar", + "Appearance name": "Nombre de apariencia", + "Applied Radius": "Radio aplicado", + "Apply To": "Aplicar a", + "Apply to selection": "Aplicar a la selección", + "Arc length": "Longitud del arco", + "Area": "Área", + "Attachment": "Vinculación", + "BROWSER": "EXPLORADOR", + "Bend Line Position": "Posición de la línea de plegado", + "Bend Position": "Posición del pliegue", + "Bend Radius": "Radio de plegado", + "Bends": "Pliegues", + "Body": "Cuerpo", + "Boundary": "Contorno", + "Bounding box": "Caja delimitadora", + "CSV or JSON: updates existing parameters, adds new ones": "CSV o JSON: actualiza los parámetros existentes y añade otros nuevos", + "Capture All Positions": "Capturar todas las posiciones", + "Catch Direction": "Dirección de enganche", + "Cells": "Celdas", + "Center X": "Centro X", + "Center Y": "Centro Y", + "Center of mass": "Centro de masas", + "Centre to centre": "Entre centros", + "Chamfer Type": "Tipo de chaflán", + "Check collisions": "Comprobar colisiones", + "Checked": "Comprobado", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Elige un plano nuevo. La geometría y las cotas del boceto se mantienen en sus coordenadas bidimensionales.", + "Circumference": "Circunferencia", + "Close draft": "Cerrar borrador", + "Collisions": "Colisiones", + "Colour": "Color", + "Component1": "Componente1", + "Configuration": "Configuración", + "Configuration rules": "Reglas de configuración", + "Construction": "Construcción", + "Contact": "Contacto", + "Continuity": "Continuidad", + "Copy Error Message": "Copiar mensaje de error", + "Copy Warning Message": "Copiar mensaje de advertencia", + "Copy all licences": "Copiar todas las licencias", + "Copy diagnostic information": "Copiar información de diagnóstico", + "Copy report": "Copiar informe", + "Copy screenshot": "Copiar captura de pantalla", + "Corner Relief": "Alivio de esquina", + "Corner Relief Shape": "Forma del alivio de esquina", + "Counterbore depth": "Profundidad del avellanado cilíndrico", + "Countersink angle": "Ángulo de avellanado", + "Create Issue — review draft": "Crear incidencia — revisar borrador", + "Cube": "Cubo", + "Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)", + "Current perspective view": "Vista en perspectiva actual", + "Curvature (G2)": "Curvatura (G2)", + "Customize Toolbar": "Personalizar barra de herramientas", + "Delete all": "Eliminar todo", + "Delete rule": "Eliminar regla", + "Delete the row": "Eliminar la fila", + "Delete the set": "Eliminar el conjunto", + "Delete this parameter": "Eliminar este parámetro", + "Density": "Densidad", + "Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.", + "Discard": "Descartar", + "Do not export hidden edges": "No exportar aristas ocultas", + "Double-click to rename": "Doble clic para cambiar el nombre", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada", + "Drive Joint": "Accionar unión", + "Edges": "Aristas", + "Edit Profile Sketch": "Editar boceto del perfil", + "Enable rule": "Activar regla", + "Enabled": "Activado", + "Explode": "Explosionar", + "Export Current View as DXF": "Exportar vista actual como DXF", + "Export Current View as DXF…": "Exportar vista actual como DXF…", + "Export DXF…": "Exportar DXF…", + "Export hidden edges as dashed lines": "Exportar aristas ocultas como líneas discontinuas", + "Extents": "Extensión", + "Face": "Cara", + "Face attachment lost": "Se ha perdido la vinculación de la cara", + "Faces": "Caras", + "Faces / edges / vertices": "Caras / aristas / vértices", + "Favourite": "Favorito", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Los archivos de muchos programas CAD usan Y como eje vertical, por eso aquí se abren tumbados (con Z hacia arriba).", + "Filter": "Filtrar", + "Find in Browser": "Buscar en el explorador", + "Flat": "Plano", + "Flat size": "Tamaño del desarrollo", + "Follow camera (Look At sketches)": "Seguir cámara (orientar hacia los bocetos)", + "Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)", + "Font": "Fuente", + "Full Length": "Longitud total", + "Gap": "Separación", + "Grid": "Cuadrícula", + "Height": "Altura", + "Hidden edges": "Aristas ocultas", + "Holes": "Agujeros", + "Holes (edges on them)": "Agujeros (aristas que contienen)", + "Home view": "Vista de inicio", + "In This Design": "En este diseño", + "In place (surfaces)": "En su posición (superficies)", + "Inside": "Interior", + "Interferes": "Interfiere", + "Issue title": "Título de la incidencia", + "Joint Origin1": "Origen de unión1", + "Keep Tools": "Conservar herramientas", + "Later": "Más tarde", + "Leave it": "Dejar como está", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Vinculación antigua de cara. Volver a vincularla a una cara verificada crea una vinculación exacta.", + "Length": "Longitud", + "Length units": "Unidades de longitud", + "Letter Spacing": "Espaciado entre letras", + "Library": "Biblioteca", + "Line Spacing": "Interlineado", + "Loop": "Bucle", + "Margin": "Margen", + "Mass": "Masa", + "Material": "Material", + "Max": "Máx.", + "Mid Radius": "Radio intermedio", + "Middle double-click": "Doble clic central", + "Min": "Mín.", + "Mitre": "Inglete", + "Modeled": "Modelado", + "More toolbar panels": "Más paneles de la barra de herramientas", + "Motion": "Movimiento", + "Move Type": "Tipo de movimiento", + "Move earlier": "Mover antes", + "Move later": "Mover después", + "Name": "Nombre", + "No crash reports.": "No hay informes de fallos.", + "No matching appearances": "No hay apariencias coincidentes", + "No visible bodies to export.": "No hay cuerpos visibles para exportar.", + "None": "Ninguno", + "Not available yet": "Todavía no disponible", + "Notch": "Muesca", + "Opacity": "Opacidad", + "Open GitHub draft": "Abrir borrador de GitHub", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código abierto desarrollado en Rust. Licencia MIT O Apache-2.0.", + "Other side": "Lado opuesto", + "Outside": "Exterior", + "Overall length": "Longitud total", + "Parameter values": "Valores de parámetros", + "Pass through": "Pasante", + "Path": "Trayectoria", + "Per Edge": "Por arista", + "Pick the tool bodies": "Selecciona los cuerpos herramienta", + "Picked": "Seleccionado", + "Place at the origin instead": "Colocar en el origen", + "Playback": "Reproducción", + "Position": "Posición", + "Position (0 to 1)": "Posición (de 0 a 1)", + "Position (G0)": "Posición (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro", + "Preview Row": "Previsualizar fila", + "Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.", + "Profiles/Curves": "Perfiles/curvas", + "Projection": "Proyección", + "RECENT": "RECIENTES", + "RULED SURFACE": "SUPERFICIE REGLADA", + "Radius Fit": "Ajuste de radio", + "Ratio": "Relación", + "Reattach selected face": "Volver a vincular la cara seleccionada", + "Recover": "Recuperar", + "Recover unsaved design?": "¿Recuperar el diseño sin guardar?", + "Rectangular": "Rectangular", + "Reference face": "Cara de referencia", + "Remove Tools": "Eliminar herramientas", + "Remove from list": "Quitar de la lista", + "Remove override": "Quitar anulación", + "Remove private design details or paths before sharing.": "Elimina los detalles privados del diseño o las rutas antes de compartirlo.", + "Remove screenshot": "Quitar captura de pantalla", + "Remove the column": "Eliminar la columna", + "Remove the track": "Eliminar la pista", + "Remove this point": "Eliminar este punto", + "Report a bug — review a screenshot and open a GitHub draft": "Notificar un error — revisar una captura y abrir un borrador de GitHub", + "Reset Toolbar": "Restablecer barra de herramientas", + "Restore outer boundary": "Restaurar contorno exterior", + "Restore the inherited component or physical material appearance": "Restaurar la apariencia heredada del componente o del material físico", + "Result": "Resultado", + "Retake screenshot": "Repetir captura de pantalla", + "Reverse": "Invertir", + "Reverse Direction": "Invertir dirección", + "Revert": "Revertir", + "Revert All Positions": "Revertir todas las posiciones", + "Revert Position": "Revertir posición", + "Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.", + "Rim Edge": "Arista del borde", + "Roll History Marker Here": "Desplazar marcador del historial hasta aquí", + "Round": "Redondo", + "Rule": "Regla", + "Save Rule": "Guardar regla", + "Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.", + "Scale factor": "Factor de escala", + "Search": "Buscar", + "Search commands": "Buscar comandos", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).", + "Select bodies (a face picks its body)": "Selecciona cuerpos (al seleccionar una cara se selecciona su cuerpo)", + "Select bodies or faces instead": "Selecciona cuerpos o caras en su lugar", + "Selection filter": "Filtro de selección", + "Semantic faces share their appearance across instances.": "Las caras semánticas comparten su apariencia entre instancias.", + "Send your report on GitHub": "Enviar tu informe a GitHub", + "Shape": "Forma", + "Show Agent Cursor": "Mostrar cursor del agente", + "Show in folder": "Mostrar en carpeta", + "Show the pointer an AI agent moves (#35)": "Mostrar el puntero que mueve un agente de IA (#35)", + "Signed axes": "Ejes con signo", + "Size": "Tamaño", + "Sketch constraints": "Restricciones del boceto", + "Sketch curves": "Curvas del boceto", + "Sketch dimensions": "Cotas del boceto", + "Sketch geometry and profiles": "Geometría y perfiles del boceto", + "Sketch plane has no body-face attachment.": "El plano del boceto no está vinculado a ninguna cara del cuerpo.", + "Sketch points": "Puntos del boceto", + "Slot width": "Anchura de la ranura", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.", + "Speed": "Velocidad", + "Stand it up": "Poner en vertical", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poner en vertical: un cuarto de vuelta alrededor del eje X, como una única operación Mover que puedes deshacer.", + "Stand this part up?": "¿Poner esta pieza en vertical?", + "Step": "Paso", + "Steps": "Pasos", + "Style": "Estilo", + "Suppressed": "Suprimido", + "Swap Ends": "Intercambiar extremos", + "Tangent (G1)": "Tangencia (G1)", + "Tangent Chain": "Cadena tangente", + "Tear": "Desgarro", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "El boceto discontinuo sirve de guía del plano guardado. Selecciona una cara nueva para reparar la vinculación.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Falta la instancia de autor guardada. Selecciona una posición de autor válida antes de volver a vincular.", + "Then": "Entonces", + "These features will be deleted:": "Se eliminarán estas operaciones:", + "These features will fail:": "Estas operaciones fallarán:", + "Thickness Outside": "Espesor hacia fuera", + "Thin": "Delgado", + "Thin Extrude": "Extrusión delgada", + "This child is shared. Deleting it removes it from every copy of its parent.": "Este elemento secundario es compartido. Si lo eliminas, desaparecerá de todas las copias de su elemento principal.", + "This child is shared. Position changes apply to every copy of its parent.": "Este elemento secundario es compartido. Los cambios de posición se aplican a todas las copias de su elemento principal.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.", + "To Offset": "Hasta desfase", + "Tolerance": "Tolerancia", + "Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.", + "Type": "Tipo", + "UNTRIM": "ANULAR RECORTE", + "Updating the preview": "Actualizando vista previa", + "Use active author placement": "Usar posición de autor activa", + "Use fixed plane": "Usar plano fijo", + "Use saved fixed plane": "Usar plano fijo guardado", + "Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo", + "VIEW": "VISTA", + "Value": "Valor", + "Vertices": "Vértices", + "View navigator": "Navegador de vistas", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).", + "Volume": "Volumen", + "Wall": "Pared", + "Wall Location": "Posición de la pared", + "Wall Thickness": "Espesor de pared", + "When": "Cuando", + "Width": "Anchura", + "Will fail": "Fallará", + "angle, height": "ángulo, altura", + "appearance.dielectric_ior": "Índice de refracción dieléctrico", + "appearance.metallic": "Metalicidad", + "appearance.pbr_help": "Visualiza esta superficie en Renderizado o en un PNG del producto. Sin refracción ni transmisión.", + "appearance.pbr_opaque": "PBR opaco", + "appearance.roughness": "Rugosidad", + "appearance.surface_response": "Respuesta de la superficie", + "browser.body_name": "Cuerpo{number}", + "browser.component_name": "Componente{number}", + "browser.parameters": "Parámetros ({count})", + "browser.precision": "Precisión: {precision}", + "browser.sketch_name": "Boceto{number}", + "browser.units": "Unidades: {units}", + "cmd.appearance_assign": "Apariencia", + "cmd.appearance_library": "Biblioteca de apariencias", + "cmd.appearance_list": "Listar apariencias", + "cmd.body_rename": "Cambiar nombre del cuerpo", + "cmd.browser_group": "Nuevo grupo", + "cmd.browser_move": "Mover elementos del explorador", + "cmd.browser_order": "Ordenar elementos del explorador", + "cmd.browser_rename_group": "Cambiar nombre del grupo", + "cmd.browser_ungroup": "Desagrupar", + "cmd.browser_visibility": "Mostrar/ocultar", + "cmd.canvas_calibrate": "Calibrar lienzo", + "cmd.canvas_decal": "Calcomanía", + "cmd.canvas_delete": "Eliminar lienzo", + "cmd.canvas_edit": "Editar lienzo", + "cmd.canvas_insert": "Lienzo", + "cmd.component_activate": "Activar componente", + "cmd.component_break_link": "Romper vínculo", + "cmd.component_capture_position": "Capturar posición", + "cmd.component_create": "Nuevo componente", + "cmd.component_delete": "Eliminar componente", + "cmd.component_from_bodies": "Crear componentes a partir de cuerpos", + "cmd.component_insert_design": "Insertar diseño", + "cmd.component_links": "Listar vínculos", + "cmd.component_list": "Listar componentes", + "cmd.component_move_bodies": "Mover cuerpos al componente", + "cmd.component_move_sketches": "Mover bocetos al componente", + "cmd.component_paste_new": "Pegar nuevo", + "cmd.component_properties": "Propiedades físicas del componente", + "cmd.component_rename": "Cambiar nombre del componente", + "cmd.component_revert_position": "Revertir posición", + "cmd.component_unlock": "Desbloquear contenido", + "cmd.component_update_links": "Actualizar vínculos", + "cmd.config_activate": "Activar configuración", + "cmd.config_column": "Columna de configuración", + "cmd.config_configure": "Configurar", + "cmd.config_preview": "Previsualizar configuración", + "cmd.config_row": "Fila de configuración", + "cmd.config_rule": "Regla de configuración", + "cmd.config_rules": "Reglas de configuración", + "cmd.config_table": "Mostrar tabla de configuraciones", + "cmd.construct_axis_edge": "Eje por arista", + "cmd.construct_axis_normal_to_face": "Eje perpendicular a cara", + "cmd.construct_axis_through_cylinder": "Eje por cilindro/cono/toro", + "cmd.construct_axis_two_planes": "Eje por dos planos", + "cmd.construct_axis_two_points": "Eje por dos puntos", + "cmd.construct_plane_along_path": "Plano a lo largo de trayectoria", + "cmd.construct_plane_midplane": "Plano medio", + "cmd.construct_plane_perpendicular": "Plano perpendicular", + "cmd.construct_plane_tangent": "Plano tangente", + "cmd.construct_plane_three_points": "Plano por tres puntos", + "cmd.construct_plane_two_edges": "Plano por dos aristas", + "cmd.construct_point_along_path": "Punto en trayectoria", + "cmd.construct_point_center": "Punto en el centro de círculo/esfera/toro", + "cmd.construct_point_edge_plane": "Punto en arista y plano", + "cmd.construct_point_three_planes": "Punto por tres planos", + "cmd.construct_point_two_edges": "Punto por dos aristas", + "cmd.construct_point_vertex": "Punto en vértice", + "cmd.contact_check": "Comprobar contacto", + "cmd.contact_create": "Nuevo conjunto de contactos", + "cmd.contact_disable_all": "Desactivar contacto", + "cmd.contact_edit": "Editar conjunto de contactos", + "cmd.contact_enable_all": "Activar todos los contactos", + "cmd.contact_enable_sets": "Activar conjuntos de contactos", + "cmd.contact_list": "Listar conjuntos de contactos", + "cmd.diagnostics_crash_report": "Informe de fallo", + "cmd.diagnostics_crash_reports": "Informes de fallos", + "cmd.diagnostics_crash_reports_clear": "Eliminar informes de fallos", + "cmd.diagnostics_crash_test": "Prueba de fallo", + "cmd.doc_recover": "Recuperar diseño", + "cmd.doc_recovery_discard": "Descartar diseño recuperado", + "cmd.doc_recovery_list": "Diseños recuperables", + "cmd.document_inspect": "Inspeccionar diseño", + "cmd.document_metadata": "Propiedades del diseño", + "cmd.document_metadata_remove": "Eliminar propiedad del diseño", + "cmd.document_metadata_set": "Cambiar propiedades del diseño", + "cmd.document_units": "Cambiar unidades del diseño", + "cmd.edit_checkpoint": "Punto de control", + "cmd.edit_restore_checkpoint": "Restaurar punto de control", + "cmd.engine_commands": "Listar comandos", + "cmd.explode_create": "Vista explosionada", + "cmd.explode_delete": "Eliminar vista explosionada", + "cmd.explode_list": "Listar vistas explosionadas", + "cmd.explode_show": "Mostrar vista explosionada", + "cmd.expr_evaluate": "Evaluar expresión", + "cmd.face_delete": "Eliminar cara", + "cmd.file_export": "Exportar", + "cmd.file_insert_mesh": "Insertar malla", + "cmd.file_insert_step": "Insertar STEP", + "cmd.file_save_mesh": "Guardar como malla", + "cmd.file_slicers": "Laminadores instalados", + "cmd.inspect_accessibility": "Análisis de accesibilidad", + "cmd.inspect_center_of_mass": "Centro de masas", + "cmd.inspect_compare": "Comparar cuerpos", + "cmd.inspect_continuity": "Análisis de continuidad", + "cmd.inspect_curvature_comb": "Análisis de peine de curvatura", + "cmd.inspect_curvature_map": "Análisis de mapa de curvatura", + "cmd.inspect_draft": "Análisis de ángulos de salida", + "cmd.inspect_environment_map": "Análisis de mapa de entorno", + "cmd.inspect_interference": "Interferencia", + "cmd.inspect_isocurve": "Análisis de isocurvas", + "cmd.inspect_minimum_radius": "Análisis de radio mínimo", + "cmd.inspect_zebra": "Análisis de franjas cebra", + "cmd.joint_as_built": "Unión según posición actual", + "cmd.joint_create": "Unión", + "cmd.joint_delete": "Eliminar unión", + "cmd.joint_drive": "Accionar uniones", + "cmd.joint_edit": "Editar unión", + "cmd.joint_gear": "Engranaje", + "cmd.joint_limits": "Límites de unión", + "cmd.joint_list": "Listar uniones", + "cmd.joint_motion_link": "Vínculo de movimiento", + "cmd.joint_origin": "Origen de unión", + "cmd.joint_rack_pinion": "Piñón y cremallera", + "cmd.joint_reach": "Punto de alcance", + "cmd.joint_rigid_group": "Grupo rígido", + "cmd.joint_screw": "Tornillo", + "cmd.joint_solve": "Resolver uniones", + "cmd.library_apply": "Aplicar elemento de biblioteca local", + "cmd.library_export": "Exportar biblioteca de diseños", + "cmd.library_inspect": "Inspeccionar biblioteca local", + "cmd.material_assign": "Material físico", + "cmd.material_create": "Nuevo material físico", + "cmd.material_delete": "Eliminar material físico", + "cmd.material_edit": "Editar material físico", + "cmd.material_list": "Listar materiales", + "cmd.material_properties_directional": "Módulo elástico direccional", + "cmd.material_properties_validate": "Validar datos del material físico", + "cmd.mesh_convert": "Convertir malla", + "cmd.model_edges": "Listar aristas", + "cmd.model_faces": "Listar caras", + "cmd.model_threads": "Listar roscas", + "cmd.motion_delete": "Eliminar estudio de movimiento", + "cmd.motion_export": "Exportar estudio de movimiento", + "cmd.motion_list": "Listar estudios de movimiento", + "cmd.motion_play": "Reproducir estudio de movimiento", + "cmd.motion_study": "Estudio de movimiento", + "cmd.motion_trace": "Trayectoria de movimiento", + "cmd.motion_trace_keep": "Conservar trayectoria de movimiento", + "cmd.occurrence_copy": "Pegar (instancia)", + "cmd.occurrence_ground": "Fijar", + "cmd.occurrence_move": "Mover instancia", + "cmd.occurrence_properties": "Propiedades físicas de la instancia", + "cmd.parameters_add": "Añadir parámetro de usuario", + "cmd.parameters_change": "Cambiar parámetros", + "cmd.parameters_comment": "Comentar parámetro", + "cmd.parameters_complete": "Completar expresión", + "cmd.parameters_delete": "Eliminar parámetro", + "cmd.parameters_export": "Exportar parámetros", + "cmd.parameters_favorite": "Parámetro favorito", + "cmd.parameters_graph": "Gráfico de parámetros", + "cmd.parameters_import": "Importar parámetros", + "cmd.parameters_list": "Listar parámetros", + "cmd.parameters_move": "Mover parámetro", + "cmd.parameters_rename": "Cambiar nombre del parámetro", + "cmd.parameters_users": "Usos del parámetro", + "cmd.parts_fastener": "Insertar elemento de fijación", + "cmd.parts_insert": "Insertar pieza", + "cmd.parts_library": "Piezas normalizadas", + "cmd.parts_resize": "Cambiar tamaño de pieza", + "cmd.plastic_assign_rule": "Asignar regla de plástico", + "cmd.plastic_boss": "Resalte", + "cmd.plastic_lip": "Labio", + "cmd.plastic_manage_rules": "Gestionar reglas de plástico", + "cmd.plastic_rest": "Apoyo", + "cmd.plastic_rules": "Listar reglas de plástico", + "cmd.plastic_snap_fit": "Encaje a presión", + "cmd.render_backends": "Motores de renderizado", + "cmd.render_cancel": "Cancelar renderizado", + "cmd.render_export": "Exportar imagen renderizada", + "cmd.render_preflight": "Comprobar solicitud de renderizado", + "cmd.render_scene": "Inspeccionar escena de renderizado", + "cmd.render_settings_apply": "Aplicar cámara de renderizado", + "cmd.render_settings_get": "Ajustes de renderizado", + "cmd.render_settings_set": "Guardar ajustes de renderizado", + "cmd.render_start": "Renderizar imagen", + "cmd.render_status": "Progreso del renderizado", + "cmd.select_clear": "Borrar selección", + "cmd.sheet_convert": "Convertir a chapa", + "cmd.sheet_export_bend_table": "Exportar tabla de plegados", + "cmd.sheet_export_dxf": "Exportar desarrollo como DXF", + "cmd.sheet_flange": "Pestaña", + "cmd.sheet_flat_pattern": "Crear desarrollo", + "cmd.sheet_fold": "Plegar", + "cmd.sheet_hem": "Reborde", + "cmd.sheet_manage_rules": "Reglas de chapa", + "cmd.sheet_perforate": "Perforar", + "cmd.sheet_refold": "Volver a plegar", + "cmd.sheet_rules": "Listar reglas de chapa", + "cmd.sheet_unfold": "Desplegar", + "cmd.sketch_arc_center_point": "Arco de centro y punto", + "cmd.sketch_arc_tangent": "Arco tangente", + "cmd.sketch_arc_three_point": "Arco de 3 puntos", + "cmd.sketch_auto_constrain": "Restringir automáticamente", + "cmd.sketch_auto_constrain_from_datum": "Restringir automáticamente desde referencia", + "cmd.sketch_auto_project": "Proyectar automáticamente aristas sobre referencia", + "cmd.sketch_blend_curve": "Curva de transición", + "cmd.sketch_chain": "Cadena", + "cmd.sketch_chamfer_distance_angle": "Chaflán por distancia y ángulo", + "cmd.sketch_chamfer_equal_distance": "Chaflán de distancias iguales", + "cmd.sketch_chamfer_two_distance": "Chaflán por dos distancias", + "cmd.sketch_circle_center": "Círculo por centro y diámetro", + "cmd.sketch_circle_three_point": "Círculo de 3 puntos", + "cmd.sketch_circle_three_tangent": "Círculo de 3 tangencias", + "cmd.sketch_circle_two_point": "Círculo de 2 puntos", + "cmd.sketch_circle_two_tangent": "Círculo de 2 tangencias", + "cmd.sketch_constrainer": "Restricciones del boceto", + "cmd.sketch_constraint_fix": "Fijar/liberar", + "cmd.sketch_constraint_midpoint": "Punto medio", + "cmd.sketch_construction": "Normal/construcción", + "cmd.sketch_copy": "Copiar entidades del boceto", + "cmd.sketch_cut": "Cortar entidades del boceto", + "cmd.sketch_delete": "Eliminar entidades del boceto", + "cmd.sketch_dimension_text": "Mover texto de cota", + "cmd.sketch_drag": "Arrastrar geometría del boceto", + "cmd.sketch_drag_end": "Terminar arrastre de boceto", + "cmd.sketch_draw_dimensioned": "Dibujar geometría de boceto acotada", + "cmd.sketch_edit": "Editar boceto", + "cmd.sketch_edit_text": "Editar texto", + "cmd.sketch_export_dxf": "Guardar como DXF", + "cmd.sketch_finish_auto_constrain": "Finalizar con restricciones automáticas", + "cmd.sketch_fit_curves_to_section": "Ajustar curvas a sección de malla", + "cmd.sketch_glyphs": "Símbolos de restricción", + "cmd.sketch_include_3d": "Incluir geometría 3D", + "cmd.sketch_infinite": "Línea infinita", + "cmd.sketch_insert_dxf": "Insertar DXF", + "cmd.sketch_insert_svg": "Insertar SVG", + "cmd.sketch_inspect": "Inspeccionar boceto", + "cmd.sketch_intersection_curve": "Curva de intersección", + "cmd.sketch_isoparametric_curve": "Curva isoparamétrica", + "cmd.sketch_line3d": "Línea 3D", + "cmd.sketch_line_midpoint": "Línea por punto medio", + "cmd.sketch_move": "Mover/copiar entidades de boceto", + "cmd.sketch_move3d": "Mover punto 3D", + "cmd.sketch_move_point": "Arrastrar punto de boceto", + "cmd.sketch_offset_break": "Romper asociación de desfase", + "cmd.sketch_options": "Opciones de la paleta de bocetos", + "cmd.sketch_paste": "Pegar entidades del boceto", + "cmd.sketch_point3d": "Punto 3D", + "cmd.sketch_polygon_circumscribed": "Polígono circunscrito", + "cmd.sketch_polygon_edge": "Polígono por arista", + "cmd.sketch_polygon_inscribed": "Polígono inscrito", + "cmd.sketch_project_to_surface": "Proyectar sobre superficie", + "cmd.sketch_rectangle_center": "Rectángulo por centro", + "cmd.sketch_rectangle_rounded": "Rectángulo redondeado", + "cmd.sketch_rectangle_three_point": "Rectángulo de 3 puntos", + "cmd.sketch_rectangle_two_point": "Rectángulo de 2 puntos", + "cmd.sketch_redefine": "Redefinir plano del boceto", + "cmd.sketch_slot_arc_center": "Ranura de arco por centro", + "cmd.sketch_slot_arc_three_point": "Ranura de arco de tres puntos", + "cmd.sketch_slot_center_point": "Ranura por punto central", + "cmd.sketch_slot_center_to_center": "Ranura entre centros", + "cmd.sketch_slot_overall": "Ranura por longitud total", + "cmd.sketch_snap": "Detección de referencias", + "cmd.sketch_solve": "Resolver boceto", + "cmd.sketch_spline3d": "Spline 3D", + "cmd.sketch_spline_control_point": "Spline por puntos de control", + "cmd.sketch_spline_control_point_5": "Spline por puntos de control (grado 5)", + "cmd.sketch_spline_fit_point": "Spline por puntos de ajuste", + "cmd.sketch_spun_profile": "Perfil de revolución", + "cmd.sketch_toggle_driven": "Alternar cota de referencia", + "cmd.solid_align": "Alinear", + "cmd.solid_boundary_fill": "Relleno de contorno", + "cmd.solid_bounding_solid": "Sólido envolvente", + "cmd.solid_emboss": "Relieve", + "cmd.solid_move": "Mover/copiar", + "cmd.solid_offset_face": "Desfasar cara", + "cmd.solid_press_pull": "Pulsar/tirar", + "cmd.solid_remove": "Eliminar", + "cmd.solid_replace_face": "Sustituir cara", + "cmd.solid_rib": "Nervio", + "cmd.solid_split_face": "Dividir cara", + "cmd.solid_web": "Refuerzo", + "cmd.surface_blend": "Superficie de transición", + "cmd.surface_boundary": "Superficie de contorno", + "cmd.surface_extrude": "Extruir superficie", + "cmd.surface_fair": "Suavizar superficie", + "cmd.surface_fillet": "Redondeo de superficie", + "cmd.surface_loft": "Transición de superficies", + "cmd.surface_match": "Igualar superficie", + "cmd.surface_offset": "Desfasar superficie", + "cmd.surface_patch": "Parche", + "cmd.surface_reverse_normal": "Invertir normal", + "cmd.surface_revolve": "Revolución de superficie", + "cmd.surface_ruled": "Superficie reglada", + "cmd.surface_stitch": "Coser", + "cmd.surface_sweep": "Barrido de superficie", + "cmd.surface_thicken": "Engrosar", + "cmd.surface_unstitch": "Descoser", + "cmd.surface_untrim": "Anular recorte", + "cmd.timeline_add": "Añadir operación registrada", + "cmd.timeline_compute_all": "Calcular todo", + "cmd.timeline_copy": "Copiar operaciones", + "cmd.timeline_dependents": "Operaciones dependientes", + "cmd.timeline_export_script": "Exportar script de comandos", + "cmd.timeline_inspect": "Inspeccionar operación", + "cmd.timeline_paste": "Pegar operaciones", + "cmd.timeline_redefine": "Editar operación", + "cmd.timeline_rename": "Cambiar nombre", + "cmd.timeline_reorder": "Reordenar operación", + "cmd.timeline_roll_to": "Desplazar marcador del historial", + "cmd.timeline_rollback": "Mover marcador de la línea de tiempo", + "cmd.timeline_suppress": "Suprimir operación", + "cmd.view_apply": "Restaurar vista guardada", + "cmd.view_delete": "Eliminar vista guardada", + "cmd.view_list": "Listar vistas guardadas", + "cmd.view_navigate": "Navegar por la vista", + "cmd.view_rename": "Cambiar nombre de vista guardada", + "cmd.view_save": "Nueva vista guardada", + "command name": "nombre del comando", + "coupling.gear_1": "Engranaje 1", + "coupling.gear_1_pick": "Engranaje 1 (opcional)", + "coupling.gear_2": "Engranaje 2", + "coupling.gear_2_pick": "Engranaje 2 (opcional)", + "coupling.internal": "Engranaje interior", + "coupling.needs_turn_and_slide": "necesita una unión giratoria y otra deslizante", + "coupling.needs_turns": "necesita dos uniones giratorias", + "coupling.not_round": "no es una cara circular", + "coupling.nut": "Tuerca", + "coupling.picked": "seleccionado", + "coupling.pinion": "Piñón", + "coupling.pinion_pick": "Piñón (opcional)", + "coupling.pitch": "Paso", + "coupling.rack": "Cremallera", + "coupling.radius_1": "Radio 1", + "coupling.radius_2": "Radio 2", + "dialog.computing": "Calculando… {seconds} s", + "dialog.custom_axis": "↖ Eje personalizado", + "dialog.edit_title": "EDITAR {title}", + "dialog.select": "↖ Seleccionar", + "dialog.selected_count": "↖ {count} seleccionados", + "dialog.selection_guidance": "Selecciona una entrada para cambiarla. Haz clic en los objetos seleccionados para quitarlos; Ctrl/Cmd+clic también alterna su selección. Revisa la vista previa y pulsa Aceptar.", + "dialog.selection_step": "{step} de {total}: Selecciona {input}", + "dialog.step": "Paso", + "document.save_changes": "¿Guardar los cambios de {name}?", + "document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.", + "drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}", + "drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más", + "drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado", + "expression, e.g. 25 mm": "expresión, p. ej., 25 mm", + "fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8", + "from the rule": "de la regla", + "home.documentation": "Documentación", + "home.in_this_browser": "en este navegador", + "home.missing": "no disponible", + "home.open": "Abrir…", + "home.opened_days_ago": "Abierto hace {count} días", + "home.opened_hours_ago": "Abierto hace {count} h", + "home.opened_just_now": "Acaba de abrirse", + "home.opened_minutes_ago": "Abierto hace {count} min", + "home.opening": "abriendo…", + "home.recent": "Recientes", + "home.report_issue": "Notificar un problema", + "home.samples": "Ejemplos", + "home.whats_new": "Novedades", + "install.button_tip": "Instalar SolidCraft como aplicación: se abre en una ventana propia y funciona sin conexión", + "install.dock_1": "Elige Archivo › Añadir al Dock.", + "install.dock_2": "Haz clic en Añadir.", + "install.dock_after": "A partir de entonces, SolidCraft se abrirá desde el Dock en su propia ventana y funcionará sin conexión.", + "install.done": "Listo", + "install.home_screen_1": "Toca Compartir (en el menú … si no aparece en la barra de herramientas).", + "install.home_screen_2": "Elige Añadir a la pantalla de inicio.", + "install.home_screen_3": "Toca Añadir.", + "install.home_screen_after": "SolidCraft se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.", + "install.menu": "Instalar aplicación…", + "install.steps_intro": "Safari instala aplicaciones web desde sus propios menús:", + "install.title": "Instalar SolidCraft", + "joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.", + "joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.", + "joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.", + "nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.", + "nav.touchpad.label": "Panel táctil", + "nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)", + "nav.touchpad.pan": "Desplazar (mantén Mayús y mueve el puntero; sin pulsar botones)", + "nav.touchpad.scroll": "El panel táctil usa el desplazamiento vertical para hacer zoom. La opción de desplazamiento con dos dedos se conserva para otros ajustes predefinidos.", + "nav.touchpad.zoom": "Zoom (desplazamiento vertical o gesto de pellizco)", + "needs two joints": "necesita dos uniones", + "new configuration": "nueva configuración", + "new name": "nuevo nombre", + "new parameter": "nuevo parámetro", + "no interference": "sin interferencias", + "no joint that moves yet": "todavía no hay ninguna unión móvil", + "no picture": "sin imagen", + "pattern.alignment_2": "Alineación 2", + "pattern.alignment_hint": "Los recuentos incluyen el original. Centrar equilibra las copias; Invertir elige el lado adicional cuando el total es par.", + "pattern.direction_1": "Dirección 1", + "pattern.direction_2": "Dirección 2", + "pattern.flip_direction": "Invertir dirección", + "pick a hole to seat it, or OK places it at the origin": "selecciona un agujero para colocarlo o pulsa Aceptar para situarlo en el origen", + "pick one or two vertices, edges or faces": "selecciona uno o dos vértices, aristas o caras", + "render.background": "Fondo de la escena", + "render.fit": "Ajustar producto", + "render.image_cancel": "Cancelar renderizado", + "render.image_cancelled": "Renderizado cancelado", + "render.image_cancelling": "Cancelando", + "render.image_check": "Comprobar solicitud de renderizado", + "render.image_completed": "Imagen lista para exportar", + "render.image_encoding": "Codificando PNG", + "render.image_export": "Exportar PNG", + "render.image_failed": "Error de renderizado", + "render.image_height": "Altura", + "render.image_help": "Imagen de vista previa por CPU · PNG · transparencia aproximada", + "render.image_overwrite": "Sustituir archivo existente", + "render.image_preparing": "Preparando imagen", + "render.image_queued": "Esperando para renderizar", + "render.image_reduce": "Prueba con una imagen más pequeña o una escena más sencilla.", + "render.image_running": "Renderizando imagen", + "render.image_stale": "Esta imagen utiliza la escena capturada antes de los cambios posteriores.", + "render.image_start": "Iniciar renderizado", + "render.image_supported": "La solicitud de renderizado es compatible; la cola y la memoria disponible se comprobarán de nuevo al iniciar.", + "render.image_title": "Renderizar imagen", + "render.image_too_large": "Supera el límite de imagen: 4096 píxeles por lado y 4.194.304 píxeles en total.", + "render.image_transparent": "Fondo transparente", + "render.image_unknown": "Estado del renderizado no disponible", + "render.image_use_largest": "Usar el tamaño máximo para esta proporción:", + "render.image_width": "Anchura", + "render.navigation_help": "La cámara se mueve como en Diseño (Preferencias › Navegación). Guardar configuración registra la cámara actual.", + "render.opaque_image_summary": "PNG de vista previa · PBR opaco · transparencia combinada", + "render.opaque_preview_help": "PBR opaco y colores sencillos; transparencia combinada. La refracción, HDRI y el renderizado final aún no están disponibles.", + "render.perspective": "Cámara en perspectiva", + "render.preview": "Vista previa integrada", + "render.preview_help": "Sombreado por rasterización y transparencia combinada. La refracción, HDRI y el renderizado final aún no están disponibles.", + "render.reload_settings": "Recargar ajustes guardados", + "render.restore_camera": "Restaurar cámara guardada", + "render.return_design": "Volver a Diseño", + "render.save_setup": "Guardar configuración", + "render.setup_name": "Nombre de configuración", + "render.unavailable_preview": "Esta configuración solicita un perfil de vista previa no disponible.", + "render.unsaved": "Cambios de vista previa sin guardar", + "render.use_preview": "Usar vista previa integrada", + "sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm", + "sample.bracket.name": "Soporte de chapa", + "sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión", + "sample.enclosure.name": "Carcasa de plástico", + "sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro", + "sample.flange.name": "Brida atornillada", + "sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°", + "sample.hinge.name": "Conjunto de bisagra", + "sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado", + "sample.plate.name": "Placa de ejemplo", + "sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros", + "sample.vents.name": "Placa de ventilación", + "settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.", + "settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.", + "settings.measure_axis_distances": "Mostrar distancias X/Y/Z", + "settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.", + "settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft", + "settings.msaa_reset": "{setting}: este dispositivo gráfico no admite {requested}×; se ha establecido {reset}× (admitidos: {supported}).", + "settings.msaa_unsupported": "No compatible con este dispositivo gráfico", + "settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:", + "settings.reset_title": "Se han cambiado los ajustes", + "settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.", + "sketch.degrees_of_freedom": "{count} grados de libertad", + "sketch.draw_curves": "Dibujar como {linetype}", + "sketch.make_selection": "Convertir la selección a {linetype}", + "sketch_text.align_center": "Centrar", + "sketch_text.align_left": "Alinear a la izquierda", + "sketch_text.align_right": "Alinear a la derecha", + "sketch_text.bold": "Negrita", + "sketch_text.builtin_font": "{family} (integrada)", + "sketch_text.font_search": "Buscar fuentes", + "sketch_text.italic": "Cursiva", + "sketch_text.loading_fonts": "Buscando fuentes instaladas…", + "sketch_text.on_path": "El texto sigue su trayectoria: la curva determina su posición", + "sketch_text.path_clear": "Separar el texto de su curva", + "sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva", + "sketch_text.use_font_file": "Usar el archivo de fuente {path}", + "the parts library is not available": "la biblioteca de piezas no está disponible", + "timeline.group": "Nueva carpeta de operaciones", + "timeline.move_many": "Mover elementos de la línea de tiempo", + "timeline.move_to_group": "Mover operaciones a la carpeta", + "timeline.rename_group": "Cambiar nombre de carpeta de operaciones", + "timeline.reorder_many": "Reordenar operaciones", + "timeline.ungroup": "Quitar carpetas de operaciones", + "to the next face": "hasta la siguiente cara", + "toolbar.image": "IMAGEN", + "toolbar.render": "RENDERIZAR", + "toolbar.render_scene": "ESCENA", + "trace.points": "{count} puntos a lo largo de la trayectoria", + "trace.stopped_by_contact": "Termina en la muestra {sample}: {a} atravesaría {b}", + "trace.stopped_by_loop": "Termina en la muestra {sample}: el bucle que pasa por {joint} no puede cerrarse más", + "ui.1_degree_of_freedom": "1 grado de libertad", + "ui.3d_mouse_speed": "Velocidad del ratón 3D", + "ui.3d_mouse_windows": "Ratón 3D", + "ui.3d_sketch": "Boceto 3D", + "ui.about": "Acerca de", + "ui.activate": "Activar", + "ui.active_component": "Componente activo", + "ui.agent_chat": "Chat del agente", + "ui.agent_chat_allow": "Permitir", + "ui.agent_chat_allowed": "Permitido", + "ui.agent_chat_answered": "Respuesta: {option}", + "ui.agent_chat_approval": "¿Permitir que el agente haga esto?", + "ui.agent_chat_attach_view": "Adjuntar vista", + "ui.agent_chat_attach_view_tip": "Enviar una imagen de la vista actual con el mensaje", + "ui.agent_chat_auth_agent": "Iniciar sesión con {name}", + "ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia SolidCraft.", + "ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; SolidCraft nunca ve tu contraseña ni tus claves.", + "ui.agent_chat_auth_none": "El agente no ofrece aquí ningún método de inicio de sesión: sigue las instrucciones de su documentación.", + "ui.agent_chat_auth_retry": "Ya he iniciado sesión: volver a conectar", + "ui.agent_chat_auth_terminal": "Ejecuta esto en una terminal y vuelve a pulsar Conectar:", + "ui.agent_chat_auth_title": "Iniciar sesión en el agente", + "ui.agent_chat_cancel": "Cancelar", + "ui.agent_chat_changes": "Cambios: {n}, un paso para deshacer", + "ui.agent_chat_changes_of": "{n}/{limit} cambios", + "ui.agent_chat_close": "Cerrar", + "ui.agent_chat_command": "Comando del agente", + "ui.agent_chat_connect": "Conectar", + "ui.agent_chat_copy": "Copiar", + "ui.agent_chat_custom_hint": "Cualquier programa que utilice el protocolo Agent Client Protocol por entrada y salida estándar. No se ejecuta mediante un intérprete de comandos.", + "ui.agent_chat_denied": "Denegado", + "ui.agent_chat_deny": "Denegar", + "ui.agent_chat_desktop_only": "El chat del agente requiere la aplicación de escritorio.", + "ui.agent_chat_detecting": "Buscando agentes…", + "ui.agent_chat_dock_left": "Acoplar a la izquierda", + "ui.agent_chat_dock_right": "Acoplar a la derecha", + "ui.agent_chat_edit": "Editar", + "ui.agent_chat_exited": "El agente se ha detenido.", + "ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}", + "ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.", + "ui.agent_chat_key_in_env": "clave configurada", + "ui.agent_chat_left": "Izquierda", + "ui.agent_chat_limit": "El agente ha alcanzado el límite de {limit} cambios por mensaje. Envía otro mensaje para que continúe.", + "ui.agent_chat_minimize": "Minimizar a una franja", + "ui.agent_chat_model_tip": "Modelo que utiliza el agente. Se recuerda tu elección para este agente.", + "ui.agent_chat_new": "Nuevo chat", + "ui.agent_chat_new_confirm": "¿Iniciar un nuevo chat? Se borrará la conversación actual.", + "ui.agent_chat_new_yes": "Nuevo chat", + "ui.agent_chat_no_cli": "No se ha encontrado solidcraft-cli junto a la aplicación, por lo que el agente no puede acceder al diseño.", + "ui.agent_chat_no_control": "No se ha iniciado el punto de conexión del chat del agente, por lo que no puede acceder al diseño.", + "ui.agent_chat_no_images": "Este agente no admite imágenes", + "ui.agent_chat_none_found": "No se ha encontrado ningún agente de programación. Instala uno de estos y pulsa Actualizar:", + "ui.agent_chat_none_found_short": "No se ha encontrado ningún agente", + "ui.agent_chat_not_installed": "No instalado", + "ui.agent_chat_not_signed_in": "sesión no iniciada", + "ui.agent_chat_permission": "El agente solicita ejecutar: {title}", + "ui.agent_chat_pick": "Agente", + "ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…", + "ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)", + "ui.agent_chat_privacy": "Solo se envían datos al agente que ejecutes, con tu propia cuenta.", + "ui.agent_chat_privacy_note": "SolidCraft no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. SolidCraft nunca ve ni almacena claves o contraseñas.", + "ui.agent_chat_queue": "Poner en cola", + "ui.agent_chat_queue_full": "Hay demasiados mensajes en espera: elimina uno o espera a que termine el agente.", + "ui.agent_chat_queued": "En cola", + "ui.agent_chat_queued_stopped": "En cola (detenido, sin enviar)", + "ui.agent_chat_refresh": "Actualizar", + "ui.agent_chat_refused": "No permitido para el agente: {reason}", + "ui.agent_chat_remove": "Quitar de la cola", + "ui.agent_chat_right": "Derecha", + "ui.agent_chat_save": "Guardar", + "ui.agent_chat_send": "Enviar", + "ui.agent_chat_send_now": "Enviar ahora", + "ui.agent_chat_send_now_tip": "Detener el paso actual del agente y enviar esto a continuación (Ctrl+Intro)", + "ui.agent_chat_side": "Lado del chat del agente", + "ui.agent_chat_signed_in": "sesión iniciada", + "ui.agent_chat_state_auth": "Se requiere iniciar sesión", + "ui.agent_chat_state_busy": "Trabajando…", + "ui.agent_chat_state_exited": "Agente detenido", + "ui.agent_chat_state_off": "Sin conexión", + "ui.agent_chat_state_ready": "Listo", + "ui.agent_chat_state_starting": "Iniciando…", + "ui.agent_chat_stop": "Detener", + "ui.agent_chat_stop_tip": "Detener el agente y todo lo que haya iniciado", + "ui.agent_chat_stop_turn_tip": "Detener el agente (Esc). Los mensajes en cola permanecen en cola.", + "ui.agent_chat_turn_limit": "Cambios por mensaje", + "ui.agent_chat_undo_changes": "Deshacer estos cambios", + "ui.agent_chat_undo_label": "Chat del agente: {message}", + "ui.agent_chat_undone": "(deshecho)", + "ui.agent_chat_view_attached": "Vista adjunta", + "ui.agent_cursor": "Cursor del agente", + "ui.agent_cursor_speed": "Velocidad del cursor del agente", + "ui.ai_add": "Añadir", + "ui.ai_add_agent": "Añadir agente…", + "ui.ai_add_agent_title": "Añadir agente", + "ui.ai_add_api": "API compatible con OpenAI", + "ui.ai_add_custom": "Comando personalizado", + "ui.ai_add_installed": "Agente instalado", + "ui.ai_add_installed_note": "Una entrada personalizada para un agente instalado, por ejemplo, con otros argumentos. Los agentes instalados también aparecen por separado.", + "ui.ai_added": "añadido", + "ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de SolidCraft siguen los permisos de Preferencias > IA.", + "ui.ai_agents": "Agentes", + "ui.ai_always_allow": "Permitir siempre", + "ui.ai_always_allow_tip": "Permitir {tool} a este agente a partir de ahora, sin preguntar", + "ui.ai_always_allowed": "Permitido siempre", + "ui.ai_always_allowed_none": "Todavía no hay permisos permanentes. Aquí se muestran las respuestas «Permitir siempre» que hayas dado en el chat.", + "ui.ai_auto_allowed": "Permitido automáticamente: {title} ({why})", + "ui.ai_auto_mode": "tu configuración de permisos", + "ui.ai_auto_ours": "SolidCraft comprueba sus propias herramientas", + "ui.ai_auto_remembered": "elegiste Permitir siempre", + "ui.ai_bad_url": "Introduce una URL que empiece por http:// o https://", + "ui.ai_base_url": "URL base", + "ui.ai_button": "AI", + "ui.ai_copy_suffix": "copia", + "ui.ai_custom_url": "URL personalizada", + "ui.ai_default": "Predeterminado", + "ui.ai_http_busy": "el servicio está ocupado o ha superado su límite; inténtalo más tarde", + "ui.ai_http_key": "el servicio ha rechazado la clave: compruébala en Preferencias > IA", + "ui.ai_http_not_found": "no existe ese servicio o modelo: comprueba la URL base y el nombre del modelo", + "ui.ai_key": "Clave de API", + "ui.ai_key_hint": "opcional; se guarda en el llavero del sistema", + "ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.", + "ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de SolidCraft. Déjalo vacío para un servidor local.", + "ui.ai_keychain_error": "El llavero del sistema ha rechazado la operación: {error}", + "ui.ai_mode_all": "Permitir todo", + "ui.ai_mode_all_note": "No se pide permiso previamente. Los informes de fallos siguen estando restringidos y se mantienen los límites de carpetas.", + "ui.ai_mode_all_warning": "El agente puede guardar, sobrescribir, exportar y sustituir tu diseño sin preguntar. Utiliza esta opción solo con agentes de confianza y diseños guardados.", + "ui.ai_mode_ask": "Preguntar siempre", + "ui.ai_mode_ask_note": "Cada modificación del diseño y cada operación de archivo esperan a que pulses Permitir.", + "ui.ai_mode_edits": "Permitir automáticamente cambios del modelo", + "ui.ai_mode_edits_note": "Los comandos de modelado se ejecutan sin preguntar; guardar, abrir, exportar y otras acciones sobre archivos o destructivas siguen requiriendo permiso.", + "ui.ai_mode_global": "Según la configuración anterior", + "ui.ai_model": "Modelo", + "ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de SolidCraft. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.", + "ui.ai_name": "Nombre", + "ui.ai_need_command": "Introduce el comando que inicia el agente", + "ui.ai_need_model": "Introduce el nombre del modelo", + "ui.ai_no_answer": "El agente no ha respondido a tiempo", + "ui.ai_no_http": "Esta compilación no puede acceder a servicios API", + "ui.ai_no_keychain": "El llavero del sistema no está disponible, por lo que no se puede guardar una clave. Un servidor local sin clave seguirá funcionando.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Permisos", + "ui.ai_preset": "Preajuste", + "ui.ai_remove": "Eliminar", + "ui.ai_request_limit": "se ha detenido tras 24 solicitudes en un mensaje; envía otro para continuar", + "ui.ai_reset": "Restablecer", + "ui.ai_settings": "Ajustes de IA…", + "ui.ai_show_features": "Mostrar funciones de IA", + "ui.ai_show_features_note": "Chat del agente: panel en el que un agente de programación que ya utilices, o un servicio de modelos que elijas, trabaja sobre el diseño abierto. Desactivado: no hay botón, atajo, entrada de menú ni panel de IA, y no se busca ni inicia nada.", + "ui.ai_switch_tip": "Cambiar el agente de esta conversación", + "ui.ai_test": "Probar", + "ui.ai_test_acp_ok": "Responde: {agent}", + "ui.ai_test_acp_tip": "Inicia brevemente el agente, le solicita una sesión y después lo detiene", + "ui.ai_test_api_tip": "Solicita la lista de modelos al servicio", + "ui.ai_test_no_model": "Conectado: {count} modelos, pero no {model}", + "ui.ai_test_ok": "Conectado: {count} modelos", + "ui.ai_testing": "Probando…", + "ui.ai_tool_done": "finalizado", + "ui.ai_tool_failed": "fallido", + "ui.ai_tool_running": "en ejecución", + "ui.ai_tool_unfold": "Haz clic para ver el resultado", + "ui.ai_tool_waiting": "en espera", + "ui.ai_toolbar_button": "Mostrar botón en la barra de herramientas", + "ui.ai_toolbar_button_note": "El botón IA situado al extremo derecho de la barra. Ctrl+Mayús+A y el menú Ver también abren el panel.", + "ui.ai_undo_tip": "Deshacer todos los cambios del agente en este mensaje, en un solo paso", + "ui.all": "Todo", + "ui.analysis": "Análisis", + "ui.angle": "Ángulo", + "ui.angle_decimals": "Decimales de ángulo", + "ui.animateJoint": "Animar unión", + "ui.animate_view_changes": "Animar cambios de vista", + "ui.animation": "Animación", + "ui.animation_speed": "Velocidad de animación", + "ui.anti_aliasing": "Suavizado de bordes", + "ui.apply": "Aplicar", + "ui.arc": "Arco", + "ui.assemble": "Ensamblar", + "ui.autosave_every": "Guardar automáticamente cada", + "ui.axes": "Ejes", + "ui.axis": "Eje", + "ui.base_point": "Punto base", + "ui.blocked": "Bloqueado", + "ui.bodies": "Cuerpos", + "ui.box": "Caja", + "ui.break": "Romper", + "ui.calibrate": "Calibrar", + "ui.cancel": "Cancelar", + "ui.canvases": "Lienzos", + "ui.center": "Centro", + "ui.centerline": "Línea de centro", + "ui.chain": "Cadena", + "ui.chamfer": "Chaflán", + "ui.chamfer_type": "Tipo de chaflán", + "ui.change_workspace": "Cambiar espacio de trabajo", + "ui.chord_length": "Longitud de cuerda", + "ui.circle": "Círculo", + "ui.circular_pattern": "Patrón circular", + "ui.clear": "Borrar", + "ui.clear_recent": "Borrar recientes", + "ui.clear_the_selection": "Borrar la selección", + "ui.click_selects_whole_bodies": "Un clic selecciona cuerpos completos", + "ui.click_then_press_key": "Haz clic y pulsa la nueva tecla", + "ui.close": "Cerrar", + "ui.coil": "Hélice", + "ui.coincident": "Coincidente", + "ui.collapse": "Contraer", + "ui.collinear": "Colineal", + "ui.colour_vision": "Visión del color", + "ui.colour_vision_deutan": "Deuteranopía", + "ui.colour_vision_deutan_tip": "Para personas con sensibilidad reducida o ausente al verde", + "ui.colour_vision_failed": "Fallido", + "ui.colour_vision_fixed": "Fijo", + "ui.colour_vision_mode": "Modo", + "ui.colour_vision_note": "Cambia los colores que transmiten información: estados de bocetos, ejes, pliegues, análisis e iconos. Si usas filtros de color de Windows, mantenlo desactivado.", + "ui.colour_vision_off": "Desactivado", + "ui.colour_vision_off_tip": "Colores propios del tema", + "ui.colour_vision_projected": "Proyectado", + "ui.colour_vision_protan": "Protanopía", + "ui.colour_vision_protan_tip": "Para personas con sensibilidad reducida o ausente al rojo", + "ui.colour_vision_sample": "Muestra", + "ui.colour_vision_tritan": "Tritanopía", + "ui.colour_vision_tritan_tip": "Para personas con sensibilidad reducida o ausente al azul", + "ui.combine": "Combinar", + "ui.coming_later": "próximamente", + "ui.command_done": "{command} completado", + "ui.command_palette_hint": "Escribe un comando, p. ej., {example}, o pulsa S para buscar", + "ui.concentric": "Concéntrico", + "ui.configurations": "Configuraciones", + "ui.conic_curve": "Curva cónica", + "ui.constant": "Constante", + "ui.constraints_conflict": "Conflicto entre restricciones", + "ui.construct": "Construir", + "ui.construction": "Construcción", + "ui.context_menu": "Menú contextual", + "ui.copy": "Copiar", + "ui.corner": "Esquina", + "ui.corners": "Esquinas", + "ui.counterbore": "Avellanado cilíndrico", + "ui.countersink": "Avellanado cónico", + "ui.create": "Crear", + "ui.create_copy": "Crear copia", + "ui.create_sketch": "Crear boceto", + "ui.current": "actual", + "ui.curvature": "Curvatura", + "ui.curvature_g2": "Curvatura (G2)", + "ui.curves": "Curvas", + "ui.custom_program": "Personalizado…", + "ui.custom_program_tip": "Elige un programa laminador para añadirlo a la lista", + "ui.customizeToolbar": "Personalizar barra de herramientas…", + "ui.cut": "Cortar", + "ui.cylinder": "Cilindro", + "ui.dark": "Oscuro", + "ui.default_modelling_orientation": "Orientación de modelado predeterminada", + "ui.degrees_of_freedom": "grados de libertad", + "ui.delete": "Eliminar", + "ui.depth": "Profundidad", + "ui.design": "Diseño", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Descripción", + "ui.design_properties.exports": "Las exportaciones STEP, IGES y 3MF incluyen estos datos cuando el formato lo permite.", + "ui.design_properties.license": "Licencia", + "ui.design_properties.not_text": "Está guardado en un formato distinto del texto; escribir aquí lo sustituirá.", + "ui.design_properties.organization": "Organización", + "ui.design_properties.others": "Datos de otras herramientas", + "ui.design_properties.others_note": "Se conservan en el diseño tal cual y nunca se exportan.", + "ui.design_properties.part_number": "Número de pieza", + "ui.design_properties.project": "Proyecto", + "ui.design_properties.revision": "Revisión", + "ui.diameter": "Diámetro", + "ui.dimension": "Cota", + "ui.direction": "Dirección", + "ui.display_quality": "Calidad de visualización", + "ui.display_quality_coarse": "Baja", + "ui.display_quality_fine": "Alta", + "ui.display_quality_normal": "Normal", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Distancia", + "ui.distance_1": "Distancia 1", + "ui.distance_2": "Distancia 2", + "ui.document_settings": "Configuración del documento", + "ui.don_t_save": "No guardar", + "ui.draft": "Ángulo de salida", + "ui.drawing": "Dibujo", + "ui.drill_point": "Punta de broca", + "ui.edit": "Editar", + "ui.editJointLimits": "Editar límites de unión", + "ui.edit_section_analysis": "Editar análisis de sección", + "ui.ellipse": "Elipse", + "ui.end_radius": "Radio final", + "ui.end_scale": "Escala final", + "ui.entities": "Entidades", + "ui.equal": "Igual", + "ui.equivalent_radius": "Radio equivalente", + "ui.expand": "Expandir", + "ui.exportDxf": "Exportar DXF…", + "ui.export_ellipsis": "Exportar...", + "ui.export_ellipsis_keymap": "Exportar…", + "ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo", + "ui.extend": "Extender", + "ui.extent_type": "Tipo de extensión", + "ui.extrude": "Extruir", + "ui.factor": "Factor", + "ui.feature_history": "Historial de operaciones", + "ui.feature_options": "Opciones de operación", + "ui.features": "Operaciones", + "ui.file": "Archivo", + "ui.fillet": "Redondeo", + "ui.filter_commands_or_keys": "Filtrar comandos o teclas", + "ui.findInTimeline": "Buscar en la línea de tiempo", + "ui.findInWindow": "Buscar en la ventana", + "ui.finish_sketch": "Finalizar boceto", + "ui.fit": "Ajustar", + "ui.fix_unfix": "Fijar/liberar", + "ui.flat": "Plano", + "ui.flip": "Invertir", + "ui.format": "Formato", + "ui.frame_rate": "Frecuencia de fotogramas", + "ui.front": "Frontal", + "ui.fully_constrained": "Totalmente restringido", + "ui.general": "General", + "ui.graphics": "Gráficos", + "ui.ground_shadow": "Sombra en el suelo", + "ui.groupSelected": "Agrupar selección", + "ui.heading_add_ins": "COMPLEMENTOS", + "ui.heading_assemble": "ENSAMBLAR", + "ui.heading_box": "CAJA", + "ui.heading_chamfer": "CHAFLÁN", + "ui.heading_circular_pattern": "PATRÓN CIRCULAR", + "ui.heading_coil": "HÉLICE", + "ui.heading_combine": "COMBINAR", + "ui.heading_configure": "CONFIGURAR", + "ui.heading_constraints": "RESTRICCIONES", + "ui.heading_construct": "CONSTRUIR", + "ui.heading_create": "CREAR", + "ui.heading_create_sketch": "CREAR BOCETO", + "ui.heading_cylinder": "CILINDRO", + "ui.heading_design": "DISEÑO", + "ui.heading_draft": "ÁNGULO DE SALIDA", + "ui.heading_edit": "EDITAR", + "ui.heading_extrude": "EXTRUIR", + "ui.heading_fillet": "REDONDEO", + "ui.heading_finish_sketch": "FINALIZAR BOCETO", + "ui.heading_front": "FRONTAL", + "ui.heading_hole": "AGUJERO", + "ui.heading_home": "INICIO", + "ui.heading_insert": "INSERTAR", + "ui.heading_inspect": "INSPECCIONAR", + "ui.heading_loft": "TRANSICIÓN", + "ui.heading_make": "FABRICAR", + "ui.heading_measure": "MEDIR", + "ui.heading_mesh": "MALLA", + "ui.heading_mirror": "SIMETRÍA", + "ui.heading_modify": "MODIFICAR", + "ui.heading_move": "MOVER", + "ui.heading_move_copy": "MOVER/COPIAR", + "ui.heading_offset": "DESFASE", + "ui.heading_offset_plane": "PLANO DESFASADO", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARÁMETROS", + "ui.heading_pattern": "PATRÓN", + "ui.heading_pattern_on_path": "PATRÓN EN TRAYECTORIA", + "ui.heading_pipe": "TUBO", + "ui.heading_plane_at_angle": "PLANO EN ÁNGULO", + "ui.heading_plastic": "PLÁSTICO", + "ui.heading_position": "POSICIÓN", + "ui.heading_prepare": "PREPARAR", + "ui.heading_rectangular_pattern": "PATRÓN RECTANGULAR", + "ui.heading_revolve": "REVOLUCIÓN", + "ui.heading_right": "DERECHA", + "ui.heading_save_mesh": "GUARDAR COMO MALLA", + "ui.heading_scale": "ESCALA", + "ui.heading_section_analysis": "ANÁLISIS DE SECCIÓN", + "ui.heading_select": "SELECCIONAR", + "ui.heading_setup": "CONFIGURACIÓN", + "ui.heading_sheet_metal": "CHAPA", + "ui.heading_shell": "VACIADO", + "ui.heading_sketch": "BOCETO", + "ui.heading_sketch_palette": "PALETA DE BOCETOS", + "ui.heading_sketch_scale": "ESCALA DEL BOCETO", + "ui.heading_solid": "SÓLIDO", + "ui.heading_sphere": "ESFERA", + "ui.heading_split_body": "DIVIDIR CUERPO", + "ui.heading_surface": "SUPERFICIE", + "ui.heading_sweep": "BARRIDO", + "ui.heading_thread": "ROSCA", + "ui.heading_top": "SUPERIOR", + "ui.heading_torus": "TORO", + "ui.heading_utilities": "UTILIDADES", + "ui.heading_utility": "UTILIDAD", + "ui.help": "Ayuda", + "ui.hide": "Ocultar", + "ui.hideAxes": "Ocultar ejes", + "ui.hideDimension": "Ocultar cota", + "ui.hideOrigin": "Ocultar origen", + "ui.hidePlanes": "Ocultar planos", + "ui.hideProfile": "Ocultar perfil", + "ui.hole": "Agujero", + "ui.hole_tap_type": "Tipo de roscado del agujero", + "ui.hole_type": "Tipo de agujero", + "ui.home": "Inicio", + "ui.homeView": "Vista de inicio", + "ui.horizontal_vertical": "Horizontal/vertical", + "ui.import_ellipsis": "Importar…", + "ui.infinite": "Infinito", + "ui.insert": "Insertar", + "ui.inspect": "Inspeccionar", + "ui.intersect": "Intersecar", + "ui.isolate": "Aislar", + "ui.join": "Unir", + "ui.joints": "Uniones", + "ui.keyboard_shortcuts": "Atajos de teclado", + "ui.language": "Idioma", + "ui.length_decimals": "Decimales de longitud", + "ui.light": "Claro", + "ui.line": "Línea", + "ui.linetype": "Tipo de línea", + "ui.lock": "Bloquear", + "ui.loft": "Transición", + "ui.look_at": "Mirar hacia", + "ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar", + "ui.manufacture": "Fabricación", + "ui.material_review.axes": "Ejes locales del material; no se ha asignado ninguna orientación al cuerpo.", + "ui.material_review.claims": "Las fuentes y licencias son declaraciones sin verificar. Los valores ausentes siguen siendo desconocidos.", + "ui.material_review.compressive_strength": "Resistencia a compresión", + "ui.material_review.conditions": "Condiciones declaradas", + "ui.material_review.density": "Densidad", + "ui.material_review.derived": "Módulos isotrópicos derivados", + "ui.material_review.elasticity": "Elasticidad", + "ui.material_review.input": "Pega una ficha de material de esquema 1 (JSON)", + "ui.material_review.limit": "La ficha supera el límite de entrada de 256 KiB.", + "ui.material_review.moisture_unit": "kg de agua / kg de material seco", + "ui.material_review.properties": "Propiedades", + "ui.material_review.review": "Revisar ficha", + "ui.material_review.scope": "Solo candidato: no se asigna ni se guarda. La validación numérica no equivale a una certificación de ingeniería.", + "ui.material_review.shear_strength": "Resistencia a cortante", + "ui.material_review.source": "Fuente", + "ui.material_review.specific_heat": "Calor específico", + "ui.material_review.tensile_strength": "Resistencia a tracción", + "ui.material_review.thermal_conductivity": "Conductividad térmica", + "ui.material_review.thermal_expansion": "Dilatación térmica", + "ui.material_review.title": "Revisar datos del material físico", + "ui.material_review.unknown": "Desconocido", + "ui.material_review.yield_strength": "Límite elástico", + "ui.measure": "Medir", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF con etiquetas de cara", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Punto medio", + "ui.mirror": "Simetría", + "ui.mirror_line": "Línea de simetría", + "ui.modify": "Modificar", + "ui.move": "Mover", + "ui.multiple": "Múltiple", + "ui.named_views": "Vistas guardadas", + "ui.navigation": "Navegación", + "ui.needs_design": "{command}: abre primero un diseño", + "ui.newSketchGroup": "Nuevo grupo de bocetos", + "ui.new_body": "Nuevo cuerpo", + "ui.new_design": "Nuevo diseño", + "ui.no_slicer_found": "No se ha encontrado ningún laminador", + "ui.normal": "Normal", + "ui.normalConstruction": "Normal / construcción", + "ui.not_a_program": "{path} no es un programa que se pueda ejecutar", + "ui.object_type": "Tipo de objeto", + "ui.objects": "Objetos", + "ui.offer_to_stand_up_y_up_files": "Ofrecer poner en vertical los archivos con eje Y hacia arriba", + "ui.offset": "Desfase", + "ui.offset_plane": "Plano desfasado", + "ui.one_side": "Un lado", + "ui.open": "Abrir", + "ui.open_ellipsis": "Abrir...", + "ui.open_preferences": "Abrir preferencias", + "ui.open_recent": "Abrir reciente", + "ui.operation": "Operación", + "ui.options": "Opciones", + "ui.orbit": "Orbitar", + "ui.orbit_around": "Orbitar alrededor de", + "ui.orientation": "Orientación", + "ui.origin": "Origen", + "ui.palette_points": "Puntos", + "ui.palette_profile": "Perfil", + "ui.pan": "Desplazar", + "ui.pan_zoom_orbit": "Desplazar, zoom y orbitar", + "ui.parallel": "Paralelo", + "ui.parameters": "Parámetros", + "ui.paste": "Pegar", + "ui.path": "Trayectoria", + "ui.pattern_on_path": "Patrón en trayectoria", + "ui.perpendicular": "Perpendicular", + "ui.perspective_view": "Vista en perspectiva", + "ui.pinToShortcuts": "Fijar en accesos directos", + "ui.pinToToolbar": "Fijar en barra de herramientas", + "ui.pipe": "Tubo", + "ui.placement": "Colocación", + "ui.plane": "Plano", + "ui.plane_at_angle": "Plano en ángulo", + "ui.point": "Punto", + "ui.point_under_the_cursor": "Punto bajo el cursor", + "ui.points": "Puntos", + "ui.polygon": "Polígono", + "ui.preferences": "Preferencias", + "ui.press_a_key": "pulsa una tecla…", + "ui.profile": "Perfil", + "ui.profile_plane": "Plano de perfil", + "ui.profiles": "Perfiles", + "ui.project": "Proyecto", + "ui.project_face_edges_sketch": "Proyectar aristas de caras en bocetos nuevos", + "ui.properties": "Propiedades", + "ui.pull_direction": "Dirección de tracción", + "ui.quantity": "Cantidad", + "ui.quantity_2": "Cantidad 2", + "ui.quit": "Salir", + "ui.radial_context_menu": "Menú contextual radial", + "ui.radius": "Radio", + "ui.radius_type": "Tipo de radio", + "ui.reachable": "Alcanzable", + "ui.rectangle": "Rectángulo", + "ui.rectangular_pattern": "Patrón rectangular", + "ui.redo": "Rehacer", + "ui.removeFromToolbar": "Quitar de la barra de herramientas", + "ui.removeSection": "Quitar sección", + "ui.render": "Renderizar", + "ui.renderer": "Motor de renderizado", + "ui.repeat": "Repetir", + "ui.report_a_bug": "Notificar un error", + "ui.reset": "Restablecer", + "ui.reset_all": "Restablecer todo", + "ui.reset_the_settings_on_this_page": "Restablecer los ajustes de esta página", + "ui.restore": "Restaurar", + "ui.restore_defaults": "Restaurar valores predeterminados", + "ui.reverse_zoom_direction": "Invertir dirección del zoom", + "ui.revolve": "Revolución", + "ui.right": "Derecha", + "ui.save": "Guardar", + "ui.save_as": "Guardar como", + "ui.save_as_ellipsis": "Guardar como...", + "ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador", + "ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar", + "ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}", + "ui.saved_to": "Guardado en {path}", + "ui.scale": "Escala", + "ui.section_analysis": "Análisis de sección", + "ui.select": "Seleccionar", + "ui.selectOther": "Seleccionar otro…", + "ui.selectTangentChain": "Seleccionar cadena tangente", + "ui.select_other_hint": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todos los elementos bajo el cursor; vuelve a pulsar en el mismo punto para seleccionar el siguiente.", + "ui.selection": "Selección", + "ui.send": "Enviar", + "ui.send_to_slicer": "Enviar al laminador", + "ui.sent_to_slicer": "Enviado a {slicer}", + "ui.shell": "Vaciado", + "ui.show": "Mostrar", + "ui.showAll": "Mostrar todo", + "ui.showAllBodies": "Mostrar todos los cuerpos", + "ui.showAxes": "Mostrar ejes", + "ui.showDimension": "Mostrar cota", + "ui.showOrigin": "Mostrar origen", + "ui.showPlanes": "Mostrar planos", + "ui.show_constraints": "Restricciones", + "ui.show_construction_geometries": "Geometrías de construcción", + "ui.show_dimensions": "Cotas", + "ui.show_points": "Mostrar puntos", + "ui.show_profile": "Mostrar perfil", + "ui.show_projected_geometries": "Geometrías proyectadas", + "ui.simple": "Sencillo", + "ui.simulation": "Simulación", + "ui.single": "Único", + "ui.sketch": "Boceto", + "ui.sketch_dimension": "Cota de boceto", + "ui.sketch_grid": "Cuadrícula de boceto", + "ui.sketch_scale": "Escala del boceto", + "ui.sketch_status": "Estado del boceto", + "ui.sketches": "Bocetos", + "ui.slice": "Laminar", + "ui.slicer": "Laminador", + "ui.slicer_delete_tip": "{program}\nPulsa Supr para quitarlo de la lista", + "ui.slot": "Ranura", + "ui.smooth": "Suave", + "ui.smooth_wheel_zoom": "Zoom suave con la rueda", + "ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior", + "ui.snap": "Ajustar a referencias", + "ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.", + "ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse", + "ui.spacing": "Espaciado", + "ui.spacing_2": "Espaciado 2", + "ui.sphere": "Esfera", + "ui.spline": "Spline", + "ui.split_body": "Dividir cuerpo", + "ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X", + "ui.start": "Iniciar", + "ui.start_radius": "Radio inicial", + "ui.supersampling": "Supermuestreo (SSAA)", + "ui.supersampling_adaptive": "Solo cuando la vista está inmóvil", + "ui.supersampling_off": "Desactivado (1×)", + "ui.suppress": "Suprimir", + "ui.sweep": "Barrido", + "ui.sweep_follow_direction": "Seguir dirección", + "ui.sweep_keep_as_drawn": "Mantener como se ha dibujado", + "ui.sweep_turn_with_path": "Girar con la trayectoria", + "ui.symmetric": "Simétrico", + "ui.symmetry": "Simetría", + "ui.system": "Sistema", + "ui.tangent": "Tangente", + "ui.taper_angle": "Ángulo de conicidad", + "ui.tapped": "Roscado", + "ui.text": "Texto", + "ui.theme": "Tema", + "ui.thickness": "Espesor", + "ui.thread": "Rosca", + "ui.timeline_bottom": "Línea de tiempo inferior", + "ui.timeline_left_note": "La disposición izquierda muestra la barra de retroceso encima del explorador de piezas y escenas.", + "ui.timeline_left_sidebar": "Barra lateral izquierda", + "ui.tip_angle": "Ángulo de punta", + "ui.to_object": "Hasta objeto", + "ui.tool_direction": "Dirección de herramienta", + "ui.top": "Superior", + "ui.torus": "Toro", + "ui.total_angle": "Ángulo total", + "ui.trackpad_pan_tip": "Los gestos del panel táctil desplazan la vista (desactivado: hacen zoom como la rueda); la rueda del ratón hace zoom, la rueda lateral desplaza horizontalmente y el gesto de pellizco siempre hace zoom", + "ui.trim": "Recortar", + "ui.twist": "Torsión", + "ui.two_finger_scroll_pans": "Desplazamiento con dos dedos", + "ui.two_sides": "Dos lados", + "ui.ui_scale": "Escala de interfaz", + "ui.undo": "Deshacer", + "ui.unground": "Liberar fijación", + "ui.unisolate": "Dejar de aislar", + "ui.units": "Unidades", + "ui.units_of_new_designs": "Unidades de diseños nuevos", + "ui.unlock": "Desbloquear", + "ui.unsuppress": "Dejar de suprimir", + "ui.untitled": "Sin título", + "ui.up_axis_note": "Eje que apunta hacia arriba en las vistas (vista de eje, Inicio, Mirar hacia). Los diseños no cambian.", + "ui.updateToCurrentView": "Actualizar a vista actual", + "ui.variable": "Variable", + "ui.view_centre": "Centro de la vista", + "ui.visual_shaded_hidden_edges": "Sombreado con aristas ocultas", + "ui.visual_shaded_with_edges": "Sombreado con aristas", + "ui.visual_style": "Estilo visual", + "ui.visual_wireframe": "Estructura alámbrica", + "ui.workspace": "espacio de trabajo", + "ui.x_distance": "Distancia X", + "ui.y_distance": "Distancia Y", + "ui.y_up": "Y hacia arriba", + "ui.z_up": "Z hacia arriba", + "ui.zoom": "Zoom", + "ui.zoom_to_cursor_tip": "La rueda hace zoom hacia el punto bajo el cursor (desactivado: hacia el centro de la vista)", + "ui.zoom_to_the_cursor": "Zoom hacia el cursor", + "update.available": "SolidCraft {version} está disponible", + "update.button": "Actualizar", + "update.button_tip": "SolidCraft {version} está disponible", + "update.button_version": "Actualizar a {version}", + "update.check_automatically": "Buscar actualizaciones automáticamente", + "update.check_now": "Comprobar ahora", + "update.checking": "Comprobando…", + "update.copy": "Copiar", + "update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra SolidCraft a Aplicaciones, sustituye la copia anterior y reinicia SolidCraft.", + "update.download": "Descargar", + "update.download_open": "Descargar y abrir", + "update.downloading": "Descargando y verificando… {done} de {total} MB", + "update.failed": "No se ha podido comprobar: {error}", + "update.file": "La descarga obtiene {file}.", + "update.install_failed": "No se ha podido instalar la actualización: {error}. No se ha modificado nada; la opción Descargar obtiene el paquete.", + "update.install_msi": "Instalar (cierra SolidCraft)", + "update.installed": "SolidCraft {version} está instalado. Reinicia para utilizarlo.", + "update.installer_started": "El instalador se iniciará cuando se cierre SolidCraft.", + "update.later": "Recordármelo más tarde", + "update.no_notes": "Esta versión no tiene notas.", + "update.package_hint": "Esta copia procede de {kind}. Descarga el paquete nuevo e instálalo con:", + "update.prefs_dev": "Esta es una compilación de desarrollo ({version}): no busca actualizaciones.", + "update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. SolidCraft no lo comprueba.", + "update.prefs_label": "Actualizaciones", + "update.prefs_note": "Esta es la versión {version} de SolidCraft ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.", + "update.release_page": "Abrir página de la versión", + "update.restart": "Reiniciar ahora", + "update.restart_later": "Más tarde", + "update.show": "Mostrar actualización a {version}", + "update.skip": "Omitir esta versión", + "update.system_hint": "Esta copia es de tipo {kind}. Actualízala con:", + "update.title": "Actualización disponible", + "update.up_to_date": "SolidCraft está actualizado.", + "update.update_now": "Actualizar ahora", + "update.you_have": "Tienes la versión {version}." +} diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 8e52631c..9610dcb8 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -5,7 +5,9 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Joindre votre capture d'écran : la copier-coller, ou enregistrer le PNG puis le téléverser.", "3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.", "A new configuration, a copy of the active one": "Nouvelle configuration, copie de la configuration active", + "AI": "IA", "About SolidCraft": "À propos de SolidCraft", + "Acceleration": "Accélération", "Active": "Actif", "Active rule": "Règle active", "Add": "Ajouter", @@ -13,8 +15,10 @@ "Add Mid Radius": "Ajouter un rayon intermédiaire", "Add Row": "Ajouter une ligne", "Add condition": "Ajouter une condition", + "Against": "Contre", "Alignment": "Alignement", "All Sketch Points": "Tous les points d'esquisse", + "All components": "Tous les composants", "All open designs": "Toutes les conceptions ouvertes", "Allowed expressions, separated by semicolons": "Expressions autorisées, séparées par des points-virgules", "Also deletes": "Supprime aussi", @@ -24,6 +28,7 @@ "Applied Radius": "Rayon appliqué", "Apply To": "Appliquer à", "Apply to selection": "Appliquer à la sélection", + "Arc length": "Longueur d'arc", "Area": "Aire", "Attachment": "Rattachement", "BROWSER": "NAVIGATEUR", @@ -41,10 +46,12 @@ "Center X": "Centre X", "Center Y": "Centre Y", "Center of mass": "Centre de gravité", + "Centre to centre": "Entraxe", "Chamfer Type": "Type de chanfrein", "Check collisions": "Vérifier les collisions", "Checked": "Vérifié", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Choisir un nouveau plan. La géométrie et les cotes de l'esquisse restent inchangées dans ses coordonnées bidimensionnelles.", + "Circumference": "Circonférence", "Close draft": "Fermer le brouillon", "Collisions": "Collisions", "Colour": "Couleur", @@ -53,6 +60,7 @@ "Configuration rules": "Règles de configuration", "Construction": "Construction", "Contact": "Contact", + "Contact sets": "Jeux de contact", "Continuity": "Continuité", "Copy Error Message": "Copier le message d'erreur", "Copy Warning Message": "Copier le message d'avertissement", @@ -66,6 +74,8 @@ "Countersink angle": "Angle de la fraisure", "Create Issue — review draft": "Créer un ticket — relire le brouillon", "Cube": "Cube", + "Current orthographic view (1:1 mm)": "Vue orthographique actuelle (1:1 mm)", + "Current perspective view": "Vue en perspective actuelle", "Curvature (G2)": "Courbure (G2)", "Customize Toolbar": "Personnaliser la barre d'outils", "Delete all": "Tout supprimer", @@ -77,14 +87,22 @@ "Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.", "Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.", "Discard": "Abandonner", + "Do not export hidden edges": "Ne pas exporter les arêtes cachées", + "Don't ask again": "Ne plus demander", "Double-click to rename": "Double-cliquer pour renommer", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clic sur le bouton du milieu dans la vue : Accueil (vue d'accueil, cadrant ce qui est affiché), Cadrer (cadrer ce qui est affiché en gardant l'orientation) ou rien", "Drive Joint": "Piloter la liaison", + "Duration": "Durée", "Edges": "Arêtes", "Edit Profile Sketch": "Modifier l'esquisse du profil", "Enable rule": "Activer la règle", "Enabled": "Activé", "Explode": "Éclater", + "Export Animation…": "Exporter l'animation…", + "Export Current View as DXF": "Exporter la vue actuelle en DXF", + "Export Current View as DXF…": "Exporter la vue actuelle en DXF…", + "Export DXF…": "Exporter en DXF…", + "Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets", "Extents": "Étendues", "Face": "Face", "Face attachment lost": "Rattachement à la face perdu", @@ -103,6 +121,7 @@ "Gap": "Jeu", "Grid": "Grille", "Height": "Hauteur", + "Hidden edges": "Arêtes cachées", "Holes": "Trous", "Holes (edges on them)": "Trous (par leurs arêtes)", "Home view": "Vue d'accueil", @@ -125,8 +144,10 @@ "Margin": "Marge", "Mass": "Masse", "Material": "Matériau", + "Max": "Max", "Mid Radius": "Rayon intermédiaire", "Middle double-click": "Double-clic milieu", + "Min": "Min", "Mitre": "Onglet", "Modeled": "Modélisé", "More toolbar panels": "Autres groupes de la barre d'outils", @@ -135,8 +156,10 @@ "Move earlier": "Déplacer plus tôt", "Move later": "Déplacer plus tard", "Name": "Nom", + "New study": "Nouvelle étude", "No crash reports.": "Aucun rapport de plantage.", "No matching appearances": "Aucune apparence correspondante", + "No visible bodies to export.": "Aucun corps visible à exporter.", "None": "Aucun", "Not available yet": "Pas encore disponible", "Notch": "Encoche", @@ -145,21 +168,27 @@ "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAO 3D paramétrique open source en Rust. Sous licence MIT OR Apache-2.0.", "Other side": "Autre côté", "Outside": "Extérieur", + "Overall length": "Longueur hors tout", "Parameter values": "Valeurs des paramètres", "Pass through": "Passer par", "Path": "Trajectoire", "Per Edge": "Par arête", "Pick the tool bodies": "Choisir les corps outils", "Picked": "Sélectionnés", + "Picked point": "Point choisi", "Place at the origin instead": "Placer plutôt à l'origine", "Playback": "Lecture", + "Plot": "Graphique", "Position": "Position", "Position (0 to 1)": "Position (0 à 1)", "Position (G0)": "Position (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Position le long de l'arête : 0 à l'extrémité de départ, 1 à l'autre", + "Preferences › Design turns the offer back on": "Préférences › Conception réactive la proposition", "Preview Row": "Aperçu de la ligne", "Preview leaves the current design unchanged.": "L'aperçu ne modifie pas la conception actuelle.", "Profiles/Curves": "Profils/Courbes", + "Projection": "Projection", + "Quantity": "Grandeur", "RECENT": "RÉCENTS", "RULED SURFACE": "SURFACE RÉGLÉE", "Radius Fit": "Ajustement du rayon", @@ -194,6 +223,7 @@ "Round": "Arrondi", "Rule": "Règle", "Save Rule": "Enregistrer la règle", + "Save the study as an animated GIF, seen as the viewport shows it": "Enregistrer l'étude en GIF animé, telle que la fenêtre d'affichage la montre", "Saved preview. Geometry is being checked before editing.": "Aperçu enregistré. La géométrie est en cours de vérification avant modification.", "Scale factor": "Facteur d'échelle", "Search": "Rechercher", @@ -216,6 +246,7 @@ "Sketch geometry and profiles": "Géométrie et profils d'esquisse", "Sketch plane has no body-face attachment.": "Le plan d'esquisse n'est rattaché à aucune face de corps.", "Sketch points": "Points d'esquisse", + "Slot width": "Largeur de rainure", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.", "Speed": "Vitesse", "Stand it up": "Redresser", @@ -223,6 +254,7 @@ "Stand this part up?": "Redresser cette pièce ?", "Step": "Étape", "Steps": "Étapes", + "Study": "Étude", "Style": "Style", "Suppressed": "Désactivé", "Swap Ends": "Permuter les extrémités", @@ -242,6 +274,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.", "To Offset": "Jusqu'au décalage", "Tolerance": "Tolérance", + "Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.", "Type": "Type", "UNTRIM": "ANNULER L'AJUSTEMENT", "Updating the preview": "Mise à jour de l'aperçu", @@ -251,8 +284,10 @@ "Use selected face as fixed plane": "Utiliser la face sélectionnée comme plan fixe", "VIEW": "VUE", "Value": "Valeur", + "Velocity": "Vitesse", "Vertices": "Sommets", "View navigator": "Repère de navigation", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Corps visibles uniquement. Les courbes sont approchées par des segments de droite (tolérance de 0,01 mm).", "Volume": "Volume", "Wall": "Paroi", "Wall Location": "Position de la paroi", @@ -273,6 +308,13 @@ "browser.precision": "Précision : {precision}", "browser.sketch_name": "Esquisse{number}", "browser.units": "Unités : {units}", + "cam.flat": "Plateau", + "cam.follower": "Suiveur", + "cam.knife": "Arête", + "cam.needs_joints": "nécessite une liaison qui tourne et une qui glisse ou pivote", + "cam.roller": "Galet", + "cam.roller_pick": "Galet (facultatif)", + "cam.tip_pick": "Pointe (facultatif)", "cmd.appearance_assign": "Apparence", "cmd.appearance_library": "Bibliothèque d'apparences", "cmd.appearance_list": "Lister les apparences", @@ -376,6 +418,7 @@ "cmd.inspect_minimum_radius": "Analyse du rayon minimal", "cmd.inspect_zebra": "Analyse zébrée", "cmd.joint_as_built": "Liaison telle que construite", + "cmd.joint_cam": "Came", "cmd.joint_create": "Liaison", "cmd.joint_delete": "Supprimer la liaison", "cmd.joint_drive": "Piloter les liaisons", @@ -406,8 +449,11 @@ "cmd.model_threads": "Lister les filetages", "cmd.motion_delete": "Supprimer l'étude de mouvement", "cmd.motion_export": "Exporter l'étude de mouvement", + "cmd.motion_export_animation": "Exporter l'animation du mouvement", + "cmd.motion_interference": "Collisions de l'étude de mouvement", "cmd.motion_list": "Lister les études de mouvement", "cmd.motion_play": "Lire l'étude de mouvement", + "cmd.motion_plot": "Graphique de l'étude de mouvement", "cmd.motion_study": "Étude de mouvement", "cmd.motion_trace": "Trajectoire de mouvement", "cmd.motion_trace_keep": "Conserver la trajectoire de mouvement", @@ -548,8 +594,10 @@ "cmd.surface_blend": "Surface de raccordement", "cmd.surface_boundary": "Surface frontière", "cmd.surface_extrude": "Extrusion de surface", + "cmd.surface_fair": "Régulariser la surface", "cmd.surface_fillet": "Congé de surface", "cmd.surface_loft": "Lissage de surface", + "cmd.surface_match": "Raccorder la surface", "cmd.surface_offset": "Surface décalée", "cmd.surface_patch": "Surface de remplissage", "cmd.surface_reverse_normal": "Inverser la normale", @@ -609,6 +657,18 @@ "drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}", "drive.stopped_by_loop": "Arrêté à {value} : la boucle passant par {joint} ne peut plus se fermer", "drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé", + "dxf.cancel_wait": "Attente de l'arrêt de l'export…", + "dxf.cancelled": "Export DXF annulé", + "dxf.cancelling": "Annulation de l'export DXF", + "dxf.exporting": "Export DXF en cours", + "dxf.invalid_tolerance": "La tolérance des courbes doit être comprise entre 0,0001 et 1 mm.", + "dxf.progress.index": "Préparation des surfaces (2/5)", + "dxf.progress.meshes": "Préparation des maillages (1/5)", + "dxf.progress.occlusion": "Résolution des lignes cachées (4/5)", + "dxf.progress.projection": "Projection de la géométrie (3/5)", + "dxf.progress.write": "Écriture du DXF (5/5)", + "dxf.tolerance": "Tolérance des courbes", + "dxf.tolerance_help": "Corps visibles uniquement. Les courbes sont approchées par des segments de droite. Une tolérance plus faible donne des courbes plus fines, au prix de plus de temps et de mémoire.", "expression, e.g. 25 mm": "expression, par ex. 25 mm", "fits the face, e.g. M8": "adapté à la face, par ex. M8", "from the rule": "selon la règle", @@ -640,6 +700,10 @@ "joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.", "joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.", "joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.", + "motion.checked_to": "Vérifié jusqu'à l'étape {step} seulement : la vérification a épuisé son budget de calcul", + "motion.collision": "{a} · {b} : étapes {steps}", + "motion.duration_hint": "secondes (vide : par étape)", + "motion.no_collisions": "Aucune collision au cours de l'étude", "nav.touchpad.help": "Maintenir Maj et déplacer le pointeur pour faire un panoramique ; maintenir Alt/Option et le déplacer pour orbiter. Aucun bouton de souris n'est nécessaire. Le défilement vertical zoome.", "nav.touchpad.label": "Pavé tactile", "nav.touchpad.orbit": "Orbite (maintenir Alt/Option et déplacer ; sans bouton de souris)", @@ -738,6 +802,10 @@ "sketch_text.path_hint": "Une courbe de l'esquisse que le texte suit : cliquer ici, puis sur la courbe", "sketch_text.use_font_file": "Utiliser le fichier de police {path}", "the parts library is not available": "la bibliothèque de pièces n'est pas disponible", + "thread.automatic_size": "Taille automatique", + "thread.designation": "Désignation", + "thread.iso_metric": "Métrique ISO", + "thread.select_size": "Sélectionner la taille", "timeline.group": "Nouveau dossier de fonctions", "timeline.move_many": "Déplacer des éléments de la chronologie", "timeline.move_to_group": "Déplacer les fonctions vers le dossier", @@ -758,8 +826,162 @@ "ui.about": "À propos", "ui.activate": "Activer", "ui.active_component": "Composant actif", + "ui.agent_chat": "Chat de l'agent", + "ui.agent_chat_allow": "Autoriser", + "ui.agent_chat_allowed": "Autorisé", + "ui.agent_chat_answered": "Réponse : {option}", + "ui.agent_chat_approval": "Autoriser l'agent à effectuer cette action ?", + "ui.agent_chat_attach_view": "Joindre la vue", + "ui.agent_chat_attach_view_tip": "Envoyer une image de la vue actuelle avec le message", + "ui.agent_chat_auth_agent": "S'authentifier avec {name}", + "ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer SolidCraft.", + "ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; SolidCraft ne voit jamais votre mot de passe ni vos clés.", + "ui.agent_chat_auth_none": "L'agent ne propose ici aucun moyen d'authentification : s'authentifier comme le décrit sa propre documentation.", + "ui.agent_chat_auth_retry": "Je suis authentifié : reconnecter", + "ui.agent_chat_auth_terminal": "Exécuter ceci dans un terminal, puis appuyer de nouveau sur Connecter :", + "ui.agent_chat_auth_title": "Authentification auprès de l'agent", + "ui.agent_chat_cancel": "Annuler", + "ui.agent_chat_changes": "Modifications : {n}, en une seule étape d'annulation", + "ui.agent_chat_changes_of": "{n}/{limit} modifications", + "ui.agent_chat_close": "Fermer", + "ui.agent_chat_command": "Commande de l'agent", + "ui.agent_chat_connect": "Connecter", + "ui.agent_chat_copy": "Copier", + "ui.agent_chat_custom_hint": "Tout programme qui parle l'Agent Client Protocol sur stdin et stdout. Aucun shell ne le lance.", + "ui.agent_chat_denied": "Refusé", + "ui.agent_chat_deny": "Refuser", + "ui.agent_chat_desktop_only": "Le chat de l'agent nécessite l'application de bureau.", + "ui.agent_chat_detecting": "Recherche d'agents…", + "ui.agent_chat_dock_left": "Ancrer à gauche", + "ui.agent_chat_dock_right": "Ancrer à droite", + "ui.agent_chat_edit": "Modifier", + "ui.agent_chat_exited": "L'agent s'est arrêté.", + "ui.agent_chat_failed_start": "L'agent n'a pas pu démarrer : {error}", + "ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.", + "ui.agent_chat_key_in_env": "clé définie", + "ui.agent_chat_left": "Gauche", + "ui.agent_chat_limit": "L'agent a atteint la limite de {limit} modifications pour un message. Envoyez un autre message pour qu'il poursuive.", + "ui.agent_chat_minimize": "Réduire en bandeau", + "ui.agent_chat_model_tip": "Le modèle utilisé par l'agent. Votre choix est mémorisé pour cet agent.", + "ui.agent_chat_new": "Nouveau chat", + "ui.agent_chat_new_confirm": "Démarrer un nouveau chat ? Le chat actuel sera effacé.", + "ui.agent_chat_new_yes": "Nouveau chat", + "ui.agent_chat_no_cli": "solidcraft-cli est introuvable à côté de l'application : l'agent ne peut donc pas accéder à la conception.", + "ui.agent_chat_no_control": "Le point d'accès du chat de l'agent n'a pas démarré : l'agent ne peut donc pas accéder à la conception.", + "ui.agent_chat_no_images": "Cet agent n'accepte pas les images", + "ui.agent_chat_none_found": "Aucun agent de programmation trouvé. En installer un parmi ceux-ci, puis appuyer sur Actualiser :", + "ui.agent_chat_none_found_short": "Aucun agent trouvé", + "ui.agent_chat_not_installed": "Non installé", + "ui.agent_chat_not_signed_in": "non authentifié", + "ui.agent_chat_permission": "L'agent demande à exécuter : {title}", + "ui.agent_chat_pick": "Agent", + "ui.agent_chat_placeholder": "Demander à l'agent de modifier la conception…", + "ui.agent_chat_placeholder_busy": "Mettre un message en file d'attente pour quand l'agent aura terminé (Ctrl+Enter : envoyer maintenant)", + "ui.agent_chat_privacy": "Rien n'est envoyé ailleurs qu'à l'agent que vous lancez, sous votre propre compte.", + "ui.agent_chat_privacy_note": "SolidCraft n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. SolidCraft ne voit ni ne stocke jamais de clé ni de mot de passe.", + "ui.agent_chat_queue": "Mettre en file", + "ui.agent_chat_queue_full": "Trop de messages sont en attente : retirez-en un ou attendez l'agent.", + "ui.agent_chat_queued": "En file d'attente", + "ui.agent_chat_queued_stopped": "En file d'attente (arrêté, non envoyé)", + "ui.agent_chat_refresh": "Actualiser", + "ui.agent_chat_refused": "Interdit à l'agent : {reason}", + "ui.agent_chat_remove": "Retirer de la file d'attente", + "ui.agent_chat_right": "Droite", + "ui.agent_chat_save": "Enregistrer", + "ui.agent_chat_send": "Envoyer", + "ui.agent_chat_send_now": "Envoyer maintenant", + "ui.agent_chat_send_now_tip": "Arrêter l'étape en cours de l'agent et envoyer celui-ci ensuite (Ctrl+Enter)", + "ui.agent_chat_side": "Côté du chat de l'agent", + "ui.agent_chat_signed_in": "authentifié", + "ui.agent_chat_state_auth": "Authentification requise", + "ui.agent_chat_state_busy": "Travail en cours…", + "ui.agent_chat_state_exited": "Agent arrêté", + "ui.agent_chat_state_off": "Non connecté", + "ui.agent_chat_state_ready": "Prêt", + "ui.agent_chat_state_starting": "Démarrage…", + "ui.agent_chat_stop": "Arrêter", + "ui.agent_chat_stop_tip": "Arrêter l'agent et tout ce qu'il a lancé", + "ui.agent_chat_stop_turn_tip": "Arrêter l'agent (Esc). Les messages en file d'attente y restent.", + "ui.agent_chat_turn_limit": "Modifications par message", + "ui.agent_chat_undo_changes": "Annuler ces modifications", + "ui.agent_chat_undo_label": "Chat de l'agent : {message}", + "ui.agent_chat_undone": "(annulé)", + "ui.agent_chat_view_attached": "Vue jointe", "ui.agent_cursor": "Curseur de l'agent", "ui.agent_cursor_speed": "Vitesse du curseur de l'agent", + "ui.ai_add": "Ajouter", + "ui.ai_add_agent": "Ajouter un agent…", + "ui.ai_add_agent_title": "Ajouter un agent", + "ui.ai_add_api": "API compatible OpenAI", + "ui.ai_add_custom": "Commande personnalisée", + "ui.ai_add_installed": "Agent installé", + "ui.ai_add_installed_note": "Une entrée à vous pour un agent installé, par exemple avec d'autres arguments. Les agents installés sont listés automatiquement.", + "ui.ai_added": "ajouté", + "ui.ai_agent_mode_tip": "Le mode propre à l'agent : ses propres outils (shell, modifications de fichiers) le suivent. Les outils de SolidCraft suivent les autorisations de Préférences › IA.", + "ui.ai_agents": "Agents", + "ui.ai_always_allow": "Toujours autoriser", + "ui.ai_always_allow_tip": "Autoriser {tool} pour cet agent dorénavant, sans demander", + "ui.ai_always_allowed": "Toujours autorisé", + "ui.ai_always_allowed_none": "Rien n'est encore toujours autorisé. Les réponses « Toujours autoriser » données dans le chat sont listées ici.", + "ui.ai_auto_allowed": "Autorisé automatiquement : {title} ({why})", + "ui.ai_auto_mode": "votre réglage des autorisations", + "ui.ai_auto_ours": "SolidCraft vérifie lui-même ses propres outils", + "ui.ai_auto_remembered": "vous avez choisi Toujours autoriser", + "ui.ai_bad_url": "Saisir une URL commençant par http:// ou https://", + "ui.ai_base_url": "URL de base", + "ui.ai_button": "IA", + "ui.ai_copy_suffix": "copie", + "ui.ai_custom_url": "URL personnalisée", + "ui.ai_default": "Par défaut", + "ui.ai_http_busy": "le point d'accès est occupé ou a dépassé sa limite ; réessayez plus tard", + "ui.ai_http_key": "le point d'accès a refusé la clé : vérifiez-la dans Préférences › IA", + "ui.ai_http_not_found": "point d'accès ou modèle introuvable : vérifiez l'URL de base et le nom du modèle", + "ui.ai_key": "Clé d'API", + "ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système", + "ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.", + "ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de SolidCraft. Laisser vide pour un serveur local.", + "ui.ai_keychain_error": "Le trousseau de clés du système a refusé : {error}", + "ui.ai_mode_all": "Tout autoriser", + "ui.ai_mode_all_note": "Rien ne demande d'abord. Les rapports de plantage restent inaccessibles et les limites de dossiers s'appliquent toujours.", + "ui.ai_mode_all_warning": "L'agent peut enregistrer, écraser, exporter et remplacer votre conception sans demander. N'utilisez ceci qu'avec un agent de confiance, sur des conceptions que vous avez enregistrées.", + "ui.ai_mode_ask": "Demander à chaque fois", + "ui.ai_mode_ask_note": "Chaque modification de la conception et chaque action sur un fichier attend votre autorisation.", + "ui.ai_mode_edits": "Autoriser automatiquement les modifications du modèle", + "ui.ai_mode_edits_note": "Les commandes de modélisation s'exécutent sans demander ; l'enregistrement, l'ouverture, l'exportation et les autres actions sur les fichiers ou destructrices demandent toujours.", + "ui.ai_mode_global": "Comme défini ci-dessus", + "ui.ai_model": "Modèle", + "ui.ai_modes_scope": "Ces réglages concernent les outils de SolidCraft. Les outils propres à un agent (son shell, ses modifications de fichiers) suivent le mode de l'agent, que vous choisissez dans l'en-tête du panneau de chat lorsque l'agent propose des modes.", + "ui.ai_name": "Nom", + "ui.ai_need_command": "Saisir la commande qui lance l'agent", + "ui.ai_need_model": "Saisir le nom du modèle", + "ui.ai_no_answer": "L'agent n'a pas répondu à temps", + "ui.ai_no_http": "Cette version ne peut pas joindre les points d'accès d'API", + "ui.ai_no_keychain": "Le trousseau de clés du système n'est pas disponible : une clé ne peut donc pas être stockée. Un serveur local sans clé fonctionne toujours.", + "ui.ai_panel": "Panneau", + "ui.ai_permissions": "Autorisations", + "ui.ai_preset": "Préréglage", + "ui.ai_remove": "Retirer", + "ui.ai_request_limit": "arrêté après 24 requêtes pour un message ; envoyez un autre message pour poursuivre", + "ui.ai_reset": "Réinitialiser", + "ui.ai_settings": "Réglages IA…", + "ui.ai_show_features": "Afficher les fonctionnalités IA", + "ui.ai_show_features_note": "Le chat de l'agent : un panneau où un agent de programmation que vous utilisez déjà, ou un point d'accès de modèle de votre choix, travaille sur la conception ouverte. Désactivé : ni bouton IA, ni raccourci, ni entrée de menu, ni panneau, et rien n'est recherché ni lancé.", + "ui.ai_switch_tip": "Changer d'agent pour cette conversation", + "ui.ai_test": "Tester", + "ui.ai_test_acp_ok": "Répond : {agent}", + "ui.ai_test_acp_tip": "Lance brièvement l'agent, lui demande une session, puis l'arrête", + "ui.ai_test_api_tip": "Demande ses modèles au point d'accès", + "ui.ai_test_no_model": "Atteint : {count} modèle(s), mais pas {model}", + "ui.ai_test_ok": "Atteint : {count} modèle(s)", + "ui.ai_testing": "Test en cours…", + "ui.ai_tool_done": "terminé", + "ui.ai_tool_failed": "échec", + "ui.ai_tool_running": "en cours", + "ui.ai_tool_unfold": "Cliquer pour afficher ce qu'il a renvoyé", + "ui.ai_tool_waiting": "en attente", + "ui.ai_toolbar_button": "Afficher le bouton dans la barre d'outils", + "ui.ai_toolbar_button_note": "Le bouton IA à l'extrémité droite de la barre d'outils. Ctrl+Shift+A et le menu Vue ouvrent toujours le panneau.", + "ui.ai_undo_tip": "Annuler tout ce que l'agent a modifié pour ce message, en une seule étape", "ui.all": "Tout", "ui.analysis": "Analyse", "ui.angle": "Angle", @@ -1110,6 +1332,8 @@ "ui.radial_context_menu": "Menu contextuel radial", "ui.radius": "Rayon", "ui.radius_type": "Type de rayon", + "ui.rail": "Rail", + "ui.rail_fit": "Ajustement au rail", "ui.reachable": "Accessible", "ui.rectangle": "Rectangle", "ui.rectangular_pattern": "Réseau rectangulaire", @@ -1136,6 +1360,7 @@ "ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}", "ui.saved_to": "Enregistré : {path}", "ui.scale": "Échelle", + "ui.second_rail": "Second rail", "ui.section_analysis": "Analyse de coupe", "ui.select": "Sélectionner", "ui.selectOther": "Sélectionner un autre…", @@ -1186,6 +1411,7 @@ "ui.stand_up_tip": "Après l'ouverture d'un fichier STEP ou IGES avec Z vers le haut, proposer un quart de tour autour de X", "ui.start": "Début", "ui.start_radius": "Rayon initial", + "ui.stretch": "Étirer", "ui.supersampling": "Suréchantillonnage (SSAA)", "ui.supersampling_adaptive": "Seulement quand la vue est immobile", "ui.supersampling_off": "Désactivé (1×)", diff --git a/assets/locales/gl.json b/assets/locales/gl.json new file mode 100644 index 00000000..725f3d9c --- /dev/null +++ b/assets/locales/gl.json @@ -0,0 +1,1480 @@ +{ + "0 turns it off": "0 desactívao", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Abre o borrador de GitHub de abaixo e pega o informe copiado no portapapeis.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Abre o borrador de GitHub de abaixo. O título e o informe encheranse automaticamente.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Achega a captura de pantalla: cópiaa e pégaa, ou gárdaa e sube o PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Preme Submit new issue en GitHub para enviar o informe.", + "A new configuration, a copy of the active one": "Unha configuración nova, copia da activa", + "AI": "IA", + "About SolidCraft": "Acerca de SolidCraft", + "Active": "Activa", + "Active rule": "Regra activa", + "Add": "Engadir", + "Add Key": "Engadir clave", + "Add Mid Radius": "Engadir raio medio", + "Add Row": "Engadir fila", + "Add condition": "Engadir condición", + "Alignment": "Aliñamento", + "All Sketch Points": "Todos os puntos do bosquexo", + "All open designs": "Todos os deseños abertos", + "Allowed expressions, separated by semicolons": "Expresións permitidas, separadas por punto e coma", + "Also deletes": "Tamén elimina", + "Angle 2": "Ángulo 2", + "Animate": "Animar", + "Appearance name": "Nome da aparencia", + "Applied Radius": "Raio aplicado", + "Apply To": "Aplicar a", + "Apply to selection": "Aplicar á selección", + "Arc length": "Lonxitude do arco", + "Area": "Área", + "Attachment": "Ancoraxe", + "BROWSER": "NAVEGADOR", + "Bend Line Position": "Posición da liña de dobra", + "Bend Position": "Posición da dobra", + "Bend Radius": "Raio de dobra", + "Bends": "Dobras", + "Body": "Corpo", + "Boundary": "Contorno", + "Bounding box": "Caixa delimitadora", + "CSV or JSON: updates existing parameters, adds new ones": "CSV ou JSON: actualiza os parámetros existentes e engade os novos", + "Capture All Positions": "Capturar todas as posicións", + "Catch Direction": "Dirección de captura", + "Cells": "Celas", + "Center X": "Centro X", + "Center Y": "Centro Y", + "Center of mass": "Centro de masas", + "Centre to centre": "Entre centros", + "Chamfer Type": "Tipo de chafrán", + "Check collisions": "Comprobar colisións", + "Checked": "Marcado", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Escolle un plano novo. A xeometría e as cotas do bosquexo mantéñense nas súas coordenadas bidimensionais.", + "Circumference": "Circunferencia", + "Close draft": "Pechar o borrador", + "Collisions": "Colisións", + "Colour": "Cor", + "Component1": "Compoñente1", + "Configuration": "Configuración", + "Configuration rules": "Regras de configuración", + "Construction": "Construción", + "Contact": "Contacto", + "Continuity": "Continuidade", + "Copy Error Message": "Copiar a mensaxe de erro", + "Copy Warning Message": "Copiar a mensaxe de aviso", + "Copy all licences": "Copiar todas as licenzas", + "Copy diagnostic information": "Copiar a información de diagnóstico", + "Copy report": "Copiar o informe", + "Copy screenshot": "Copiar a captura de pantalla", + "Corner Relief": "Desafogo de esquina", + "Corner Relief Shape": "Forma do desafogo de esquina", + "Counterbore depth": "Profundidade do avellanado cilíndrico", + "Countersink angle": "Ángulo do avellanado cónico", + "Create Issue — review draft": "Crear incidencia — revisar o borrador", + "Cube": "Cubo", + "Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)", + "Current perspective view": "Vista en perspectiva actual", + "Curvature (G2)": "Curvatura (G2)", + "Customize Toolbar": "Personalizar a barra de ferramentas", + "Delete all": "Eliminar todo", + "Delete rule": "Eliminar a regra", + "Delete the row": "Eliminar a fila", + "Delete the set": "Eliminar o conxunto", + "Delete this parameter": "Eliminar este parámetro", + "Density": "Densidade", + "Designs you open or save appear here.": "Os deseños que abras ou gardes aparecen aquí.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Ata o obxecto. Escolle Ángulo para editar a extensión angular.", + "Discard": "Descartar", + "Do not export hidden edges": "Non exportar as arestas ocultas", + "Double-click to rename": "Dobre clic para renomear", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dobre clic co botón central na vista: Inicio (a vista de inicio, encadrando o que se mostra), Axustar (encadrar o que se mostra mantendo a orientación) ou nada", + "Drive Joint": "Accionar a unión", + "Edges": "Arestas", + "Edit Profile Sketch": "Editar o bosquexo do perfil", + "Enable rule": "Activar a regra", + "Enabled": "Activado", + "Explode": "Explosionar", + "Export Animation…": "Exportar animación…", + "Export Current View as DXF": "Exportar a vista actual como DXF", + "Export Current View as DXF…": "Exportar a vista actual como DXF…", + "Export DXF…": "Exportar DXF…", + "Export hidden edges as dashed lines": "Exportar as arestas ocultas como liñas descontinuas", + "Extents": "Extensións", + "Face": "Cara", + "Face attachment lost": "Perdeuse a ancoraxe á cara", + "Faces": "Caras", + "Faces / edges / vertices": "Caras / arestas / vértices", + "Favourite": "Favorito", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Os ficheiros de moitos programas CAD teñen o Y cara arriba, así que aquí ábrense deitados (Z cara arriba).", + "Filter": "Filtro", + "Find in Browser": "Buscar no navegador", + "Flat": "Plano", + "Flat size": "Tamaño desenvolvido", + "Follow camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)", + "Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)", + "Font": "Tipo de letra", + "Full Length": "Lonxitude completa", + "Gap": "Separación", + "Grid": "Grella", + "Height": "Altura", + "Hidden edges": "Arestas ocultas", + "Holes": "Furados", + "Holes (edges on them)": "Furados (arestas sobre eles)", + "Home view": "Vista de inicio", + "In This Design": "Neste deseño", + "In place (surfaces)": "No sitio (superficies)", + "Inside": "Dentro", + "Interferes": "Interfire", + "Issue title": "Título da incidencia", + "Joint Origin1": "Orixe da unión1", + "Keep Tools": "Manter as ferramentas", + "Later": "Máis tarde", + "Leave it": "Deixalo", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Ancoraxe á cara antiga. Volver vincular a unha cara certificada crea unha ancoraxe exacta.", + "Length": "Lonxitude", + "Length units": "Unidades de lonxitude", + "Letter Spacing": "Espazado entre letras", + "Library": "Biblioteca", + "Line Spacing": "Espazado entre liñas", + "Loop": "Bucle", + "Margin": "Marxe", + "Mass": "Masa", + "Material": "Material", + "Max": "Máx.", + "Mid Radius": "Raio medio", + "Middle double-click": "Dobre clic central", + "Min": "Mín.", + "Mitre": "Inglete", + "Modeled": "Modelado", + "More toolbar panels": "Máis paneis da barra de ferramentas", + "Motion": "Movemento", + "Move Type": "Tipo de movemento", + "Move earlier": "Mover antes", + "Move later": "Mover despois", + "Name": "Nome", + "No crash reports.": "Non hai informes de fallos.", + "No matching appearances": "Non hai aparencias coincidentes", + "No visible bodies to export.": "Non hai corpos visibles para exportar.", + "None": "Ningún", + "Not available yet": "Aínda non dispoñible", + "Notch": "Entalle", + "Opacity": "Opacidade", + "Open GitHub draft": "Abrir o borrador de GitHub", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto en Rust. Con licenza MIT OR Apache-2.0.", + "Other side": "Outro lado", + "Outside": "Fóra", + "Overall length": "Lonxitude total", + "Parameter values": "Valores dos parámetros", + "Pass through": "Atravesar", + "Path": "Traxectoria", + "Per Edge": "Por aresta", + "Pick the tool bodies": "Escolle os corpos ferramenta", + "Picked": "Escollido", + "Place at the origin instead": "Colocar na orixe no seu lugar", + "Playback": "Reprodución", + "Position": "Posición", + "Position (0 to 1)": "Posición (de 0 a 1)", + "Position (G0)": "Posición (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Posición ao longo da aresta, 0 no extremo inicial e 1 no outro", + "Preview Row": "Previsualizar a fila", + "Preview leaves the current design unchanged.": "A previsualización non cambia o deseño actual.", + "Profiles/Curves": "Perfís/curvas", + "Projection": "Proxección", + "RECENT": "RECENTES", + "RULED SURFACE": "SUPERFICIE REGRADA", + "Radius Fit": "Axuste do raio", + "Ratio": "Proporción", + "Reattach selected face": "Volver ancorar a cara seleccionada", + "Recover": "Recuperar", + "Recover unsaved design?": "Recuperar o deseño sen gardar?", + "Rectangular": "Rectangular", + "Reference face": "Cara de referencia", + "Remove Tools": "Quitar as ferramentas", + "Remove from list": "Quitar da lista", + "Remove override": "Quitar a substitución", + "Remove private design details or paths before sharing.": "Elimina os detalles privados do deseño ou as rutas antes de compartir.", + "Remove screenshot": "Quitar a captura de pantalla", + "Remove the column": "Quitar a columna", + "Remove the track": "Quitar a pista", + "Remove this point": "Quitar este punto", + "Report a bug — review a screenshot and open a GitHub draft": "Informar dun erro — revisar unha captura e abrir un borrador en GitHub", + "Reset Toolbar": "Restablecer a barra de ferramentas", + "Restore outer boundary": "Restaurar o contorno exterior", + "Restore the inherited component or physical material appearance": "Restaurar a aparencia herdada do compoñente ou do material físico", + "Result": "Resultado", + "Retake screenshot": "Repetir a captura de pantalla", + "Reverse": "Inverter", + "Reverse Direction": "Inverter a dirección", + "Revert": "Reverter", + "Revert All Positions": "Reverter todas as posicións", + "Revert Position": "Reverter a posición", + "Review the report and screenshot before opening GitHub.": "Revisa o informe e a captura de pantalla antes de abrir GitHub.", + "Rim Edge": "Aresta do bordo", + "Roll History Marker Here": "Levar o marcador do historial aquí", + "Round": "Redondo", + "Rule": "Regra", + "Save Rule": "Gardar a regra", + "Save the study as an animated GIF, seen as the viewport shows it": "Gardar o estudo como GIF animado, tal e como o mostra a vista", + "Saved preview. Geometry is being checked before editing.": "Previsualización gardada. Estase a comprobar a xeometría antes de editar.", + "Scale factor": "Factor de escala", + "Search": "Buscar", + "Search commands": "Buscar ordes", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar outro: mantén o botón esquerdo quieto sobre o modelo (ou clic dereito › Seleccionar outro…) para listar todo o que hai baixo o cursor; fai clic de novo no mesmo punto para seleccionar o seguinte. Fai dobre clic co botón central para ir ao Inicio (Preferencias › Navegación).", + "Select bodies (a face picks its body)": "Selecciona corpos (unha cara escolle o seu corpo)", + "Select bodies or faces instead": "Seleccionar corpos ou caras no seu lugar", + "Selection filter": "Filtro de selección", + "Semantic faces share their appearance across instances.": "As caras semánticas comparten a súa aparencia entre instancias.", + "Send your report on GitHub": "Envía o informe en GitHub", + "Shape": "Forma", + "Show Agent Cursor": "Mostrar o cursor do axente", + "Show in folder": "Mostrar no cartafol", + "Show the pointer an AI agent moves (#35)": "Mostrar o punteiro que move un axente de IA (#35)", + "Signed axes": "Eixes con signo", + "Size": "Tamaño", + "Sketch constraints": "Restricións do bosquexo", + "Sketch curves": "Curvas do bosquexo", + "Sketch dimensions": "Cotas do bosquexo", + "Sketch geometry and profiles": "Xeometría e perfís do bosquexo", + "Sketch plane has no body-face attachment.": "O plano do bosquexo non está ancorado a ningunha cara dun corpo.", + "Sketch points": "Puntos do bosquexo", + "Slot width": "Largura da ranura", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.", + "Speed": "Velocidade", + "Stand it up": "Poñela de pé", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poñela de pé: un cuarto de volta arredor de X, como unha operación Mover que podes desfacer.", + "Stand this part up?": "Poñer esta peza de pé?", + "Step": "Paso", + "Steps": "Pasos", + "Style": "Estilo", + "Suppressed": "Suprimida", + "Swap Ends": "Intercambiar os extremos", + "Tangent (G1)": "Tanxente (G1)", + "Tangent Chain": "Cadea tanxente", + "Tear": "Rasgado", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "O bosquexo descontinuo é unha guía de plano gardada. Selecciona unha cara nova para reparar a ancoraxe.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "Falta a aparición do autor gardada. Selecciona unha colocación de autor válida antes de volver vincular.", + "Then": "Entón", + "These features will be deleted:": "Eliminaranse estas operacións:", + "These features will fail:": "Fallarán estas operacións:", + "Thickness Outside": "Espesor cara a fóra", + "Thin": "Delgado", + "Thin Extrude": "Extrusión delgada", + "This child is shared. Deleting it removes it from every copy of its parent.": "Este fillo está compartido. Eliminalo quítao de todas as copias do seu pai.", + "This child is shared. Position changes apply to every copy of its parent.": "Este fillo está compartido. Os cambios de posición aplícanse a todas as copias do seu pai.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara non pode ofrecer unha ancoraxe duradeira. Escolle outra cara ou usa expresamente un plano fixo.", + "To Offset": "Ata o desprazamento", + "Tolerance": "Tolerancia", + "Turn off section analysis before exporting the view.": "Desactiva a análise de sección antes de exportar a vista.", + "Type": "Tipo", + "UNTRIM": "DESRECORTAR", + "Updating the preview": "Actualizando a previsualización", + "Use active author placement": "Usar a colocación de autor activa", + "Use fixed plane": "Usar un plano fixo", + "Use saved fixed plane": "Usar o plano fixo gardado", + "Use selected face as fixed plane": "Usar a cara seleccionada como plano fixo", + "VIEW": "VISTA", + "Value": "Valor", + "Vertices": "Vértices", + "View navigator": "Navegador de vistas", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Só os corpos visibles. As curvas usan segmentos de liña (tolerancia de 0,01 mm).", + "Volume": "Volume", + "Wall": "Parede", + "Wall Location": "Situación da parede", + "Wall Thickness": "Espesor da parede", + "When": "Cando", + "Width": "Largura", + "Will fail": "Fallará", + "angle, height": "ángulo, altura", + "appearance.dielectric_ior": "IOR dieléctrico", + "appearance.metallic": "Metálico", + "appearance.pbr_help": "Ve esta superficie en Render ou nun PNG de produto. Sen refracción nin transmisión.", + "appearance.pbr_opaque": "PBR opaco", + "appearance.roughness": "Rugosidade", + "appearance.surface_response": "Resposta da superficie", + "browser.body_name": "Corpo{number}", + "browser.component_name": "Compoñente{number}", + "browser.parameters": "Parámetros ({count})", + "browser.precision": "Precisión: {precision}", + "browser.sketch_name": "Bosquexo{number}", + "browser.units": "Unidades: {units}", + "cam.flat": "Cara plana", + "cam.follower": "Seguidor", + "cam.knife": "Gume de coitelo", + "cam.needs_joints": "precisa unha unión que xire e outra que esvare ou oscile", + "cam.roller": "Rolete", + "cam.roller_pick": "Rolete (opcional)", + "cam.tip_pick": "Punta (opcional)", + "cmd.appearance_assign": "Aparencia", + "cmd.appearance_library": "Biblioteca de aparencias", + "cmd.appearance_list": "Listar as aparencias", + "cmd.body_rename": "Renomear o corpo", + "cmd.browser_group": "Grupo novo", + "cmd.browser_move": "Mover elementos do navegador", + "cmd.browser_order": "Ordenar elementos do navegador", + "cmd.browser_rename_group": "Renomear o grupo", + "cmd.browser_ungroup": "Desagrupar", + "cmd.browser_visibility": "Mostrar/Ocultar", + "cmd.canvas_calibrate": "Calibrar o lenzo", + "cmd.canvas_decal": "Calcomanía", + "cmd.canvas_delete": "Eliminar o lenzo", + "cmd.canvas_edit": "Editar o lenzo", + "cmd.canvas_insert": "Lenzo", + "cmd.component_activate": "Activar o compoñente", + "cmd.component_break_link": "Romper a ligazón", + "cmd.component_capture_position": "Capturar a posición", + "cmd.component_create": "Compoñente novo", + "cmd.component_delete": "Eliminar o compoñente", + "cmd.component_from_bodies": "Crear compoñentes a partir de corpos", + "cmd.component_insert_design": "Inserir deseño", + "cmd.component_links": "Listar as ligazóns", + "cmd.component_list": "Listar os compoñentes", + "cmd.component_move_bodies": "Mover corpos ao compoñente", + "cmd.component_move_sketches": "Mover bosquexos ao compoñente", + "cmd.component_paste_new": "Pegar como novo", + "cmd.component_properties": "Propiedades físicas do compoñente", + "cmd.component_rename": "Renomear o compoñente", + "cmd.component_revert_position": "Reverter a posición", + "cmd.component_unlock": "Desbloquear o contido", + "cmd.component_update_links": "Actualizar as ligazóns", + "cmd.config_activate": "Activar a configuración", + "cmd.config_column": "Columna de configuración", + "cmd.config_configure": "Configurar", + "cmd.config_preview": "Previsualizar a configuración", + "cmd.config_row": "Fila de configuración", + "cmd.config_rule": "Regra de configuración", + "cmd.config_rules": "Regras de configuración", + "cmd.config_table": "Mostrar a táboa de configuracións", + "cmd.construct_axis_edge": "Eixe pola aresta", + "cmd.construct_axis_normal_to_face": "Eixe perpendicular á cara", + "cmd.construct_axis_through_cylinder": "Eixe por cilindro/cono/toro", + "cmd.construct_axis_two_planes": "Eixe por dous planos", + "cmd.construct_axis_two_points": "Eixe por dous puntos", + "cmd.construct_plane_along_path": "Plano ao longo da traxectoria", + "cmd.construct_plane_midplane": "Plano medio", + "cmd.construct_plane_perpendicular": "Plano perpendicular", + "cmd.construct_plane_tangent": "Plano tanxente", + "cmd.construct_plane_three_points": "Plano por tres puntos", + "cmd.construct_plane_two_edges": "Plano por dúas arestas", + "cmd.construct_point_along_path": "Punto ao longo da traxectoria", + "cmd.construct_point_center": "Punto no centro de círculo/esfera/toro", + "cmd.construct_point_edge_plane": "Punto en aresta e plano", + "cmd.construct_point_three_planes": "Punto por tres planos", + "cmd.construct_point_two_edges": "Punto por dúas arestas", + "cmd.construct_point_vertex": "Punto no vértice", + "cmd.contact_check": "Comprobar o contacto", + "cmd.contact_create": "Conxunto de contacto novo", + "cmd.contact_disable_all": "Desactivar o contacto", + "cmd.contact_edit": "Editar o conxunto de contacto", + "cmd.contact_enable_all": "Activar todo o contacto", + "cmd.contact_enable_sets": "Activar os conxuntos de contacto", + "cmd.contact_list": "Listar os conxuntos de contacto", + "cmd.diagnostics_crash_report": "Informe de fallo", + "cmd.diagnostics_crash_reports": "Informes de fallos", + "cmd.diagnostics_crash_reports_clear": "Eliminar os informes de fallos", + "cmd.diagnostics_crash_test": "Proba de fallo", + "cmd.doc_recover": "Recuperar o deseño", + "cmd.doc_recovery_discard": "Descartar o deseño recuperado", + "cmd.doc_recovery_list": "Deseños recuperables", + "cmd.document_inspect": "Inspeccionar o deseño", + "cmd.document_metadata": "Propiedades do deseño", + "cmd.document_metadata_remove": "Quitar unha propiedade do deseño", + "cmd.document_metadata_set": "Cambiar as propiedades do deseño", + "cmd.document_units": "Cambiar as unidades do deseño", + "cmd.edit_checkpoint": "Punto de control", + "cmd.edit_restore_checkpoint": "Restaurar o punto de control", + "cmd.engine_commands": "Listar as ordes", + "cmd.explode_create": "Vista explosionada", + "cmd.explode_delete": "Eliminar a vista explosionada", + "cmd.explode_list": "Listar as vistas explosionadas", + "cmd.explode_show": "Mostrar a vista explosionada", + "cmd.expr_evaluate": "Avaliar a expresión", + "cmd.face_delete": "Eliminar a cara", + "cmd.file_export": "Exportar", + "cmd.file_insert_mesh": "Inserir malla", + "cmd.file_insert_step": "Inserir STEP", + "cmd.file_save_mesh": "Gardar como malla", + "cmd.file_slicers": "Laminadores instalados", + "cmd.inspect_accessibility": "Análise de accesibilidade", + "cmd.inspect_center_of_mass": "Centro de masas", + "cmd.inspect_compare": "Comparar corpos", + "cmd.inspect_continuity": "Análise de continuidade", + "cmd.inspect_curvature_comb": "Análise con pente de curvatura", + "cmd.inspect_curvature_map": "Análise con mapa de curvatura", + "cmd.inspect_draft": "Análise de ángulo de desmoldaxe", + "cmd.inspect_environment_map": "Análise con mapa de contorno", + "cmd.inspect_interference": "Interferencia", + "cmd.inspect_isocurve": "Análise de isocurvas", + "cmd.inspect_minimum_radius": "Análise de raio mínimo", + "cmd.inspect_zebra": "Análise cebra", + "cmd.joint_as_built": "Unión tal como está", + "cmd.joint_cam": "Leva", + "cmd.joint_create": "Unión", + "cmd.joint_delete": "Eliminar a unión", + "cmd.joint_drive": "Accionar as unións", + "cmd.joint_edit": "Editar a unión", + "cmd.joint_gear": "Engrenaxe", + "cmd.joint_limits": "Límites da unión", + "cmd.joint_list": "Listar as unións", + "cmd.joint_motion_link": "Vínculo de movemento", + "cmd.joint_origin": "Orixe da unión", + "cmd.joint_rack_pinion": "Cremalleira e piñón", + "cmd.joint_reach": "Punto de alcance", + "cmd.joint_rigid_group": "Grupo ríxido", + "cmd.joint_screw": "Parafuso", + "cmd.joint_solve": "Resolver as unións", + "cmd.library_apply": "Aplicar unha entrada da biblioteca local", + "cmd.library_export": "Exportar a biblioteca do deseño", + "cmd.library_inspect": "Inspeccionar a biblioteca local", + "cmd.material_assign": "Material físico", + "cmd.material_create": "Material físico novo", + "cmd.material_delete": "Eliminar o material físico", + "cmd.material_edit": "Editar o material físico", + "cmd.material_list": "Listar os materiais", + "cmd.material_properties_directional": "Módulo elástico direccional", + "cmd.material_properties_validate": "Validar os datos do material físico", + "cmd.mesh_convert": "Converter a malla", + "cmd.model_edges": "Listar as arestas", + "cmd.model_faces": "Listar as caras", + "cmd.model_threads": "Listar as roscas", + "cmd.motion_delete": "Eliminar o estudo de movemento", + "cmd.motion_export": "Exportar o estudo de movemento", + "cmd.motion_export_animation": "Exportar a animación do movemento", + "cmd.motion_list": "Listar os estudos de movemento", + "cmd.motion_play": "Reproducir o estudo de movemento", + "cmd.motion_study": "Estudo de movemento", + "cmd.motion_trace": "Traxectoria do movemento", + "cmd.motion_trace_keep": "Manter a traxectoria do movemento", + "cmd.occurrence_copy": "Pegar (instancia)", + "cmd.occurrence_ground": "Fixar ao chan", + "cmd.occurrence_move": "Mover a aparición", + "cmd.occurrence_properties": "Propiedades físicas da aparición", + "cmd.parameters_add": "Engadir parámetro de usuario", + "cmd.parameters_change": "Cambiar os parámetros", + "cmd.parameters_comment": "Comentar o parámetro", + "cmd.parameters_complete": "Completar a expresión", + "cmd.parameters_delete": "Eliminar o parámetro", + "cmd.parameters_export": "Exportar os parámetros", + "cmd.parameters_favorite": "Parámetro favorito", + "cmd.parameters_graph": "Gráfico de parámetros", + "cmd.parameters_import": "Importar parámetros", + "cmd.parameters_list": "Listar os parámetros", + "cmd.parameters_move": "Mover o parámetro", + "cmd.parameters_rename": "Renomear o parámetro", + "cmd.parameters_users": "Usos do parámetro", + "cmd.parts_fastener": "Inserir elemento de fixación", + "cmd.parts_insert": "Inserir peza", + "cmd.parts_library": "Pezas normalizadas", + "cmd.parts_resize": "Cambiar o tamaño da peza", + "cmd.plastic_assign_rule": "Asignar regra de plástico", + "cmd.plastic_boss": "Saínte", + "cmd.plastic_lip": "Beizo", + "cmd.plastic_manage_rules": "Xestionar as regras de plástico", + "cmd.plastic_rest": "Apoio", + "cmd.plastic_rules": "Listar as regras de plástico", + "cmd.plastic_snap_fit": "Encaixe a presión", + "cmd.render_backends": "Motores de render", + "cmd.render_cancel": "Cancelar o render", + "cmd.render_export": "Exportar a imaxe do render", + "cmd.render_preflight": "Comprobar a solicitude de render", + "cmd.render_scene": "Inspeccionar a escena do render", + "cmd.render_settings_apply": "Aplicar a cámara do render", + "cmd.render_settings_get": "Axustes do render", + "cmd.render_settings_set": "Gardar os axustes do render", + "cmd.render_start": "Renderizar imaxe", + "cmd.render_status": "Progreso do render", + "cmd.select_clear": "Borrar a selección", + "cmd.sheet_convert": "Converter en chapa", + "cmd.sheet_export_bend_table": "Exportar a táboa de dobras", + "cmd.sheet_export_dxf": "Exportar o desenvolvemento como DXF", + "cmd.sheet_flange": "Pestana", + "cmd.sheet_flat_pattern": "Crear o desenvolvemento", + "cmd.sheet_fold": "Dobrar", + "cmd.sheet_hem": "Dobradillo", + "cmd.sheet_manage_rules": "Regras de chapa", + "cmd.sheet_perforate": "Perforar", + "cmd.sheet_refold": "Volver dobrar", + "cmd.sheet_rules": "Listar as regras de chapa", + "cmd.sheet_unfold": "Despregar", + "cmd.sketch_arc_center_point": "Arco por punto central", + "cmd.sketch_arc_tangent": "Arco tanxente", + "cmd.sketch_arc_three_point": "Arco por 3 puntos", + "cmd.sketch_auto_constrain": "Restrición automática", + "cmd.sketch_auto_constrain_from_datum": "Restrición automática desde a referencia", + "cmd.sketch_auto_project": "Proxectar automaticamente as arestas na referencia", + "cmd.sketch_blend_curve": "Curva de transición", + "cmd.sketch_chain": "Cadea", + "cmd.sketch_chamfer_distance_angle": "Chafrán por distancia e ángulo", + "cmd.sketch_chamfer_equal_distance": "Chafrán de distancias iguais", + "cmd.sketch_chamfer_two_distance": "Chafrán de dúas distancias", + "cmd.sketch_circle_center": "Círculo por centro e diámetro", + "cmd.sketch_circle_three_point": "Círculo por 3 puntos", + "cmd.sketch_circle_three_tangent": "Círculo tanxente a 3", + "cmd.sketch_circle_two_point": "Círculo por 2 puntos", + "cmd.sketch_circle_two_tangent": "Círculo tanxente a 2", + "cmd.sketch_constrainer": "Asistente de restricións do bosquexo", + "cmd.sketch_constraint_fix": "Fixar/Liberar", + "cmd.sketch_constraint_midpoint": "Punto medio", + "cmd.sketch_construction": "Normal/Construción", + "cmd.sketch_copy": "Copiar entidades do bosquexo", + "cmd.sketch_cut": "Cortar entidades do bosquexo", + "cmd.sketch_delete": "Eliminar entidades do bosquexo", + "cmd.sketch_dimension_text": "Mover o texto da cota", + "cmd.sketch_drag": "Arrastrar xeometría do bosquexo", + "cmd.sketch_drag_end": "Rematar o arrastre no bosquexo", + "cmd.sketch_draw_dimensioned": "Debuxar xeometría do bosquexo con cotas", + "cmd.sketch_edit": "Editar o bosquexo", + "cmd.sketch_edit_text": "Editar o texto", + "cmd.sketch_export_dxf": "Gardar como DXF", + "cmd.sketch_finish_auto_constrain": "Rematar con restrición automática", + "cmd.sketch_fit_curves_to_section": "Axustar curvas a unha sección de malla", + "cmd.sketch_glyphs": "Iconas de restricións", + "cmd.sketch_include_3d": "Incluír xeometría 3D", + "cmd.sketch_infinite": "Liña infinita", + "cmd.sketch_insert_dxf": "Inserir DXF", + "cmd.sketch_insert_svg": "Inserir SVG", + "cmd.sketch_inspect": "Inspeccionar o bosquexo", + "cmd.sketch_intersection_curve": "Curva de intersección", + "cmd.sketch_isoparametric_curve": "Curva isoparamétrica", + "cmd.sketch_line3d": "Liña 3D", + "cmd.sketch_line_midpoint": "Liña desde o punto medio", + "cmd.sketch_move": "Mover/Copiar entidades do bosquexo", + "cmd.sketch_move3d": "Mover punto 3D", + "cmd.sketch_move_point": "Arrastrar punto do bosquexo", + "cmd.sketch_offset_break": "Romper a asociación do desprazamento", + "cmd.sketch_options": "Opcións da paleta do bosquexo", + "cmd.sketch_paste": "Pegar entidades do bosquexo", + "cmd.sketch_point3d": "Punto 3D", + "cmd.sketch_polygon_circumscribed": "Polígono circunscrito", + "cmd.sketch_polygon_edge": "Polígono por aresta", + "cmd.sketch_polygon_inscribed": "Polígono inscrito", + "cmd.sketch_project_to_surface": "Proxectar na superficie", + "cmd.sketch_rectangle_center": "Rectángulo desde o centro", + "cmd.sketch_rectangle_rounded": "Rectángulo redondeado", + "cmd.sketch_rectangle_three_point": "Rectángulo por 3 puntos", + "cmd.sketch_rectangle_two_point": "Rectángulo por 2 puntos", + "cmd.sketch_redefine": "Redefinir o plano do bosquexo", + "cmd.sketch_slot_arc_center": "Ranura en arco por punto central", + "cmd.sketch_slot_arc_three_point": "Ranura en arco por tres puntos", + "cmd.sketch_slot_center_point": "Ranura por punto central", + "cmd.sketch_slot_center_to_center": "Ranura de centro a centro", + "cmd.sketch_slot_overall": "Ranura total", + "cmd.sketch_snap": "Inferencia de axuste", + "cmd.sketch_solve": "Resolver o bosquexo", + "cmd.sketch_spline3d": "Spline 3D", + "cmd.sketch_spline_control_point": "Spline por puntos de control", + "cmd.sketch_spline_control_point_5": "Spline por puntos de control (grao 5)", + "cmd.sketch_spline_fit_point": "Spline por puntos de paso", + "cmd.sketch_spun_profile": "Perfil de revolución", + "cmd.sketch_toggle_driven": "Alternar cota conducida", + "cmd.solid_align": "Aliñar", + "cmd.solid_boundary_fill": "Encher contorno", + "cmd.solid_bounding_solid": "Sólido envolvente", + "cmd.solid_emboss": "Gravar en relevo", + "cmd.solid_move": "Mover/Copiar", + "cmd.solid_offset_face": "Desprazar a cara", + "cmd.solid_press_pull": "Empurrar/Tirar", + "cmd.solid_remove": "Quitar", + "cmd.solid_replace_face": "Substituír a cara", + "cmd.solid_rib": "Nervio", + "cmd.solid_split_face": "Dividir a cara", + "cmd.solid_web": "Alma", + "cmd.surface_blend": "Superficie de transición", + "cmd.surface_boundary": "Superficie de contorno", + "cmd.surface_extrude": "Extrusión de superficie", + "cmd.surface_fair": "Suavizar a superficie", + "cmd.surface_fillet": "Arredondamento de superficie", + "cmd.surface_loft": "Transición de superficie", + "cmd.surface_match": "Igualar a superficie", + "cmd.surface_offset": "Superficie desprazada", + "cmd.surface_patch": "Parche", + "cmd.surface_reverse_normal": "Inverter a normal", + "cmd.surface_revolve": "Revolución de superficie", + "cmd.surface_ruled": "Superficie regrada", + "cmd.surface_stitch": "Coser", + "cmd.surface_sweep": "Varrido de superficie", + "cmd.surface_thicken": "Engrosar", + "cmd.surface_unstitch": "Descoser", + "cmd.surface_untrim": "Desrecortar", + "cmd.timeline_add": "Engadir operación gravada", + "cmd.timeline_compute_all": "Calcular todo", + "cmd.timeline_copy": "Copiar operacións", + "cmd.timeline_dependents": "Dependentes da operación", + "cmd.timeline_export_script": "Exportar o script de ordes", + "cmd.timeline_inspect": "Inspeccionar a operación", + "cmd.timeline_paste": "Pegar operacións", + "cmd.timeline_redefine": "Editar a operación", + "cmd.timeline_rename": "Renomear", + "cmd.timeline_reorder": "Reordenar a operación", + "cmd.timeline_roll_to": "Levar o marcador do historial", + "cmd.timeline_rollback": "Mover o marcador da liña de tempo", + "cmd.timeline_suppress": "Suprimir a operación", + "cmd.view_apply": "Restaurar a vista con nome", + "cmd.view_delete": "Eliminar a vista con nome", + "cmd.view_list": "Listar as vistas con nome", + "cmd.view_navigate": "Navegar pola vista", + "cmd.view_rename": "Renomear a vista con nome", + "cmd.view_save": "Vista con nome nova", + "command name": "nome da orde", + "coupling.gear_1": "Engrenaxe 1", + "coupling.gear_1_pick": "Engrenaxe 1 (opcional)", + "coupling.gear_2": "Engrenaxe 2", + "coupling.gear_2_pick": "Engrenaxe 2 (opcional)", + "coupling.internal": "Engrenaxe interna", + "coupling.needs_turn_and_slide": "precisa unha unión que xire e outra que esvare", + "coupling.needs_turns": "precisa dúas unións que xiren", + "coupling.not_round": "non é unha cara redonda", + "coupling.nut": "Porca", + "coupling.picked": "escollido", + "coupling.pinion": "Piñón", + "coupling.pinion_pick": "Piñón (opcional)", + "coupling.pitch": "Paso", + "coupling.rack": "Cremalleira", + "coupling.radius_1": "Raio 1", + "coupling.radius_2": "Raio 2", + "dialog.computing": "Calculando… {seconds} s", + "dialog.custom_axis": "↖ Eixe personalizado", + "dialog.edit_title": "EDITAR {title}", + "dialog.select": "↖ Seleccionar", + "dialog.selected_count": "↖ {count} seleccionados", + "dialog.selection_guidance": "Selecciona unha entrada para cambiala. Fai clic nos obxectos seleccionados para quitalos; Ctrl/Cmd-clic tamén os alterna. Revisa a previsualización e preme Aceptar.", + "dialog.selection_step": "{step} de {total}: selecciona {input}", + "dialog.step": "Paso", + "document.save_changes": "Gardar os cambios en {name}?", + "document.unsaved_changes": "{name} ten cambios sen gardar. Se non os gardas, perderanse.", + "drive.stopped_by_contact": "Detido en {value}: {a} atravesaría {b}", + "drive.stopped_by_loop": "Detido en {value}: o bucle a través de {joint} non pode pecharse máis", + "drive.unconnected": "Non move nada: {part} non está conectado a nada fixado", + "dxf.cancel_wait": "Agardando a que se deteña a exportación…", + "dxf.cancelled": "Exportación DXF cancelada", + "dxf.cancelling": "Cancelando a exportación DXF", + "dxf.exporting": "Exportando DXF", + "dxf.invalid_tolerance": "A tolerancia da curva debe estar entre 0,0001 e 1 mm.", + "dxf.progress.index": "Preparando as superficies (2/5)", + "dxf.progress.meshes": "Preparando as mallas (1/5)", + "dxf.progress.occlusion": "Resolvendo as liñas ocultas (4/5)", + "dxf.progress.projection": "Proxectando a xeometría (3/5)", + "dxf.progress.write": "Escribindo o DXF (5/5)", + "dxf.tolerance": "Tolerancia da curva", + "dxf.tolerance_help": "Só os corpos visibles. As curvas usan segmentos rectos. Unha tolerancia menor dá curvas máis finas e usa máis tempo e memoria.", + "expression, e.g. 25 mm": "expresión, p. ex. 25 mm", + "fits the face, e.g. M8": "encaixa na cara, p. ex. M8", + "from the rule": "da regra", + "home.documentation": "Documentación", + "home.in_this_browser": "neste navegador", + "home.missing": "non atopado", + "home.open": "Abrir…", + "home.opened_days_ago": "Aberto hai {count} días", + "home.opened_hours_ago": "Aberto hai {count} h", + "home.opened_just_now": "Aberto agora mesmo", + "home.opened_minutes_ago": "Aberto hai {count} min", + "home.opening": "abrindo…", + "home.recent": "Recentes", + "home.report_issue": "Informar dunha incidencia", + "home.samples": "Exemplos", + "home.whats_new": "Novidades", + "install.button_tip": "Instala SolidCraft como aplicación: ábrese na súa propia xanela e funciona sen conexión", + "install.dock_1": "Escolle Ficheiro › Engadir ao Dock.", + "install.dock_2": "Fai clic en Engadir.", + "install.dock_after": "Despois, SolidCraft ábrese na súa propia xanela desde o Dock e funciona sen conexión.", + "install.done": "Feito", + "install.home_screen_1": "Toca Compartir (no menú … se non está na barra de ferramentas).", + "install.home_screen_2": "Escolle Engadir á pantalla de inicio.", + "install.home_screen_3": "Toca Engadir.", + "install.home_screen_after": "Despois, SolidCraft ábrese desde a súa icona na pantalla de inicio, sen as barras de Safari, e funciona sen conexión.", + "install.menu": "Instalar a aplicación…", + "install.steps_intro": "Safari instala as aplicacións web desde os seus propios menús:", + "install.title": "Instalar SolidCraft", + "joint.from_bodies_hint": "Cada corpo escollido pasa a un compoñente novo propio e queda onde está.", + "joint.one_body": "As dúas seleccións están en {body}. Escolle a segunda noutra peza.", + "joint.one_component": "Estas pezas son corpos dun mesmo compoñente: {bodies}. Unha unión move un compoñente respecto doutro, así que cada peza precisa o seu propio compoñente.", + "nav.touchpad.help": "Mantén Maiús e move o punteiro para desprazar; mantén Alt/Opción e móveo para orbitar. Non fai falta ningún botón do rato. O desprazamento vertical fai zoom.", + "nav.touchpad.label": "Panel táctil", + "nav.touchpad.orbit": "Orbitar (mantén Alt/Opción e move; sen botón do rato)", + "nav.touchpad.pan": "Desprazar (mantén Maiús e move; sen botón do rato)", + "nav.touchpad.scroll": "O panel táctil usa o desprazamento vertical para facer zoom. A túa configuración de desprazamento con dous dedos mantense para os outros axustes predefinidos.", + "nav.touchpad.zoom": "Zoom (desprazamento vertical ou pinza)", + "needs two joints": "precisa dúas unións", + "new configuration": "configuración nova", + "new name": "nome novo", + "new parameter": "parámetro novo", + "no interference": "sen interferencias", + "no joint that moves yet": "aínda non hai ningunha unión que se mova", + "no picture": "sen imaxe", + "pattern.alignment_2": "Aliñamento 2", + "pattern.alignment_hint": "O número inclúe o orixinal. Centrar equilibra as copias; Inverter escolle o lado da copia extra cando o total é par.", + "pattern.direction_1": "Dirección 1", + "pattern.direction_2": "Dirección 2", + "pattern.flip_direction": "Inverter a dirección", + "pick a hole to seat it, or OK places it at the origin": "escolle un furado onde asentala, ou Aceptar colócaa na orixe", + "pick one or two vertices, edges or faces": "escolle un ou dous vértices, arestas ou caras", + "render.background": "Fondo da escena", + "render.fit": "Encadrar o produto", + "render.image_cancel": "Cancelar o render", + "render.image_cancelled": "Render cancelado", + "render.image_cancelling": "Cancelando", + "render.image_check": "Comprobar a solicitude de render", + "render.image_completed": "Imaxe lista para exportar", + "render.image_encoding": "Codificando o PNG", + "render.image_export": "Exportar PNG", + "render.image_failed": "Fallou o render", + "render.image_height": "Altura", + "render.image_help": "Imaxe de previsualización por CPU · PNG · transparencia aproximada", + "render.image_overwrite": "Substituír o ficheiro existente", + "render.image_preparing": "Preparando a imaxe", + "render.image_queued": "Agardando para renderizar", + "render.image_reduce": "Proba cunha imaxe máis pequena ou unha escena máis sinxela.", + "render.image_running": "Renderizando a imaxe", + "render.image_stale": "Esta imaxe usa a escena capturada antes dos cambios posteriores.", + "render.image_start": "Iniciar o render", + "render.image_supported": "A solicitude de render é compatible; a cola e a memoria comprobaranse de novo ao comezar.", + "render.image_title": "Renderizar imaxe", + "render.image_too_large": "Supera o límite de imaxe: 4096 píxeles por lado e 4.194.304 píxeles en total.", + "render.image_transparent": "Fondo transparente", + "render.image_unknown": "Estado do render non dispoñible", + "render.image_use_largest": "Usar o tamaño máis grande con esta proporción:", + "render.image_width": "Largura", + "render.navigation_help": "A cámara móvese como en Deseño (Preferencias › Navegación). Gardar configuración rexistra a cámara actual.", + "render.opaque_image_summary": "PNG de previsualización · PBR opaco · transparencia mesturada", + "render.opaque_preview_help": "PBR opaco e cores simples; transparencia mesturada. A refracción, o HDRI e o render final aínda non están dispoñibles.", + "render.perspective": "Cámara en perspectiva", + "render.preview": "Previsualización integrada", + "render.preview_help": "Sombreado ráster e transparencia mesturada. A refracción, o HDRI e o render final aínda non están dispoñibles.", + "render.reload_settings": "Volver cargar os axustes gardados", + "render.restore_camera": "Restaurar a cámara gardada", + "render.return_design": "Volver a Deseño", + "render.save_setup": "Gardar configuración", + "render.setup_name": "Nome da configuración", + "render.unavailable_preview": "Esta configuración pide un perfil de previsualización non dispoñible.", + "render.unsaved": "Cambios da previsualización sen gardar", + "render.use_preview": "Usar a previsualización integrada", + "sample.bracket.about": "Pestana base con pestanas de aresta e un dobradillo cunha regra de 2 mm", + "sample.bracket.name": "Escuadra de chapa", + "sample.enclosure.about": "Caixa baleirada cun beizo, saíntes para parafusos, un apoio e un encaixe a presión", + "sample.enclosure.name": "Carcasa de plástico", + "sample.flange.about": "Cubo e brida cun patrón circular controlado por un parámetro", + "sample.flange.name": "Brida aparafusada", + "sample.hinge.about": "Dous compoñentes e unha unión de revolución, levada a 60°", + "sample.hinge.name": "Conxunto de bisagra", + "sample.plate.about": "Placa paramétrica con arredondamentos, furados e un saínte mandrilado", + "sample.plate.name": "Placa de exemplo", + "sample.vents.about": "Ranuras nun patrón rectangular: filas, columnas e paso son parámetros", + "sample.vents.name": "Placa de ventilación", + "settings.display_quality_note": "Os niveis máis finos debuxan as caras e arestas curvas máis suaves e tardan máis en xerar a malla. Só cambia a vista, nunca o deseño nin as exportacións.", + "settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista. Mentres está activado, a vista redebúxase continuamente.", + "settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z", + "settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.", + "settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft", + "settings.msaa_reset": "{setting}: este dispositivo gráfico non admite {requested}×; agora é {reset}× (admitidos: {supported}).", + "settings.msaa_unsupported": "Non admitido por este dispositivo gráfico", + "settings.reset_intro": "Estes axustes non funcionan neste ordenador e impedirían que SolidCraft se iniciase, así que se cambiaron:", + "settings.reset_title": "Cambiáronse os axustes", + "settings.supersampling_note": "Debuxa a vista a unha resolución maior e fíltraa cara abaixo: arestas, liñas e grella máis suaves en pantallas por debaixo de 4K. Consome tempo de GPU: uns 2,25× píxeles a 1,5× e 4× a 2×.", + "sketch.degrees_of_freedom": "{count} graos de liberdade", + "sketch.draw_curves": "Debuxar como {linetype}", + "sketch.make_selection": "Converter a selección en {linetype}", + "sketch_text.align_center": "Centro", + "sketch_text.align_left": "Esquerda", + "sketch_text.align_right": "Dereita", + "sketch_text.bold": "Negra", + "sketch_text.builtin_font": "{family} (integrado)", + "sketch_text.font_search": "Buscar tipos de letra", + "sketch_text.italic": "Cursiva", + "sketch_text.loading_fonts": "Buscando os tipos de letra instalados…", + "sketch_text.on_path": "O texto segue a súa traxectoria: a curva colócao", + "sketch_text.path_clear": "Separar o texto da súa curva", + "sketch_text.path_hint": "Unha curva do bosquexo que segue o texto: fai clic aquí e despois na curva", + "sketch_text.use_font_file": "Usar o ficheiro de tipo de letra {path}", + "the parts library is not available": "a biblioteca de pezas non está dispoñible", + "timeline.group": "Cartafol de operacións novo", + "timeline.move_many": "Mover elementos da liña de tempo", + "timeline.move_to_group": "Mover operacións ao cartafol", + "timeline.rename_group": "Renomear o cartafol de operacións", + "timeline.reorder_many": "Reordenar operacións", + "timeline.ungroup": "Quitar os cartafoles de operacións", + "to the next face": "ata a seguinte cara", + "toolbar.image": "IMAXE", + "toolbar.render": "RENDER", + "toolbar.render_scene": "ESCENA", + "trace.points": "{count} puntos ao longo da traxectoria", + "trace.stopped_by_contact": "Remata na mostra {sample}: {a} atravesaría {b}", + "trace.stopped_by_loop": "Remata na mostra {sample}: o bucle a través de {joint} non pode pecharse máis", + "ui.1_degree_of_freedom": "1 grao de liberdade", + "ui.3d_mouse_speed": "Velocidade do rato 3D", + "ui.3d_mouse_windows": "Rato 3D", + "ui.3d_sketch": "Bosquexo 3D", + "ui.about": "Acerca de", + "ui.activate": "Activar", + "ui.active_component": "Compoñente activo", + "ui.agent_chat": "Chat co axente", + "ui.agent_chat_allow": "Permitir", + "ui.agent_chat_allowed": "Permitido", + "ui.agent_chat_answered": "Resposta: {option}", + "ui.agent_chat_approval": "Permitir que o axente faga isto?", + "ui.agent_chat_attach_view": "Achegar a vista", + "ui.agent_chat_attach_view_tip": "Envía unha imaxe da vista actual coa mensaxe", + "ui.agent_chat_auth_agent": "Iniciar sesión con {name}", + "ui.agent_chat_auth_env": "Define {vars} no teu contorno e reinicia SolidCraft.", + "ui.agent_chat_auth_needed": "O axente pídeche que inicies sesión. Inicias sesión co propio axente; SolidCraft nunca ve o teu contrasinal nin as túas claves.", + "ui.agent_chat_auth_none": "O axente non ofrece aquí ningún método para iniciar sesión: inicia sesión como indica a súa propia documentación.", + "ui.agent_chat_auth_retry": "Xa iniciei sesión: conectar de novo", + "ui.agent_chat_auth_terminal": "Executa isto nun terminal e despois preme Conectar de novo:", + "ui.agent_chat_auth_title": "Iniciar sesión no axente", + "ui.agent_chat_cancel": "Cancelar", + "ui.agent_chat_changes": "Cambios: {n}, un só paso de desfacer", + "ui.agent_chat_changes_of": "{n}/{limit} cambios", + "ui.agent_chat_close": "Pechar", + "ui.agent_chat_command": "Orde do axente", + "ui.agent_chat_connect": "Conectar", + "ui.agent_chat_copy": "Copiar", + "ui.agent_chat_custom_hint": "Calquera programa que fale o Agent Client Protocol por stdin e stdout. Non se executa en ningún shell.", + "ui.agent_chat_denied": "Denegado", + "ui.agent_chat_deny": "Denegar", + "ui.agent_chat_desktop_only": "O chat co axente precisa a aplicación de escritorio.", + "ui.agent_chat_detecting": "Buscando axentes…", + "ui.agent_chat_dock_left": "Acoplar á esquerda", + "ui.agent_chat_dock_right": "Acoplar á dereita", + "ui.agent_chat_edit": "Editar", + "ui.agent_chat_exited": "O axente detívose.", + "ui.agent_chat_failed_start": "Non se puido iniciar o axente: {error}", + "ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de SolidCraft: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.", + "ui.agent_chat_key_in_env": "clave configurada", + "ui.agent_chat_left": "Esquerda", + "ui.agent_chat_limit": "O axente chegou ao límite de {limit} cambios por mensaxe. Envía outra mensaxe para que continúe.", + "ui.agent_chat_minimize": "Minimizar nunha franxa", + "ui.agent_chat_model_tip": "O modelo que usa o axente. A túa escolla lémbrase para este axente.", + "ui.agent_chat_new": "Chat novo", + "ui.agent_chat_new_confirm": "Comezar un chat novo? Borrarase o actual.", + "ui.agent_chat_new_yes": "Chat novo", + "ui.agent_chat_no_cli": "Non se atopou solidcraft-cli xunto á aplicación, así que o axente non pode chegar ao deseño.", + "ui.agent_chat_no_control": "O punto de acceso do chat co axente non se iniciou, así que o axente non pode chegar ao deseño.", + "ui.agent_chat_no_images": "Este axente non admite imaxes", + "ui.agent_chat_none_found": "Non se atopou ningún axente de programación. Instala un destes e despois preme Actualizar:", + "ui.agent_chat_none_found_short": "Non se atopou ningún axente", + "ui.agent_chat_not_installed": "Non instalado", + "ui.agent_chat_not_signed_in": "sen sesión iniciada", + "ui.agent_chat_permission": "O axente pide executar: {title}", + "ui.agent_chat_pick": "Axente", + "ui.agent_chat_placeholder": "Pídelle ao axente que cambie o deseño…", + "ui.agent_chat_placeholder_busy": "Pon unha mensaxe na cola para cando remate o axente (Ctrl+Intro: enviar agora)", + "ui.agent_chat_privacy": "Nada vai a ningún sitio agás ao axente que executas, coa túa propia conta.", + "ui.agent_chat_privacy_note": "SolidCraft non envía nada a ningún sitio: o axente que executas fala co seu propio servizo coa túa propia conta. SolidCraft nunca ve nin garda ningunha clave nin contrasinal.", + "ui.agent_chat_queue": "Pór na cola", + "ui.agent_chat_queue_full": "Hai demasiadas mensaxes agardando: quita unha ou agarda polo axente.", + "ui.agent_chat_queued": "Na cola", + "ui.agent_chat_queued_stopped": "Na cola (detido, sen enviar)", + "ui.agent_chat_refresh": "Actualizar", + "ui.agent_chat_refused": "Non permitido ao axente: {reason}", + "ui.agent_chat_remove": "Quitar da cola", + "ui.agent_chat_right": "Dereita", + "ui.agent_chat_save": "Gardar", + "ui.agent_chat_send": "Enviar", + "ui.agent_chat_send_now": "Enviar agora", + "ui.agent_chat_send_now_tip": "Detén o paso actual do axente e envía isto a continuación (Ctrl+Intro)", + "ui.agent_chat_side": "Lado do chat co axente", + "ui.agent_chat_signed_in": "sesión iniciada", + "ui.agent_chat_state_auth": "Hai que iniciar sesión", + "ui.agent_chat_state_busy": "Traballando…", + "ui.agent_chat_state_exited": "Axente detido", + "ui.agent_chat_state_off": "Sen conexión", + "ui.agent_chat_state_ready": "Listo", + "ui.agent_chat_state_starting": "Iniciando…", + "ui.agent_chat_stop": "Deter", + "ui.agent_chat_stop_tip": "Detén o axente e todo o que iniciou", + "ui.agent_chat_stop_turn_tip": "Detén o axente (Esc). As mensaxes na cola seguen na cola.", + "ui.agent_chat_turn_limit": "Cambios por mensaxe", + "ui.agent_chat_undo_changes": "Desfacer estes cambios", + "ui.agent_chat_undo_label": "Chat co axente: {message}", + "ui.agent_chat_undone": "(desfeito)", + "ui.agent_chat_view_attached": "Vista achegada", + "ui.agent_cursor": "Cursor do axente", + "ui.agent_cursor_speed": "Velocidade do cursor do axente", + "ui.ai_add": "Engadir", + "ui.ai_add_agent": "Engadir axente…", + "ui.ai_add_agent_title": "Engadir axente", + "ui.ai_add_api": "API compatible con OpenAI", + "ui.ai_add_custom": "Orde personalizada", + "ui.ai_add_installed": "Axente instalado", + "ui.ai_add_installed_note": "Unha entrada propia para un axente instalado, por exemplo con outros argumentos. Os axentes instalados lístanse por si sós.", + "ui.ai_added": "engadido", + "ui.ai_agent_mode_tip": "O modo propio do axente: as súas propias ferramentas (shell, edición de ficheiros) seguen ese modo. As ferramentas de SolidCraft seguen os permisos de Preferencias > IA.", + "ui.ai_agents": "Axentes", + "ui.ai_always_allow": "Permitir sempre", + "ui.ai_always_allow_tip": "Permitir {tool} a este axente de agora en diante, sen preguntar", + "ui.ai_always_allowed": "Permitido sempre", + "ui.ai_always_allowed_none": "Aínda non hai nada permitido sempre. As respostas «Permitir sempre» que deas no chat lístanse aquí.", + "ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})", + "ui.ai_auto_mode": "a túa configuración de permisos", + "ui.ai_auto_ours": "SolidCraft comproba as súas propias ferramentas", + "ui.ai_auto_remembered": "escolliches Permitir sempre", + "ui.ai_bad_url": "Introduce un URL que comece por http:// ou https://", + "ui.ai_base_url": "URL base", + "ui.ai_button": "IA", + "ui.ai_copy_suffix": "copia", + "ui.ai_custom_url": "URL personalizado", + "ui.ai_default": "Predeterminado", + "ui.ai_http_busy": "o punto de acceso está ocupado ou superou o seu límite; téntao de novo máis tarde", + "ui.ai_http_key": "o punto de acceso rexeitou a clave: compróbaa en Preferencias > IA", + "ui.ai_http_not_found": "non existe ese punto de acceso ou modelo: comproba o URL base e o nome do modelo", + "ui.ai_key": "Clave da API", + "ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema", + "ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.", + "ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de SolidCraft. Déixaa baleira para un servidor local.", + "ui.ai_keychain_error": "O chaveiro do sistema rexeitouno: {error}", + "ui.ai_mode_all": "Permitir todo", + "ui.ai_mode_all_note": "Nada pregunta antes. Os informes de fallos seguen fóra do seu alcance e os límites de cartafoles seguen aplicándose.", + "ui.ai_mode_all_warning": "O axente pode gardar, sobrescribir, exportar e substituír o teu deseño sen preguntar. Usa isto só cun axente no que confíes e con deseños que teñas gardados.", + "ui.ai_mode_ask": "Preguntar sempre", + "ui.ai_mode_ask_note": "Cada cambio no deseño e cada acción con ficheiros agardan a que premas Permitir.", + "ui.ai_mode_edits": "Permitir automaticamente as edicións do modelo", + "ui.ai_mode_edits_note": "As ordes de modelado execútanse sen preguntar; gardar, abrir, exportar e outras accións con ficheiros ou destrutivas seguen preguntando.", + "ui.ai_mode_global": "Como se definiu arriba", + "ui.ai_model": "Modelo", + "ui.ai_modes_scope": "Estes axustes cobren as ferramentas de SolidCraft. As ferramentas propias dun axente (o seu shell, as súas edicións de ficheiros) seguen o modo propio do axente, que escolles na cabeceira do panel de chat cando o axente ten modos.", + "ui.ai_name": "Nome", + "ui.ai_need_command": "Introduce a orde que inicia o axente", + "ui.ai_need_model": "Introduce o nome do modelo", + "ui.ai_no_answer": "O axente non respondeu a tempo", + "ui.ai_no_http": "Esta compilación non pode chegar a puntos de acceso de API", + "ui.ai_no_keychain": "O chaveiro do sistema non está dispoñible, así que non se pode gardar unha clave. Un servidor local sen clave segue funcionando.", + "ui.ai_panel": "Panel", + "ui.ai_permissions": "Permisos", + "ui.ai_preset": "Axuste predefinido", + "ui.ai_remove": "Quitar", + "ui.ai_request_limit": "detívose tras 24 solicitudes para unha mensaxe; envía outra mensaxe para continuar", + "ui.ai_reset": "Restablecer", + "ui.ai_settings": "Axustes de IA…", + "ui.ai_show_features": "Mostrar as funcións de IA", + "ui.ai_show_features_note": "O chat co axente: un panel onde un axente de programación que xa usas, ou un punto de acceso de modelo que escollas, traballa no deseño aberto. Desactivado: sen botón de IA, atallo, entrada de menú nin panel, e non se busca nin se inicia nada.", + "ui.ai_switch_tip": "Cambiar o axente desta conversa", + "ui.ai_test": "Probar", + "ui.ai_test_acp_ok": "Responde: {agent}", + "ui.ai_test_acp_tip": "Inicia o axente un momento e pídelle unha sesión; despois remátaa", + "ui.ai_test_api_tip": "Pídelle ao punto de acceso os seus modelos", + "ui.ai_test_no_model": "Alcanzado: {count} modelos, pero non {model}", + "ui.ai_test_ok": "Alcanzado: {count} modelos", + "ui.ai_testing": "Probando…", + "ui.ai_tool_done": "feito", + "ui.ai_tool_failed": "fallou", + "ui.ai_tool_running": "en execución", + "ui.ai_tool_unfold": "Fai clic para ver o que devolveu", + "ui.ai_tool_waiting": "agardando", + "ui.ai_toolbar_button": "Mostrar o botón na barra de ferramentas", + "ui.ai_toolbar_button_note": "O botón de IA no extremo dereito da barra de ferramentas. Ctrl+Maiús+A e o menú Vista seguen abrindo o panel.", + "ui.ai_undo_tip": "Desfai nun só paso todo o que cambiou o axente para esta mensaxe", + "ui.all": "Todo", + "ui.analysis": "Análise", + "ui.angle": "Ángulo", + "ui.angle_decimals": "Decimais dos ángulos", + "ui.animateJoint": "Animar a unión", + "ui.animate_view_changes": "Animar os cambios de vista", + "ui.animation": "Animación", + "ui.animation_speed": "Velocidade da animación", + "ui.anti_aliasing": "Antialiasing", + "ui.apply": "Aplicar", + "ui.arc": "Arco", + "ui.assemble": "Ensamblar", + "ui.autosave_every": "Gardar automaticamente cada", + "ui.axes": "Eixes", + "ui.axis": "Eixe", + "ui.base_point": "Punto base", + "ui.blocked": "Bloqueado", + "ui.bodies": "Corpos", + "ui.box": "Caixa", + "ui.break": "Romper", + "ui.calibrate": "Calibrar", + "ui.cancel": "Cancelar", + "ui.canvases": "Lenzos", + "ui.center": "Centro", + "ui.centerline": "Liña central", + "ui.chain": "Cadea", + "ui.chamfer": "Chafrán", + "ui.chamfer_type": "Tipo de chafrán", + "ui.change_workspace": "Cambiar de espazo de traballo", + "ui.chord_length": "Lonxitude da corda", + "ui.circle": "Círculo", + "ui.circular_pattern": "Patrón circular", + "ui.clear": "Borrar", + "ui.clear_recent": "Borrar recentes", + "ui.clear_the_selection": "Borrar a selección", + "ui.click_selects_whole_bodies": "Un clic selecciona corpos enteiros", + "ui.click_then_press_key": "Fai clic e despois preme a tecla nova", + "ui.close": "Pechar", + "ui.coil": "Hélice", + "ui.coincident": "Coincidente", + "ui.collapse": "Contraer", + "ui.collinear": "Colineal", + "ui.colour_vision": "Visión da cor", + "ui.colour_vision_deutan": "Deutan", + "ui.colour_vision_deutan_tip": "Para unha percepción do verde débil ou ausente", + "ui.colour_vision_failed": "Fallido", + "ui.colour_vision_fixed": "Fixo", + "ui.colour_vision_mode": "Modo", + "ui.colour_vision_note": "Cambia as cores que levan un significado: estados do bosquexo, eixes, dobras, análises e iconas. Se usas os filtros de cor de Windows, déixao desactivado.", + "ui.colour_vision_off": "Desactivado", + "ui.colour_vision_off_tip": "As cores propias do tema", + "ui.colour_vision_projected": "Proxectado", + "ui.colour_vision_protan": "Protan", + "ui.colour_vision_protan_tip": "Para unha percepción do vermello débil ou ausente", + "ui.colour_vision_sample": "Mostra", + "ui.colour_vision_tritan": "Tritan", + "ui.colour_vision_tritan_tip": "Para unha percepción do azul débil ou ausente", + "ui.combine": "Combinar", + "ui.coming_later": "máis adiante", + "ui.command_done": "{command} feito", + "ui.command_palette_hint": "Escribe unha orde, p. ex. {example} — ou preme S para buscar", + "ui.concentric": "Concéntrica", + "ui.configurations": "Configuracións", + "ui.conic_curve": "Curva cónica", + "ui.constant": "Constante", + "ui.constraints_conflict": "Conflito de restricións", + "ui.construct": "Construír", + "ui.construction": "Construción", + "ui.context_menu": "Menú contextual", + "ui.copy": "Copiar", + "ui.corner": "Esquina", + "ui.corners": "Esquinas", + "ui.counterbore": "Avellanado cilíndrico", + "ui.countersink": "Avellanado cónico", + "ui.create": "Crear", + "ui.create_copy": "Crear copia", + "ui.create_sketch": "Crear bosquexo", + "ui.current": "actual", + "ui.curvature": "Curvatura", + "ui.curvature_g2": "Curvatura (G2)", + "ui.curves": "Curvas", + "ui.custom_program": "Personalizado…", + "ui.custom_program_tip": "Escolle un programa laminador para engadilo á lista", + "ui.customizeToolbar": "Personalizar a barra de ferramentas…", + "ui.cut": "Cortar", + "ui.cylinder": "Cilindro", + "ui.dark": "Escuro", + "ui.default_modelling_orientation": "Orientación de modelado predeterminada", + "ui.degrees_of_freedom": "graos de liberdade", + "ui.delete": "Eliminar", + "ui.depth": "Profundidade", + "ui.design": "Deseño", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Descrición", + "ui.design_properties.exports": "As exportacións STEP, IGES e 3MF levan estes datos cando o formato ten sitio para eles.", + "ui.design_properties.license": "Licenza", + "ui.design_properties.not_text": "Gardado como algo distinto de texto; escribir aquí substitúeo.", + "ui.design_properties.organization": "Organización", + "ui.design_properties.others": "Datos doutras ferramentas", + "ui.design_properties.others_note": "Consérvanse no deseño tal como están e nunca se exportan.", + "ui.design_properties.part_number": "Número de peza", + "ui.design_properties.project": "Proxecto", + "ui.design_properties.revision": "Revisión", + "ui.diameter": "Diámetro", + "ui.dimension": "Cota", + "ui.direction": "Dirección", + "ui.display_quality": "Calidade de visualización", + "ui.display_quality_coarse": "Baixa", + "ui.display_quality_fine": "Fina", + "ui.display_quality_normal": "Normal", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Distancia", + "ui.distance_1": "Distancia 1", + "ui.distance_2": "Distancia 2", + "ui.document_settings": "Axustes do documento", + "ui.don_t_save": "Non gardar", + "ui.draft": "Ángulo de desmoldaxe", + "ui.drawing": "Debuxo", + "ui.drill_point": "Punta de broca", + "ui.edit": "Editar", + "ui.editJointLimits": "Editar os límites da unión", + "ui.edit_section_analysis": "Editar a análise de sección", + "ui.ellipse": "Elipse", + "ui.end_radius": "Raio final", + "ui.end_scale": "Escala final", + "ui.entities": "Entidades", + "ui.equal": "Igual", + "ui.equivalent_radius": "Raio equivalente", + "ui.expand": "Expandir", + "ui.exportDxf": "Exportar DXF…", + "ui.export_ellipsis": "Exportar...", + "ui.export_ellipsis_keymap": "Exportar…", + "ui.export_keymap_tip": "Teclas, favoritos da caixa S e a barra de ferramentas, como un ficheiro", + "ui.extend": "Estender", + "ui.extent_type": "Tipo de extensión", + "ui.extrude": "Extruír", + "ui.factor": "Factor", + "ui.feature_history": "Historial de operacións", + "ui.feature_options": "Opcións da operación", + "ui.features": "Operacións", + "ui.file": "Ficheiro", + "ui.fillet": "Arredondamento", + "ui.filter_commands_or_keys": "Filtrar ordes ou teclas", + "ui.findInTimeline": "Buscar na liña de tempo", + "ui.findInWindow": "Buscar na xanela", + "ui.finish_sketch": "Rematar o bosquexo", + "ui.fit": "Axustar", + "ui.fix_unfix": "Fixar/Liberar", + "ui.flat": "Plano", + "ui.flip": "Inverter", + "ui.format": "Formato", + "ui.frame_rate": "Imaxes por segundo", + "ui.front": "Fronte", + "ui.fully_constrained": "Totalmente restrinxido", + "ui.general": "Xeral", + "ui.graphics": "Gráficos", + "ui.ground_shadow": "Sombra no chan", + "ui.groupSelected": "Agrupar a selección", + "ui.heading_add_ins": "COMPLEMENTOS", + "ui.heading_assemble": "ENSAMBLAR", + "ui.heading_box": "CAIXA", + "ui.heading_chamfer": "CHAFRÁN", + "ui.heading_circular_pattern": "PATRÓN CIRCULAR", + "ui.heading_coil": "HÉLICE", + "ui.heading_combine": "COMBINAR", + "ui.heading_configure": "CONFIGURAR", + "ui.heading_constraints": "RESTRICIÓNS", + "ui.heading_construct": "CONSTRUÍR", + "ui.heading_create": "CREAR", + "ui.heading_create_sketch": "CREAR BOSQUEXO", + "ui.heading_cylinder": "CILINDRO", + "ui.heading_design": "DESEÑO", + "ui.heading_draft": "ÁNGULO DE DESMOLDAXE", + "ui.heading_edit": "EDITAR", + "ui.heading_extrude": "EXTRUÍR", + "ui.heading_fillet": "ARREDONDAMENTO", + "ui.heading_finish_sketch": "REMATAR O BOSQUEXO", + "ui.heading_front": "FRONTE", + "ui.heading_hole": "FURADO", + "ui.heading_home": "INICIO", + "ui.heading_insert": "INSERIR", + "ui.heading_inspect": "INSPECCIONAR", + "ui.heading_loft": "TRANSICIÓN", + "ui.heading_make": "FABRICAR", + "ui.heading_measure": "MEDIR", + "ui.heading_mesh": "MALLA", + "ui.heading_mirror": "SIMETRÍA", + "ui.heading_modify": "MODIFICAR", + "ui.heading_move": "MOVER", + "ui.heading_move_copy": "MOVER/COPIAR", + "ui.heading_offset": "DESPRAZAMENTO", + "ui.heading_offset_plane": "PLANO DESPRAZADO", + "ui.heading_ok": "ACEPTAR", + "ui.heading_parameters": "PARÁMETROS", + "ui.heading_pattern": "PATRÓN", + "ui.heading_pattern_on_path": "PATRÓN NUNHA TRAXECTORIA", + "ui.heading_pipe": "TUBO", + "ui.heading_plane_at_angle": "PLANO EN ÁNGULO", + "ui.heading_plastic": "PLÁSTICO", + "ui.heading_position": "POSICIÓN", + "ui.heading_prepare": "PREPARAR", + "ui.heading_rectangular_pattern": "PATRÓN RECTANGULAR", + "ui.heading_revolve": "REVOLUCIÓN", + "ui.heading_right": "DEREITA", + "ui.heading_save_mesh": "GARDAR COMO MALLA", + "ui.heading_scale": "ESCALA", + "ui.heading_section_analysis": "ANÁLISE DE SECCIÓN", + "ui.heading_select": "SELECCIONAR", + "ui.heading_setup": "CONFIGURACIÓN", + "ui.heading_sheet_metal": "CHAPA", + "ui.heading_shell": "BALEIRADO", + "ui.heading_sketch": "BOSQUEXO", + "ui.heading_sketch_palette": "PALETA DO BOSQUEXO", + "ui.heading_sketch_scale": "ESCALA DO BOSQUEXO", + "ui.heading_solid": "SÓLIDO", + "ui.heading_sphere": "ESFERA", + "ui.heading_split_body": "DIVIDIR O CORPO", + "ui.heading_surface": "SUPERFICIE", + "ui.heading_sweep": "VARRIDO", + "ui.heading_thread": "ROSCA", + "ui.heading_top": "SUPERIOR", + "ui.heading_torus": "TORO", + "ui.heading_utilities": "UTILIDADES", + "ui.heading_utility": "UTILIDADE", + "ui.help": "Axuda", + "ui.hide": "Ocultar", + "ui.hideAxes": "Ocultar os eixes", + "ui.hideDimension": "Ocultar a cota", + "ui.hideOrigin": "Ocultar a orixe", + "ui.hidePlanes": "Ocultar os planos", + "ui.hideProfile": "Ocultar o perfil", + "ui.hole": "Furado", + "ui.hole_tap_type": "Tipo de roscado do furado", + "ui.hole_type": "Tipo de furado", + "ui.home": "Inicio", + "ui.homeView": "Vista de inicio", + "ui.horizontal_vertical": "Horizontal/Vertical", + "ui.import_ellipsis": "Importar…", + "ui.infinite": "Infinito", + "ui.insert": "Inserir", + "ui.inspect": "Inspeccionar", + "ui.intersect": "Intersecar", + "ui.isolate": "Illar", + "ui.join": "Unir", + "ui.joints": "Unións", + "ui.keyboard_shortcuts": "Atallos de teclado", + "ui.language": "Idioma", + "ui.length_decimals": "Decimais das lonxitudes", + "ui.light": "Claro", + "ui.line": "Liña", + "ui.linetype": "Tipo de liña", + "ui.lock": "Bloquear", + "ui.loft": "Transición", + "ui.look_at": "Mirar cara a", + "ui.look_at_anim_desc": "Vista de eixe, Mirar cara a, Inicio e Axustar", + "ui.manufacture": "Fabricación", + "ui.material_review.axes": "Eixes locais do material; non se asigna ningunha orientación do corpo.", + "ui.material_review.claims": "As fontes e as licenzas son afirmacións sen verificar. Os valores que faltan seguen sendo descoñecidos.", + "ui.material_review.compressive_strength": "Resistencia á compresión", + "ui.material_review.conditions": "Condicións indicadas", + "ui.material_review.density": "Densidade", + "ui.material_review.derived": "Módulos isótropos derivados", + "ui.material_review.elasticity": "Elasticidade", + "ui.material_review.input": "Pega unha ficha de material do esquema 1 (JSON)", + "ui.material_review.limit": "A ficha supera o límite de entrada de 256 KiB.", + "ui.material_review.moisture_unit": "kg de auga / kg de material seco", + "ui.material_review.properties": "Propiedades", + "ui.material_review.review": "Revisar a ficha", + "ui.material_review.scope": "Só candidato: non se asigna nin se garda. A validación numérica non é unha certificación de enxeñaría.", + "ui.material_review.shear_strength": "Resistencia ao cortante", + "ui.material_review.source": "Fonte", + "ui.material_review.specific_heat": "Calor específica", + "ui.material_review.tensile_strength": "Resistencia á tracción", + "ui.material_review.thermal_conductivity": "Condutividade térmica", + "ui.material_review.thermal_expansion": "Dilatación térmica", + "ui.material_review.title": "Revisar os datos do material físico", + "ui.material_review.unknown": "Descoñecido", + "ui.material_review.yield_strength": "Límite elástico", + "ui.measure": "Medir", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF con etiquetas de cara", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Punto medio", + "ui.mirror": "Simetría", + "ui.mirror_line": "Liña de simetría", + "ui.modify": "Modificar", + "ui.move": "Mover", + "ui.multiple": "Múltiple", + "ui.named_views": "Vistas con nome", + "ui.navigation": "Navegación", + "ui.needs_design": "{command}: abre primeiro un deseño", + "ui.newSketchGroup": "Grupo de bosquexos novo", + "ui.new_body": "Corpo novo", + "ui.new_design": "Deseño novo", + "ui.no_slicer_found": "Non se atopou ningún laminador", + "ui.normal": "Normal", + "ui.normalConstruction": "Normal / Construción", + "ui.not_a_program": "{path} non é un programa que se poida iniciar", + "ui.object_type": "Tipo de obxecto", + "ui.objects": "Obxectos", + "ui.offer_to_stand_up_y_up_files": "Ofrecer poñer de pé os ficheiros con Y cara arriba", + "ui.offset": "Desprazamento", + "ui.offset_plane": "Plano desprazado", + "ui.one_side": "Un lado", + "ui.open": "Abrir", + "ui.open_ellipsis": "Abrir...", + "ui.open_preferences": "Abrir as preferencias", + "ui.open_recent": "Abrir recentes", + "ui.operation": "Operación", + "ui.options": "Opcións", + "ui.orbit": "Orbitar", + "ui.orbit_around": "Orbitar arredor de", + "ui.orientation": "Orientación", + "ui.origin": "Orixe", + "ui.palette_points": "Puntos", + "ui.palette_profile": "Perfil", + "ui.pan": "Desprazar", + "ui.pan_zoom_orbit": "Desprazar, zoom, orbitar", + "ui.parallel": "Paralela", + "ui.parameters": "Parámetros", + "ui.paste": "Pegar", + "ui.path": "Traxectoria", + "ui.pattern_on_path": "Patrón nunha traxectoria", + "ui.perpendicular": "Perpendicular", + "ui.perspective_view": "Vista en perspectiva", + "ui.pinToShortcuts": "Fixar nos atallos", + "ui.pinToToolbar": "Fixar na barra de ferramentas", + "ui.pipe": "Tubo", + "ui.placement": "Colocación", + "ui.plane": "Plano", + "ui.plane_at_angle": "Plano en ángulo", + "ui.point": "Punto", + "ui.point_under_the_cursor": "Punto baixo o cursor", + "ui.points": "Puntos", + "ui.polygon": "Polígono", + "ui.preferences": "Preferencias", + "ui.press_a_key": "preme unha tecla…", + "ui.profile": "Perfil", + "ui.profile_plane": "Plano do perfil", + "ui.profiles": "Perfís", + "ui.project": "Proxectar", + "ui.project_face_edges_sketch": "Proxectar as arestas da cara en bosquexos novos", + "ui.properties": "Propiedades", + "ui.pull_direction": "Dirección de extracción", + "ui.quantity": "Cantidade", + "ui.quantity_2": "Cantidade 2", + "ui.quit": "Saír", + "ui.radial_context_menu": "Menú contextual radial", + "ui.radius": "Raio", + "ui.radius_type": "Tipo de raio", + "ui.reachable": "Alcanzable", + "ui.rectangle": "Rectángulo", + "ui.rectangular_pattern": "Patrón rectangular", + "ui.redo": "Refacer", + "ui.removeFromToolbar": "Quitar da barra de ferramentas", + "ui.removeSection": "Quitar a sección", + "ui.render": "Render", + "ui.renderer": "Motor de render", + "ui.repeat": "Repetir", + "ui.report_a_bug": "Informar dun erro", + "ui.reset": "Restablecer", + "ui.reset_all": "Restablecer todo", + "ui.reset_the_settings_on_this_page": "Restablecer os axustes desta páxina", + "ui.restore": "Restaurar", + "ui.restore_defaults": "Restaurar os valores predeterminados", + "ui.reverse_zoom_direction": "Inverter a dirección do zoom", + "ui.revolve": "Revolución", + "ui.right": "Dereita", + "ui.save": "Gardar", + "ui.save_as": "Gardar como", + "ui.save_as_ellipsis": "Gardar como...", + "ui.save_mesh_no_slicer": "Non se atopou ningún laminador: escolle Personalizado… e selecciona o teu programa laminador, ou desactiva Enviar ao laminador", + "ui.save_mesh_pick_bodies": "Selecciona os corpos que queres gardar", + "ui.save_mesh_temp_failed": "Non se puido crear {folder}: {error}", + "ui.saved_to": "Gardado {path}", + "ui.scale": "Escala", + "ui.section_analysis": "Análise de sección", + "ui.select": "Seleccionar", + "ui.selectOther": "Seleccionar outro…", + "ui.selectTangentChain": "Seleccionar cadea tanxente", + "ui.select_other_hint": "Seleccionar outro: mantén o botón esquerdo quieto sobre o modelo (ou clic dereito › Seleccionar outro…) para listar todo o que hai baixo o cursor; fai clic de novo no mesmo punto para seleccionar o seguinte.", + "ui.selection": "Selección", + "ui.send": "Enviar", + "ui.send_to_slicer": "Enviar ao laminador", + "ui.sent_to_slicer": "Enviado a {slicer}", + "ui.shell": "Baleirado", + "ui.show": "Mostrar", + "ui.showAll": "Mostrar todo", + "ui.showAllBodies": "Mostrar todos os corpos", + "ui.showAxes": "Mostrar os eixes", + "ui.showDimension": "Mostrar a cota", + "ui.showOrigin": "Mostrar a orixe", + "ui.showPlanes": "Mostrar os planos", + "ui.show_constraints": "Restricións", + "ui.show_construction_geometries": "Xeometrías de construción", + "ui.show_dimensions": "Cotas", + "ui.show_points": "Mostrar os puntos", + "ui.show_profile": "Mostrar o perfil", + "ui.show_projected_geometries": "Xeometrías proxectadas", + "ui.simple": "Simple", + "ui.simulation": "Simulación", + "ui.single": "Único", + "ui.sketch": "Bosquexo", + "ui.sketch_dimension": "Cota do bosquexo", + "ui.sketch_grid": "Grella do bosquexo", + "ui.sketch_scale": "Escala do bosquexo", + "ui.sketch_status": "Estado do bosquexo", + "ui.sketches": "Bosquexos", + "ui.slice": "Laminar", + "ui.slicer": "Laminador", + "ui.slicer_delete_tip": "{program}\nPreme Suprimir para quitalo da lista", + "ui.slot": "Ranura", + "ui.smooth": "Suave", + "ui.smooth_wheel_zoom": "Zoom suave coa roda", + "ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado", + "ui.snap": "Axuste", + "ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.", + "ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse", + "ui.spacing": "Espazado", + "ui.spacing_2": "Espazado 2", + "ui.sphere": "Esfera", + "ui.spline": "Spline", + "ui.split_body": "Dividir o corpo", + "ui.stand_up_tip": "Despois de abrir un ficheiro STEP ou IGES con Z cara arriba, ofrecer un cuarto de volta arredor de X", + "ui.start": "Inicio", + "ui.start_radius": "Raio inicial", + "ui.supersampling": "Supermostraxe (SSAA)", + "ui.supersampling_adaptive": "Só cando a vista está quieta", + "ui.supersampling_off": "Desactivado (1×)", + "ui.suppress": "Suprimir", + "ui.sweep": "Varrido", + "ui.sweep_follow_direction": "Seguir a dirección", + "ui.sweep_keep_as_drawn": "Manter como se debuxou", + "ui.sweep_turn_with_path": "Xirar coa traxectoria", + "ui.symmetric": "Simétrico", + "ui.symmetry": "Simetría", + "ui.system": "Sistema", + "ui.tangent": "Tanxente", + "ui.taper_angle": "Ángulo de conicidade", + "ui.tapped": "Roscado", + "ui.text": "Texto", + "ui.theme": "Tema", + "ui.thickness": "Espesor", + "ui.thread": "Rosca", + "ui.timeline_bottom": "Liña de tempo abaixo", + "ui.timeline_left_note": "A disposición á esquerda mostra a barra de retroceso enriba do navegador de pezas e escena.", + "ui.timeline_left_sidebar": "Barra lateral esquerda", + "ui.tip_angle": "Ángulo da punta", + "ui.to_object": "Ata o obxecto", + "ui.tool_direction": "Dirección da ferramenta", + "ui.top": "Superior", + "ui.torus": "Toro", + "ui.total_angle": "Ángulo total", + "ui.trackpad_pan_tip": "Os xestos do panel táctil desprazan (desactivado: fan zoom como a roda); a roda do rato fai zoom, a roda do polgar despraza de lado e a pinza sempre fai zoom", + "ui.trim": "Recortar", + "ui.twist": "Torsión", + "ui.two_finger_scroll_pans": "O desprazamento con dous dedos despraza a vista", + "ui.two_sides": "Dous lados", + "ui.ui_scale": "Escala da interface", + "ui.undo": "Desfacer", + "ui.unground": "Soltar do chan", + "ui.unisolate": "Deixar de illar", + "ui.units": "Unidades", + "ui.units_of_new_designs": "Unidades dos deseños novos", + "ui.unlock": "Desbloquear", + "ui.unsuppress": "Deixar de suprimir", + "ui.untitled": "Sen título", + "ui.up_axis_note": "Que eixe apunta cara arriba nas vistas (vista de eixe, Inicio, Mirar cara a). Os deseños non cambian.", + "ui.updateToCurrentView": "Actualizar á vista actual", + "ui.variable": "Variable", + "ui.view_centre": "Centro da vista", + "ui.visual_shaded_hidden_edges": "Sombreado con arestas ocultas", + "ui.visual_shaded_with_edges": "Sombreado con arestas", + "ui.visual_style": "Estilo visual", + "ui.visual_wireframe": "Arames", + "ui.workspace": "espazo de traballo", + "ui.x_distance": "Distancia en X", + "ui.y_distance": "Distancia en Y", + "ui.y_up": "Y cara arriba", + "ui.z_up": "Z cara arriba", + "ui.zoom": "Zoom", + "ui.zoom_to_cursor_tip": "A roda fai zoom cara ao punto baixo o cursor (desactivado: cara ao centro da vista)", + "ui.zoom_to_the_cursor": "Zoom cara ao cursor", + "update.available": "Está dispoñible SolidCraft {version}", + "update.button": "Actualizar", + "update.button_tip": "Está dispoñible SolidCraft {version}", + "update.button_version": "Actualizar a {version}", + "update.check_automatically": "Buscar actualizacións automaticamente", + "update.check_now": "Comprobar agora", + "update.checking": "Comprobando…", + "update.copy": "Copiar", + "update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra SolidCraft a Aplicacións, substituíndo a copia antiga, e despois reinicia SolidCraft.", + "update.download": "Descargar", + "update.download_open": "Descargar e abrir", + "update.downloading": "Descargando e verificando… {done} de {total} MB", + "update.failed": "Non se puido comprobar: {error}", + "update.file": "Descargar obtén {file}.", + "update.install_failed": "A actualización non se instalou: {error}. Non se cambiou nada; Descargar obtén o paquete no seu lugar.", + "update.install_msi": "Instalar (pecha SolidCraft)", + "update.installed": "SolidCraft {version} está instalado. Reinicia para usalo.", + "update.installer_started": "O instalador iníciase cando se pecha SolidCraft.", + "update.later": "Lembrarmo máis tarde", + "update.no_notes": "Esta versión non ten notas.", + "update.package_hint": "Esta copia veu dun {kind}. Descarga o paquete novo e despois instálao con:", + "update.prefs_dev": "Esta é unha compilación de desenvolvemento ({version}): non busca actualizacións.", + "update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. SolidCraft non comproba.", + "update.prefs_label": "Actualizacións", + "update.prefs_note": "Esta é a versión {version} de SolidCraft ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.", + "update.release_page": "Abrir a páxina da versión", + "update.restart": "Reiniciar agora", + "update.restart_later": "Máis tarde", + "update.show": "Mostrar a actualización a {version}", + "update.skip": "Omitir esta versión", + "update.system_hint": "Esta copia é un {kind}. Actualízaa con:", + "update.title": "Actualización dispoñible", + "update.up_to_date": "SolidCraft está actualizado.", + "update.update_now": "Actualizar agora", + "update.you_have": "Tes a versión {version}." +} diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 4bb4502f..2a6a35e1 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成", "AI": "AI", "About SolidCraft": "SolidCraft について", + "Acceleration": "加速度", "Active": "アクティブ", "Active rule": "有効なルール", "Add": "追加", @@ -14,8 +15,10 @@ "Add Mid Radius": "中間半径を追加", "Add Row": "行を追加", "Add condition": "条件を追加", + "Against": "相手", "Alignment": "配置", "All Sketch Points": "すべてのスケッチ点", + "All components": "すべてのコンポーネント", "All open designs": "すべての開いているデザイン", "Allowed expressions, separated by semicolons": "使用可能な式(セミコロンで区切る)", "Also deletes": "同時に削除される要素:", @@ -57,6 +60,7 @@ "Configuration rules": "構成ルール", "Construction": "コンストラクション", "Contact": "接触", + "Contact sets": "接触セット", "Continuity": "連続性", "Copy Error Message": "エラーメッセージをコピー", "Copy Warning Message": "警告メッセージをコピー", @@ -84,14 +88,18 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。", "Discard": "破棄", "Do not export hidden edges": "出力しない", + "Don't ask again": "今後確認しない", "Double-click to rename": "ダブルクリックで名前変更", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効", "Drive Joint": "ジョイントを駆動", + "Duration": "時間", "Edges": "エッジ", "Edit Profile Sketch": "プロファイルスケッチを編集", "Enable rule": "ルールを有効化", "Enabled": "有効", "Explode": "分解", + "Explode Text": "テキストを分解", + "Export Animation…": "アニメーションをエクスポート…", "Export Current View as DXF": "現在の表示をDXFでエクスポート", "Export Current View as DXF…": "現在の表示をDXFでエクスポート…", "Export DXF…": "DXFをエクスポート…", @@ -149,6 +157,8 @@ "Move earlier": "前へ移動", "Move later": "後へ移動", "Name": "名前", + "Nearest": "最も近い", + "New study": "新規スタディ", "No crash reports.": "クラッシュレポートはありません。", "No matching appearances": "一致する外観がありません", "No visible bodies to export.": "出力する表示中のボディがありません。", @@ -167,16 +177,20 @@ "Per Edge": "エッジごと", "Pick the tool bodies": "ツールボディを選択", "Picked": "選択したもの", + "Picked point": "選択した点", "Place at the origin instead": "代わりに原点に配置", "Playback": "再生", + "Plot": "グラフ", "Position": "位置", "Position (0 to 1)": "位置 (0〜1)", "Position (G0)": "位置 (G0)", "Position along the edge, 0 at the start end, 1 at the other": "エッジ上の位置(始点が0、終点が1)", + "Preferences › Design turns the offer back on": "環境設定 › デザイン で再びオンにできます", "Preview Row": "行をプレビュー", "Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。", "Profiles/Curves": "プロファイル/曲線", "Projection": "投影", + "Quantity": "量", "RECENT": "最近使用した項目", "RULED SURFACE": "ルールドサーフェス", "Radius Fit": "半径フィット", @@ -211,6 +225,7 @@ "Round": "丸め", "Rule": "ルール", "Save Rule": "ルールを保存", + "Save the study as an animated GIF, seen as the viewport shows it": "ビューポートの表示どおりにスタディをアニメーション GIF として保存", "Saved preview. Geometry is being checked before editing.": "保存済みのプレビューです。編集前にジオメトリを検証しています。", "Scale factor": "尺度係数", "Search": "検索", @@ -241,6 +256,7 @@ "Stand this part up?": "このパーツを正立させますか?", "Step": "ステップ", "Steps": "ステップ数", + "Study": "スタディ", "Style": "スタイル", "Suppressed": "抑制", "Swap Ends": "端点を入れ替え", @@ -260,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。", "To Offset": "オフセットまで", "Tolerance": "公差", + "Too close": "近すぎる", "Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。", "Type": "タイプ", "UNTRIM": "トリム解除", @@ -270,6 +287,7 @@ "Use selected face as fixed plane": "選択した面を固定平面として使用", "VIEW": "表示", "Value": "値", + "Velocity": "速度", "Vertices": "頂点", "View navigator": "ビューナビゲーター", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "表示中のボディを出力します。曲線は線分で近似します(許容誤差0.01 mm)。", @@ -293,6 +311,13 @@ "browser.precision": "精度: {precision}", "browser.sketch_name": "スケッチ{number}", "browser.units": "単位: {units}", + "cam.flat": "平面", + "cam.follower": "従動節", + "cam.knife": "ナイフエッジ", + "cam.needs_joints": "回転するジョイントと、スライドまたは揺動するジョイントが必要", + "cam.roller": "ローラー", + "cam.roller_pick": "ローラー (オプション)", + "cam.tip_pick": "先端 (オプション)", "cmd.appearance_assign": "外観", "cmd.appearance_library": "外観ライブラリ", "cmd.appearance_list": "外観を一覧表示", @@ -385,6 +410,7 @@ "cmd.file_slicers": "インストール済みのスライサー", "cmd.inspect_accessibility": "アクセシビリティ解析", "cmd.inspect_center_of_mass": "重心", + "cmd.inspect_clearance": "クリアランス", "cmd.inspect_compare": "ボディを比較", "cmd.inspect_continuity": "連続性解析", "cmd.inspect_curvature_comb": "曲率コーム解析", @@ -394,8 +420,10 @@ "cmd.inspect_interference": "干渉", "cmd.inspect_isocurve": "アイソカーブ解析", "cmd.inspect_minimum_radius": "最小半径解析", + "cmd.inspect_silhouette": "シルエット", "cmd.inspect_zebra": "ゼブラ解析", "cmd.joint_as_built": "現位置ジョイント", + "cmd.joint_cam": "カム", "cmd.joint_create": "ジョイント", "cmd.joint_delete": "ジョイントを削除", "cmd.joint_drive": "ジョイントを駆動", @@ -426,8 +454,11 @@ "cmd.model_threads": "ねじを一覧表示", "cmd.motion_delete": "モーション スタディを削除", "cmd.motion_export": "モーション スタディをエクスポート", + "cmd.motion_export_animation": "モーション アニメーションをエクスポート", + "cmd.motion_interference": "モーション スタディの干渉", "cmd.motion_list": "モーション スタディを一覧表示", "cmd.motion_play": "モーション スタディを再生", + "cmd.motion_plot": "モーション スタディのグラフ", "cmd.motion_study": "モーション スタディ", "cmd.motion_trace": "モーション トレース", "cmd.motion_trace_keep": "モーション トレースを保持", @@ -631,6 +662,18 @@ "drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます", "drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません", "drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません", + "dxf.cancel_wait": "出力処理の停止を待っています…", + "dxf.cancelled": "DXF出力をキャンセルしました", + "dxf.cancelling": "DXF出力をキャンセル中", + "dxf.exporting": "DXFを出力中", + "dxf.invalid_tolerance": "曲線の許容誤差は0.0001~1 mmで指定してください。", + "dxf.progress.index": "面データを準備中 (2/5)", + "dxf.progress.meshes": "メッシュを準備中 (1/5)", + "dxf.progress.occlusion": "隠線を判定中 (4/5)", + "dxf.progress.projection": "形状を投影中 (3/5)", + "dxf.progress.write": "DXFを書き込み中 (5/5)", + "dxf.tolerance": "曲線の許容誤差", + "dxf.tolerance_help": "表示中のボディを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。", "expression, e.g. 25 mm": "計算式 (例: 25 mm)", "fits the face, e.g. M8": "面に適合するサイズ (例: M8)", "from the rule": "ルールから", @@ -662,6 +705,10 @@ "joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。", "joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。", "joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。", + "motion.checked_to": "ステップ {step} までしか確認していません: 確認の計算量の上限に達しました", + "motion.collision": "{a} · {b}: ステップ {steps}", + "motion.duration_hint": "秒 (空欄: ステップごと)", + "motion.no_collisions": "スタディ全体で衝突はありません", "nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。", "nav.touchpad.label": "タッチパッド", "nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)", @@ -760,6 +807,10 @@ "sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック", "sketch_text.use_font_file": "フォント ファイル {path} を使用", "the parts library is not available": "パーツライブラリは利用できません", + "thread.automatic_size": "サイズを自動選択", + "thread.designation": "ねじの呼び", + "thread.iso_metric": "ISOメートルねじ", + "thread.select_size": "サイズを選択", "timeline.group": "フィーチャーフォルダを新規作成", "timeline.move_many": "タイムライン項目を移動", "timeline.move_to_group": "フィーチャーをフォルダへ移動", @@ -1005,6 +1056,9 @@ "ui.construct": "構築", "ui.construction": "コンストラクション", "ui.context_menu": "コンテキスト メニュー", + "ui.continuity_g0": "G0 折れ", + "ui.continuity_g1": "G1 接線", + "ui.continuity_g2": "G2 曲率", "ui.copy": "コピー", "ui.corner": "コーナー", "ui.corners": "コーナー", @@ -1286,6 +1340,8 @@ "ui.radial_context_menu": "パイ メニュー", "ui.radius": "半径", "ui.radius_type": "半径タイプ", + "ui.rail": "レール", + "ui.rail_fit": "レールの合わせ方", "ui.reachable": "到達可能", "ui.rectangle": "長方形", "ui.rectangular_pattern": "矩形パターン", @@ -1312,6 +1368,7 @@ "ui.save_mesh_temp_failed": "{folder} を作成できません: {error}", "ui.saved_to": "保存しました: {path}", "ui.scale": "尺度", + "ui.second_rail": "第2レール", "ui.section_analysis": "断面解析", "ui.select": "選択", "ui.selectOther": "その他を選択…", @@ -1362,6 +1419,7 @@ "ui.stand_up_tip": "Z軸上向きで開いたSTEPまたはIGESファイルに対し、X軸周りに90度回転させるか確認します", "ui.start": "開始", "ui.start_radius": "開始半径", + "ui.stretch": "伸縮", "ui.supersampling": "スーパーサンプリング (SSAA)", "ui.supersampling_adaptive": "ビューが静止しているときのみ", "ui.supersampling_off": "オフ (1×)", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a274a358..928f4b65 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -4,15 +4,21 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.", "3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.", "AI": "AI", + "Acceleration": "가속도", + "Against": "상대", "Alignment": "정렬", + "All components": "모든 구성요소", "Arc length": "호 길이", "Area": "면적", "BROWSER": "브라우저", "Bends": "벤딩", "Boundary": "경계", "Centre to centre": "중심 간", + "Check collisions": "충돌 확인", "Circumference": "원주", + "Collisions": "충돌", "Construction": "구성선", + "Contact sets": "접촉 집합", "Continuity": "연속성", "Copy Error Message": "오류 메시지 복사", "Copy Warning Message": "경고 메시지 복사", @@ -21,9 +27,13 @@ "Current perspective view": "현재 원근 투영 뷰", "Curvature (G2)": "곡률 (G2)", "Do not export hidden edges": "숨겨진 모서리 내보내지 않기", + "Don't ask again": "다시 묻지 않기", "Drive Joint": "조인트 구동", + "Duration": "지속 시간", "Edges": "모서리", "Edit Profile Sketch": "윤곽 스케치 편집", + "Explode Text": "텍스트 분해", + "Export Animation…": "애니메이션 내보내기…", "Export Current View as DXF": "현재 뷰를 DXF로 내보내기", "Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…", "Export DXF…": "DXF 내보내기…", @@ -32,6 +42,7 @@ "Faces": "면", "Find in Browser": "브라우저에서 찾기", "Flat": "플랫", + "Gap": "틈", "Hidden edges": "숨겨진 모서리", "Holes": "구멍", "Holes (edges on them)": "구멍 (구멍의 모서리)", @@ -41,6 +52,7 @@ "Max": "최대", "Min": "최소", "Mitre": "연귀", + "New study": "새 연구", "No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.", "None": "없음", "Notch": "노치", @@ -48,9 +60,13 @@ "Overall length": "전체 길이", "Pass through": "통과점", "Picked": "선택한 항목", + "Picked point": "선택한 점", + "Plot": "그래프", "Position (G0)": "위치 (G0)", + "Preferences › Design turns the offer back on": "환경 설정 › 디자인에서 다시 켤 수 있습니다", "Profiles/Curves": "프로파일/곡선", "Projection": "투영", + "Quantity": "양", "RULED SURFACE": "룰드 서피스", "Rectangular": "직사각형", "Reference face": "참조 면", @@ -59,6 +75,7 @@ "Restore outer boundary": "외부 경계 복원", "Revert Position": "위치 복원", "Round": "둥글게", + "Save the study as an animated GIF, seen as the viewport shows it": "뷰포트에 보이는 대로 스터디를 애니메이션 GIF로 저장", "Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)", "Selection filter": "선택 필터", "Send your report on GitHub": "GitHub에서 보고서 보내기", @@ -68,6 +85,7 @@ "Sketch geometry and profiles": "스케치 기하 및 프로파일", "Sketch points": "스케치 점", "Slot width": "슬롯 폭", + "Study": "연구", "Tangent (G1)": "접선 (G1)", "Tear": "찢김", "Thickness Outside": "바깥쪽 두께", @@ -76,6 +94,7 @@ "Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.", "Type": "유형", "UNTRIM": "트림 해제", + "Velocity": "속도", "Vertices": "꼭짓점", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "표시된 바디만 내보냅니다. 곡선은 선분으로 근사합니다 (허용오차 0.01 mm).", "Wall Location": "벽 위치", @@ -92,6 +111,13 @@ "browser.precision": "정밀도: {precision}", "browser.sketch_name": "스케치{number}", "browser.units": "단위: {units}", + "cam.flat": "평면", + "cam.follower": "종동절", + "cam.knife": "나이프 에지", + "cam.needs_joints": "회전하는 관절과 미끄러지거나 흔들리는 관절이 필요함", + "cam.roller": "롤러", + "cam.roller_pick": "롤러 (선택 사항)", + "cam.tip_pick": "끝점 (선택 사항)", "cmd.appearance_assign": "외관", "cmd.appearance_library": "외관 라이브러리", "cmd.appearance_list": "외관 목록", @@ -195,6 +221,7 @@ "cmd.inspect_minimum_radius": "최소 반경 분석", "cmd.inspect_zebra": "얼룩말줄무늬 분석", "cmd.joint_as_built": "조립 상태 관절", + "cmd.joint_cam": "캠", "cmd.joint_create": "관절", "cmd.joint_delete": "관절 삭제", "cmd.joint_drive": "관절 구동", @@ -225,8 +252,11 @@ "cmd.model_threads": "나사산 목록", "cmd.motion_delete": "운동 연구 삭제", "cmd.motion_export": "운동 연구 내보내기", + "cmd.motion_export_animation": "모션 애니메이션 내보내기", + "cmd.motion_interference": "운동 연구 간섭", "cmd.motion_list": "운동 연구 목록", "cmd.motion_play": "운동 연구 재생", + "cmd.motion_plot": "운동 연구 그래프", "cmd.motion_study": "운동 연구", "cmd.motion_trace": "모션 궤적", "cmd.motion_trace_keep": "모션 궤적 유지", @@ -428,6 +458,18 @@ "drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다", "drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다", "drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다", + "dxf.cancel_wait": "내보내기가 중지될 때까지 기다리는 중…", + "dxf.cancelled": "DXF 내보내기를 취소했습니다", + "dxf.cancelling": "DXF 내보내기 취소 중", + "dxf.exporting": "DXF 내보내는 중", + "dxf.invalid_tolerance": "곡선 허용 오차는 0.0001~1 mm 범위여야 합니다.", + "dxf.progress.index": "면 데이터 준비 중 (2/5)", + "dxf.progress.meshes": "메시 준비 중 (1/5)", + "dxf.progress.occlusion": "숨은선 판정 중 (4/5)", + "dxf.progress.projection": "형상 투영 중 (3/5)", + "dxf.progress.write": "DXF 쓰는 중 (5/5)", + "dxf.tolerance": "곡선 허용 오차", + "dxf.tolerance_help": "표시된 바디만 내보내며 곡선은 직선 구간으로 근사합니다. 허용 오차가 작을수록 곡선이 매끄러워지지만 처리 시간과 메모리 사용량이 늘어납니다.", "home.documentation": "문서", "home.in_this_browser": "이 브라우저에 저장됨", "home.missing": "찾을 수 없음", @@ -456,6 +498,10 @@ "joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.", "joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.", "joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.", + "motion.checked_to": "{step} 단계까지만 확인했습니다: 확인 작업량 한도에 도달했습니다", + "motion.collision": "{a} · {b}: {steps} 단계", + "motion.duration_hint": "초 (비우면: 단계당)", + "motion.no_collisions": "연구 전체에서 충돌이 없습니다", "nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.", "nav.touchpad.label": "터치패드", "nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)", @@ -767,6 +813,9 @@ "ui.constraints_conflict": "구속 조건 충돌", "ui.construct": "구성", "ui.construction": "구성선", + "ui.continuity_g0": "G0 꺾임", + "ui.continuity_g1": "G1 접선", + "ui.continuity_g2": "G2 곡률", "ui.copy": "복사", "ui.corner": "코너", "ui.corners": "코너", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json new file mode 100644 index 00000000..c45e1a6a --- /dev/null +++ b/assets/locales/pt-BR.json @@ -0,0 +1,1460 @@ +{ + "0 turns it off": "0 desativa", + "1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Abra o rascunho do GitHub abaixo e cole o relatório copiado para a área de transferência.", + "1. Open GitHub draft below. Your title and report will be filled in.": "1. Abra o rascunho do GitHub abaixo. O título e o relatório serão preenchidos.", + "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Anexe a captura de tela: copie e cole, ou salve e envie o PNG.", + "3. Click Submit new issue on GitHub to send your report.": "3. Clique em Submit new issue no GitHub para enviar o relatório.", + "A new configuration, a copy of the active one": "Uma nova configuração, cópia da ativa", + "AI": "IA", + "About SolidCraft": "Sobre o SolidCraft", + "Active": "Ativo", + "Active rule": "Regra ativa", + "Add": "Adicionar", + "Add Key": "Adicionar chave", + "Add Mid Radius": "Adicionar raio intermediário", + "Add Row": "Adicionar linha", + "Add condition": "Adicionar condição", + "Alignment": "Alinhamento", + "All Sketch Points": "Todos os pontos do esboço", + "All open designs": "Todos os projetos abertos", + "Allowed expressions, separated by semicolons": "Expressões permitidas, separadas por ponto e vírgula", + "Also deletes": "Também exclui", + "Angle 2": "Ângulo 2", + "Animate": "Animar", + "Appearance name": "Nome da aparência", + "Applied Radius": "Raio aplicado", + "Apply To": "Aplicar a", + "Apply to selection": "Aplicar à seleção", + "Arc length": "Comprimento do arco", + "Area": "Área", + "Attachment": "Fixação", + "BROWSER": "NAVEGADOR", + "Bend Line Position": "Posição da linha de dobra", + "Bend Position": "Posição da dobra", + "Bend Radius": "Raio de dobra", + "Bends": "Dobras", + "Body": "Corpo", + "Boundary": "Contorno", + "Bounding box": "Caixa delimitadora", + "CSV or JSON: updates existing parameters, adds new ones": "CSV ou JSON: atualiza parâmetros existentes e adiciona novos", + "Capture All Positions": "Capturar todas as posições", + "Catch Direction": "Direção do encaixe", + "Cells": "Células", + "Center X": "Centro X", + "Center Y": "Centro Y", + "Center of mass": "Centro de massa", + "Centre to centre": "Centro a centro", + "Chamfer Type": "Tipo de chanfro", + "Check collisions": "Verificar colisões", + "Checked": "Marcado", + "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Escolha um novo plano. A geometria e as cotas do esboço permanecem inalteradas em suas coordenadas bidimensionais.", + "Circumference": "Circunferência", + "Close draft": "Fechar rascunho", + "Collisions": "Colisões", + "Colour": "Cor", + "Component1": "Componente1", + "Configuration": "Configuração", + "Configuration rules": "Regras de configuração", + "Construction": "Construção", + "Contact": "Contato", + "Continuity": "Continuidade", + "Copy Error Message": "Copiar mensagem de erro", + "Copy Warning Message": "Copiar mensagem de aviso", + "Copy all licences": "Copiar todas as licenças", + "Copy diagnostic information": "Copiar informações de diagnóstico", + "Copy report": "Copiar relatório", + "Copy screenshot": "Copiar captura de tela", + "Corner Relief": "Alívio de canto", + "Corner Relief Shape": "Forma do alívio de canto", + "Counterbore depth": "Profundidade do rebaixo", + "Countersink angle": "Ângulo do escareado", + "Create Issue — review draft": "Criar issue — revisar rascunho", + "Cube": "Cubo", + "Current orthographic view (1:1 mm)": "Vista ortográfica atual (1:1 mm)", + "Current perspective view": "Vista em perspectiva atual", + "Curvature (G2)": "Curvatura (G2)", + "Customize Toolbar": "Personalizar barra de ferramentas", + "Delete all": "Excluir tudo", + "Delete rule": "Excluir regra", + "Delete the row": "Excluir a linha", + "Delete the set": "Excluir o conjunto", + "Delete this parameter": "Excluir este parâmetro", + "Density": "Densidade", + "Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.", + "Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.", + "Discard": "Descartar", + "Do not export hidden edges": "Não exportar arestas ocultas", + "Double-click to rename": "Clique duas vezes para renomear", + "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Clique duplo com o botão do meio na vista: Início (a vista inicial, enquadrando o que é mostrado), Ajustar (enquadra o que é mostrado, mantendo a orientação) ou nada", + "Drive Joint": "Acionar junta", + "Edges": "Arestas", + "Edit Profile Sketch": "Editar esboço do perfil", + "Enable rule": "Ativar regra", + "Enabled": "Ativado", + "Explode": "Explodir", + "Export Animation…": "Exportar animação…", + "Export Current View as DXF": "Exportar vista atual como DXF", + "Export Current View as DXF…": "Exportar vista atual como DXF…", + "Export DXF…": "Exportar DXF…", + "Export hidden edges as dashed lines": "Exportar arestas ocultas como linhas tracejadas", + "Extents": "Extensões", + "Face": "Face", + "Face attachment lost": "Fixação à face perdida", + "Faces": "Faces", + "Faces / edges / vertices": "Faces / arestas / vértices", + "Favourite": "Favorito", + "Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Arquivos de muitos programas CAD usam Y para cima, então abrem deitados aqui (Z para cima).", + "Filter": "Filtro", + "Find in Browser": "Localizar no navegador", + "Flat": "Plano", + "Flat size": "Tamanho planificado", + "Follow camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)", + "Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)", + "Font": "Fonte", + "Full Length": "Comprimento total", + "Gap": "Folga", + "Grid": "Grade", + "Height": "Altura", + "Hidden edges": "Arestas ocultas", + "Holes": "Furos", + "Holes (edges on them)": "Furos (arestas neles)", + "Home view": "Vista inicial", + "In This Design": "Neste projeto", + "In place (surfaces)": "No lugar (superfícies)", + "Inside": "Dentro", + "Interferes": "Interfere", + "Issue title": "Título da issue", + "Joint Origin1": "Origem da junta1", + "Keep Tools": "Manter ferramentas", + "Later": "Depois", + "Leave it": "Deixar assim", + "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Fixação de face antiga. Vincular novamente a uma face certificada cria uma fixação exata.", + "Length": "Comprimento", + "Length units": "Unidades de comprimento", + "Letter Spacing": "Espaçamento entre letras", + "Library": "Biblioteca", + "Line Spacing": "Espaçamento entre linhas", + "Loop": "Laço", + "Margin": "Margem", + "Mass": "Massa", + "Material": "Material", + "Max": "Máx.", + "Mid Radius": "Raio intermediário", + "Middle double-click": "Clique duplo do meio", + "Min": "Mín.", + "Mitre": "Meia-esquadria", + "Modeled": "Modelado", + "More toolbar panels": "Mais painéis da barra de ferramentas", + "Motion": "Movimento", + "Move Type": "Tipo de movimento", + "Move earlier": "Mover para antes", + "Move later": "Mover para depois", + "Name": "Nome", + "No crash reports.": "Nenhum relatório de falha.", + "No matching appearances": "Nenhuma aparência correspondente", + "No visible bodies to export.": "Nenhum corpo visível para exportar.", + "None": "Nenhum", + "Not available yet": "Ainda não disponível", + "Notch": "Entalhe", + "Opacity": "Opacidade", + "Open GitHub draft": "Abrir rascunho no GitHub", + "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto em Rust. Licenciado sob MIT OU Apache-2.0.", + "Other side": "Outro lado", + "Outside": "Fora", + "Overall length": "Comprimento total", + "Parameter values": "Valores dos parâmetros", + "Pass through": "Atravessar", + "Path": "Caminho", + "Per Edge": "Por aresta", + "Pick the tool bodies": "Selecione os corpos de ferramenta", + "Picked": "Selecionado", + "Place at the origin instead": "Colocar na origem", + "Playback": "Reprodução", + "Position": "Posição", + "Position (0 to 1)": "Posição (0 a 1)", + "Position (G0)": "Posição (G0)", + "Position along the edge, 0 at the start end, 1 at the other": "Posição ao longo da aresta: 0 na extremidade inicial, 1 na outra", + "Preview Row": "Visualizar linha", + "Preview leaves the current design unchanged.": "A visualização não altera o projeto atual.", + "Profiles/Curves": "Perfis/Curvas", + "Projection": "Projeção", + "RECENT": "RECENTES", + "RULED SURFACE": "SUPERFÍCIE REGRADA", + "Radius Fit": "Ajuste de raio", + "Ratio": "Razão", + "Reattach selected face": "Fixar novamente na face selecionada", + "Recover": "Recuperar", + "Recover unsaved design?": "Recuperar projeto não salvo?", + "Rectangular": "Retangular", + "Reference face": "Face de referência", + "Remove Tools": "Remover ferramentas", + "Remove from list": "Remover da lista", + "Remove override": "Remover substituição", + "Remove private design details or paths before sharing.": "Remova detalhes privados do projeto ou caminhos antes de compartilhar.", + "Remove screenshot": "Remover captura de tela", + "Remove the column": "Remover a coluna", + "Remove the track": "Remover a trilha", + "Remove this point": "Remover este ponto", + "Report a bug — review a screenshot and open a GitHub draft": "Relatar um bug — revise uma captura de tela e abra um rascunho no GitHub", + "Reset Toolbar": "Redefinir barra de ferramentas", + "Restore outer boundary": "Restaurar contorno externo", + "Restore the inherited component or physical material appearance": "Restaurar a aparência herdada do componente ou do material físico", + "Result": "Resultado", + "Retake screenshot": "Refazer captura de tela", + "Reverse": "Inverter", + "Reverse Direction": "Inverter direção", + "Revert": "Reverter", + "Revert All Positions": "Reverter todas as posições", + "Revert Position": "Reverter posição", + "Review the report and screenshot before opening GitHub.": "Revise o relatório e a captura de tela antes de abrir o GitHub.", + "Rim Edge": "Aresta da borda", + "Roll History Marker Here": "Mover o marcador do histórico para cá", + "Round": "Arredondado", + "Rule": "Regra", + "Save Rule": "Salvar regra", + "Save the study as an animated GIF, seen as the viewport shows it": "Salvar o estudo como GIF animado, como a janela de visualização o mostra", + "Saved preview. Geometry is being checked before editing.": "Visualização salva. A geometria está sendo verificada antes da edição.", + "Scale factor": "Fator de escala", + "Search": "Pesquisar", + "Search commands": "Pesquisar comandos", + "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Selecionar outro: mantenha o botão esquerdo parado sobre o modelo (ou clique com o botão direito › Selecionar outro…) para listar tudo sob o cursor; clique de novo no mesmo ponto para selecionar o próximo item ali. Clique duas vezes com o botão do meio para ir ao Início (Preferências › Navegação).", + "Select bodies (a face picks its body)": "Selecione corpos (uma face seleciona seu corpo)", + "Select bodies or faces instead": "Selecionar corpos ou faces em vez disso", + "Selection filter": "Filtro de seleção", + "Semantic faces share their appearance across instances.": "Faces semânticas compartilham a aparência entre instâncias.", + "Send your report on GitHub": "Envie seu relatório no GitHub", + "Shape": "Forma", + "Show Agent Cursor": "Mostrar cursor do agente", + "Show in folder": "Mostrar na pasta", + "Show the pointer an AI agent moves (#35)": "Mostrar o ponteiro que um agente de IA move (#35)", + "Signed axes": "Eixos com sinal", + "Size": "Tamanho", + "Sketch constraints": "Restrições do esboço", + "Sketch curves": "Curvas do esboço", + "Sketch dimensions": "Cotas do esboço", + "Sketch geometry and profiles": "Geometria e perfis do esboço", + "Sketch plane has no body-face attachment.": "O plano do esboço não está fixado a uma face de corpo.", + "Sketch points": "Pontos do esboço", + "Slot width": "Largura do rasgo", + "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "O SolidCraft fechou antes de estes projetos serem salvos. O último salvamento automático deles pode ser aberto.", + "Speed": "Velocidade", + "Stand it up": "Colocar em pé", + "Stand it up: a quarter turn about X, as one Move feature you can undo.": "Colocar em pé: um quarto de volta em torno de X, como um recurso Mover que pode ser desfeito.", + "Stand this part up?": "Colocar esta peça em pé?", + "Step": "Passo", + "Steps": "Passos", + "Style": "Estilo", + "Suppressed": "Suprimido", + "Swap Ends": "Trocar extremidades", + "Tangent (G1)": "Tangente (G1)", + "Tangent Chain": "Cadeia tangente", + "Tear": "Rasgo", + "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "O esboço tracejado é uma guia de plano salva. Selecione uma nova face para reparar a fixação.", + "The saved author occurrence is missing. Select a valid author placement before rebinding.": "A ocorrência de autoria salva não existe mais. Selecione um posicionamento de autoria válido antes de vincular novamente.", + "Then": "Então", + "These features will be deleted:": "Estes recursos serão excluídos:", + "These features will fail:": "Estes recursos vão falhar:", + "Thickness Outside": "Espessura para fora", + "Thin": "Fino", + "Thin Extrude": "Extrusão fina", + "This child is shared. Deleting it removes it from every copy of its parent.": "Este filho é compartilhado. Excluí-lo o remove de todas as cópias do pai.", + "This child is shared. Position changes apply to every copy of its parent.": "Este filho é compartilhado. Mudanças de posição se aplicam a todas as cópias do pai.", + "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta face não pode fornecer uma fixação duradoura. Escolha outra face ou use explicitamente um plano fixo.", + "To Offset": "Deslocamento final", + "Tolerance": "Tolerância", + "Turn off section analysis before exporting the view.": "Desative a análise de seção antes de exportar a vista.", + "Type": "Tipo", + "UNTRIM": "DESAPARAR", + "Updating the preview": "Atualizando a visualização", + "Use active author placement": "Usar o posicionamento de autoria ativo", + "Use fixed plane": "Usar plano fixo", + "Use saved fixed plane": "Usar o plano fixo salvo", + "Use selected face as fixed plane": "Usar a face selecionada como plano fixo", + "VIEW": "VISTA", + "Value": "Valor", + "Vertices": "Vértices", + "View navigator": "Navegador de vistas", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Somente corpos visíveis. As curvas usam segmentos de reta (tolerância de 0,01 mm).", + "Volume": "Volume", + "Wall": "Parede", + "Wall Location": "Posição da parede", + "Wall Thickness": "Espessura da parede", + "When": "Quando", + "Width": "Largura", + "Will fail": "Vai falhar", + "angle, height": "ângulo, altura", + "appearance.dielectric_ior": "IOR dielétrico", + "appearance.metallic": "Metálico", + "appearance.pbr_help": "Veja esta superfície em Renderizar ou em um PNG de produto. Sem refração nem transmissão.", + "appearance.pbr_opaque": "PBR opaco", + "appearance.roughness": "Rugosidade", + "appearance.surface_response": "Resposta da superfície", + "browser.body_name": "Corpo{number}", + "browser.component_name": "Componente{number}", + "browser.parameters": "Parâmetros ({count})", + "browser.precision": "Precisão: {precision}", + "browser.sketch_name": "Esboço{number}", + "browser.units": "Unidades: {units}", + "cmd.appearance_assign": "Aparência", + "cmd.appearance_library": "Biblioteca de aparências", + "cmd.appearance_list": "Listar aparências", + "cmd.body_rename": "Renomear corpo", + "cmd.browser_group": "Novo grupo", + "cmd.browser_move": "Mover itens do navegador", + "cmd.browser_order": "Ordenar itens do navegador", + "cmd.browser_rename_group": "Renomear grupo", + "cmd.browser_ungroup": "Desagrupar", + "cmd.browser_visibility": "Mostrar/Ocultar", + "cmd.canvas_calibrate": "Calibrar tela de referência", + "cmd.canvas_decal": "Decalque", + "cmd.canvas_delete": "Excluir tela de referência", + "cmd.canvas_edit": "Editar tela de referência", + "cmd.canvas_insert": "Tela de referência", + "cmd.component_activate": "Ativar componente", + "cmd.component_break_link": "Quebrar vínculo", + "cmd.component_capture_position": "Capturar posição", + "cmd.component_create": "Novo componente", + "cmd.component_delete": "Excluir componente", + "cmd.component_from_bodies": "Criar componentes a partir de corpos", + "cmd.component_insert_design": "Inserir projeto", + "cmd.component_links": "Listar vínculos", + "cmd.component_list": "Listar componentes", + "cmd.component_move_bodies": "Mover corpos para o componente", + "cmd.component_move_sketches": "Mover esboços para o componente", + "cmd.component_paste_new": "Colar novo", + "cmd.component_properties": "Propriedades físicas do componente", + "cmd.component_rename": "Renomear componente", + "cmd.component_revert_position": "Reverter posição", + "cmd.component_unlock": "Desbloquear conteúdo", + "cmd.component_update_links": "Atualizar vínculos", + "cmd.config_activate": "Ativar configuração", + "cmd.config_column": "Coluna de configuração", + "cmd.config_configure": "Configurar", + "cmd.config_preview": "Visualizar configuração", + "cmd.config_row": "Linha de configuração", + "cmd.config_rule": "Regra de configuração", + "cmd.config_rules": "Regras de configuração", + "cmd.config_table": "Exibir tabela de configurações", + "cmd.construct_axis_edge": "Eixo pela aresta", + "cmd.construct_axis_normal_to_face": "Eixo perpendicular à face", + "cmd.construct_axis_through_cylinder": "Eixo pelo cilindro/cone/toro", + "cmd.construct_axis_two_planes": "Eixo por dois planos", + "cmd.construct_axis_two_points": "Eixo por dois pontos", + "cmd.construct_plane_along_path": "Plano ao longo do caminho", + "cmd.construct_plane_midplane": "Plano médio", + "cmd.construct_plane_perpendicular": "Plano perpendicular", + "cmd.construct_plane_tangent": "Plano tangente", + "cmd.construct_plane_three_points": "Plano por três pontos", + "cmd.construct_plane_two_edges": "Plano por duas arestas", + "cmd.construct_point_along_path": "Ponto ao longo do caminho", + "cmd.construct_point_center": "Ponto no centro do círculo/esfera/toro", + "cmd.construct_point_edge_plane": "Ponto na aresta e no plano", + "cmd.construct_point_three_planes": "Ponto por três planos", + "cmd.construct_point_two_edges": "Ponto por duas arestas", + "cmd.construct_point_vertex": "Ponto no vértice", + "cmd.contact_check": "Verificar contato", + "cmd.contact_create": "Novo conjunto de contato", + "cmd.contact_disable_all": "Desativar contato", + "cmd.contact_edit": "Editar conjunto de contato", + "cmd.contact_enable_all": "Ativar todo o contato", + "cmd.contact_enable_sets": "Ativar conjuntos de contato", + "cmd.contact_list": "Listar conjuntos de contato", + "cmd.diagnostics_crash_report": "Relatório de falha", + "cmd.diagnostics_crash_reports": "Relatórios de falha", + "cmd.diagnostics_crash_reports_clear": "Excluir relatórios de falha", + "cmd.diagnostics_crash_test": "Teste de falha", + "cmd.doc_recover": "Recuperar projeto", + "cmd.doc_recovery_discard": "Descartar projeto recuperado", + "cmd.doc_recovery_list": "Projetos recuperáveis", + "cmd.document_inspect": "Inspecionar projeto", + "cmd.document_metadata": "Propriedades do projeto", + "cmd.document_metadata_remove": "Remover propriedade do projeto", + "cmd.document_metadata_set": "Alterar propriedades do projeto", + "cmd.document_units": "Alterar unidades do projeto", + "cmd.edit_checkpoint": "Ponto de restauração", + "cmd.edit_restore_checkpoint": "Restaurar ponto de restauração", + "cmd.engine_commands": "Listar comandos", + "cmd.explode_create": "Vista explodida", + "cmd.explode_delete": "Excluir vista explodida", + "cmd.explode_list": "Listar vistas explodidas", + "cmd.explode_show": "Mostrar vista explodida", + "cmd.expr_evaluate": "Avaliar expressão", + "cmd.face_delete": "Excluir face", + "cmd.file_export": "Exportar", + "cmd.file_insert_mesh": "Inserir malha", + "cmd.file_insert_step": "Inserir STEP", + "cmd.file_save_mesh": "Salvar como malha", + "cmd.file_slicers": "Fatiadores instalados", + "cmd.inspect_accessibility": "Análise de acessibilidade", + "cmd.inspect_center_of_mass": "Centro de massa", + "cmd.inspect_compare": "Comparar corpos", + "cmd.inspect_continuity": "Análise de continuidade", + "cmd.inspect_curvature_comb": "Análise de pente de curvatura", + "cmd.inspect_curvature_map": "Análise de mapa de curvatura", + "cmd.inspect_draft": "Análise de ângulo de saída", + "cmd.inspect_environment_map": "Análise de mapa de ambiente", + "cmd.inspect_interference": "Interferência", + "cmd.inspect_isocurve": "Análise de isocurvas", + "cmd.inspect_minimum_radius": "Análise de raio mínimo", + "cmd.inspect_zebra": "Análise zebra", + "cmd.joint_as_built": "Junta como construída", + "cmd.joint_create": "Junta", + "cmd.joint_delete": "Excluir junta", + "cmd.joint_drive": "Acionar juntas", + "cmd.joint_edit": "Editar junta", + "cmd.joint_gear": "Engrenagem", + "cmd.joint_limits": "Limites da junta", + "cmd.joint_list": "Listar juntas", + "cmd.joint_motion_link": "Vínculo de movimento", + "cmd.joint_origin": "Origem da junta", + "cmd.joint_rack_pinion": "Cremalheira e pinhão", + "cmd.joint_reach": "Alcançar ponto", + "cmd.joint_rigid_group": "Grupo rígido", + "cmd.joint_screw": "Parafuso", + "cmd.joint_solve": "Resolver juntas", + "cmd.library_apply": "Aplicar entrada da biblioteca local", + "cmd.library_export": "Exportar biblioteca do projeto", + "cmd.library_inspect": "Inspecionar biblioteca local", + "cmd.material_assign": "Material físico", + "cmd.material_create": "Novo material físico", + "cmd.material_delete": "Excluir material físico", + "cmd.material_edit": "Editar material físico", + "cmd.material_list": "Listar materiais", + "cmd.material_properties_directional": "Módulo de elasticidade direcional", + "cmd.material_properties_validate": "Validar dados do material físico", + "cmd.mesh_convert": "Converter malha", + "cmd.model_edges": "Listar arestas", + "cmd.model_faces": "Listar faces", + "cmd.model_threads": "Listar roscas", + "cmd.motion_delete": "Excluir estudo de movimento", + "cmd.motion_export": "Exportar estudo de movimento", + "cmd.motion_export_animation": "Exportar animação de movimento", + "cmd.motion_list": "Listar estudos de movimento", + "cmd.motion_play": "Reproduzir estudo de movimento", + "cmd.motion_study": "Estudo de movimento", + "cmd.motion_trace": "Traçado de movimento", + "cmd.motion_trace_keep": "Manter traçado de movimento", + "cmd.occurrence_copy": "Colar (instância)", + "cmd.occurrence_ground": "Fixar no solo", + "cmd.occurrence_move": "Mover ocorrência", + "cmd.occurrence_properties": "Propriedades físicas da ocorrência", + "cmd.parameters_add": "Adicionar parâmetro do usuário", + "cmd.parameters_change": "Alterar parâmetros", + "cmd.parameters_comment": "Comentar parâmetro", + "cmd.parameters_complete": "Completar expressão", + "cmd.parameters_delete": "Excluir parâmetro", + "cmd.parameters_export": "Exportar parâmetros", + "cmd.parameters_favorite": "Favoritar parâmetro", + "cmd.parameters_graph": "Grafo de parâmetros", + "cmd.parameters_import": "Importar parâmetros", + "cmd.parameters_list": "Listar parâmetros", + "cmd.parameters_move": "Mover parâmetro", + "cmd.parameters_rename": "Renomear parâmetro", + "cmd.parameters_users": "Usuários do parâmetro", + "cmd.parts_fastener": "Inserir fixador", + "cmd.parts_insert": "Inserir peça", + "cmd.parts_library": "Peças padrão", + "cmd.parts_resize": "Alterar tamanho da peça", + "cmd.plastic_assign_rule": "Atribuir regra de plástico", + "cmd.plastic_boss": "Ressalto", + "cmd.plastic_lip": "Lábio", + "cmd.plastic_manage_rules": "Gerenciar regras de plástico", + "cmd.plastic_rest": "Apoio", + "cmd.plastic_rules": "Listar regras de plástico", + "cmd.plastic_snap_fit": "Encaixe por pressão", + "cmd.render_backends": "Backends de renderização", + "cmd.render_cancel": "Cancelar renderização", + "cmd.render_export": "Exportar imagem renderizada", + "cmd.render_preflight": "Verificar pedido de renderização", + "cmd.render_scene": "Inspecionar cena de renderização", + "cmd.render_settings_apply": "Aplicar câmera de renderização", + "cmd.render_settings_get": "Configurações de renderização", + "cmd.render_settings_set": "Salvar configurações de renderização", + "cmd.render_start": "Renderizar imagem", + "cmd.render_status": "Progresso da renderização", + "cmd.select_clear": "Limpar seleção", + "cmd.sheet_convert": "Converter em chapa metálica", + "cmd.sheet_export_bend_table": "Exportar tabela de dobras", + "cmd.sheet_export_dxf": "Exportar planificação como DXF", + "cmd.sheet_flange": "Flange", + "cmd.sheet_flat_pattern": "Criar planificação", + "cmd.sheet_fold": "Dobrar", + "cmd.sheet_hem": "Bainha", + "cmd.sheet_manage_rules": "Regras de chapa metálica", + "cmd.sheet_perforate": "Perfurar", + "cmd.sheet_refold": "Redobrar", + "cmd.sheet_rules": "Listar regras de chapa metálica", + "cmd.sheet_unfold": "Desdobrar", + "cmd.sketch_arc_center_point": "Arco por ponto central", + "cmd.sketch_arc_tangent": "Arco tangente", + "cmd.sketch_arc_three_point": "Arco por 3 pontos", + "cmd.sketch_auto_constrain": "Restrição automática", + "cmd.sketch_auto_constrain_from_datum": "Restrição automática a partir da referência", + "cmd.sketch_auto_project": "Projetar arestas automaticamente na referência", + "cmd.sketch_blend_curve": "Curva de concordância", + "cmd.sketch_chain": "Cadeia", + "cmd.sketch_chamfer_distance_angle": "Chanfro por distância e ângulo", + "cmd.sketch_chamfer_equal_distance": "Chanfro de distâncias iguais", + "cmd.sketch_chamfer_two_distance": "Chanfro de duas distâncias", + "cmd.sketch_circle_center": "Círculo por centro e diâmetro", + "cmd.sketch_circle_three_point": "Círculo por 3 pontos", + "cmd.sketch_circle_three_tangent": "Círculo tangente a 3", + "cmd.sketch_circle_two_point": "Círculo por 2 pontos", + "cmd.sketch_circle_two_tangent": "Círculo tangente a 2", + "cmd.sketch_constrainer": "Restringidor de esboço", + "cmd.sketch_constraint_fix": "Fixar/Liberar", + "cmd.sketch_constraint_midpoint": "Ponto médio", + "cmd.sketch_construction": "Normal/Construção", + "cmd.sketch_copy": "Copiar entidades do esboço", + "cmd.sketch_cut": "Recortar entidades do esboço", + "cmd.sketch_delete": "Excluir entidades do esboço", + "cmd.sketch_dimension_text": "Mover texto da cota", + "cmd.sketch_drag": "Arrastar geometria do esboço", + "cmd.sketch_drag_end": "Terminar arrasto do esboço", + "cmd.sketch_draw_dimensioned": "Desenhar geometria cotada", + "cmd.sketch_edit": "Editar esboço", + "cmd.sketch_edit_text": "Editar texto", + "cmd.sketch_export_dxf": "Salvar como DXF", + "cmd.sketch_finish_auto_constrain": "Concluir com restrição automática", + "cmd.sketch_fit_curves_to_section": "Ajustar curvas à seção da malha", + "cmd.sketch_glyphs": "Símbolos de restrição", + "cmd.sketch_include_3d": "Incluir geometria 3D", + "cmd.sketch_infinite": "Linha infinita", + "cmd.sketch_insert_dxf": "Inserir DXF", + "cmd.sketch_insert_svg": "Inserir SVG", + "cmd.sketch_inspect": "Inspecionar esboço", + "cmd.sketch_intersection_curve": "Curva de interseção", + "cmd.sketch_isoparametric_curve": "Curva isoparamétrica", + "cmd.sketch_line3d": "Linha 3D", + "cmd.sketch_line_midpoint": "Linha pelo ponto médio", + "cmd.sketch_move": "Mover/Copiar entidades do esboço", + "cmd.sketch_move3d": "Mover ponto 3D", + "cmd.sketch_move_point": "Arrastar ponto do esboço", + "cmd.sketch_offset_break": "Quebrar associação de deslocamento", + "cmd.sketch_options": "Opções da paleta de esboço", + "cmd.sketch_paste": "Colar entidades do esboço", + "cmd.sketch_point3d": "Ponto 3D", + "cmd.sketch_polygon_circumscribed": "Polígono circunscrito", + "cmd.sketch_polygon_edge": "Polígono por aresta", + "cmd.sketch_polygon_inscribed": "Polígono inscrito", + "cmd.sketch_project_to_surface": "Projetar na superfície", + "cmd.sketch_rectangle_center": "Retângulo pelo centro", + "cmd.sketch_rectangle_rounded": "Retângulo arredondado", + "cmd.sketch_rectangle_three_point": "Retângulo por 3 pontos", + "cmd.sketch_rectangle_two_point": "Retângulo por 2 pontos", + "cmd.sketch_redefine": "Redefinir plano do esboço", + "cmd.sketch_slot_arc_center": "Rasgo em arco por ponto central", + "cmd.sketch_slot_arc_three_point": "Rasgo em arco por três pontos", + "cmd.sketch_slot_center_point": "Rasgo por ponto central", + "cmd.sketch_slot_center_to_center": "Rasgo de centro a centro", + "cmd.sketch_slot_overall": "Rasgo pelo comprimento total", + "cmd.sketch_snap": "Inferência de encaixe", + "cmd.sketch_solve": "Resolver esboço", + "cmd.sketch_spline3d": "Spline 3D", + "cmd.sketch_spline_control_point": "Spline por pontos de controle", + "cmd.sketch_spline_control_point_5": "Spline por pontos de controle (grau 5)", + "cmd.sketch_spline_fit_point": "Spline por pontos de ajuste", + "cmd.sketch_spun_profile": "Perfil de revolução", + "cmd.sketch_toggle_driven": "Alternar cota conduzida", + "cmd.solid_align": "Alinhar", + "cmd.solid_boundary_fill": "Preenchimento de contorno", + "cmd.solid_bounding_solid": "Sólido envolvente", + "cmd.solid_emboss": "Gravar em relevo", + "cmd.solid_move": "Mover/Copiar", + "cmd.solid_offset_face": "Deslocar face", + "cmd.solid_press_pull": "Empurrar/Puxar", + "cmd.solid_remove": "Remover", + "cmd.solid_replace_face": "Substituir face", + "cmd.solid_rib": "Nervura", + "cmd.solid_split_face": "Dividir face", + "cmd.solid_web": "Alma", + "cmd.surface_blend": "Superfície de concordância", + "cmd.surface_boundary": "Superfície de contorno", + "cmd.surface_extrude": "Extrusão de superfície", + "cmd.surface_fair": "Suavizar superfície", + "cmd.surface_fillet": "Concordância de superfície", + "cmd.surface_loft": "Loft de superfície", + "cmd.surface_match": "Casar superfície", + "cmd.surface_offset": "Deslocar superfície", + "cmd.surface_patch": "Remendo", + "cmd.surface_reverse_normal": "Inverter normal", + "cmd.surface_revolve": "Revolução de superfície", + "cmd.surface_ruled": "Superfície regrada", + "cmd.surface_stitch": "Costurar", + "cmd.surface_sweep": "Varredura de superfície", + "cmd.surface_thicken": "Espessar", + "cmd.surface_unstitch": "Descosturar", + "cmd.surface_untrim": "Desaparar", + "cmd.timeline_add": "Adicionar recurso gravado", + "cmd.timeline_compute_all": "Calcular tudo", + "cmd.timeline_copy": "Copiar recursos", + "cmd.timeline_dependents": "Dependentes do recurso", + "cmd.timeline_export_script": "Exportar script de comandos", + "cmd.timeline_inspect": "Inspecionar recurso", + "cmd.timeline_paste": "Colar recursos", + "cmd.timeline_redefine": "Editar recurso", + "cmd.timeline_rename": "Renomear", + "cmd.timeline_reorder": "Reordenar recurso", + "cmd.timeline_roll_to": "Mover marcador do histórico", + "cmd.timeline_rollback": "Mover marcador da linha do tempo", + "cmd.timeline_suppress": "Suprimir recurso", + "cmd.view_apply": "Restaurar vista nomeada", + "cmd.view_delete": "Excluir vista nomeada", + "cmd.view_list": "Listar vistas nomeadas", + "cmd.view_navigate": "Navegar na vista", + "cmd.view_rename": "Renomear vista nomeada", + "cmd.view_save": "Nova vista nomeada", + "command name": "nome do comando", + "coupling.gear_1": "Engrenagem 1", + "coupling.gear_1_pick": "Engrenagem 1 (opcional)", + "coupling.gear_2": "Engrenagem 2", + "coupling.gear_2_pick": "Engrenagem 2 (opcional)", + "coupling.internal": "Engrenagem interna", + "coupling.needs_turn_and_slide": "precisa de uma junta que gire e outra que deslize", + "coupling.needs_turns": "precisa de duas juntas que girem", + "coupling.not_round": "não é uma face circular", + "coupling.nut": "Porca", + "coupling.picked": "selecionado", + "coupling.pinion": "Pinhão", + "coupling.pinion_pick": "Pinhão (opcional)", + "coupling.pitch": "Passo", + "coupling.rack": "Cremalheira", + "coupling.radius_1": "Raio 1", + "coupling.radius_2": "Raio 2", + "dialog.computing": "Calculando… {seconds} s", + "dialog.custom_axis": "↖ Eixo personalizado", + "dialog.edit_title": "EDITAR {title}", + "dialog.select": "↖ Selecionar", + "dialog.selected_count": "↖ {count} selecionados", + "dialog.selection_guidance": "Selecione uma entrada para alterá-la. Clique nos objetos selecionados para removê-los; Ctrl/Cmd-clique também os alterna. Revise a visualização e pressione OK.", + "dialog.selection_step": "{step} de {total}: selecione {input}", + "dialog.step": "Passo", + "document.save_changes": "Salvar as alterações em {name}?", + "document.unsaved_changes": "{name} tem alterações não salvas. Se você não salvar, elas serão perdidas.", + "drive.stopped_by_contact": "Parado em {value}: {a} atravessaria {b}", + "drive.stopped_by_loop": "Parado em {value}: o laço por {joint} não pode fechar mais", + "drive.unconnected": "Não move nada: {part} não está conectado a nada fixo", + "expression, e.g. 25 mm": "expressão, p. ex. 25 mm", + "fits the face, e.g. M8": "se ajusta à face, p. ex. M8", + "from the rule": "da regra", + "home.documentation": "Documentação", + "home.in_this_browser": "neste navegador", + "home.missing": "ausente", + "home.open": "Abrir…", + "home.opened_days_ago": "Aberto há {count} dias", + "home.opened_hours_ago": "Aberto há {count} h", + "home.opened_just_now": "Aberto agora mesmo", + "home.opened_minutes_ago": "Aberto há {count} min", + "home.opening": "abrindo…", + "home.recent": "Recentes", + "home.report_issue": "Relatar um problema", + "home.samples": "Exemplos", + "home.whats_new": "Novidades", + "install.button_tip": "Instale o SolidCraft como aplicativo: ele abre em sua própria janela e funciona offline", + "install.dock_1": "Escolha Arquivo › Adicionar ao Dock.", + "install.dock_2": "Clique em Adicionar.", + "install.dock_after": "O SolidCraft passa a abrir em sua própria janela pelo Dock e funciona offline.", + "install.done": "Concluído", + "install.home_screen_1": "Toque em Compartilhar (no menu … se não estiver na barra de ferramentas).", + "install.home_screen_2": "Escolha Adicionar à Tela de Início.", + "install.home_screen_3": "Toque em Adicionar.", + "install.home_screen_after": "O SolidCraft passa a abrir pelo ícone na Tela de Início, sem as barras do Safari, e funciona offline.", + "install.menu": "Instalar aplicativo…", + "install.steps_intro": "O Safari instala aplicativos web pelos próprios menus:", + "install.title": "Instalar o SolidCraft", + "joint.from_bodies_hint": "Cada corpo selecionado vai para um novo componente próprio e permanece onde está.", + "joint.one_body": "As duas seleções estão em {body}. Selecione a segunda em outra peça.", + "joint.one_component": "Estas peças são corpos de um mesmo componente: {bodies}. Uma junta move um componente em relação a outro, então cada peça precisa de seu próprio componente.", + "nav.touchpad.help": "Segure Shift e mova o ponteiro para deslocar; segure Alt/Option e mova para orbitar. Não é preciso botão do mouse. A rolagem vertical aplica zoom.", + "nav.touchpad.label": "Touchpad", + "nav.touchpad.orbit": "Orbitar (segure Alt/Option e mova; sem botão do mouse)", + "nav.touchpad.pan": "Deslocar (segure Shift e mova; sem botão do mouse)", + "nav.touchpad.scroll": "O touchpad usa a rolagem vertical para zoom. Sua configuração de deslocamento com dois dedos é mantida para as outras predefinições.", + "nav.touchpad.zoom": "Zoom (rolagem vertical ou pinça)", + "needs two joints": "precisa de duas juntas", + "new configuration": "nova configuração", + "new name": "novo nome", + "new parameter": "novo parâmetro", + "no interference": "sem interferência", + "no joint that moves yet": "ainda não há junta que se mova", + "no picture": "sem imagem", + "pattern.alignment_2": "Alinhamento 2", + "pattern.alignment_hint": "As contagens incluem o original. Centralizar equilibra as cópias; Inverter escolhe o lado extra para totais pares.", + "pattern.direction_1": "Direção 1", + "pattern.direction_2": "Direção 2", + "pattern.flip_direction": "Inverter direção", + "pick a hole to seat it, or OK places it at the origin": "selecione um furo para assentá-la, ou OK a coloca na origem", + "pick one or two vertices, edges or faces": "selecione um ou dois vértices, arestas ou faces", + "render.background": "Fundo da cena", + "render.fit": "Enquadrar produto", + "render.image_cancel": "Cancelar renderização", + "render.image_cancelled": "Renderização cancelada", + "render.image_cancelling": "Cancelando", + "render.image_check": "Verificar pedido de renderização", + "render.image_completed": "Imagem pronta para exportar", + "render.image_encoding": "Codificando PNG", + "render.image_export": "Exportar PNG", + "render.image_failed": "Falha na renderização", + "render.image_height": "Altura", + "render.image_help": "Imagem de pré-visualização por CPU · PNG · transparência aproximada", + "render.image_overwrite": "Substituir arquivo existente", + "render.image_preparing": "Preparando imagem", + "render.image_queued": "Aguardando para renderizar", + "render.image_reduce": "Tente uma imagem menor ou uma cena mais simples.", + "render.image_running": "Renderizando imagem", + "render.image_stale": "Esta imagem usa a cena capturada antes das alterações seguintes.", + "render.image_start": "Iniciar renderização", + "render.image_supported": "O pedido de renderização é suportado; a fila e o pool de memória são verificados de novo no início.", + "render.image_title": "Renderizar imagem", + "render.image_too_large": "Acima do limite de imagem: 4096 pixels por lado e 4.194.304 pixels no total.", + "render.image_transparent": "Fundo transparente", + "render.image_unknown": "Estado da renderização indisponível", + "render.image_use_largest": "Usar o maior tamanho nesta proporção:", + "render.image_width": "Largura", + "render.navigation_help": "A câmera se move como em Projeto (Preferências › Navegação). Salvar configuração grava a câmera atual.", + "render.opaque_image_summary": "PNG de pré-visualização · PBR opaco · transparência mesclada", + "render.opaque_preview_help": "PBR opaco e cores simples; transparência mesclada. Refração, HDRI e renderização final ainda não estão disponíveis.", + "render.perspective": "Câmera em perspectiva", + "render.preview": "Pré-visualização integrada", + "render.preview_help": "Sombreamento rasterizado e transparência mesclada. Refração, HDRI e renderização final ainda não estão disponíveis.", + "render.reload_settings": "Recarregar configurações salvas", + "render.restore_camera": "Restaurar câmera salva", + "render.return_design": "Voltar ao projeto", + "render.save_setup": "Salvar configuração", + "render.setup_name": "Nome da configuração", + "render.unavailable_preview": "Esta configuração pede um perfil de pré-visualização indisponível.", + "render.unsaved": "Alterações de pré-visualização não salvas", + "render.use_preview": "Usar pré-visualização integrada", + "sample.bracket.about": "Flange base com flanges de aresta e uma bainha, com regra de 2 mm", + "sample.bracket.name": "Suporte de chapa metálica", + "sample.enclosure.about": "Caixa oca com lábio, ressaltos de parafuso, apoio e encaixe por pressão", + "sample.enclosure.name": "Gabinete plástico", + "sample.flange.about": "Cubo e flange com padrão circular controlado por um parâmetro", + "sample.flange.name": "Flange aparafusado", + "sample.hinge.about": "Dois componentes e uma junta de revolução, acionada até 60°", + "sample.hinge.name": "Montagem de dobradiça", + "sample.plate.about": "Placa paramétrica com concordâncias, furos e um ressalto furado", + "sample.plate.name": "Placa de exemplo", + "sample.vents.about": "Rasgos em padrão retangular: linhas, colunas e passo são parâmetros", + "sample.vents.name": "Placa de ventilação", + "settings.display_quality_note": "Níveis mais finos desenham faces e arestas curvas mais suaves e demoram mais para gerar a malha. Só a janela de visualização muda, nunca o projeto ou as exportações.", + "settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista. Enquanto estiver ativo, a vista é redesenhada continuamente.", + "settings.measure_axis_distances": "Mostrar distâncias X/Y/Z", + "settings.measure_axis_distances_help": "Mostrar as componentes nos eixos do mundo junto com a distância total enquanto Medir está aberto.", + "settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o SolidCraft iniciar", + "settings.msaa_reset": "{setting}: {requested}× não é suportado por este dispositivo gráfico; agora é {reset}× (suportados: {supported}).", + "settings.msaa_unsupported": "Não suportado por este dispositivo gráfico", + "settings.reset_intro": "Estas configurações não funcionam neste computador e impediriam o SolidCraft de iniciar, por isso foram alteradas:", + "settings.reset_title": "As configurações foram alteradas", + "settings.supersampling_note": "Desenha a vista em resolução maior e a filtra para baixo: arestas, linhas e grade mais suaves em telas abaixo de 4K. Custa tempo de GPU: cerca de 2,25× os pixels em 1,5× e 4× em 2×.", + "sketch.degrees_of_freedom": "{count} graus de liberdade", + "sketch.draw_curves": "Desenhar como {linetype}", + "sketch.make_selection": "Converter a seleção em {linetype}", + "sketch_text.align_center": "Centro", + "sketch_text.align_left": "Esquerda", + "sketch_text.align_right": "Direita", + "sketch_text.bold": "Negrito", + "sketch_text.builtin_font": "{family} (incorporada)", + "sketch_text.font_search": "Pesquisar fontes", + "sketch_text.italic": "Itálico", + "sketch_text.loading_fonts": "Procurando fontes instaladas…", + "sketch_text.on_path": "O texto segue seu caminho: a curva o posiciona", + "sketch_text.path_clear": "Tirar o texto da curva", + "sketch_text.path_hint": "Uma curva do esboço que o texto segue: clique aqui e depois na curva", + "sketch_text.use_font_file": "Usar o arquivo de fonte {path}", + "the parts library is not available": "a biblioteca de peças não está disponível", + "timeline.group": "Nova pasta de recursos", + "timeline.move_many": "Mover itens da linha do tempo", + "timeline.move_to_group": "Mover recursos para a pasta", + "timeline.rename_group": "Renomear pasta de recursos", + "timeline.reorder_many": "Reordenar recursos", + "timeline.ungroup": "Remover pastas de recursos", + "to the next face": "até a próxima face", + "toolbar.image": "IMAGEM", + "toolbar.render": "RENDERIZAR", + "toolbar.render_scene": "CENA", + "trace.points": "{count} pontos ao longo do caminho", + "trace.stopped_by_contact": "Termina na amostra {sample}: {a} atravessaria {b}", + "trace.stopped_by_loop": "Termina na amostra {sample}: o laço por {joint} não pode fechar mais", + "ui.1_degree_of_freedom": "1 grau de liberdade", + "ui.3d_mouse_speed": "Velocidade do mouse 3D", + "ui.3d_mouse_windows": "Mouse 3D", + "ui.3d_sketch": "Esboço 3D", + "ui.about": "Sobre", + "ui.activate": "Ativar", + "ui.active_component": "Componente ativo", + "ui.agent_chat": "Chat com agente", + "ui.agent_chat_allow": "Permitir", + "ui.agent_chat_allowed": "Permitido", + "ui.agent_chat_answered": "Resposta: {option}", + "ui.agent_chat_approval": "Permitir que o agente faça isto?", + "ui.agent_chat_attach_view": "Anexar vista", + "ui.agent_chat_attach_view_tip": "Enviar uma imagem da vista atual com a mensagem", + "ui.agent_chat_auth_agent": "Entrar com {name}", + "ui.agent_chat_auth_env": "Defina {vars} no seu ambiente e reinicie o SolidCraft.", + "ui.agent_chat_auth_needed": "O agente pede que você entre. Você entra no próprio agente; o SolidCraft nunca vê sua senha ou chaves.", + "ui.agent_chat_auth_none": "O agente não oferece forma de entrar aqui: entre do jeito descrito na documentação dele.", + "ui.agent_chat_auth_retry": "Já entrei: conectar de novo", + "ui.agent_chat_auth_terminal": "Execute isto em um terminal e depois pressione Conectar de novo:", + "ui.agent_chat_auth_title": "Entrar no agente", + "ui.agent_chat_cancel": "Cancelar", + "ui.agent_chat_changes": "Alterações: {n}, um passo de desfazer", + "ui.agent_chat_changes_of": "{n}/{limit} alterações", + "ui.agent_chat_close": "Fechar", + "ui.agent_chat_command": "Comando do agente", + "ui.agent_chat_connect": "Conectar", + "ui.agent_chat_copy": "Copiar", + "ui.agent_chat_custom_hint": "Qualquer programa que fale o Agent Client Protocol por stdin e stdout. Nenhum shell o executa.", + "ui.agent_chat_denied": "Negado", + "ui.agent_chat_deny": "Negar", + "ui.agent_chat_desktop_only": "O chat com agente precisa do aplicativo para desktop.", + "ui.agent_chat_detecting": "Procurando agentes…", + "ui.agent_chat_dock_left": "Encaixar à esquerda", + "ui.agent_chat_dock_right": "Encaixar à direita", + "ui.agent_chat_edit": "Editar", + "ui.agent_chat_exited": "O agente parou.", + "ui.agent_chat_failed_start": "O agente não pôde iniciar: {error}", + "ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do SolidCraft: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.", + "ui.agent_chat_key_in_env": "chave definida", + "ui.agent_chat_left": "Esquerda", + "ui.agent_chat_limit": "O agente atingiu o limite de {limit} alterações por mensagem. Envie outra mensagem para que ele continue.", + "ui.agent_chat_minimize": "Minimizar para uma faixa", + "ui.agent_chat_model_tip": "O modelo que o agente usa. Sua escolha é lembrada para este agente.", + "ui.agent_chat_new": "Novo chat", + "ui.agent_chat_new_confirm": "Começar um novo chat? O atual será apagado.", + "ui.agent_chat_new_yes": "Novo chat", + "ui.agent_chat_no_cli": "O solidcraft-cli não foi encontrado junto ao aplicativo, então o agente não consegue acessar o projeto.", + "ui.agent_chat_no_control": "O endpoint do chat com agente não iniciou, então o agente não consegue acessar o projeto.", + "ui.agent_chat_no_images": "Este agente não aceita imagens", + "ui.agent_chat_none_found": "Nenhum agente de programação encontrado. Instale um destes e pressione Atualizar:", + "ui.agent_chat_none_found_short": "Nenhum agente encontrado", + "ui.agent_chat_not_installed": "Não instalado", + "ui.agent_chat_not_signed_in": "não conectado", + "ui.agent_chat_permission": "O agente pede para executar: {title}", + "ui.agent_chat_pick": "Agente", + "ui.agent_chat_placeholder": "Peça ao agente para alterar o projeto…", + "ui.agent_chat_placeholder_busy": "Enfileirar uma mensagem para quando o agente terminar (Ctrl+Enter: enviar agora)", + "ui.agent_chat_privacy": "Nada vai para lugar algum além do agente que você executa, na sua própria conta.", + "ui.agent_chat_privacy_note": "O SolidCraft não envia nada a lugar nenhum: o agente que você executa fala com o próprio serviço, na sua própria conta. O SolidCraft nunca vê nem guarda chaves ou senhas.", + "ui.agent_chat_queue": "Fila", + "ui.agent_chat_queue_full": "Há mensagens demais esperando: remova uma ou aguarde o agente.", + "ui.agent_chat_queued": "Na fila", + "ui.agent_chat_queued_stopped": "Na fila (parado, não enviado)", + "ui.agent_chat_refresh": "Atualizar", + "ui.agent_chat_refused": "Não permitido para o agente: {reason}", + "ui.agent_chat_remove": "Remover da fila", + "ui.agent_chat_right": "Direita", + "ui.agent_chat_save": "Salvar", + "ui.agent_chat_send": "Enviar", + "ui.agent_chat_send_now": "Enviar agora", + "ui.agent_chat_send_now_tip": "Parar o passo atual do agente e enviar esta em seguida (Ctrl+Enter)", + "ui.agent_chat_side": "Lado do chat com agente", + "ui.agent_chat_signed_in": "conectado", + "ui.agent_chat_state_auth": "É preciso entrar", + "ui.agent_chat_state_busy": "Trabalhando…", + "ui.agent_chat_state_exited": "Agente parado", + "ui.agent_chat_state_off": "Não conectado", + "ui.agent_chat_state_ready": "Pronto", + "ui.agent_chat_state_starting": "Iniciando…", + "ui.agent_chat_stop": "Parar", + "ui.agent_chat_stop_tip": "Parar o agente e tudo o que ele iniciou", + "ui.agent_chat_stop_turn_tip": "Parar o agente (Esc). As mensagens na fila continuam na fila.", + "ui.agent_chat_turn_limit": "Alterações por mensagem", + "ui.agent_chat_undo_changes": "Desfazer estas alterações", + "ui.agent_chat_undo_label": "Chat com agente: {message}", + "ui.agent_chat_undone": "(desfeito)", + "ui.agent_chat_view_attached": "Vista anexada", + "ui.agent_cursor": "Cursor do agente", + "ui.agent_cursor_speed": "Velocidade do cursor do agente", + "ui.ai_add": "Adicionar", + "ui.ai_add_agent": "Adicionar agente…", + "ui.ai_add_agent_title": "Adicionar agente", + "ui.ai_add_api": "API compatível com OpenAI", + "ui.ai_add_custom": "Comando personalizado", + "ui.ai_add_installed": "Agente instalado", + "ui.ai_add_installed_note": "Uma entrada própria para um agente instalado, por exemplo com outros argumentos. Os agentes instalados são listados por si mesmos.", + "ui.ai_added": "adicionado", + "ui.ai_agent_mode_tip": "O modo do próprio agente: as ferramentas dele (shell, edição de arquivos) o seguem. As ferramentas do SolidCraft seguem as permissões em Preferências > IA.", + "ui.ai_agents": "Agentes", + "ui.ai_always_allow": "Sempre permitir", + "ui.ai_always_allow_tip": "Permitir {tool} para este agente daqui em diante, sem perguntar", + "ui.ai_always_allowed": "Sempre permitidos", + "ui.ai_always_allowed_none": "Nada é sempre permitido ainda. As respostas \"Sempre permitir\" que você der no chat aparecem aqui.", + "ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})", + "ui.ai_auto_mode": "sua configuração de permissão", + "ui.ai_auto_ours": "o SolidCraft verifica as próprias ferramentas", + "ui.ai_auto_remembered": "você escolheu Sempre permitir", + "ui.ai_bad_url": "Digite uma URL que comece com http:// ou https://", + "ui.ai_base_url": "URL base", + "ui.ai_button": "IA", + "ui.ai_copy_suffix": "cópia", + "ui.ai_custom_url": "URL personalizada", + "ui.ai_default": "Padrão", + "ui.ai_http_busy": "o endpoint está ocupado ou acima do limite; tente de novo mais tarde", + "ui.ai_http_key": "o endpoint recusou a chave: verifique-a em Preferências > IA", + "ui.ai_http_not_found": "endpoint ou modelo inexistente: verifique a URL base e o nome do modelo", + "ui.ai_key": "Chave de API", + "ui.ai_key_hint": "opcional; guardada no chaveiro do sistema", + "ui.ai_key_needs_https": "Uma chave só é enviada por HTTPS ou para este computador.", + "ui.ai_key_note": "Guardada no chaveiro do sistema, nunca nas configurações do SolidCraft. Deixe em branco para um servidor local.", + "ui.ai_keychain_error": "O chaveiro do sistema recusou: {error}", + "ui.ai_mode_all": "Permitir tudo", + "ui.ai_mode_all_note": "Nada pergunta antes. Os relatórios de falha continuam fora de alcance e os limites de pasta ainda valem.", + "ui.ai_mode_all_warning": "O agente pode salvar, sobrescrever, exportar e substituir seu projeto sem perguntar. Use isto só com um agente em que você confie e em projetos que você já salvou.", + "ui.ai_mode_ask": "Perguntar sempre", + "ui.ai_mode_ask_note": "Cada alteração no projeto e cada ação com arquivos aguarda o seu Permitir.", + "ui.ai_mode_edits": "Permitir edições do modelo automaticamente", + "ui.ai_mode_edits_note": "Comandos de modelagem rodam sem perguntar; salvar, abrir, exportar e outras ações com arquivos ou destrutivas ainda perguntam.", + "ui.ai_mode_global": "Como definido acima", + "ui.ai_model": "Modelo", + "ui.ai_modes_scope": "Estas configurações valem para as ferramentas do SolidCraft. As ferramentas do próprio agente (shell, edição de arquivos) seguem o modo do agente, que você escolhe no cabeçalho do painel de chat quando o agente tem modos.", + "ui.ai_name": "Nome", + "ui.ai_need_command": "Digite o comando que inicia o agente", + "ui.ai_need_model": "Digite o nome do modelo", + "ui.ai_no_answer": "O agente não respondeu a tempo", + "ui.ai_no_http": "Esta versão não consegue acessar endpoints de API", + "ui.ai_no_keychain": "O chaveiro do sistema não está disponível, então não é possível guardar uma chave. Um servidor local sem chave ainda funciona.", + "ui.ai_panel": "Painel", + "ui.ai_permissions": "Permissões", + "ui.ai_preset": "Predefinição", + "ui.ai_remove": "Remover", + "ui.ai_request_limit": "parou após 24 requisições para uma mensagem; envie outra mensagem para continuar", + "ui.ai_reset": "Redefinir", + "ui.ai_settings": "Configurações de IA…", + "ui.ai_show_features": "Mostrar recursos de IA", + "ui.ai_show_features_note": "O chat com agente: um painel onde um agente de programação que você já usa, ou um endpoint de modelo que você escolher, trabalha no projeto aberto. Desativado: sem botão de IA, atalho, item de menu ou painel, e nada é procurado nem iniciado.", + "ui.ai_switch_tip": "Trocar o agente desta conversa", + "ui.ai_test": "Testar", + "ui.ai_test_acp_ok": "Responde: {agent}", + "ui.ai_test_acp_tip": "Inicia o agente por um instante e pede uma sessão; depois o encerra", + "ui.ai_test_api_tip": "Pede ao endpoint a lista de modelos", + "ui.ai_test_no_model": "Acessado: {count} modelos, mas não {model}", + "ui.ai_test_ok": "Acessado: {count} modelos", + "ui.ai_testing": "Testando…", + "ui.ai_tool_done": "concluído", + "ui.ai_tool_failed": "falhou", + "ui.ai_tool_running": "executando", + "ui.ai_tool_unfold": "Clique para ver o que retornou", + "ui.ai_tool_waiting": "aguardando", + "ui.ai_toolbar_button": "Mostrar botão na barra de ferramentas", + "ui.ai_toolbar_button_note": "O botão de IA no fim direito da barra de ferramentas. Ctrl+Shift+A e o menu Exibir ainda abrem o painel.", + "ui.ai_undo_tip": "Desfazer tudo o que o agente alterou nesta mensagem, em um passo", + "ui.all": "Tudo", + "ui.analysis": "Análise", + "ui.angle": "Ângulo", + "ui.angle_decimals": "Casas decimais de ângulo", + "ui.animateJoint": "Animar junta", + "ui.animate_view_changes": "Animar mudanças de vista", + "ui.animation": "Animação", + "ui.animation_speed": "Velocidade da animação", + "ui.anti_aliasing": "Antisserrilhamento", + "ui.apply": "Aplicar", + "ui.arc": "Arco", + "ui.assemble": "Montar", + "ui.autosave_every": "Salvar automaticamente a cada", + "ui.axes": "Eixos", + "ui.axis": "Eixo", + "ui.base_point": "Ponto base", + "ui.blocked": "Bloqueado", + "ui.bodies": "Corpos", + "ui.box": "Caixa", + "ui.break": "Quebrar", + "ui.calibrate": "Calibrar", + "ui.cancel": "Cancelar", + "ui.canvases": "Telas de referência", + "ui.center": "Centro", + "ui.centerline": "Linha de centro", + "ui.chain": "Cadeia", + "ui.chamfer": "Chanfro", + "ui.chamfer_type": "Tipo de chanfro", + "ui.change_workspace": "Mudar espaço de trabalho", + "ui.chord_length": "Comprimento da corda", + "ui.circle": "Círculo", + "ui.circular_pattern": "Padrão circular", + "ui.clear": "Limpar", + "ui.clear_recent": "Limpar recentes", + "ui.clear_the_selection": "Limpar a seleção", + "ui.click_selects_whole_bodies": "O clique seleciona corpos inteiros", + "ui.click_then_press_key": "Clique e depois pressione a nova tecla", + "ui.close": "Fechar", + "ui.coil": "Espiral", + "ui.coincident": "Coincidente", + "ui.collapse": "Recolher", + "ui.collinear": "Colinear", + "ui.colour_vision": "Visão de cores", + "ui.colour_vision_deutan": "Deutan", + "ui.colour_vision_deutan_tip": "Para percepção do verde fraca ou ausente", + "ui.colour_vision_failed": "Falhou", + "ui.colour_vision_fixed": "Fixo", + "ui.colour_vision_mode": "Modo", + "ui.colour_vision_note": "Muda as cores que carregam significado: estados do esboço, eixos, dobras, análises e ícones. Se você usa filtros de cor do Windows, deixe isto desativado.", + "ui.colour_vision_off": "Desativado", + "ui.colour_vision_off_tip": "As cores do próprio tema", + "ui.colour_vision_projected": "Projetado", + "ui.colour_vision_protan": "Protan", + "ui.colour_vision_protan_tip": "Para percepção do vermelho fraca ou ausente", + "ui.colour_vision_sample": "Amostra", + "ui.colour_vision_tritan": "Tritan", + "ui.colour_vision_tritan_tip": "Para percepção do azul fraca ou ausente", + "ui.combine": "Combinar", + "ui.coming_later": "em breve", + "ui.command_done": "{command} concluído", + "ui.command_palette_hint": "Digite um comando, p. ex. {example} — ou pressione S para pesquisar", + "ui.concentric": "Concêntrico", + "ui.configurations": "Configurações", + "ui.conic_curve": "Curva cônica", + "ui.constant": "Constante", + "ui.constraints_conflict": "Restrições em conflito", + "ui.construct": "Construir", + "ui.construction": "Construção", + "ui.context_menu": "Menu de contexto", + "ui.copy": "Copiar", + "ui.corner": "Canto", + "ui.corners": "Cantos", + "ui.counterbore": "Rebaixo", + "ui.countersink": "Escareado", + "ui.create": "Criar", + "ui.create_copy": "Criar cópia", + "ui.create_sketch": "Criar esboço", + "ui.current": "atual", + "ui.curvature": "Curvatura", + "ui.curvature_g2": "Curvatura (G2)", + "ui.curves": "Curvas", + "ui.custom_program": "Personalizado…", + "ui.custom_program_tip": "Escolha um programa fatiador para adicionar à lista", + "ui.customizeToolbar": "Personalizar barra de ferramentas…", + "ui.cut": "Recortar", + "ui.cylinder": "Cilindro", + "ui.dark": "Escuro", + "ui.default_modelling_orientation": "Orientação padrão de modelagem", + "ui.degrees_of_freedom": "graus de liberdade", + "ui.delete": "Excluir", + "ui.depth": "Profundidade", + "ui.design": "Projeto", + "ui.design_properties.author": "Autor", + "ui.design_properties.description": "Descrição", + "ui.design_properties.exports": "As exportações STEP, IGES e 3MF levam estes dados onde o formato tem lugar para eles.", + "ui.design_properties.license": "Licença", + "ui.design_properties.not_text": "Salvo como algo diferente de texto; digitar aqui o substitui.", + "ui.design_properties.organization": "Organização", + "ui.design_properties.others": "Dados de outras ferramentas", + "ui.design_properties.others_note": "Mantidos no projeto como estão e nunca exportados.", + "ui.design_properties.part_number": "Número da peça", + "ui.design_properties.project": "Projeto", + "ui.design_properties.revision": "Revisão", + "ui.diameter": "Diâmetro", + "ui.dimension": "Cota", + "ui.direction": "Direção", + "ui.display_quality": "Qualidade de exibição", + "ui.display_quality_coarse": "Grosseira", + "ui.display_quality_fine": "Fina", + "ui.display_quality_normal": "Normal", + "ui.display_quality_ultra": "Ultra", + "ui.distance": "Distância", + "ui.distance_1": "Distância 1", + "ui.distance_2": "Distância 2", + "ui.document_settings": "Configurações do documento", + "ui.don_t_save": "Não salvar", + "ui.draft": "Ângulo de saída", + "ui.drawing": "Desenho", + "ui.drill_point": "Ponta de broca", + "ui.edit": "Editar", + "ui.editJointLimits": "Editar limites da junta", + "ui.edit_section_analysis": "Editar análise de seção", + "ui.ellipse": "Elipse", + "ui.end_radius": "Raio final", + "ui.end_scale": "Escala final", + "ui.entities": "Entidades", + "ui.equal": "Igual", + "ui.equivalent_radius": "Raio equivalente", + "ui.expand": "Expandir", + "ui.exportDxf": "Exportar DXF…", + "ui.export_ellipsis": "Exportar...", + "ui.export_ellipsis_keymap": "Exportar…", + "ui.export_keymap_tip": "Teclas, favoritos da caixa S e a barra de ferramentas, como um arquivo", + "ui.extend": "Estender", + "ui.extent_type": "Tipo de extensão", + "ui.extrude": "Extrudar", + "ui.factor": "Fator", + "ui.feature_history": "Histórico de recursos", + "ui.feature_options": "Opções do recurso", + "ui.features": "Recursos", + "ui.file": "Arquivo", + "ui.fillet": "Concordância", + "ui.filter_commands_or_keys": "Filtrar comandos ou teclas", + "ui.findInTimeline": "Localizar na linha do tempo", + "ui.findInWindow": "Localizar na janela", + "ui.finish_sketch": "Concluir esboço", + "ui.fit": "Ajustar", + "ui.fix_unfix": "Fixar/Liberar", + "ui.flat": "Plano", + "ui.flip": "Inverter", + "ui.format": "Formato", + "ui.frame_rate": "Taxa de quadros", + "ui.front": "Frente", + "ui.fully_constrained": "Totalmente restrito", + "ui.general": "Geral", + "ui.graphics": "Gráficos", + "ui.ground_shadow": "Sombra no chão", + "ui.groupSelected": "Agrupar seleção", + "ui.heading_add_ins": "COMPLEMENTOS", + "ui.heading_assemble": "MONTAR", + "ui.heading_box": "CAIXA", + "ui.heading_chamfer": "CHANFRO", + "ui.heading_circular_pattern": "PADRÃO CIRCULAR", + "ui.heading_coil": "ESPIRAL", + "ui.heading_combine": "COMBINAR", + "ui.heading_configure": "CONFIGURAR", + "ui.heading_constraints": "RESTRIÇÕES", + "ui.heading_construct": "CONSTRUIR", + "ui.heading_create": "CRIAR", + "ui.heading_create_sketch": "CRIAR ESBOÇO", + "ui.heading_cylinder": "CILINDRO", + "ui.heading_design": "PROJETO", + "ui.heading_draft": "ÂNGULO DE SAÍDA", + "ui.heading_edit": "EDITAR", + "ui.heading_extrude": "EXTRUDAR", + "ui.heading_fillet": "CONCORDÂNCIA", + "ui.heading_finish_sketch": "CONCLUIR ESBOÇO", + "ui.heading_front": "FRENTE", + "ui.heading_hole": "FURO", + "ui.heading_home": "INÍCIO", + "ui.heading_insert": "INSERIR", + "ui.heading_inspect": "INSPECIONAR", + "ui.heading_loft": "LOFT", + "ui.heading_make": "FABRICAR", + "ui.heading_measure": "MEDIR", + "ui.heading_mesh": "MALHA", + "ui.heading_mirror": "ESPELHAR", + "ui.heading_modify": "MODIFICAR", + "ui.heading_move": "MOVER", + "ui.heading_move_copy": "MOVER/COPIAR", + "ui.heading_offset": "DESLOCAMENTO", + "ui.heading_offset_plane": "PLANO DESLOCADO", + "ui.heading_ok": "OK", + "ui.heading_parameters": "PARÂMETROS", + "ui.heading_pattern": "PADRÃO", + "ui.heading_pattern_on_path": "PADRÃO NO CAMINHO", + "ui.heading_pipe": "TUBO", + "ui.heading_plane_at_angle": "PLANO EM ÂNGULO", + "ui.heading_plastic": "PLÁSTICO", + "ui.heading_position": "POSIÇÃO", + "ui.heading_prepare": "PREPARAR", + "ui.heading_rectangular_pattern": "PADRÃO RETANGULAR", + "ui.heading_revolve": "REVOLUÇÃO", + "ui.heading_right": "DIREITA", + "ui.heading_save_mesh": "SALVAR COMO MALHA", + "ui.heading_scale": "ESCALA", + "ui.heading_section_analysis": "ANÁLISE DE SEÇÃO", + "ui.heading_select": "SELECIONAR", + "ui.heading_setup": "CONFIGURAÇÃO", + "ui.heading_sheet_metal": "CHAPA METÁLICA", + "ui.heading_shell": "CASCA", + "ui.heading_sketch": "ESBOÇO", + "ui.heading_sketch_palette": "PALETA DE ESBOÇO", + "ui.heading_sketch_scale": "ESCALA DO ESBOÇO", + "ui.heading_solid": "SÓLIDO", + "ui.heading_sphere": "ESFERA", + "ui.heading_split_body": "DIVIDIR CORPO", + "ui.heading_surface": "SUPERFÍCIE", + "ui.heading_sweep": "VARREDURA", + "ui.heading_thread": "ROSCA", + "ui.heading_top": "TOPO", + "ui.heading_torus": "TORO", + "ui.heading_utilities": "UTILITÁRIOS", + "ui.heading_utility": "UTILITÁRIO", + "ui.help": "Ajuda", + "ui.hide": "Ocultar", + "ui.hideAxes": "Ocultar eixos", + "ui.hideDimension": "Ocultar cota", + "ui.hideOrigin": "Ocultar origem", + "ui.hidePlanes": "Ocultar planos", + "ui.hideProfile": "Ocultar perfil", + "ui.hole": "Furo", + "ui.hole_tap_type": "Tipo de rosca do furo", + "ui.hole_type": "Tipo de furo", + "ui.home": "Início", + "ui.homeView": "Vista inicial", + "ui.horizontal_vertical": "Horizontal/Vertical", + "ui.import_ellipsis": "Importar…", + "ui.infinite": "Infinita", + "ui.insert": "Inserir", + "ui.inspect": "Inspecionar", + "ui.intersect": "Interseção", + "ui.isolate": "Isolar", + "ui.join": "Unir", + "ui.joints": "Juntas", + "ui.keyboard_shortcuts": "Atalhos de teclado", + "ui.language": "Idioma", + "ui.length_decimals": "Casas decimais de comprimento", + "ui.light": "Claro", + "ui.line": "Linha", + "ui.linetype": "Tipo de linha", + "ui.lock": "Bloquear", + "ui.loft": "Loft", + "ui.look_at": "Olhar para", + "ui.look_at_anim_desc": "Vista do eixo, Olhar para, Início e Ajustar", + "ui.manufacture": "Fabricação", + "ui.material_review.axes": "Eixos locais do material; nenhuma orientação de corpo é atribuída.", + "ui.material_review.claims": "Fontes e licenças são declarações não verificadas. Valores ausentes continuam desconhecidos.", + "ui.material_review.compressive_strength": "Resistência à compressão", + "ui.material_review.conditions": "Condições declaradas", + "ui.material_review.density": "Densidade", + "ui.material_review.derived": "Módulos isotrópicos derivados", + "ui.material_review.elasticity": "Elasticidade", + "ui.material_review.input": "Cole um cartão de material schema-1 (JSON)", + "ui.material_review.limit": "O cartão excede o limite de entrada de 256 KiB.", + "ui.material_review.moisture_unit": "kg de água / kg de material seco", + "ui.material_review.properties": "Propriedades", + "ui.material_review.review": "Revisar cartão", + "ui.material_review.scope": "Apenas candidato: não é atribuído nem salvo. Validação numérica não é certificação de engenharia.", + "ui.material_review.shear_strength": "Resistência ao cisalhamento", + "ui.material_review.source": "Fonte", + "ui.material_review.specific_heat": "Calor específico", + "ui.material_review.tensile_strength": "Resistência à tração", + "ui.material_review.thermal_conductivity": "Condutividade térmica", + "ui.material_review.thermal_expansion": "Dilatação térmica", + "ui.material_review.title": "Revisar dados do material físico", + "ui.material_review.unknown": "Desconhecido", + "ui.material_review.yield_strength": "Limite de escoamento", + "ui.measure": "Medir", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF com marcação de faces", + "ui.mesh_format_stl": "STL", + "ui.midpoint": "Ponto médio", + "ui.mirror": "Espelhar", + "ui.mirror_line": "Linha de espelho", + "ui.modify": "Modificar", + "ui.move": "Mover", + "ui.multiple": "Múltiplo", + "ui.named_views": "Vistas nomeadas", + "ui.navigation": "Navegação", + "ui.needs_design": "{command}: abra um projeto primeiro", + "ui.newSketchGroup": "Novo grupo de esboços", + "ui.new_body": "Novo corpo", + "ui.new_design": "Novo projeto", + "ui.no_slicer_found": "Nenhum fatiador encontrado", + "ui.normal": "Normal", + "ui.normalConstruction": "Normal / Construção", + "ui.not_a_program": "{path} não é um programa que possa ser iniciado", + "ui.object_type": "Tipo de objeto", + "ui.objects": "Objetos", + "ui.offer_to_stand_up_y_up_files": "Oferecer colocar em pé arquivos com Y para cima", + "ui.offset": "Deslocamento", + "ui.offset_plane": "Plano deslocado", + "ui.one_side": "Um lado", + "ui.open": "Abrir", + "ui.open_ellipsis": "Abrir...", + "ui.open_preferences": "Abrir preferências", + "ui.open_recent": "Abrir recente", + "ui.operation": "Operação", + "ui.options": "Opções", + "ui.orbit": "Orbitar", + "ui.orbit_around": "Orbitar em torno de", + "ui.orientation": "Orientação", + "ui.origin": "Origem", + "ui.palette_points": "Pontos", + "ui.palette_profile": "Perfil", + "ui.pan": "Deslocar", + "ui.pan_zoom_orbit": "Deslocar, zoom, orbitar", + "ui.parallel": "Paralelo", + "ui.parameters": "Parâmetros", + "ui.paste": "Colar", + "ui.path": "Caminho", + "ui.pattern_on_path": "Padrão no caminho", + "ui.perpendicular": "Perpendicular", + "ui.perspective_view": "Vista em perspectiva", + "ui.pinToShortcuts": "Fixar nos atalhos", + "ui.pinToToolbar": "Fixar na barra de ferramentas", + "ui.pipe": "Tubo", + "ui.placement": "Posicionamento", + "ui.plane": "Plano", + "ui.plane_at_angle": "Plano em ângulo", + "ui.point": "Ponto", + "ui.point_under_the_cursor": "Ponto sob o cursor", + "ui.points": "Pontos", + "ui.polygon": "Polígono", + "ui.preferences": "Preferências", + "ui.press_a_key": "pressione uma tecla…", + "ui.profile": "Perfil", + "ui.profile_plane": "Plano do perfil", + "ui.profiles": "Perfis", + "ui.project": "Projetar", + "ui.project_face_edges_sketch": "Projetar arestas da face em novos esboços", + "ui.properties": "Propriedades", + "ui.pull_direction": "Direção de extração", + "ui.quantity": "Quantidade", + "ui.quantity_2": "Quantidade 2", + "ui.quit": "Sair", + "ui.radial_context_menu": "Menu de contexto radial", + "ui.radius": "Raio", + "ui.radius_type": "Tipo de raio", + "ui.reachable": "Alcançável", + "ui.rectangle": "Retângulo", + "ui.rectangular_pattern": "Padrão retangular", + "ui.redo": "Refazer", + "ui.removeFromToolbar": "Remover da barra de ferramentas", + "ui.removeSection": "Remover seção", + "ui.render": "Renderizar", + "ui.renderer": "Renderizador", + "ui.repeat": "Repetir", + "ui.report_a_bug": "Relatar um bug", + "ui.reset": "Redefinir", + "ui.reset_all": "Redefinir tudo", + "ui.reset_the_settings_on_this_page": "Redefinir as configurações desta página", + "ui.restore": "Restaurar", + "ui.restore_defaults": "Restaurar padrões", + "ui.reverse_zoom_direction": "Inverter direção do zoom", + "ui.revolve": "Revolução", + "ui.right": "Direita", + "ui.save": "Salvar", + "ui.save_as": "Salvar como", + "ui.save_as_ellipsis": "Salvar como...", + "ui.save_mesh_no_slicer": "Nenhum fatiador encontrado: escolha Personalizado… e selecione seu programa fatiador, ou desative Enviar ao fatiador", + "ui.save_mesh_pick_bodies": "Selecione os corpos a salvar", + "ui.save_mesh_temp_failed": "Não foi possível criar {folder}: {error}", + "ui.saved_to": "Salvo {path}", + "ui.scale": "Escala", + "ui.section_analysis": "Análise de seção", + "ui.select": "Selecionar", + "ui.selectOther": "Selecionar outro…", + "ui.selectTangentChain": "Selecionar cadeia tangente", + "ui.select_other_hint": "Selecionar outro: mantenha o botão esquerdo parado sobre o modelo (ou clique com o botão direito › Selecionar outro…) para listar tudo sob o cursor; clique de novo no mesmo ponto para selecionar o próximo item ali.", + "ui.selection": "Seleção", + "ui.send": "Enviar", + "ui.send_to_slicer": "Enviar ao fatiador", + "ui.sent_to_slicer": "Enviado para {slicer}", + "ui.shell": "Casca", + "ui.show": "Mostrar", + "ui.showAll": "Mostrar tudo", + "ui.showAllBodies": "Mostrar todos os corpos", + "ui.showAxes": "Mostrar eixos", + "ui.showDimension": "Mostrar cota", + "ui.showOrigin": "Mostrar origem", + "ui.showPlanes": "Mostrar planos", + "ui.show_constraints": "Restrições", + "ui.show_construction_geometries": "Geometrias de construção", + "ui.show_dimensions": "Cotas", + "ui.show_points": "Mostrar pontos", + "ui.show_profile": "Mostrar perfil", + "ui.show_projected_geometries": "Geometrias projetadas", + "ui.simple": "Simples", + "ui.simulation": "Simulação", + "ui.single": "Único", + "ui.sketch": "Esboço", + "ui.sketch_dimension": "Cota do esboço", + "ui.sketch_grid": "Grade do esboço", + "ui.sketch_scale": "Escala do esboço", + "ui.sketch_status": "Estado do esboço", + "ui.sketches": "Esboços", + "ui.slice": "Fatiar", + "ui.slicer": "Fatiador", + "ui.slicer_delete_tip": "{program}\nPressione Delete para removê-lo da lista", + "ui.slot": "Rasgo", + "ui.smooth": "Suave", + "ui.smooth_wheel_zoom": "Zoom suave com a roda", + "ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização", + "ui.snap": "Encaixe", + "ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.", + "ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse", + "ui.spacing": "Espaçamento", + "ui.spacing_2": "Espaçamento 2", + "ui.sphere": "Esfera", + "ui.spline": "Spline", + "ui.split_body": "Dividir corpo", + "ui.stand_up_tip": "Depois que um arquivo STEP ou IGES abrir com Z para cima, oferecer um quarto de volta em torno de X", + "ui.start": "Início", + "ui.start_radius": "Raio inicial", + "ui.supersampling": "Supersampling (SSAA)", + "ui.supersampling_adaptive": "Só quando a vista está parada", + "ui.supersampling_off": "Desativado (1×)", + "ui.suppress": "Suprimir", + "ui.sweep": "Varredura", + "ui.sweep_follow_direction": "Seguir direção", + "ui.sweep_keep_as_drawn": "Manter como desenhado", + "ui.sweep_turn_with_path": "Girar com o caminho", + "ui.symmetric": "Simétrico", + "ui.symmetry": "Simetria", + "ui.system": "Sistema", + "ui.tangent": "Tangente", + "ui.taper_angle": "Ângulo de inclinação", + "ui.tapped": "Roscado", + "ui.text": "Texto", + "ui.theme": "Tema", + "ui.thickness": "Espessura", + "ui.thread": "Rosca", + "ui.timeline_bottom": "Linha do tempo inferior", + "ui.timeline_left_note": "O layout à esquerda mostra a barra de retrocesso acima do navegador de peças e cena.", + "ui.timeline_left_sidebar": "Barra lateral esquerda", + "ui.tip_angle": "Ângulo da ponta", + "ui.to_object": "Até o objeto", + "ui.tool_direction": "Direção da ferramenta", + "ui.top": "Topo", + "ui.torus": "Toro", + "ui.total_angle": "Ângulo total", + "ui.trackpad_pan_tip": "Gestos do trackpad deslocam (desativado: dão zoom como a roda); a roda do mouse dá zoom, a roda do polegar desloca para os lados, a pinça sempre dá zoom", + "ui.trim": "Aparar", + "ui.twist": "Torção", + "ui.two_finger_scroll_pans": "Rolagem com dois dedos desloca", + "ui.two_sides": "Dois lados", + "ui.ui_scale": "Escala da interface", + "ui.undo": "Desfazer", + "ui.unground": "Liberar do solo", + "ui.unisolate": "Desfazer isolamento", + "ui.units": "Unidades", + "ui.units_of_new_designs": "Unidades de novos projetos", + "ui.unlock": "Desbloquear", + "ui.unsuppress": "Reativar", + "ui.untitled": "Sem título", + "ui.up_axis_note": "Qual eixo aponta para cima nas vistas (vista do eixo, Início, Olhar para). Os projetos não mudam.", + "ui.updateToCurrentView": "Atualizar para a vista atual", + "ui.variable": "Variável", + "ui.view_centre": "Centro da vista", + "ui.visual_shaded_hidden_edges": "Sombreado com arestas ocultas", + "ui.visual_shaded_with_edges": "Sombreado com arestas", + "ui.visual_style": "Estilo visual", + "ui.visual_wireframe": "Aramado", + "ui.workspace": "espaço de trabalho", + "ui.x_distance": "Distância X", + "ui.y_distance": "Distância Y", + "ui.y_up": "Y para cima", + "ui.z_up": "Z para cima", + "ui.zoom": "Zoom", + "ui.zoom_to_cursor_tip": "A roda dá zoom em direção ao ponto sob o cursor (desativado: em direção ao centro da vista)", + "ui.zoom_to_the_cursor": "Zoom em direção ao cursor", + "update.available": "O SolidCraft {version} está disponível", + "update.button": "Atualizar", + "update.button_tip": "O SolidCraft {version} está disponível", + "update.button_version": "Atualizar para {version}", + "update.check_automatically": "Verificar atualizações automaticamente", + "update.check_now": "Verificar agora", + "update.checking": "Verificando…", + "update.copy": "Copiar", + "update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.", + "update.download": "Baixar", + "update.download_open": "Baixar e abrir", + "update.downloading": "Baixando e verificando… {done} de {total} MB", + "update.failed": "Não foi possível verificar: {error}", + "update.file": "Baixar obtém {file}.", + "update.install_failed": "A atualização não foi instalada: {error}. Nada foi alterado; Baixar obtém o pacote em vez disso.", + "update.install_msi": "Instalar (fecha o SolidCraft)", + "update.installed": "O SolidCraft {version} está instalado. Reinicie para usá-lo.", + "update.installer_started": "O instalador começa quando o SolidCraft fechar.", + "update.later": "Lembrar mais tarde", + "update.no_notes": "Esta versão não tem notas.", + "update.package_hint": "Esta cópia veio de um {kind}. Baixe o novo pacote e instale-o com:", + "update.prefs_dev": "Esta é uma versão de desenvolvimento ({version}): ela não verifica atualizações.", + "update.prefs_flatpak": "O Flatpak atualiza esta cópia ({version}): execute {command} ou use sua central de programas. O SolidCraft não verifica.", + "update.prefs_label": "Atualizações", + "update.prefs_note": "Este é o SolidCraft {version} ({kind}). Uma verificação pergunta ao GitHub pela versão mais recente e não envia mais nada.", + "update.release_page": "Abrir página da versão", + "update.restart": "Reiniciar agora", + "update.restart_later": "Depois", + "update.show": "Mostrar a atualização para {version}", + "update.skip": "Pular esta versão", + "update.system_hint": "Esta cópia é um {kind}. Atualize-a com:", + "update.title": "Atualização disponível", + "update.up_to_date": "O SolidCraft está atualizado.", + "update.update_now": "Atualizar agora", + "update.you_have": "Você tem a {version}." +} diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 30f1ab3b..b84919c1 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -2,6 +2,7 @@ "0 turns it off": "0 отключает", "A new configuration, a copy of the active one": "Новая конфигурация — копия активной", "About SolidCraft": "О SolidCraft", + "Acceleration": "Ускорение", "Active": "Активный", "Active rule": "Активное правило", "Add": "Добавить", @@ -9,8 +10,10 @@ "Add Mid Radius": "Добавить средний радиус", "Add Row": "Добавить строку", "Add condition": "Добавить условие", + "Against": "Против", "Alignment": "Выравнивание", "All Sketch Points": "Все точки эскиза", + "All components": "Все компоненты", "All open designs": "Все открытые дизайны", "Allowed expressions, separated by semicolons": "Разрешённые выражения через точку с запятой", "Also deletes": "Также удаляет", @@ -49,6 +52,7 @@ "Configuration rules": "Правила конфигурации", "Construction": "Вспомогательная", "Contact": "Контакт", + "Contact sets": "Наборы контактов", "Continuity": "Непрерывность", "Copy Error Message": "Скопировать сообщение об ошибке", "Copy Warning Message": "Скопировать предупреждение", @@ -76,11 +80,13 @@ "Double-click to rename": "Двойной щелчок — переименовать", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего", "Drive Joint": "Привод сопряжения", + "Duration": "Длительность", "Edges": "Рёбра", "Edit Profile Sketch": "Редактировать эскиз профиля", "Enable rule": "Включить правило", "Enabled": "Включено", "Explode": "Разобрать", + "Export Animation…": "Экспортировать анимацию…", "Extents": "Пределы", "Face attachment lost": "Привязка к грани потеряна", "Faces": "Грани", @@ -125,6 +131,7 @@ "Move earlier": "Сдвинуть раньше", "Move later": "Сдвинуть позже", "Name": "Имя", + "New study": "Новое исследование", "No crash reports.": "Нет отчётов о сбоях.", "No matching appearances": "Нет подходящих внешних видов", "Not available yet": "Пока недоступно", @@ -138,14 +145,17 @@ "Path": "Траектория", "Per Edge": "По каждому ребру", "Pick the tool bodies": "Выберите тела-инструменты", + "Picked point": "Выбранная точка", "Place at the origin instead": "Разместить в начале координат", "Playback": "Воспроизведение", + "Plot": "График", "Position": "Положение", "Position (0 to 1)": "Положение (от 0 до 1)", "Position (G0)": "Положение (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Положение вдоль ребра: 0 — начало, 1 — конец", "Preview Row": "Строка предпросмотра", "Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.", + "Quantity": "Величина", "RECENT": "НЕДАВНИЕ", "Radius Fit": "Подобрать радиус", "Ratio": "Отношение", @@ -177,6 +187,7 @@ "Round": "Круглая", "Rule": "Правило", "Save Rule": "Сохранить правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Сохранить исследование как анимированный GIF, как его показывает видовой экран", "Saved preview. Geometry is being checked before editing.": "Сохранённый предпросмотр. Геометрия проверяется перед редактированием.", "Scale factor": "Коэффициент масштаба", "Search": "Поиск", @@ -205,6 +216,7 @@ "Stand this part up?": "Поставить эту деталь вертикально?", "Step": "Шаг", "Steps": "Шаги", + "Study": "Исследование", "Style": "Стиль", "Suppressed": "Подавлено", "Swap Ends": "Поменять концы", @@ -229,6 +241,7 @@ "Use selected face as fixed plane": "Использовать выбранную грань как фиксированную плоскость", "VIEW": "ВИД", "Value": "Значение", + "Velocity": "Скорость", "Vertices": "Вершины", "View navigator": "Навигатор видов", "Volume": "Объём", @@ -249,6 +262,13 @@ "browser.precision": "Точность: {precision}", "browser.sketch_name": "Эскиз{number}", "browser.units": "Единицы: {units}", + "cam.flat": "Плоский", + "cam.follower": "Толкатель", + "cam.knife": "Острие", + "cam.needs_joints": "нужны вращающееся и скользящее или качающееся сопряжения", + "cam.roller": "Ролик", + "cam.roller_pick": "Ролик (необязательно)", + "cam.tip_pick": "Острие (необязательно)", "cmd.appearance_assign": "Внешний вид", "cmd.appearance_library": "Библиотека внешних видов", "cmd.appearance_list": "Список внешних видов", @@ -352,6 +372,7 @@ "cmd.inspect_minimum_radius": "Анализ минимального радиуса", "cmd.inspect_zebra": "Зебра-анализ", "cmd.joint_as_built": "Сопряжение на месте", + "cmd.joint_cam": "Кулачок", "cmd.joint_create": "Сопряжение", "cmd.joint_delete": "Удалить сопряжение", "cmd.joint_drive": "Привод сопряжений", @@ -378,8 +399,11 @@ "cmd.model_threads": "Список резьб", "cmd.motion_delete": "Удалить исследование движения", "cmd.motion_export": "Экспортировать исследование движения", + "cmd.motion_export_animation": "Экспортировать анимацию движения", + "cmd.motion_interference": "Столкновения в исследовании движения", "cmd.motion_list": "Список исследований движения", "cmd.motion_play": "Воспроизвести исследование движения", + "cmd.motion_plot": "График исследования движения", "cmd.motion_study": "Исследование движения", "cmd.motion_trace": "Траектория движения", "cmd.motion_trace_keep": "Сохранить траекторию движения", @@ -608,6 +632,10 @@ "joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте.", "joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.", "joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.", + "motion.checked_to": "Проверено только до шага {step}: проверка исчерпала свой бюджет работы", + "motion.collision": "{a} · {b}: шаги {steps}", + "motion.duration_hint": "секунды (пусто: на шаг)", + "motion.no_collisions": "За всё исследование столкновений нет", "needs two joints": "нужны два сопряжения", "new configuration": "новая конфигурация", "new name": "новое имя", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index e916335a..001f671b 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "Etkin konfigürasyonun kopyası olan yeni bir konfigürasyon", "AI": "AI", "About SolidCraft": "SolidCraft hakkında", + "Acceleration": "İvme", "Active": "Etkin", "Active rule": "Etkin kural", "Add": "Ekle", @@ -14,8 +15,10 @@ "Add Mid Radius": "Ara yarıçap ekle", "Add Row": "Satır ekle", "Add condition": "Koşul ekle", + "Against": "Karşı", "Alignment": "Hizalama", "All Sketch Points": "Tüm eskiz noktaları", + "All components": "Tüm bileşenler", "All open designs": "Tüm açık tasarımlar", "Allowed expressions, separated by semicolons": "Noktalı virgülle ayrılmış izin verilen ifadeler", "Also deletes": "Bunları da siler", @@ -56,6 +59,7 @@ "Configuration rules": "Konfigürasyon kuralları", "Construction": "Yapı çizgisi", "Contact": "Temas", + "Contact sets": "Temas kümeleri", "Copy Error Message": "Hata mesajını kopyala", "Copy Warning Message": "Uyarı mesajını kopyala", "Copy all licences": "Tüm lisansları kopyala", @@ -80,11 +84,13 @@ "Double-click to rename": "Yeniden adlandırmak için çift tıklayın", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Görünümde orta tuşla çift tıklama: Başlangıç (gösterileni çerçeveleyen başlangıç görünümü), Sığdır (yönü koruyarak gösterileni çerçeveler) veya hiçbir şey", "Drive Joint": "Bağlantıyı tahrik et", + "Duration": "Süre", "Edges": "Kenarlar", "Edit Profile Sketch": "Profil eskizini düzenle", "Enable rule": "Kuralı etkinleştir", "Enabled": "Etkin", "Explode": "Patlat", + "Export Animation…": "Animasyonu dışa aktar…", "Extents": "Kapsamlar", "Face": "Yüz", "Face attachment lost": "Yüze bağlama kayboldu", @@ -137,6 +143,7 @@ "Move earlier": "Öne al", "Move later": "Sonraya al", "Name": "Ad", + "New study": "Yeni etüt", "No crash reports.": "Çökme raporu yok.", "No matching appearances": "Eşleşen görünüm yok", "None": "Hiçbiri", @@ -152,14 +159,17 @@ "Per Edge": "Kenar başına", "Pick the tool bodies": "Araç gövdelerini seçin", "Picked": "Seçilenler", + "Picked point": "Seçilen nokta", "Place at the origin instead": "Bunun yerine orijine yerleştir", "Playback": "Oynatma", + "Plot": "Grafik", "Position": "Konum", "Position (0 to 1)": "Konum (0 ile 1 arası)", "Position along the edge, 0 at the start end, 1 at the other": "Kenar boyunca konum: başlangıç ucunda 0, diğer uçta 1", "Preview Row": "Satırı önizle", "Preview leaves the current design unchanged.": "Önizleme geçerli tasarımı değiştirmez.", "Profiles/Curves": "Profiller/Eğriler", + "Quantity": "Büyüklük", "RECENT": "SON KULLANILANLAR", "RULED SURFACE": "REGLE YÜZEY", "Radius Fit": "Yarıçap uydurma", @@ -193,6 +203,7 @@ "Round": "Yuvarlak", "Rule": "Kural", "Save Rule": "Kuralı kaydet", + "Save the study as an animated GIF, seen as the viewport shows it": "Etüdü görünüm alanında göründüğü gibi animasyonlu GIF olarak kaydet", "Saved preview. Geometry is being checked before editing.": "Kaydedilmiş önizleme. Düzenlemeden önce geometri kontrol ediliyor.", "Scale factor": "Ölçek faktörü", "Search": "Ara", @@ -223,6 +234,7 @@ "Stand this part up?": "Bu parça dik çevrilsin mi?", "Step": "Adım", "Steps": "Adımlar", + "Study": "Etüt", "Style": "Stil", "Suppressed": "Bastırılmış", "Swap Ends": "Uçları değiştir", @@ -249,6 +261,7 @@ "Use selected face as fixed plane": "Seçili yüzü sabit düzlem olarak kullan", "VIEW": "GÖRÜNÜM", "Value": "Değer", + "Velocity": "Hız", "Vertices": "Köşe noktaları", "View navigator": "Görünüm gezgini", "Volume": "Hacim", @@ -400,8 +413,11 @@ "cmd.model_threads": "Dişleri listele", "cmd.motion_delete": "Hareket etüdünü sil", "cmd.motion_export": "Hareket etüdünü dışa aktar", + "cmd.motion_export_animation": "Hareket animasyonunu dışa aktar", + "cmd.motion_interference": "Hareket etüdü çarpışmaları", "cmd.motion_list": "Hareket etütlerini listele", "cmd.motion_play": "Hareket etüdünü oynat", + "cmd.motion_plot": "Hareket etüdü grafiği", "cmd.motion_study": "Hareket etüdü", "cmd.occurrence_copy": "Yapıştır (örnek)", "cmd.occurrence_ground": "Sabitle", @@ -615,6 +631,10 @@ "joint.from_bodies_hint": "Seçilen her gövde kendi yeni bileşenine konur ve bulunduğu yerde kalır.", "joint.one_body": "Her iki seçim de {body} üzerinde. İkincisini başka bir parçada seçin.", "joint.one_component": "Bu parçalar tek bir bileşenin gövdeleridir: {bodies}. Bir bağlantı bir bileşeni diğerine göre hareket ettirir, bu nedenle her parçanın kendi bileşeni olması gerekir.", + "motion.checked_to": "Yalnızca {step}. adıma kadar denetlendi: denetim iş bütçesini tüketti", + "motion.collision": "{a} · {b}: adımlar {steps}", + "motion.duration_hint": "saniye (boş: adım başına)", + "motion.no_collisions": "Etüt boyunca çarpışma yok", "nav.touchpad.help": "Kaydırmak için Shift tuşunu basılı tutun ve imleci hareket ettirin; Alt/Option ile döndürün. Fare tuşu gerekmez. Dikey kaydırma yakınlaştırır.", "nav.touchpad.label": "Dokunmatik yüzey", "nav.touchpad.orbit": "Döndür (Alt/Option tuşunu basılı tutup hareket ettirin; fare tuşu olmadan)", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index bd52bef4..b1e3ae32 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -5,7 +5,9 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Додайте знімок екрана: скопіюйте й вставте його або збережіть і завантажте PNG.", "3. Click Submit new issue on GitHub to send your report.": "3. Натисніть Submit new issue на GitHub, щоб надіслати звіт.", "A new configuration, a copy of the active one": "Нова конфігурація, копія активної", + "AI": "ШІ", "About SolidCraft": "Про SolidCraft", + "Acceleration": "Прискорення", "Active": "Активна", "Active rule": "Активне правило", "Add": "Додати", @@ -13,8 +15,10 @@ "Add Mid Radius": "Додати середній радіус", "Add Row": "Додати рядок", "Add condition": "Додати умову", + "Against": "Проти", "Alignment": "Вирівнювання", "All Sketch Points": "Усі точки ескізу", + "All components": "Усі компоненти", "All open designs": "Усі відкриті проєкти", "Allowed expressions, separated by semicolons": "Дозволені вирази, розділені крапками з комою", "Also deletes": "Також видаляє", @@ -24,6 +28,7 @@ "Applied Radius": "Застосований радіус", "Apply To": "Застосувати до", "Apply to selection": "Застосувати до виділення", + "Arc length": "Довжина дуги", "Area": "Площа", "Attachment": "Прив'язка", "BROWSER": "БРАУЗЕР", @@ -41,10 +46,12 @@ "Center X": "Центр X", "Center Y": "Центр Y", "Center of mass": "Центр мас", + "Centre to centre": "Від центра до центра", "Chamfer Type": "Тип фаски", "Check collisions": "Перевіряти зіткнення", "Checked": "Перевірено", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Виберіть нову площину. Геометрія та розміри ескізу залишаються незмінними у своїх двовимірних координатах.", + "Circumference": "Довжина кола", "Close draft": "Закрити чернетку", "Collisions": "Зіткнення", "Colour": "Колір", @@ -53,7 +60,10 @@ "Configuration rules": "Правила конфігурації", "Construction": "Конструкційна", "Contact": "Контакт", + "Contact sets": "Набори контактів", "Continuity": "Неперервність", + "Copy Error Message": "Копіювати повідомлення про помилку", + "Copy Warning Message": "Копіювати попереджувальне повідомлення", "Copy all licences": "Копіювати всі ліцензії", "Copy diagnostic information": "Копіювати діагностичну інформацію", "Copy report": "Копіювати звіт", @@ -64,6 +74,8 @@ "Countersink angle": "Кут зенкування", "Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки", "Cube": "Куб", + "Current orthographic view (1:1 mm)": "Поточний ортографічний вид (1:1 mm)", + "Current perspective view": "Поточний перспективний вид", "Curvature (G2)": "Кривина (G2)", "Customize Toolbar": "Налаштувати панель інструментів", "Delete all": "Видалити все", @@ -75,15 +87,23 @@ "Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.", "Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.", "Discard": "Відхилити", + "Do not export hidden edges": "Не експортувати приховані ребра", "Double-click to rename": "Двічі клацніть, щоб перейменувати", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Подвійне клацання середньою кнопкою у виді: головний (головний вид, що охоплює показане), вписування (охопити показане, зберігаючи орієнтацію) або нічого", "Drive Joint": "Керувати з'єднанням", + "Duration": "Тривалість", "Edges": "Ребра", "Edit Profile Sketch": "Редагувати ескіз профілю", "Enable rule": "Увімкнути правило", "Enabled": "Увімкнено", "Explode": "Рознести", + "Export Animation…": "Експортувати анімацію…", + "Export Current View as DXF": "Експортувати поточний вид як DXF", + "Export Current View as DXF…": "Експортувати поточний вид як DXF…", + "Export DXF…": "Експорт DXF…", + "Export hidden edges as dashed lines": "Експортувати приховані ребра як штрихові лінії", "Extents": "Межі", + "Face": "Грань", "Face attachment lost": "Втрачено прив'язку до грані", "Faces": "Грані", "Faces / edges / vertices": "Грані / ребра / вершини", @@ -100,8 +120,12 @@ "Gap": "Проміжок", "Grid": "Сітка", "Height": "Висота", + "Hidden edges": "Приховані ребра", + "Holes": "Отвори", + "Holes (edges on them)": "Отвори (ребра на них)", "Home view": "Головний вид", "In This Design": "У цьому проєкті", + "In place (surfaces)": "На місці (поверхні)", "Inside": "Усередину", "Interferes": "Перетинається", "Issue title": "Заголовок повідомлення", @@ -119,8 +143,11 @@ "Margin": "Відступ", "Mass": "Маса", "Material": "Матеріал", + "Max": "Макс.", "Mid Radius": "Середній радіус", "Middle double-click": "Подвійне клацання середньою кнопкою", + "Min": "Мін.", + "Mitre": "Скіс", "Modeled": "Змодельовано", "More toolbar panels": "Більше панелей інструментів", "Motion": "Рух", @@ -128,8 +155,11 @@ "Move earlier": "Перемістити раніше", "Move later": "Перемістити пізніше", "Name": "Назва", + "New study": "Нове дослідження", "No crash reports.": "Немає звітів про збої.", "No matching appearances": "Немає відповідних виглядів", + "No visible bodies to export.": "Немає видимих тіл для експорту.", + "None": "Немає", "Not available yet": "Ще недоступно", "Notch": "Виріз", "Opacity": "Непрозорість", @@ -137,13 +167,17 @@ "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Параметричний 3D-CAD з відкритим вихідним кодом на Rust. Ліцензовано за MIT OR Apache-2.0.", "Other side": "Інший бік", "Outside": "Назовні", + "Overall length": "Загальна довжина", "Parameter values": "Значення параметрів", "Pass through": "Через точки", "Path": "Траєкторія", "Per Edge": "На кожне ребро", "Pick the tool bodies": "Виберіть тіла інструмента", + "Picked": "Вибрано", + "Picked point": "Вибрана точка", "Place at the origin instead": "Натомість розмістити в початку координат", "Playback": "Відтворення", + "Plot": "Графік", "Position": "Положення", "Position (0 to 1)": "Положення (від 0 до 1)", "Position (G0)": "Положення (G0)", @@ -151,13 +185,17 @@ "Preview Row": "Рядок перегляду", "Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.", "Profiles/Curves": "Профілі/криві", + "Projection": "Проєкція", + "Quantity": "Величина", "RECENT": "НЕДАВНІ", + "RULED SURFACE": "ЛІНІЙЧАСТА ПОВЕРХНЯ", "Radius Fit": "Підгонка за радіусом", "Ratio": "Співвідношення", "Reattach selected face": "Повторно приєднати вибрану грань", "Recover": "Відновити", "Recover unsaved design?": "Відновити незбережений проєкт?", "Rectangular": "Прямокутний", + "Reference face": "Опорна грань", "Remove Tools": "Прибрати інструменти", "Remove from list": "Прибрати зі списку", "Remove override": "Прибрати перевизначення", @@ -168,6 +206,7 @@ "Remove this point": "Прибрати цю точку", "Report a bug — review a screenshot and open a GitHub draft": "Повідомити про помилку — переглянути знімок екрана й відкрити чернетку GitHub", "Reset Toolbar": "Скинути панель інструментів", + "Restore outer boundary": "Відновити зовнішню межу", "Restore the inherited component or physical material appearance": "Відновити успадкований вигляд компонента або фізичного матеріалу", "Result": "Результат", "Retake screenshot": "Повторити знімок екрана", @@ -182,6 +221,7 @@ "Round": "Круглий", "Rule": "Правило", "Save Rule": "Зберегти правило", + "Save the study as an animated GIF, seen as the viewport shows it": "Зберегти дослідження як анімований GIF, як його показує вікно перегляду", "Saved preview. Geometry is being checked before editing.": "Збережений перегляд. Геометрію перевіряють перед редагуванням.", "Scale factor": "Коефіцієнт масштабу", "Search": "Пошук", @@ -204,6 +244,7 @@ "Sketch geometry and profiles": "Геометрія та профілі ескізу", "Sketch plane has no body-face attachment.": "Площина ескізу не має прив'язки до грані тіла.", "Sketch points": "Точки ескізу", + "Slot width": "Ширина паза", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.", "Speed": "Швидкість", "Stand it up": "Вирівняти", @@ -211,6 +252,7 @@ "Stand this part up?": "Вирівняти цю деталь?", "Step": "Крок", "Steps": "Кроки", + "Study": "Дослідження", "Style": "Стиль", "Suppressed": "Придушено", "Swap Ends": "Поміняти кінці місцями", @@ -230,7 +272,9 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ця грань не може забезпечити надійну прив'язку. Виберіть іншу грань або явно використайте фіксовану площину.", "To Offset": "До зсуву", "Tolerance": "Допуск", + "Turn off section analysis before exporting the view.": "Вимкніть аналіз перерізу перед експортом виду.", "Type": "Тип", + "UNTRIM": "ЗНЯТИ ОБРІЗАННЯ", "Updating the preview": "Оновлення попереднього перегляду", "Use active author placement": "Використати активне вихідне розміщення", "Use fixed plane": "Використати фіксовану площину", @@ -238,8 +282,10 @@ "Use selected face as fixed plane": "Використати вибрану грань як фіксовану площину", "VIEW": "ВИД", "Value": "Значення", + "Velocity": "Швидкість", "Vertices": "Вершини", "View navigator": "Навігатор виду", + "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Лише видимі тіла. Криві подаються відрізками (допуск 0,01 mm).", "Volume": "Об'єм", "Wall": "Стінка", "Wall Location": "Розташування стінки", @@ -248,12 +294,25 @@ "Width": "Ширина", "Will fail": "Зазнає невдачі", "angle, height": "кут, висота", + "appearance.dielectric_ior": "Діелектричний IOR", + "appearance.metallic": "Металевість", + "appearance.pbr_help": "Перегляньте цю поверхню в режимі рендерингу або як PNG-зображення виробу. Без заломлення чи пропускання.", + "appearance.pbr_opaque": "Непрозорий PBR", + "appearance.roughness": "Шорсткість", + "appearance.surface_response": "Відгук поверхні", "browser.body_name": "Тіло{number}", "browser.component_name": "Компонент{number}", "browser.parameters": "Параметри ({count})", "browser.precision": "Точність: {precision}", "browser.sketch_name": "Ескіз{number}", "browser.units": "Одиниці: {units}", + "cam.flat": "Плоский", + "cam.follower": "Штовхач", + "cam.knife": "Вістря", + "cam.needs_joints": "потрібні обертове й ковзне або хитне з'єднання", + "cam.roller": "Ролик", + "cam.roller_pick": "Ролик (необов'язково)", + "cam.tip_pick": "Вістря (необов'язково)", "cmd.appearance_assign": "Вигляд", "cmd.appearance_library": "Бібліотека виглядів", "cmd.appearance_list": "Список виглядів", @@ -326,6 +385,9 @@ "cmd.doc_recovery_discard": "Відхилити відновлений проєкт", "cmd.doc_recovery_list": "Проєкти для відновлення", "cmd.document_inspect": "Перевірити проєкт", + "cmd.document_metadata": "Властивості проєкту", + "cmd.document_metadata_remove": "Видалити властивість проєкту", + "cmd.document_metadata_set": "Змінити властивості проєкту", "cmd.document_units": "Змінити одиниці проєкту", "cmd.edit_checkpoint": "Контрольна точка", "cmd.edit_restore_checkpoint": "Відновити контрольну точку", @@ -340,6 +402,7 @@ "cmd.file_insert_mesh": "Вставити сітку", "cmd.file_insert_step": "Вставити STEP", "cmd.file_save_mesh": "Зберегти як сітку", + "cmd.file_slicers": "Встановлені слайсери", "cmd.inspect_accessibility": "Аналіз доступності", "cmd.inspect_center_of_mass": "Центр мас", "cmd.inspect_compare": "Порівняти тіла", @@ -353,6 +416,7 @@ "cmd.inspect_minimum_radius": "Аналіз мінімального радіуса", "cmd.inspect_zebra": "Зебра-аналіз", "cmd.joint_as_built": "З'єднання за поточним положенням", + "cmd.joint_cam": "Кулачок", "cmd.joint_create": "З'єднання", "cmd.joint_delete": "Видалити з'єднання", "cmd.joint_drive": "Керувати з'єднаннями", @@ -383,8 +447,11 @@ "cmd.model_threads": "Список різьб", "cmd.motion_delete": "Видалити дослідження руху", "cmd.motion_export": "Експортувати дослідження руху", + "cmd.motion_export_animation": "Експортувати анімацію руху", + "cmd.motion_interference": "Зіткнення в дослідженні руху", "cmd.motion_list": "Список досліджень руху", "cmd.motion_play": "Відтворити дослідження руху", + "cmd.motion_plot": "Графік дослідження руху", "cmd.motion_study": "Дослідження руху", "cmd.motion_trace": "Траєкторія руху", "cmd.motion_trace_keep": "Зберегти траєкторію руху", @@ -417,6 +484,15 @@ "cmd.plastic_rules": "Список правил пластику", "cmd.plastic_snap_fit": "Клацкове з'єднання", "cmd.render_backends": "Рендер-бекенди", + "cmd.render_cancel": "Скасувати рендеринг", + "cmd.render_export": "Експортувати зображення рендерингу", + "cmd.render_preflight": "Перевірити запит на рендеринг", + "cmd.render_scene": "Переглянути сцену рендерингу", + "cmd.render_settings_apply": "Застосувати камеру рендерингу", + "cmd.render_settings_get": "Налаштування рендерингу", + "cmd.render_settings_set": "Зберегти налаштування рендерингу", + "cmd.render_start": "Рендеринг зображення", + "cmd.render_status": "Перебіг рендерингу", "cmd.select_clear": "Зняти виділення", "cmd.sheet_convert": "Перетворити на листовий метал", "cmd.sheet_export_bend_table": "Експортувати таблицю згинів", @@ -564,6 +640,7 @@ "coupling.rack": "Рейка", "coupling.radius_1": "Радіус 1", "coupling.radius_2": "Радіус 2", + "dialog.computing": "Обчислення… {seconds} с", "dialog.custom_axis": "↖ Власна вісь", "dialog.edit_title": "РЕДАГУВАННЯ {title}", "dialog.select": "↖ Вибрати", @@ -573,10 +650,25 @@ "dialog.step": "Крок", "document.save_changes": "Зберегти зміни в {name}?", "document.unsaved_changes": "У {name} є незбережені зміни. Якщо не зберегти, вони будуть втрачені.", + "drive.stopped_by_contact": "Зупинка на {value}: {a} пройшло б крізь {b}", + "drive.stopped_by_loop": "Зупинка на {value}: контур через {joint} далі не замикається", "drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим", "expression, e.g. 25 mm": "вираз, напр. 25 mm", "fits the face, e.g. M8": "відповідає грані, напр. M8", "from the rule": "з правила", + "home.documentation": "Документація", + "home.in_this_browser": "у цьому браузері", + "home.missing": "відсутній", + "home.open": "Відкрити…", + "home.opened_days_ago": "Відкрито {count} дн. тому", + "home.opened_hours_ago": "Відкрито {count} год тому", + "home.opened_just_now": "Щойно відкрито", + "home.opened_minutes_ago": "Відкрито {count} хв тому", + "home.opening": "відкриття…", + "home.recent": "Недавні", + "home.report_issue": "Повідомити про проблему", + "home.samples": "Зразки", + "home.whats_new": "Що нового", "install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі", "install.dock_1": "Виберіть «Файл» › «Додати в Dock».", "install.dock_2": "Натисніть «Додати».", @@ -592,6 +684,16 @@ "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.", "joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.", "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", + "motion.checked_to": "Перевірено лише до кроку {step}: перевірка вичерпала свій бюджет роботи", + "motion.collision": "{a} · {b}: кроки {steps}", + "motion.duration_hint": "секунди (порожньо: на крок)", + "motion.no_collisions": "За все дослідження зіткнень немає", + "nav.touchpad.help": "Утримуйте Shift і рухайте вказівник, щоб панорамувати; утримуйте Alt/Option і рухайте, щоб обертати. Кнопка миші не потрібна. Вертикальна прокрутка масштабує.", + "nav.touchpad.label": "Трекпад", + "nav.touchpad.orbit": "Обертання (утримуйте Alt/Option і рухайте; без кнопки миші)", + "nav.touchpad.pan": "Панорамування (утримуйте Shift і рухайте; без кнопки миші)", + "nav.touchpad.scroll": "Трекпад використовує вертикальну прокрутку для масштабування. Ваше налаштування панорамування двома пальцями зберігається для інших наборів.", + "nav.touchpad.zoom": "Масштаб (вертикальна прокрутка або стискання)", "needs two joints": "потрібні два з'єднання", "new configuration": "нова конфігурація", "new name": "нова назва", @@ -606,6 +708,60 @@ "pattern.flip_direction": "Змінити напрямок", "pick a hole to seat it, or OK places it at the origin": "виберіть отвір для посадки, або OK розмістить його в початку координат", "pick one or two vertices, edges or faces": "виберіть одну-дві вершини, ребра або грані", + "render.background": "Фон сцени", + "render.fit": "Вписати виріб", + "render.image_cancel": "Скасувати рендеринг", + "render.image_cancelled": "Рендеринг скасовано", + "render.image_cancelling": "Скасування", + "render.image_check": "Перевірити запит на рендеринг", + "render.image_completed": "Зображення готове до експорту", + "render.image_encoding": "Кодування PNG", + "render.image_export": "Експортувати PNG", + "render.image_failed": "Рендеринг не вдався", + "render.image_height": "Висота", + "render.image_help": "Попереднє зображення на CPU · PNG · наближена прозорість", + "render.image_overwrite": "Замінити наявний файл", + "render.image_preparing": "Підготовка зображення", + "render.image_queued": "Очікує рендерингу", + "render.image_reduce": "Спробуйте менше зображення або простішу сцену.", + "render.image_running": "Рендеринг зображення", + "render.image_stale": "Це зображення використовує сцену, зафіксовану до подальших змін.", + "render.image_start": "Почати рендеринг", + "render.image_supported": "Запит на рендеринг підтримується; чергу та пул пам'яті буде перевірено ще раз під час запуску.", + "render.image_title": "Рендеринг зображення", + "render.image_too_large": "Перевищено ліміт зображення: 4096 пікселів на сторону та 4 194 304 пікселі загалом.", + "render.image_transparent": "Прозорий фон", + "render.image_unknown": "Стан рендерингу недоступний", + "render.image_use_largest": "Використати найбільший розмір за такого співвідношення сторін:", + "render.image_width": "Ширина", + "render.navigation_help": "Камера рухається так само, як у режимі проєктування (Налаштування › Навігація). «Зберегти налаштування» фіксує поточну камеру.", + "render.opaque_image_summary": "Попередній PNG · непрозорий PBR · змішана прозорість", + "render.opaque_preview_help": "Непрозорий PBR і прості кольори; змішана прозорість. Заломлення, HDRI та фінальний рендеринг поки недоступні.", + "render.perspective": "Перспективна камера", + "render.preview": "Вбудований попередній перегляд", + "render.preview_help": "Растрове затінення та змішана прозорість. Заломлення, HDRI та фінальний рендеринг поки недоступні.", + "render.reload_settings": "Перезавантажити збережені налаштування", + "render.restore_camera": "Відновити збережену камеру", + "render.return_design": "Повернутися до проєктування", + "render.save_setup": "Зберегти налаштування", + "render.setup_name": "Назва налаштування", + "render.unavailable_preview": "Це налаштування вимагає недоступного профілю попереднього перегляду.", + "render.unsaved": "Незбережені зміни попереднього перегляду", + "render.use_preview": "Використати вбудований попередній перегляд", + "sample.bracket.about": "Основна полиця з кромковими полицями та загином за правилом 2 mm", + "sample.bracket.name": "Кронштейн з листового металу", + "sample.enclosure.about": "Оболонковий корпус із буртиком, бобишками під гвинти, упором і клацковим з'єднанням", + "sample.enclosure.name": "Пластиковий корпус", + "sample.flange.about": "Маточина й фланець із круговим масивом, керованим параметром", + "sample.flange.name": "Болтовий фланець", + "sample.hinge.about": "Два компоненти та обертальне з'єднання, доведене до 60°", + "sample.hinge.name": "Петльова збірка", + "sample.plate.about": "Параметрична пластина із заокругленнями, отворами та розточеною бобишкою", + "sample.plate.name": "Зразкова пластина", + "sample.vents.about": "Пази в прямокутному масиві: рядки, стовпці та крок є параметрами", + "sample.vents.name": "Вентиляційна пластина", + "settings.display_quality_note": "Дрібніші рівні малюють криві грані й ребра плавнішими, але тріангуляція триває довше. Змінюється лише вікно перегляду, ніколи не проєкт чи експорт.", + "settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.", "settings.measure_axis_distances": "Відстані X/Y/Z", "settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.", "settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft", @@ -613,6 +769,7 @@ "settings.msaa_unsupported": "Не підтримується цим графічним пристроєм", "settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б SolidCraft запуститися, тому їх змінено:", "settings.reset_title": "Налаштування було змінено", + "settings.supersampling_note": "Малює вид у вищій роздільності й потім зменшує його: плавніші ребра, лінії та сітка на екранах до 4K. Це коштує часу GPU: приблизно 2,25× пікселів за 1,5× і 4× за 2×.", "sketch.degrees_of_freedom": "{count} ступенів вільності", "sketch.draw_curves": "Малювати як {linetype}", "sketch.make_selection": "Перетворити виділене на {linetype}", @@ -636,6 +793,9 @@ "timeline.reorder_many": "Перевпорядкувати операції", "timeline.ungroup": "Прибрати теки операцій", "to the next face": "до наступної грані", + "toolbar.image": "ЗОБРАЖЕННЯ", + "toolbar.render": "РЕНДЕР", + "toolbar.render_scene": "СЦЕНА", "trace.points": "{count} точок на траєкторії", "trace.stopped_by_contact": "Закінчується на відліку {sample}: {a} пройшов би крізь {b}", "trace.stopped_by_loop": "Закінчується на відліку {sample}: контур через {joint} далі не замикається", @@ -646,8 +806,162 @@ "ui.about": "Про програму", "ui.activate": "Активувати", "ui.active_component": "Активний компонент", + "ui.agent_chat": "Чат з агентом", + "ui.agent_chat_allow": "Дозволити", + "ui.agent_chat_allowed": "Дозволено", + "ui.agent_chat_answered": "Відповідь: {option}", + "ui.agent_chat_approval": "Дозволити агенту це зробити?", + "ui.agent_chat_attach_view": "Прикріпити вид", + "ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням", + "ui.agent_chat_auth_agent": "Увійти через {name}", + "ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.", + "ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.", + "ui.agent_chat_auth_none": "Агент тут не пропонує способу входу: увійдіть так, як описано в його власній документації.", + "ui.agent_chat_auth_retry": "Я ввійшов: під'єднатися знову", + "ui.agent_chat_auth_terminal": "Виконайте це в терміналі, а потім знову натисніть «Під'єднатися»:", + "ui.agent_chat_auth_title": "Вхід до агента", + "ui.agent_chat_cancel": "Скасувати", + "ui.agent_chat_changes": "Зміни: {n}, один крок скасування", + "ui.agent_chat_changes_of": "{n}/{limit} змін", + "ui.agent_chat_close": "Закрити", + "ui.agent_chat_command": "Команда агента", + "ui.agent_chat_connect": "Під'єднатися", + "ui.agent_chat_copy": "Копіювати", + "ui.agent_chat_custom_hint": "Будь-яка програма, що підтримує Agent Client Protocol через stdin і stdout. Оболонка її не запускає.", + "ui.agent_chat_denied": "Відхилено", + "ui.agent_chat_deny": "Відхилити", + "ui.agent_chat_desktop_only": "Чат з агентом потребує настільної програми.", + "ui.agent_chat_detecting": "Пошук агентів…", + "ui.agent_chat_dock_left": "Закріпити ліворуч", + "ui.agent_chat_dock_right": "Закріпити праворуч", + "ui.agent_chat_edit": "Редагувати", + "ui.agent_chat_exited": "Агент зупинився.", + "ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}", + "ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.", + "ui.agent_chat_key_in_env": "ключ задано", + "ui.agent_chat_left": "Ліворуч", + "ui.agent_chat_limit": "Агент досяг межі {limit} змін на одне повідомлення. Надішліть інше повідомлення, щоб дозволити йому продовжити.", + "ui.agent_chat_minimize": "Згорнути до смужки", + "ui.agent_chat_model_tip": "Модель, яку використовує агент. Ваш вибір запам'ятовується для цього агента.", + "ui.agent_chat_new": "Новий чат", + "ui.agent_chat_new_confirm": "Почати новий чат? Поточний буде очищено.", + "ui.agent_chat_new_yes": "Новий чат", + "ui.agent_chat_no_cli": "solidcraft-cli не знайдено поруч із програмою, тож агент не може дістатися до проєкту.", + "ui.agent_chat_no_control": "Кінцева точка чату з агентом не запустилася, тож агент не може дістатися до проєкту.", + "ui.agent_chat_no_images": "Цей агент не приймає зображень", + "ui.agent_chat_none_found": "Агента для програмування не знайдено. Установіть один із цих, а потім натисніть «Оновити»:", + "ui.agent_chat_none_found_short": "Агента не знайдено", + "ui.agent_chat_not_installed": "Не встановлено", + "ui.agent_chat_not_signed_in": "не виконано вхід", + "ui.agent_chat_permission": "Агент просить виконати: {title}", + "ui.agent_chat_pick": "Агент", + "ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…", + "ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)", + "ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.", + "ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.", + "ui.agent_chat_queue": "У чергу", + "ui.agent_chat_queue_full": "Забагато повідомлень в очікуванні: приберіть одне або зачекайте на агента.", + "ui.agent_chat_queued": "У черзі", + "ui.agent_chat_queued_stopped": "У черзі (зупинено, не надіслано)", + "ui.agent_chat_refresh": "Оновити", + "ui.agent_chat_refused": "Не дозволено для агента: {reason}", + "ui.agent_chat_remove": "Прибрати з черги", + "ui.agent_chat_right": "Праворуч", + "ui.agent_chat_save": "Зберегти", + "ui.agent_chat_send": "Надіслати", + "ui.agent_chat_send_now": "Надіслати зараз", + "ui.agent_chat_send_now_tip": "Зупинити поточний крок агента й надіслати це наступним (Ctrl+Enter)", + "ui.agent_chat_side": "Сторона чату з агентом", + "ui.agent_chat_signed_in": "виконано вхід", + "ui.agent_chat_state_auth": "Потрібен вхід", + "ui.agent_chat_state_busy": "Працює…", + "ui.agent_chat_state_exited": "Агент зупинився", + "ui.agent_chat_state_off": "Не під'єднано", + "ui.agent_chat_state_ready": "Готово", + "ui.agent_chat_state_starting": "Запуск…", + "ui.agent_chat_stop": "Зупинити", + "ui.agent_chat_stop_tip": "Зупинити агента й усе, що він запустив", + "ui.agent_chat_stop_turn_tip": "Зупинити агента (Esc). Повідомлення в черзі залишаються в черзі.", + "ui.agent_chat_turn_limit": "Змін на повідомлення", + "ui.agent_chat_undo_changes": "Скасувати ці зміни", + "ui.agent_chat_undo_label": "Чат з агентом: {message}", + "ui.agent_chat_undone": "(скасовано)", + "ui.agent_chat_view_attached": "Вид прикріплено", "ui.agent_cursor": "Курсор агента", "ui.agent_cursor_speed": "Швидкість курсора агента", + "ui.ai_add": "Додати", + "ui.ai_add_agent": "Додати агента…", + "ui.ai_add_agent_title": "Додати агента", + "ui.ai_add_api": "API, сумісний з OpenAI", + "ui.ai_add_custom": "Власна команда", + "ui.ai_add_installed": "Встановлений агент", + "ui.ai_add_installed_note": "Ваш власний запис для встановленого агента, наприклад з іншими аргументами. Встановлені агенти перелічуються окремо.", + "ui.ai_added": "додано", + "ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти SolidCraft підкоряються дозволам у Налаштування > ШІ.", + "ui.ai_agents": "Агенти", + "ui.ai_always_allow": "Завжди дозволяти", + "ui.ai_always_allow_tip": "Дозволяти {tool} для цього агента відтепер без запиту", + "ui.ai_always_allowed": "Завжди дозволено", + "ui.ai_always_allowed_none": "Поки нічого не дозволено завжди. Відповіді «Завжди дозволяти», які ви даєте в чаті, перелічуються тут.", + "ui.ai_auto_allowed": "Автоматично дозволено: {title} ({why})", + "ui.ai_auto_mode": "ваше налаштування дозволів", + "ui.ai_auto_ours": "SolidCraft сам перевіряє власні інструменти", + "ui.ai_auto_remembered": "ви вибрали «Завжди дозволяти»", + "ui.ai_bad_url": "Введіть URL-адресу, що починається з http:// або https://", + "ui.ai_base_url": "Базова URL-адреса", + "ui.ai_button": "ШІ", + "ui.ai_copy_suffix": "копія", + "ui.ai_custom_url": "Власна URL-адреса", + "ui.ai_default": "Типовий", + "ui.ai_http_busy": "кінцева точка зайнята або перевищила ліміт; спробуйте пізніше", + "ui.ai_http_key": "кінцева точка відхилила ключ: перевірте його в Налаштування > ШІ", + "ui.ai_http_not_found": "немає такої кінцевої точки чи моделі: перевірте базову URL-адресу й назву моделі", + "ui.ai_key": "Ключ API", + "ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів", + "ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.", + "ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях SolidCraft. Залиште порожнім для локального сервера.", + "ui.ai_keychain_error": "Системне сховище ключів відмовило: {error}", + "ui.ai_mode_all": "Дозволяти все", + "ui.ai_mode_all_note": "Ніщо не запитує заздалегідь. Звіти про збої залишаються поза межами, а обмеження тек залишаються чинними.", + "ui.ai_mode_all_warning": "Агент може зберігати, перезаписувати, експортувати й замінювати ваш проєкт без запиту. Використовуйте це лише з агентом, якому довіряєте, і на збережених проєктах.", + "ui.ai_mode_ask": "Запитувати щоразу", + "ui.ai_mode_ask_note": "Кожна зміна проєкту та кожна дія з файлами чекає на ваш дозвіл.", + "ui.ai_mode_edits": "Автоматично дозволяти редагування моделі", + "ui.ai_mode_edits_note": "Команди моделювання виконуються без запиту; збереження, відкриття, експорт та інші файлові чи руйнівні дії все ще запитують.", + "ui.ai_mode_global": "Як задано вище", + "ui.ai_model": "Модель", + "ui.ai_modes_scope": "Ці налаштування стосуються інструментів SolidCraft. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.", + "ui.ai_name": "Назва", + "ui.ai_need_command": "Введіть команду, що запускає агента", + "ui.ai_need_model": "Введіть назву моделі", + "ui.ai_no_answer": "Агент не відповів вчасно", + "ui.ai_no_http": "Ця збірка не може дістатися до кінцевих точок API", + "ui.ai_no_keychain": "Системне сховище ключів недоступне, тож ключ не можна зберегти. Локальний сервер без ключа все одно працює.", + "ui.ai_panel": "Панель", + "ui.ai_permissions": "Дозволи", + "ui.ai_preset": "Набір", + "ui.ai_remove": "Видалити", + "ui.ai_request_limit": "зупинено після 24 запитів на одне повідомлення; надішліть інше повідомлення, щоб продовжити", + "ui.ai_reset": "Скинути", + "ui.ai_settings": "Налаштування ШІ…", + "ui.ai_show_features": "Показувати функції ШІ", + "ui.ai_show_features_note": "Чат з агентом: панель, де агент для програмування, яким ви вже користуєтеся, або обрана вами модельна кінцева точка працює з відкритим проєктом. Вимкнено: немає кнопки ШІ, комбінації клавіш, пункту меню чи панелі, і нічого не шукається та не запускається.", + "ui.ai_switch_tip": "Змінити агента для цієї розмови", + "ui.ai_test": "Перевірити", + "ui.ai_test_acp_ok": "Відповідає: {agent}", + "ui.ai_test_acp_tip": "Ненадовго запускає агента й запитує сесію; потім завершує його", + "ui.ai_test_api_tip": "Запитує в кінцевої точки її моделі", + "ui.ai_test_no_model": "Досягнуто: {count} моделей, але не {model}", + "ui.ai_test_ok": "Досягнуто: {count} моделей", + "ui.ai_testing": "Перевірка…", + "ui.ai_tool_done": "готово", + "ui.ai_tool_failed": "не вдалося", + "ui.ai_tool_running": "виконується", + "ui.ai_tool_unfold": "Клацніть, щоб показати, що він повернув", + "ui.ai_tool_waiting": "очікування", + "ui.ai_toolbar_button": "Показувати кнопку на панелі інструментів", + "ui.ai_toolbar_button_note": "Кнопка ШІ в правому кінці панелі інструментів. Ctrl+Shift+A і меню «Вид» усе одно відкривають панель.", + "ui.ai_undo_tip": "Скасувати все, що агент змінив для цього повідомлення, одним кроком", "ui.all": "Усі", "ui.analysis": "Аналіз", "ui.angle": "Кут", @@ -664,6 +978,7 @@ "ui.axes": "Осі", "ui.axis": "Вісь", "ui.base_point": "Базова точка", + "ui.blocked": "Заблоковані", "ui.bodies": "Тіла", "ui.box": "Куб", "ui.break": "Розірвати", @@ -689,6 +1004,21 @@ "ui.coincident": "Співпадання", "ui.collapse": "Згорнути", "ui.collinear": "Колінеарність", + "ui.colour_vision": "Кольоровий зір", + "ui.colour_vision_deutan": "Дейтеран", + "ui.colour_vision_deutan_tip": "Для слабкого або відсутнього сприйняття зеленого", + "ui.colour_vision_failed": "Невдала", + "ui.colour_vision_fixed": "Фіксована", + "ui.colour_vision_mode": "Режим", + "ui.colour_vision_note": "Змінює кольори, що несуть значення: стани ескізу, осі, згини, аналізи та значки. Якщо ви користуєтеся кольоровими фільтрами Windows, залиште це вимкненим.", + "ui.colour_vision_off": "Вимкнено", + "ui.colour_vision_off_tip": "Власні кольори теми", + "ui.colour_vision_projected": "Спроєктована", + "ui.colour_vision_protan": "Протанія", + "ui.colour_vision_protan_tip": "Для слабкого або відсутнього сприйняття червоного", + "ui.colour_vision_sample": "Зразок", + "ui.colour_vision_tritan": "Тритан", + "ui.colour_vision_tritan_tip": "Для слабкого або відсутнього сприйняття синього", "ui.combine": "Об'єднати", "ui.coming_later": "з'явиться пізніше", "ui.command_done": "{command} виконано", @@ -713,6 +1043,8 @@ "ui.curvature": "Кривизна", "ui.curvature_g2": "Кривизна (G2)", "ui.curves": "Криві", + "ui.custom_program": "Власна…", + "ui.custom_program_tip": "Виберіть програму-слайсер, щоб додати її до списку", "ui.customizeToolbar": "Налаштувати панель інструментів…", "ui.cut": "Вирізати", "ui.cylinder": "Циліндр", @@ -722,9 +1054,25 @@ "ui.delete": "Видалити", "ui.depth": "Глибина", "ui.design": "Проєкт", + "ui.design_properties.author": "Автор", + "ui.design_properties.description": "Опис", + "ui.design_properties.exports": "Експорт STEP, IGES і 3MF переносить їх туди, де формат має для них місце.", + "ui.design_properties.license": "Ліцензія", + "ui.design_properties.not_text": "Збережено не як текст; введення тут замінить його.", + "ui.design_properties.organization": "Організація", + "ui.design_properties.others": "Дані інших інструментів", + "ui.design_properties.others_note": "Зберігається в проєкті як є й ніколи не експортується.", + "ui.design_properties.part_number": "Номер деталі", + "ui.design_properties.project": "Проєкт", + "ui.design_properties.revision": "Редакція", "ui.diameter": "Діаметр", "ui.dimension": "Розмір", "ui.direction": "Напрямок", + "ui.display_quality": "Якість відображення", + "ui.display_quality_coarse": "Груба", + "ui.display_quality_fine": "Висока", + "ui.display_quality_normal": "Звичайна", + "ui.display_quality_ultra": "Ультра", "ui.distance": "Відстань", "ui.distance_1": "Відстань 1", "ui.distance_2": "Відстань 2", @@ -764,6 +1112,8 @@ "ui.fix_unfix": "Закріпити/розкріпити", "ui.flat": "Плоска", "ui.flip": "Перевернути", + "ui.format": "Формат", + "ui.frame_rate": "Частота кадрів", "ui.front": "Спереду", "ui.fully_constrained": "Повністю визначений", "ui.general": "Загальні", @@ -816,6 +1166,7 @@ "ui.heading_rectangular_pattern": "ПРЯМОКУТНИЙ МАСИВ", "ui.heading_revolve": "ОБЕРТАННЯ", "ui.heading_right": "СПРАВА", + "ui.heading_save_mesh": "ЗБЕРЕГТИ ЯК СІТКУ", "ui.heading_scale": "МАСШТАБ", "ui.heading_section_analysis": "АНАЛІЗ ПЕРЕРІЗУ", "ui.heading_select": "ВИБІР", @@ -890,6 +1241,9 @@ "ui.material_review.unknown": "Невідомо", "ui.material_review.yield_strength": "Межа плинності", "ui.measure": "Вимірювання", + "ui.mesh_format_3mf": "3MF", + "ui.mesh_format_3mf_tagged": "3MF з мітками граней", + "ui.mesh_format_stl": "STL", "ui.midpoint": "Середня точка", "ui.mirror": "Дзеркало", "ui.mirror_line": "Вісь дзеркала", @@ -902,8 +1256,10 @@ "ui.newSketchGroup": "Нова група ескізів", "ui.new_body": "Нове тіло", "ui.new_design": "Новий проєкт", + "ui.no_slicer_found": "Слайсер не знайдено", "ui.normal": "Звичайна", "ui.normalConstruction": "Звичайна / конструкційна", + "ui.not_a_program": "{path} не є програмою, яку можна запустити", "ui.object_type": "Тип об'єкта", "ui.objects": "Об'єкти", "ui.offer_to_stand_up_y_up_files": "Пропонувати вирівнювати файли з напрямком Y вгору", @@ -949,12 +1305,14 @@ "ui.project": "Проєктувати", "ui.project_face_edges_sketch": "Проєктувати ребра граней у нові ескізи", "ui.properties": "Властивості", + "ui.pull_direction": "Напрямок витягування", "ui.quantity": "Кількість", "ui.quantity_2": "Кількість 2", "ui.quit": "Вихід", "ui.radial_context_menu": "Кругове контекстне меню", "ui.radius": "Радіус", "ui.radius_type": "Тип радіуса", + "ui.reachable": "Досяжні", "ui.rectangle": "Прямокутник", "ui.rectangular_pattern": "Прямокутний масив", "ui.redo": "Повторити", @@ -975,6 +1333,10 @@ "ui.save": "Зберегти", "ui.save_as": "Зберегти як", "ui.save_as_ellipsis": "Зберегти як...", + "ui.save_mesh_no_slicer": "Слайсер не знайдено: виберіть «Власна…» і вкажіть свою програму-слайсер або вимкніть «Надіслати в слайсер»", + "ui.save_mesh_pick_bodies": "Виберіть тіла для збереження", + "ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}", + "ui.saved_to": "Збережено {path}", "ui.scale": "Масштаб", "ui.section_analysis": "Аналіз перерізу", "ui.select": "Вибрати", @@ -982,6 +1344,9 @@ "ui.selectTangentChain": "Вибрати дотичний ланцюг", "ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.", "ui.selection": "Виділення", + "ui.send": "Надіслати", + "ui.send_to_slicer": "Надіслати в слайсер", + "ui.sent_to_slicer": "Надіслано в {slicer}", "ui.shell": "Оболонка", "ui.show": "Показати", "ui.showAll": "Показати все", @@ -1006,6 +1371,8 @@ "ui.sketch_status": "Стан ескізу", "ui.sketches": "Ескізи", "ui.slice": "Розріз", + "ui.slicer": "Слайсер", + "ui.slicer_delete_tip": "{program}\nНатисніть Delete, щоб прибрати його зі списку", "ui.slot": "Паз", "ui.smooth": "Плавний", "ui.smooth_wheel_zoom": "Плавне масштабування колесом", @@ -1021,6 +1388,9 @@ "ui.stand_up_tip": "Після відкриття файлу STEP або IGES з напрямком Z вгору пропонувати поворот на чверть оберту навколо X", "ui.start": "Початок", "ui.start_radius": "Початковий радіус", + "ui.supersampling": "Наддискретизація (SSAA)", + "ui.supersampling_adaptive": "Лише коли вид нерухомий", + "ui.supersampling_off": "Вимкнено (1×)", "ui.suppress": "Придушити", "ui.sweep": "Протягування", "ui.sweep_follow_direction": "Слідувати напрямку", @@ -1041,6 +1411,7 @@ "ui.timeline_left_sidebar": "Ліва бічна панель", "ui.tip_angle": "Кут вістря", "ui.to_object": "До об'єкта", + "ui.tool_direction": "Напрямок інструмента", "ui.top": "Зверху", "ui.torus": "Тор", "ui.total_angle": "Загальний кут", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index b310f839..40120ece 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "新建配置,为当前配置的副本", "AI": "AI", "About SolidCraft": "关于 SolidCraft", + "Acceleration": "加速度", "Active": "活动", "Active rule": "活动规则", "Add": "添加", @@ -14,8 +15,10 @@ "Add Mid Radius": "添加中间半径", "Add Row": "添加行", "Add condition": "添加条件", + "Against": "对照", "Alignment": "对齐", "All Sketch Points": "所有草图点", + "All components": "所有零部件", "All open designs": "所有打开的设计", "Allowed expressions, separated by semicolons": "允许的表达式,以分号分隔", "Also deletes": "同时删除", @@ -57,6 +60,7 @@ "Configuration rules": "配置规则", "Construction": "构造", "Contact": "接触", + "Contact sets": "接触集", "Continuity": "连续性", "Copy Error Message": "复制错误消息", "Copy Warning Message": "复制警告消息", @@ -84,14 +88,18 @@ "Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。", "Discard": "放弃", "Do not export hidden edges": "不导出隐藏线", + "Don't ask again": "不再询问", "Double-click to rename": "双击重命名", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作", "Drive Joint": "驱动接头", + "Duration": "时长", "Edges": "边", "Edit Profile Sketch": "编辑轮廓草图", "Enable rule": "启用规则", "Enabled": "已启用", "Explode": "爆炸", + "Explode Text": "分解文字", + "Export Animation…": "导出动画…", "Export Current View as DXF": "将当前视图导出为DXF", "Export Current View as DXF…": "将当前视图导出为DXF…", "Export DXF…": "导出DXF…", @@ -149,6 +157,8 @@ "Move earlier": "向前移动", "Move later": "向后移动", "Name": "名称", + "Nearest": "最近", + "New study": "新建研究", "No crash reports.": "没有崩溃报告。", "No matching appearances": "没有匹配的外观", "No visible bodies to export.": "没有可导出的可见实体。", @@ -167,16 +177,20 @@ "Per Edge": "每条边", "Pick the tool bodies": "选择工具实体", "Picked": "所选", + "Picked point": "选取的点", "Place at the origin instead": "改为放置在原点", "Playback": "播放", + "Plot": "图表", "Position": "位置", "Position (0 to 1)": "位置(0 到 1)", "Position (G0)": "位置 (G0)", "Position along the edge, 0 at the start end, 1 at the other": "沿边的位置,起点端为 0,另一端为 1", + "Preferences › Design turns the offer back on": "可在 首选项 › 设计 中重新开启此提示", "Preview Row": "预览行", "Preview leaves the current design unchanged.": "预览不会更改当前设计。", "Profiles/Curves": "轮廓/曲线", "Projection": "投影", + "Quantity": "物理量", "RECENT": "最近使用", "RULED SURFACE": "直纹曲面", "Radius Fit": "半径拟合", @@ -211,6 +225,7 @@ "Round": "圆角", "Rule": "规则", "Save Rule": "保存规则", + "Save the study as an animated GIF, seen as the viewport shows it": "按视口中的显示将研究保存为动画 GIF", "Saved preview. Geometry is being checked before editing.": "已保存预览。编辑前正在检查几何。", "Scale factor": "比例因子", "Search": "搜索", @@ -241,6 +256,7 @@ "Stand this part up?": "立起此零件?", "Step": "步骤", "Steps": "步数", + "Study": "研究", "Style": "样式", "Suppressed": "已抑制", "Swap Ends": "交换端点", @@ -260,6 +276,7 @@ "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。", "To Offset": "偏移", "Tolerance": "公差", + "Too close": "过近", "Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。", "Type": "类型", "UNTRIM": "取消修剪", @@ -270,6 +287,7 @@ "Use selected face as fixed plane": "使用所选面作为固定平面", "VIEW": "视图", "Value": "值", + "Velocity": "速度", "Vertices": "顶点", "View navigator": "视图导航器", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "仅导出可见实体。曲线以线段近似(公差0.01毫米)。", @@ -293,6 +311,13 @@ "browser.precision": "精度: {precision}", "browser.sketch_name": "草图{number}", "browser.units": "单位: {units}", + "cam.flat": "平底", + "cam.follower": "从动件", + "cam.knife": "尖顶", + "cam.needs_joints": "需要一个旋转接头和一个滑动或摆动接头", + "cam.roller": "滚子", + "cam.roller_pick": "滚子(可选)", + "cam.tip_pick": "尖端(可选)", "cmd.appearance_assign": "外观", "cmd.appearance_library": "外观库", "cmd.appearance_list": "列出外观", @@ -385,6 +410,7 @@ "cmd.file_slicers": "已安装的切片软件", "cmd.inspect_accessibility": "可达性分析", "cmd.inspect_center_of_mass": "质心", + "cmd.inspect_clearance": "间隙", "cmd.inspect_compare": "比较实体", "cmd.inspect_continuity": "连续性分析", "cmd.inspect_curvature_comb": "曲率梳分析", @@ -394,8 +420,10 @@ "cmd.inspect_interference": "干涉", "cmd.inspect_isocurve": "等参线分析", "cmd.inspect_minimum_radius": "最小半径分析", + "cmd.inspect_silhouette": "轮廓", "cmd.inspect_zebra": "斑马纹分析", "cmd.joint_as_built": "现状接头", + "cmd.joint_cam": "凸轮", "cmd.joint_create": "接头", "cmd.joint_delete": "删除接头", "cmd.joint_drive": "驱动接头", @@ -426,8 +454,11 @@ "cmd.model_threads": "列出螺纹", "cmd.motion_delete": "删除运动研究", "cmd.motion_export": "导出运动研究", + "cmd.motion_export_animation": "导出运动动画", + "cmd.motion_interference": "运动研究干涉", "cmd.motion_list": "列出运动研究", "cmd.motion_play": "播放运动研究", + "cmd.motion_plot": "运动研究图表", "cmd.motion_study": "运动研究", "cmd.motion_trace": "运动轨迹", "cmd.motion_trace_keep": "保留运动轨迹", @@ -631,6 +662,18 @@ "drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}", "drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合", "drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件", + "dxf.cancel_wait": "正在等待导出停止…", + "dxf.cancelled": "DXF 导出已取消", + "dxf.cancelling": "正在取消 DXF 导出", + "dxf.exporting": "正在导出 DXF", + "dxf.invalid_tolerance": "曲线公差必须介于 0.0001 和 1 mm 之间。", + "dxf.progress.index": "正在准备曲面 (2/5)", + "dxf.progress.meshes": "正在准备网格 (1/5)", + "dxf.progress.occlusion": "正在判定隐藏线 (4/5)", + "dxf.progress.projection": "正在投影几何体 (3/5)", + "dxf.progress.write": "正在写入 DXF (5/5)", + "dxf.tolerance": "曲线公差", + "dxf.tolerance_help": "仅导出可见实体。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。", "expression, e.g. 25 mm": "表达式,例如 25 mm", "fits the face, e.g. M8": "适配该面,例如 M8", "from the rule": "来自规则", @@ -662,6 +705,10 @@ "joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。", "joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。", "joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。", + "motion.checked_to": "仅检查到第 {step} 步:检查已用完其工作量上限", + "motion.collision": "{a} · {b}:第 {steps} 步", + "motion.duration_hint": "秒(留空:按步)", + "motion.no_collisions": "整个研究中没有碰撞", "nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。", "nav.touchpad.label": "触控板", "nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)", @@ -760,6 +807,10 @@ "sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线", "sketch_text.use_font_file": "使用字体文件 {path}", "the parts library is not available": "零件库不可用", + "thread.automatic_size": "自动尺寸", + "thread.designation": "螺纹标记", + "thread.iso_metric": "ISO 公制", + "thread.select_size": "选择尺寸", "timeline.group": "新建特征文件夹", "timeline.move_many": "移动时间线项目", "timeline.move_to_group": "将特征移至文件夹", @@ -1005,6 +1056,9 @@ "ui.construct": "构造", "ui.construction": "构造", "ui.context_menu": "上下文菜单", + "ui.continuity_g0": "G0 折痕", + "ui.continuity_g1": "G1 相切", + "ui.continuity_g2": "G2 曲率", "ui.copy": "复制", "ui.corner": "角", "ui.corners": "角点", @@ -1286,6 +1340,8 @@ "ui.radial_context_menu": "环形上下文菜单", "ui.radius": "半径", "ui.radius_type": "半径类型", + "ui.rail": "导轨", + "ui.rail_fit": "导轨适配方式", "ui.reachable": "可到达", "ui.rectangle": "矩形", "ui.rectangular_pattern": "矩形阵列", @@ -1312,6 +1368,7 @@ "ui.save_mesh_temp_failed": "无法创建 {folder}:{error}", "ui.saved_to": "已保存:{path}", "ui.scale": "缩放", + "ui.second_rail": "第二导轨", "ui.section_analysis": "截面分析", "ui.select": "选择", "ui.selectOther": "选择其他…", @@ -1362,6 +1419,7 @@ "ui.stand_up_tip": "当 Z 朝上的 STEP 或 IGES 文件打开后,提示绕 X 轴旋转四分之一周", "ui.start": "开始", "ui.start_radius": "起始半径", + "ui.stretch": "伸展", "ui.supersampling": "超级采样 (SSAA)", "ui.supersampling_adaptive": "仅在视图静止时", "ui.supersampling_off": "关 (1×)", diff --git a/assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in b/assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in index 8983472d..169907fb 100644 --- a/assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in +++ b/assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in @@ -26,9 +26,9 @@ ai.storyteller.solidcraft.desktop ai.storyteller.solidcraft - https://bherbruck.github.io/solidcraft/ + https://docs.solidcraft.io/ https://github.com/bherbruck/solidcraft/issues - https://bherbruck.github.io/solidcraft/ + https://docs.solidcraft.io/ Graphics 3DGraphics diff --git a/assets/platforms/windows/solidcraft.wxs b/assets/platforms/windows/solidcraft.wxs index 3dcdef06..42c8ec4b 100644 --- a/assets/platforms/windows/solidcraft.wxs +++ b/assets/platforms/windows/solidcraft.wxs @@ -34,7 +34,7 @@ - + diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 49479153..4842fbe1 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -2,7 +2,7 @@ struct U { vp: mat4x4, back: vec4, // xyz = toward the eye, w = 1 for linear output - screen: vec4, // viewport width, height (px), depth bias (see `biased_z`), silhouettes on (1) + screen: vec4, // viewport width, height (px), unused, silhouettes on (1) clip: vec4, // section plane: normal, d (zero normal: no section) cap: vec4, // colour of the inside of cut bodies ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter @@ -14,8 +14,19 @@ struct U { // blocked; curvature flat, middle, tight ana_c: array, 8>, ana_f: vec4, // x = 1: stripe the negative draft and blocked faces + depth: vec4, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`) }; +fn reversed() -> bool { + return u.depth.x > 0.5; +} + +/// Clip-space depth measured the usual way (0 at the near plane, w at the far one), whichever +/// way the depth buffer runs. +fn near_z(c: vec4) -> f32 { + return select(c.z, c.w - c.z, reversed()); +} + @group(1) @binding(2) var acc_t: texture_2d; fn clipped(p: vec3) -> bool { @@ -70,11 +81,22 @@ struct CutOut { @fragment fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut { var o: CutOut; + // Derivatives (in `pushed`) first, before `solid` may discard. + o.depth = pushed(cap_depth(i.wp, i.pos.z)); o.c = solid(i, front); - o.depth = cap_depth(i.wp, i.pos.z); return o; } +/// A depth the shader writes itself, pushed back like the faces the GPU pushes (`FACE_BIAS`, +/// which does not apply then): its own slope over `depth.z` pixels and `depth.y` depth steps. +fn pushed(z: f32) -> f32 { + let slope = max(abs(dpdx(z)), abs(dpdy(z))); + // One step: 2⁻²⁴ in a 24-bit buffer; a float buffer's steps follow the value (2⁻²³ of it). + let step = select(1.0 / 16777216.0, abs(z) * 1.1920929e-7, reversed()); + let push = slope * u.depth.z + step * u.depth.y; + return clamp(z - select(-push, push, reversed()), 0.0, 1.0); +} + /// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut), /// never behind the wall itself (`z`). fn cap_depth(wp: vec3, z: f32) -> f32 { @@ -85,6 +107,7 @@ fn cap_depth(wp: vec3, z: f32) -> f32 { let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn); let c = u.vp * vec4(p, 1.0); if (c.w <= 0.0) { return z; } + if (reversed()) { return clamp(c.z / c.w, z, 1.0); } return clamp(c.z / c.w, 0.0, z); } @@ -223,28 +246,8 @@ fn shade(i: TOut) -> vec4 { return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); } -/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away -/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it. -/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges -/// showed through walls), so the point moves toward the eye by a fraction of its distance. -fn biased_z(p: vec3, c: vec4, s: f32) -> f32 { - if (u.eye.w > 0.5) { - let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0); - return q.z / max(q.w, 1e-6) * c.w; - } - return c.z - u.screen.z * s * c.w; -} - -@vertex -fn vs_ghost(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { - var o: TOut; - o.pos = u.vp * vec4(p, 1.0); - o.pos.z = biased_z(p, o.pos, -4.0); - o.n = n; - o.c = c; - o.wp = p; - return o; -} +// Lines, decals and ghosts are drawn at their own depth: opaque faces are pushed back by their +// slope instead (`FACE_BIAS`, #1434), so what lies on a face draws over it at any angle. struct LOut { @builtin(position) pos: vec4, @@ -261,12 +264,14 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location( // An end behind the near plane (clip z < 0) has no screen point: its divide flips the // line or drops it whole (ground grid lines running past the eye at a low angle). Cut // the segment at the plane instead; clip space is linear in the world point. - if (ca.z < 0.0 && cb.z > 0.0) { - let k = ca.z / (ca.z - cb.z); + let za = near_z(ca); + let zb = near_z(cb); + if (za < 0.0 && zb > 0.0) { + let k = za / (za - zb); ca = mix(ca, cb, k); pa = mix(a, b, k); - } else if (cb.z < 0.0 && ca.z > 0.0) { - let k = cb.z / (cb.z - ca.z); + } else if (zb < 0.0 && za > 0.0) { + let k = zb / (zb - za); cb = mix(cb, ca, k); pb = mix(b, a, k); } @@ -285,7 +290,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location( let base = select(ca, cb, t > 0.5); let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5; var o: LOut; - o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w); + o.pos = vec4(base.xy + off / half * base.w, base.z, base.w); o.c = c; o.wp = select(pa, pb, t > 0.5); return o; @@ -310,9 +315,8 @@ struct IOut { @vertex fn vs_img(@location(0) p: vec3, @location(1) uv: vec2, @location(2) a: f32) -> IOut { var o: IOut; + // On a face it shows over it: the face is pushed back (`FACE_BIAS`). o.pos = u.vp * vec4(p, 1.0); - // Pulled toward the eye like lines, so a decal on a face is not lost in it. - o.pos.z = biased_z(p, o.pos, 1.0); o.uv = uv; o.a = a; o.wp = p; diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 2236e5ff..a03f7576 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -1,6 +1,10 @@ //! Original light and dark palette values. Compiled by the UI theme boundary. use crate::theme::{AnalysisColors, IconColors, Tokens}; use egui::Color32; + +const fn icon_primary(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) +} impl Tokens { pub const fn light() -> Tokens { Tokens { @@ -13,7 +17,7 @@ impl Tokens { text_dim: Color32::from_rgb(110, 116, 128), accent: Color32::from_rgb(38, 120, 218), accent_soft: Color32::from_rgb(214, 230, 250), - sketch_accent: Color32::from_rgb(46, 150, 90), + sketch_accent: icon_primary(crate::icon_svg::SKETCH_PRIMARY[0]), error: Color32::from_rgb(214, 54, 54), warning: Color32::from_rgb(222, 150, 30), icon: Color32::from_rgb(70, 78, 92), @@ -39,11 +43,12 @@ impl Tokens { axis_y: Color32::from_rgb(0, 255, 0), axis_z: Color32::from_rgb(2, 2, 248), construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70), - construction_geom: Color32::from_rgb(232, 140, 48), + construction_geom: icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[0]), body: Color32::from_rgb(176, 186, 198), body_edge: Color32::from_rgb(40, 44, 52), manipulator: Color32::from_rgb(40, 110, 220), preview_add: Color32::from_rgb(112, 160, 226), + preview_new: Color32::from_rgb(240, 225, 105), preview_sketch: Color32::from_rgb(32, 190, 96), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), cube_face: Color32::from_rgb(236, 239, 244), @@ -71,7 +76,7 @@ impl Tokens { profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70), glyph_bg: Color32::WHITE, glyph_edge: Color32::from_rgb(190, 110, 100), - glyph_text: Color32::from_rgb(150, 40, 35), + glyph_text: icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[0]), overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235), rubber_band: Color32::from_rgb(30, 120, 230), app_bar_text: Color32::from_rgb(230, 234, 242), @@ -93,6 +98,12 @@ impl Tokens { bend_up: Color32::from_rgb(30, 140, 50), bend_down: Color32::from_rgb(200, 90, 20), interference: Color32::from_rgb(235, 45, 40), + continuity: [ + Color32::from_rgb(0, 158, 115), + Color32::from_rgb(0, 114, 178), + Color32::from_rgb(240, 200, 0), + Color32::from_rgb(204, 0, 140), + ], analysis: AnalysisColors::DEFAULT, icons: IconColors { sheet: Color32::from_rgb(170, 200, 240), @@ -132,7 +143,8 @@ impl Tokens { t.text_dim = Color32::from_rgb(148, 155, 168); t.accent = Color32::from_rgb(58, 136, 238); t.accent_soft = Color32::from_rgb(42, 70, 110); - t.sketch_accent = Color32::from_rgb(110, 200, 140); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_PRIMARY[1]); + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]); t.error = Color32::from_rgb(240, 100, 96); t.warning = Color32::from_rgb(236, 170, 70); t.icon = Color32::from_rgb(208, 214, 226); @@ -162,7 +174,7 @@ impl Tokens { t.subheading_bg = Color32::from_rgb(48, 55, 68); t.glyph_bg = Color32::from_rgb(44, 50, 60); t.glyph_edge = Color32::from_rgb(200, 130, 120); - t.glyph_text = Color32::from_rgb(240, 165, 155); + t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[1]); t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240); t.rubber_band = Color32::from_rgb(92, 160, 255); t.timeline_item = Color32::from_rgb(52, 58, 70); @@ -196,18 +208,19 @@ impl Tokens { t.icons.axes[0] = t.triad[0]; t.icons.axes[1] = t.triad[1]; t.icons.access = [good, Color32::from_rgb(217, 102, 13)]; + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]); t.icons.draft = [good, Color32::from_rgb(242, 230, 153)]; if dark { t.bend_up = Color32::from_rgb(110, 165, 255); t.bend_down = Color32::from_rgb(240, 180, 70); - t.sketch_accent = Color32::from_rgb(150, 225, 195); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[1]); } else { t.sketch_projected = Color32::from_rgb(205, 120, 215); t.sketch_locked = Color32::from_rgb(60, 150, 120); t.timeline_item_error = Color32::from_rgb(232, 196, 170); t.bend_up = Color32::from_rgb(30, 100, 210); t.bend_down = Color32::from_rgb(200, 120, 0); - t.sketch_accent = Color32::from_rgb(60, 150, 105); + t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[0]); } t } @@ -217,10 +230,11 @@ impl Tokens { let mut t = self; t.analysis.hatch = true; t.preview_sketch = Color32::from_rgb(125, 180, 105); + t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]); + t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]); if dark { t.sketch_locked = Color32::from_rgb(140, 225, 110); t.box_crossing = Color32::from_rgb(240, 110, 170); - t.glyph_text = Color32::from_rgb(240, 180, 165); } else { t.sketch_locked = Color32::from_rgb(20, 165, 70); t.box_crossing = Color32::from_rgb(200, 60, 120); diff --git a/assets/styles/web/loading-logo.svg b/assets/styles/web/loading-logo.svg new file mode 100644 index 00000000..44cab35b --- /dev/null +++ b/assets/styles/web/loading-logo.svg @@ -0,0 +1,87 @@ + + SolidCraft + A plain cube drawn in thick lines is cut twice. Each cut starts as a flat footprint on a face and presses into the solid: first a corner block through the left face, then a pocket pressed down from the top. The cut exposes its shaded faces, then the lines fade and the gaps between faces appear, leaving the SolidCraft logo. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SolidCraft + + diff --git a/assets/styles/web/loading.css b/assets/styles/web/loading.css index 9d11f792..c5bd4525 100644 --- a/assets/styles/web/loading.css +++ b/assets/styles/web/loading.css @@ -1,4 +1,7 @@ html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; } #solidcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } #solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; - color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; } + color: #4a5060; font: 14px system-ui, sans-serif; background: #e8ebf0; z-index: 10; transition: opacity 300ms ease; } + #solidcraft_loading.done { opacity: 0; pointer-events: none; } + #solidcraft_loading img { width: min(60vmin, 360px); height: auto; } + .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 74d4ff4e..4fb8e1a7 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -21,7 +21,8 @@ //! `|i: usize, n: usize| -> &'static str`; it may read the other fields; //! - `pinned`: a face pick that stays where it is when the feature moves; //! `pick_if = cond`: a face pick when `cond` holds, else a position; -//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs); +//! - `placement`: a primitive's placement point, or an optional one (its X, Y and Z expressions +//! are not inputs); //! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection; //! - `after = field`: visit this field right after `field` instead of in declaration order (where //! the parameter names already given need it); @@ -162,7 +163,8 @@ fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens { #w::Each::<#w::Vec3>::#each(#b, &mut |p| f(if #pick { #w::Geo::Pick(p) } else { #w::Geo::Pos(p) })); } } - Some(Geo::Placement) => quote! { f(#w::Geo::Placement(#b)); }, + // A point or an optional one (a hole's `at`, #1678). + Some(Geo::Placement) => quote! { #w::Each::::#each(#b, &mut |p| f(#w::Geo::Placement(p))); }, Some(Geo::Profiles { .. }) => { let (id, when) = (pre(b, "sketch"), pre(b, "when")); quote! { diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index a52580e3..c09a7725 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -244,6 +244,21 @@ impl Document { m } + /// World transform of one placement of `component`: the occurrence `path` from the root + /// (empty for the root itself). `None` when the path no longer leads to that component. + pub fn path_transform(&self, component: u64, path: &[u64]) -> Option { + if path.len() > MAX_PLACEMENT_DEPTH + 1 { + return None; + } + let (mut m, mut c) = (IDENTITY, 0); + for id in path { + let o = self.occurrences.iter().find(|o| o.id == *id && o.parent == c)?; + m = mat_mul(&m, &o.transform); + c = o.component; + } + (c == component).then_some(m) + } + /// Every placement of each component: (component, occurrence path ids, world transform). /// A branch stops past [`MAX_PLACEMENT_DEPTH`] levels, the walk past [`MAX_PLACEMENTS`] placements. pub fn placements(&self) -> Vec<(u64, Vec, Mat)> { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 21e4125c..3a88a021 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -299,6 +299,31 @@ impl SweepOrientation { } } +/// A sweep's guide rail: a chain of curves of a sketch, followed from the end nearest the path's +/// start. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +pub struct SweepRail { + pub sketch: SketchId, + pub curves: Vec, +} + +/// How a sweep's guide rails reshape its profile. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RailFit { + /// Evenly, with the distance across to the rail (between the rails). + #[default] + Scale, + /// Only across to the rail (between the rails). + Stretch, +} + +impl RailFit { + pub fn is_scale(&self) -> bool { + *self == RailFit::Scale + } +} + /// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses; /// an open chain's is the side it bends toward (a straight one's, the left of its first /// curve as drawn). @@ -563,6 +588,10 @@ pub enum FeatureKind { /// What to do when the radius is too large for the geometry around the edges. #[serde(default, skip_serializing_if = "FilletFit::is_exact")] fit: FilletFit, + /// What fills the corners where three blended edges meet: the rolling ball's sphere + /// (default) or a G2 patch repeating each blend up to the second order. + #[serde(default, skip_serializing_if = "FilletCorner::is_rolling")] + corner: FilletCorner, }, Chamfer { edges: Vec, @@ -653,11 +682,22 @@ pub enum FeatureKind { /// Join each mirrored body with its original. #[serde(default, skip_serializing_if = "std::ops::Not::not")] combine: bool, + /// Where each of `bodies` is mirrored from: the occurrence path of the placement it was + /// picked at. The body is mirrored where that placement shows it, as the feature's own + /// component sees it. Empty in older designs, which mirror the bodies' own geometry. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + placements: Vec>, }, /// A drilled hole (simple, counterbore or countersink) at a point, into the material. Hole { #[walk(pick_if = points.is_none())] position: FacePick, + /// The position as length expressions, when it was given that way (#1678): the hole is + /// drilled where they evaluate, and follows the parameters. `position` keeps the point + /// they gave when the hole was made. + #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_point_expr")] + #[walk(placement)] + at: Option, /// Drilling direction (into the material). direction: Dir, #[walk(length = "Diameter")] @@ -719,6 +759,12 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(unitless = "Scale")] scale: Option, + /// Guide rails (one or two) the profile follows, instead of orientation, twist and scale. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + rails: Vec, + /// How the rails reshape the profile. + #[serde(default, skip_serializing_if = "RailFit::is_scale")] + rail_fit: RailFit, #[serde(default)] operation: Operation, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1306,11 +1352,21 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(unitless = "Scale")] scale: Option, + /// Guide rails (one or two) the curves follow, instead of orientation, twist and scale. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + rails: Vec, + /// How the rails reshape the curves. + #[serde(default, skip_serializing_if = "RailFit::is_scale")] + rail_fit: RailFit, }, /// A surface body lofted through sections in order, each one open or closed chain of /// sketch curves: walls only, no caps. SurfaceLoft { sections: Vec, + #[serde(default = "loft_plain_continuity", skip_serializing_if = "loft_continuity_is_plain")] + continuity: [Continuity; 2], + #[serde(default, skip_serializing_if = "loft_crown_is_plain")] + crown: [f64; 2], }, /// The faces at `faces` copied out at a signed `distance` along their normals as a new /// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces; @@ -1491,6 +1547,23 @@ pub enum FeatureKind { }, } +/// What fills the corner where three blended edges meet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilletCorner { + /// A piece of the rolling ball's sphere: tangent (G1) to each blend. + #[default] + Rolling, + /// A G2 patch repeating each blend's surface up to the second order (see `corner_g2`). + G2, +} + +impl FilletCorner { + pub fn is_rolling(&self) -> bool { + matches!(self, FilletCorner::Rolling) + } +} + /// How Extend continues a surface past its edges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1734,6 +1807,28 @@ pub struct SheetSection { pub profiles: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub curves: Vec, + /// A section picked as an edge chain of a body instead of a sketch: `edges` are points on the + /// chain, and the face the chain bounds is the surface the loft leaves there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edges: Vec, +} + +/// A loft's continuity where a design does not say: both ends G0, the loft of old, which only +/// passes through its sections. +fn loft_plain_continuity() -> [Continuity; 2] { + [Continuity::G0, Continuity::G0] +} + +/// A loft's continuity is plain when it only passes through its sections: both ends G0. +fn loft_continuity_is_plain(c: &[Continuity; 2]) -> bool { + *c == [Continuity::G0, Continuity::G0] +} + +/// A loft's crown is plain when neither end is filled out. +fn loft_crown_is_plain(c: &[f64; 2]) -> bool { + c.iter().all(|v| *v == 0.0) } /// Coil cross-sections. @@ -1888,6 +1983,22 @@ mod point_expr { } } +/// [`point_expr`] for an optional point. +mod opt_point_expr { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + struct Wrap(#[serde(with = "super::point_expr")] super::PointExpr); + + pub fn serialize(p: &Option, s: S) -> Result { + p.as_ref().map(|p| Wrap(p.clone())).serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.map(|w| w.0)) + } +} + impl FeatureKind { /// A sheet metal feature (its result depends on the sheet metal rules). pub fn is_plastic(&self) -> bool { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 12003ecd..b7eb89d9 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -145,8 +145,8 @@ impl ModelBody { Some((Arc::new(plan), Arc::new(mesh))) } - /// The display mesh's chord tolerance: 1/1000 of the body's size. - fn display_tol(&self) -> f64 { + /// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm. + pub fn display_tol(&self) -> f64 { (self.body.size() * 1e-3).max(1e-3) } @@ -209,6 +209,12 @@ const ISO_COARSE: [(f64, f64); 22] = [ (56.0, 5.5), ]; +/// Nominal diameters and coarse pitches (mm) used for automatic ISO metric thread sizing. +/// Other diameter/pitch pairs can be supplied as explicit designations. +pub fn metric_thread_sizes() -> &'static [(f64, f64)] { + &ISO_COARSE +} + /// "M8" or "M8x1" → (nominal diameter, pitch). pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> { let t = s.trim().to_ascii_uppercase(); @@ -221,7 +227,7 @@ pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> { Some(p) => p, None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1, }; - (d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch)) + (d.is_finite() && pitch.is_finite() && d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch)) } /// The metric thread a cylinder of this diameter takes: a shaft is the major diameter, a hole @@ -241,6 +247,12 @@ fn format_thread(d: f64, p: f64) -> String { if coarse { format!("M{}", num(d)) } else { format!("M{}x{}", num(d), num(p)) } } +/// The automatic ISO metric designation for a cylindrical face, using the same fit check as +/// a thread feature without an explicit size. This query does not change the model. +pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result { + thread_on(st, 0, p, None, None).map(|t| t.designation) +} + /// A cosmetic thread on the cylindrical face at `p`. fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option) -> Result { let (body, cyl) = st @@ -735,6 +747,8 @@ pub struct Model { pub cache: Arc>, /// Evaluate everything from scratch (no reuse; tests compare the two). pub no_cache: bool, + /// The imported texts' digests the last `evaluate` fingerprinted the features with. + step_digests: StepDigests, } /// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a @@ -750,12 +764,12 @@ fn param_names(f: &Feature) -> Vec { names } -fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { +fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool, steps: &StepDigests) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); prev.hash(&mut h); rolled_back.hash(&mut h); vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h); - feature_json(f).hash(&mut h); + feature_json(f, steps).hash(&mut h); for n in param_names(f) { n.hash(&mut h); match vals.get(&n) { @@ -770,6 +784,44 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } +/// The digests of the Import features' STEP texts, by feature id, with the text each was taken +/// from. Every evaluation fingerprints every feature, and writing out a design's imported files +/// each time made every command as slow as the files are large: a text is shared (`Arc`), so +/// its digest is taken once and kept while the feature holds that same text. +#[derive(Clone, Debug, Default)] +struct StepDigests(std::collections::HashMap, u64)>); + +impl StepDigests { + /// The digests of `doc`'s imported texts, reusing `known` for texts it has seen. + fn of(doc: &Document, known: &StepDigests) -> StepDigests { + let texts = doc.features.iter().filter_map(|f| match &f.kind { + FeatureKind::Import { step, .. } => Some((f.id, step)), + _ => None, + }); + StepDigests( + texts + .map(|(id, step)| { + let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step)); + (id, (step.clone(), digest)) + }) + .collect(), + ) + } + + /// The digest of `step`, when it is the text feature `id` was last seen with. + fn get(&self, id: u64, step: &Arc) -> Option { + self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest) + } +} + +fn step_text_digest(text: &str) -> u64 { + #[cfg(test)] + cost_tests::count(|c| c.digested += 1); + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + h.finish() +} + /// Positions and radii are fingerprinted to this quantum (mm): far below anything a design /// can show, far above the solver's rounding, so a sketch solved again to the same geometry /// reads as the same input. @@ -794,8 +846,17 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) { } } -/// A feature as fingerprinted: its definition, a sketch's stored solve quantized. -fn feature_json(f: &Feature) -> String { +/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported +/// file's text by its digest. +fn feature_json(f: &Feature, steps: &StepDigests) -> String { + if let FeatureKind::Import { step, .. } = &f.kind { + let mut g = f.clone(); + if let FeatureKind::Import { step: text, .. } = &mut g.kind { + let digest = steps.get(f.id, step).unwrap_or_else(|| step_text_digest(step)); + *text = Arc::from(format!("{digest:016x}:{}", step.len())); + } + return serde_json::to_string(&g).unwrap_or_default(); + } if let FeatureKind::Sketch { .. } = &f.kind { let mut g = f.clone(); if let FeatureKind::Sketch { sketch, .. } = &mut g.kind { @@ -854,8 +915,8 @@ fn evaluation_values(doc: &Document) -> (BTreeMap, BTreeMap, f: &Feature, prev: u64, rolled_back: bool) -> u64 { - let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f); +fn chain_fingerprint(doc: &Document, vals: &BTreeMap, f: &Feature, prev: u64, rolled_back: bool, steps: &StepDigests) -> u64 { + let mut fp = fingerprint(prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f); // Sheet metal features also depend on the rules (and the parameters they use). if f.kind.is_sheet() { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -894,7 +955,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { for f in doc.features.iter().filter(|f| { matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }) }) { - fingerprint(0, f, vals, false).hash(&mut h); + fingerprint(0, f, vals, false, &StepDigests::default()).hash(&mut h); } doc.units.hash(&mut h); serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); @@ -950,17 +1011,25 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), _ => None, }; + let wired = |id: &crate::SketchId| doc.sketch(id.0).is_ok_and(|sketch| !sketch.wires.is_empty()); + let rails: &[crate::SweepRail] = match &f.kind { + FeatureKind::Sweep { rails, .. } | FeatureKind::SurfaceSweep { rails, .. } => rails, + _ => &[], + }; if let Some(path) = path - && doc.sketch(path.0).is_ok_and(|sketch| !sketch.wires.is_empty()) + && (wired(&path) || rails.iter().any(|r| wired(&r.sketch))) { let mut hash = std::collections::hash_map::DefaultHasher::new(); let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0); for frame in [doc.component_transform(f.component), doc.component_transform(owner)] { frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); } - if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind { - let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0); - doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + if let FeatureKind::Sweep { sketch, rails, .. } | FeatureKind::SurfaceSweep { sketch, rails, .. } = &f.kind { + // The profile's and the rails' components place them too. + for sketch in std::iter::once(sketch).chain(rails.iter().map(|r| &r.sketch)) { + let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0); + doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); + } } return hash.finish(); } @@ -970,7 +1039,9 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { FeatureKind::Revolve { to, .. } => to.is_some(), _ => false, }; - if to_object { + // A mirror of placed bodies follows where they are placed. + let placed_mirror = matches!(&f.kind, FeatureKind::Mirror { placements, .. } if !placements.is_empty()); + if to_object || placed_mirror { return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) }; } let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 }; @@ -1069,10 +1140,11 @@ impl Model { } // The fingerprints from the first stored sketch on. let vals = evaluation_values(doc).0; + let steps = StepDigests::of(doc, &self.step_digests); let marker = doc.marker.unwrap_or(usize::MAX); let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint); for (i, f) in doc.features.iter().enumerate().skip(first) { - prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker); + prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker, &steps); if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) { r.fingerprint = prev_fp; } @@ -1100,6 +1172,7 @@ impl Model { let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); + self.step_digests = StepDigests::of(doc, &self.step_digests); // Each feature's component, for `Document::legacy_body_component` on every body after // every feature without searching the timeline for each (the first feature with an id). let mut feature_component: std::collections::HashMap = std::collections::HashMap::with_capacity(doc.features.len()); @@ -1111,7 +1184,7 @@ impl Model { let rolled_back = i >= marker; // Opening was cancelled: the rest is skipped and the result discarded. let cancelled = watched.step(i); - let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back); + let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests); prev_fp = fp; if reuse && let Some(old) = self.results.get(i) @@ -1142,7 +1215,7 @@ impl Model { continue; } // The same feature on the same inputs: its earlier result. - let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state); + let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f), ctx, &state); if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { @@ -1171,7 +1244,8 @@ impl Model { b.made = crate::lineage::Made::Before; } let mut warning = None; - // Face references by name: move each face point onto its named face. + // Face references by name: move each face point onto its named face (on the body the + // feature names, when it names one and the face is there). let renamed; // Cylindrical emboss resolves its named face in the selected body's frame // and certifies an analytic anchor; mesh projection here would undo that. @@ -1191,7 +1265,7 @@ impl Model { if n.is_empty() { continue; } - match crate::naming::locate_face(&fst, n, *p) { + match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) { Some(hit) => { if let Some(note) = hit.note(n) { notes.push(note); @@ -1733,7 +1807,9 @@ fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[Strin let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); let margin = (size * 1e-4).max(1e-4); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); let want = op == Operation::Join; let mut holes = Vec::with_capacity(r.holes.len()); for h in &r.holes { @@ -1762,140 +1838,266 @@ fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[Strin Region2 { outer: r.outer.clone(), holes } } -/// Grow profile edges that lie on a face of a target body away from the profile, by a small -/// amount, when the space they grow into is already material (join) or empty (cut). `probe` -/// maps a sketch point to the world points where the tool would be (along the extrude, around -/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre -/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where -/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left -/// as it is. -fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec) -> Region2 { +/// The bodies a cut or join works on (its targets, else every body), asked what its tool would +/// take or add if it reached past its sketch. +struct Surroundings<'a> { + /// Each body with its display mesh and, once something near it is asked, its faces. + bodies: Vec<(&'a ModelBody, Arc, std::cell::OnceCell>>)>, + op: Operation, + /// The largest body's size. + size: f64, + /// Faces this close to a plane lie in it (the kernel's measure for coincident faces). + skin: f64, + /// How far off a round face a space must stay to be asked about: the face's triangles may + /// stand off it by twice the chord they are made with, and the check pads them as far again. + round: f64, +} + +impl<'a> Surroundings<'a> { + /// The chord of the meshes the bodies' faces are asked on, as a share of each one's size. + const CHORD: f64 = 2e-4; + + /// `None` for an operation other than a cut or a join, or without a body to work on. + fn of(st: &'a ModelState, op: Operation, targets: &[String]) -> Option> { + if !matches!(op, Operation::Cut | Operation::Join) { + return None; + } + let bodies: Vec<_> = + st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).map(|b| (b, b.mesh(), std::cell::OnceCell::new())).collect(); + let size = bodies.iter().map(|(b, _, _)| b.body.size()).fold(0.0, f64::max); + (!bodies.is_empty()).then(|| Surroundings { bodies, op, size, skin: (size * 1e-6).max(1e-8), round: 4.0 * Self::CHORD * size }) + } + + /// Does the space the triangles `pieces` of `plane` sweep along its normal, from the height + /// `lo` to `hi`, hold just what the tool may gain without changing the result: nothing of + /// the bodies (a cut), or one body's material (a join)? Decided on the bodies' faces (see + /// [`kernel::Clearance`]), so a wall, a rib, a gap or a hole anywhere in it counts. + /// `margin`: how far the region reaches past the triangles sideways (negative: their sides + /// are drawn in, so that faces lying against them don't count). + fn harmless(&self, plane: &Plane, pieces: &[[Vec2; 3]], margin: f64, lo: f64, hi: f64) -> bool { + let n = plane.normal(); + let faces: Vec<([Vec3; 3], f64)> = pieces.iter().map(|t| (t.map(|p| plane.to_world(p)), margin)).collect(); + let mut space = solidcraft_geom::Aabb3::EMPTY; + for p in faces.iter().flat_map(|(t, _)| t) { + space.add(*p + n * lo); + space.add(*p + n * hi); + } + let mut full = false; + for (body, mesh, faces_of) in &self.bodies { + // A body well away from that space holds none of it (its faces lie within the + // display mesh's chord tolerance of the mesh). + let (b, pad) = (mesh.bounds(), margin.abs() + 2.0 * body.display_tol()); + let away = |lo: Vec3, hi: Vec3| lo.x > hi.x + pad || lo.y > hi.y + pad || lo.z > hi.z + pad; + if !b.is_empty() && (away(b.min, space.max) || away(space.min, b.max)) { + continue; + } + let Some(clearance) = faces_of.get_or_init(|| kernel::Clearance::new(&body.body, body.body.size() * Self::CHORD)) else { return false }; + match clearance.swept(&faces, n, lo, hi) { + kernel::Occupancy::Mixed => return false, + kernel::Occupancy::Full => full = true, + kernel::Occupancy::Empty => {} + } + } + full == (self.op == Operation::Join) + } +} + +/// The outward normal (away from the profile) at parameter `t` of a segment of a +/// counterclockwise loop: the tangent turned clockwise. +fn outward(s: &solidcraft_geom::Seg2, t: f64) -> Option { use solidcraft_geom::Seg2; - if !matches!(op, Operation::Cut | Operation::Join) { - return r.clone(); + match *s { + Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)), + Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()), + _ => None, } - let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect(); - if bodies.is_empty() { - return r.clone(); +} + +/// A counterclockwise loop of lines and arcs, each moved out by `by` where `grow` says so: a +/// line moves out and meets its neighbours again, an arc keeps its centre and changes its +/// radius. `None` when an edge would turn round (too short for the move), edges in one line +/// would part, a segment would leave a tangent neighbour behind, or an arc would move at a +/// sharp corner. +fn offset_segs(segs: &[solidcraft_geom::Seg2], grow: &[bool], by: f64) -> Option> { + use solidcraft_geom::Seg2; + let n = segs.len(); + let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0; + let step = |g: bool| if g { by } else { 0.0 }; + // Where segment `i` starts once moved: the corner of it and the one before. + let mut starts = Vec::with_capacity(n); + for i in 0..n { + let (prev, cur, g0, g1) = (segs.get((i + n - 1) % n)?, segs.get(i)?, *grow.get((i + n - 1) % n)?, *grow.get(i)?); + let corner = cur.start(); + if !g0 && !g1 { + starts.push(corner); + } else if tangent(prev, cur) { + // Moved together, a tangent joint moves along the common normal and stays tangent. + if g0 != g1 { + return None; + } + starts.push(corner + outward(cur, 0.0)? * by); + } else { + let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return None }; + let (d0, d1) = ((b0 - a0).normalized()?, (b1 - a1).normalized()?); + let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * step(g0), a1 + Vec2::new(d1.y, -d1.x) * step(g1)); + let den = d0.cross(d1); + if den.abs() >= 1e-12 { + starts.push(p0 + d0 * ((p1 - p0).cross(d1) / den)); + } else if g0 == g1 { + starts.push(p1); + } else { + return None; + } + } } - let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); - let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); - let delta = (size * 0.005).max(1e-3); + let mut out = Vec::with_capacity(n); + for (i, seg) in segs.iter().enumerate() { + let (a, b, g) = (*starts.get(i)?, *starts.get((i + 1) % n)?, *grow.get(i)?); + out.push(match *seg { + Seg2::Line { a: a0, b: b0 } if (b - a).dot(b0 - a0) > 0.0 => Seg2::Line { a, b }, + Seg2::Arc { center, radius, start, sweep } if radius + step(g) * sweep.signum() > 0.0 => { + Seg2::Arc { center, radius: radius + step(g) * sweep.signum(), start, sweep } + } + _ => return None, + }); + } + Some(out) +} + +/// Grow profile edges that lie on a face of a body away from the profile, by a small amount, +/// so that the boolean meets no coincident face there. `probe` maps a sketch point to the +/// world points where the tool would be (along the extrude, around the revolve): an edge with +/// material on one side and room on the other at three points along it may grow. It grows +/// only where `gains` finds the whole strip it would add (triangles in the sketch) harmless, +/// asked a quarter wider than it grows, so that the moved side comes near nothing either: +/// three points miss the walls at the edge's ends, a neighbour across a gap, or a wall thinner +/// than the step. Elsewhere the edge stays where it was drawn. Loops of lines and arcs are +/// adjusted: a line moves out, an arc keeps its centre and changes its radius (a rounded corner +/// of a face cut whole, #1275); an arc's strip, and a line's where it meets one, is asked from +/// a little off the round face the arc lies on (see [`Surroundings`]'s `round`). Edges in one +/// line move together or not at all; arcs move only where both of their ends meet tangent +/// neighbours that move with them, otherwise the loop is left as it is. +fn grow_profile_for_coplanar(sur: &Surroundings, r: &Region2, probe: &dyn Fn(Vec2) -> Vec, gains: &dyn Fn(&[[Vec2; 3]]) -> bool) -> Region2 { + use solidcraft_geom::Seg2; + let delta = (sur.size * 0.005).max(1e-3); let lp = r.outer.ccw(); let segs = &lp.segs; if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { return r.clone(); } - // The outward normal (away from the profile) at parameter `t` of a segment of the - // counterclockwise loop: the tangent turned clockwise. - let out_at = |s: &Seg2, t: f64| -> Option { - match *s { - Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)), - Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()), + let n = segs.len(); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = sur.bodies.iter().map(|(_, m, _)| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); + let want_outer = sur.op == Operation::Join; + let mut grow: Vec = segs + .iter() + .map(|seg| { + if let Seg2::Arc { radius, sweep, .. } = *seg + && sweep < 0.0 + && radius <= 2.0 * delta + { + // A concave arc too small to shrink by the step. + return false; + } + // Is the edge on a body face? Material on exactly one side of it. + let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), outward(seg, *t)?))).collect(); + if mids.len() < 3 { + return false; + } + let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(&inside_any).collect::>(); + let (outer, inner) = (side(0.5), side(-0.5)); + let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer); + on_face && side(1.0).iter().all(|x| *x == want_outer) + }) + .collect(); + let in_line = |i: usize| match (segs.get(i % n), segs.get((i + 1) % n)) { + (Some(Seg2::Line { a: a0, b: b0 }), Some(Seg2::Line { a: a1, b: b1 })) => { + matches!(((*b0 - *a0).normalized(), (*b1 - *a1).normalized()), (Some(d0), Some(d1)) if d0.cross(d1).abs() < 1e-12) + } + _ => false, + }; + let round = |k: usize| matches!(segs.get(k % n), Some(Seg2::Arc { .. })); + // What segment `i` gains moving out to `moved`, as triangles in the sketch; `None` when + // nothing of it can be asked. + let strip = |i: usize, seg: &Seg2, moved: &Seg2| -> Option> { + match (*seg, *moved) { + (Seg2::Line { a, b }, Seg2::Line { a: a2, b: b2 }) => { + // Its ends drawn back from the round faces of arcs it runs into. + let d = (b - a).normalized()?; + let s0 = if round(i + n - 1) { sur.round } else { 0.0 }; + let s1 = if round(i + 1) { sur.round } else { 0.0 }; + if (b - a).len() <= s0 + s1 { + return None; + } + let (a, a2, b, b2) = (a + d * s0, a2 + d * s0, b - d * s1, b2 - d * s1); + Some(vec![[a, b, b2], [a, b2, a2]]) + } + (Seg2::Arc { center, radius, start, sweep }, Seg2::Arc { radius: far, .. }) => { + // From `round` off the face out to the moved arc, in pieces whose chords stand off + // the circles by less than `round`: on circles drawn round those circles' chords + // where the arc is convex (the chords then stay off the face and reach past the + // moved arc), on the circles themselves where it is concave. + let near = radius + sur.round * sweep.signum(); + let steps = ((sweep.abs() / (8.0 * sur.round / radius.max(far)).sqrt()).ceil() as usize).clamp(1, 256); + let scale = if sweep > 0.0 { 1.0 / (sweep.abs() / steps as f64 / 2.0).cos() } else { 1.0 }; + let at = |k: usize, rad: f64| center + Vec2::from_angle(start + sweep * k as f64 / steps as f64) * (rad * scale); + Some((0..steps).flat_map(|k| [[at(k, near), at(k + 1, near), at(k + 1, far)], [at(k, near), at(k + 1, far), at(k, far)]]).collect()) + } _ => None, } }; - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); - let mut grow = vec![0.0; segs.len()]; - let mut any = false; - for (i, seg) in segs.iter().enumerate() { - if let Seg2::Arc { radius, sweep, .. } = *seg - && sweep < 0.0 - && radius <= 2.0 * delta - { - // A concave arc too small to shrink by the step. - continue; + for _ in 0..=n { + // Edges in one line move together or not at all. + while let Some(i) = (0..n).find(|&i| grow.get(i) != grow.get((i + 1) % n) && in_line(i)) { + grow.iter_mut().enumerate().filter(|(k, _)| *k == i || *k == (i + 1) % n).for_each(|(_, g)| *g = false); } - // Is the edge on a body face? Material on exactly one side of it. - let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect(); - if mids.len() < 3 { - continue; - } - let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::>(); - let (outer, inner) = (side(0.5), side(-0.5)); - let want_outer = op == Operation::Join; - let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer); - let room = side(1.0).iter().all(|x| *x == want_outer); - if on_face - && room - && let Some(g) = grow.get_mut(i) - { - *g = delta; - any = true; - } - } - if !any { - return r.clone(); - } - let n = segs.len(); - let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0; - // Where segment `i` starts once grown: the corner of it and the one before. - let mut starts = Vec::with_capacity(n); - for i in 0..n { - let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else { + if !grow.contains(&true) { return r.clone(); - }; - let corner = cur.start(); - if g0 == 0.0 && g1 == 0.0 { - starts.push(corner); - continue; } - if tangent(prev, cur) { - // Moved together, a tangent joint moves along the common normal and stays tangent. - if g0 != g1 { - return r.clone(); + let Some(reach) = offset_segs(segs, &grow, delta * 1.25) else { return r.clone() }; + let mut dropped = false; + for (i, (seg, moved)) in segs.iter().zip(&reach).enumerate() { + if grow.get(i) == Some(&true) + && !strip(i, seg, moved).is_some_and(|pieces| gains(&pieces)) + && let Some(g) = grow.get_mut(i) + { + *g = false; + dropped = true; } - let Some(out) = out_at(cur, 0.0) else { return r.clone() }; - starts.push(corner + out * g1); - continue; } - let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() }; - let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() }; - let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1); - let den = d0.cross(d1); - if den.abs() < 1e-12 { - starts.push(p1); - continue; + if !dropped { + return match offset_segs(segs, &grow, delta) { + Some(segs) => Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: r.holes.clone() }, + None => r.clone(), + }; } - let t = (p1 - p0).cross(d1) / den; - starts.push(p0 + d0 * t); } - let mut out = Vec::with_capacity(n); - for (i, seg) in segs.iter().enumerate() { - let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() }; - out.push(match *seg { - Seg2::Line { .. } => Seg2::Line { a, b }, - Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep }, - _ => return r.clone(), - }); - } - Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() } + r.clone() } /// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss /// starting on the face it joins). Extending the tool past such an end changes nothing when the /// space beyond is empty (cut) or already material (join), so do exactly that. Only an end -/// that lies on a target face parallel to it moves, and by less than half the way to the next -/// such face level: the samples can miss material beyond the end (a thin floor between a recess -/// above and a ring pocket below), which a longer tool would cut through. -fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) { - if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() { - return (lo, hi); - } - let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect(); - if bodies.is_empty() { - return (lo, hi); - } - let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); +/// that lies on a body face parallel to it moves, by less than half the way to the next such +/// face level, and only when the whole slab it gains (asked a quarter further than it moves) +/// is harmless on the bodies' faces (see [`Surroundings::harmless`]): sample points miss a rib +/// beyond the end, a thin floor between a recess above and a ring pocket below, or a hole or a +/// gap behind it. +fn extend_for_coplanar(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, hi: f64) -> (f64, f64) { + let size = sur.size; let delta = (size * 0.01).max(1e-3).min((hi - lo).abs().max(1e-3)); - let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); let samples = region_samples(r); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); - // Heights of the targets' faces parallel to the profile plane. + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = sur.bodies.iter().map(|(_, m, _)| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); + // Heights of the bodies' faces parallel to the profile plane. let tol = (size * 1e-7).max(1e-9); - let levels: Vec = meshes + let levels: Vec = sur + .bodies .iter() + .map(|(_, m, _)| m) .flat_map(|m| { m.triangles.iter().filter_map(|t| { let [a, b, c] = m.tri(t)?; @@ -1913,15 +2115,25 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min); delta.min(gap * 0.5) }; + // The profile's triangles; curved sides stand off their chords by up to `chord`. + let chord = (size * 2e-4).max(1e-6); + let straight = std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. })); let ok_beyond = |h: f64, dir: f64, step: f64| { - samples.iter().all(|s| { + let sampled = samples.iter().all(|s| { let base = plane.to_world(*s); - [0.5, 1.0].iter().all(|k| { - let p = base + n * (h + dir * step * k); - let inside = inside_any(p); - if op == Operation::Cut { !inside } else { inside } - }) - }) + [0.5, 1.0].iter().all(|k| inside_any(base + n * (h + dir * step * k)) == (sur.op == Operation::Join)) + }); + if !sampled { + return false; + } + let pieces = r.triangulate(chord); + // Triangles that leave part of a straight profile uncovered leave it unasked. + let covered: f64 = pieces.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum(); + if pieces.is_empty() || (straight && (covered - r.area()).abs() > 1e-7 * r.area()) { + return false; + } + let (h0, h1) = if dir > 0.0 { (h + sur.skin, h + step * 1.25) } else { (h - step * 1.25, h - sur.skin) }; + sur.harmless(plane, &pieces, if straight { -sur.skin } else { 2.0 * chord }, h0, h1) }; let (down, up) = (room(lo, -1.0), room(hi, 1.0)); let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo }; @@ -1929,6 +2141,39 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: (lo2, hi2) } +/// Whether a tapered tool, run on by `depth` behind the plane it starts in (`dir` the way it +/// extrudes), stays where running on changes nothing: in the targets for a join, out of them for +/// a cut. Its sides keep their slope, so the section there is the profile grown by up to `widen` +/// all round; that grown outline is probed at two depths. +#[allow(clippy::too_many_arguments)] +fn taper_run_on_fits(st: &ModelState, start: &Plane, dir: Vec3, r: &Region2, depth: f64, widen: f64, op: Operation, targets: &[String]) -> bool { + const MAX_SEGMENTS: usize = 2_000; + let meshes: Vec> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).map(|b| b.mesh()).collect(); + if meshes.is_empty() || !depth.is_finite() || !widen.is_finite() { + return false; + } + let want = op == Operation::Join; + // Each loop counterclockwise with the side away from the material: right of the outer loop, + // left of a hole. + let loops: Vec<(solidcraft_geom::Loop2, f64)> = std::iter::once((r.outer.ccw(), 1.0)).chain(r.holes.iter().map(|h| (h.ccw(), -1.0))).collect(); + let count: usize = loops.iter().map(|(l, _)| l.segs.len()).sum(); + if count > MAX_SEGMENTS { + return false; + } + loops.iter().all(|(lp, side)| { + lp.segs.iter().all(|seg| { + [0.0, 0.25, 0.5, 0.75].iter().all(|t| { + let Some(d) = seg.derivative(*t).normalized() else { return true }; + let out = Vec2::new(d.y, -d.x) * *side; + [0.5, 1.0].iter().all(|k| { + let p = start.to_world(seg.point_at(*t) + out * (widen * k)) - dir * (depth * k); + meshes.iter().any(|m| m.contains(p)) == want + }) + }) + }) + }) +} + /// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run /// on into. const TAPER_FOOT: f64 = 0.01; @@ -2299,8 +2544,8 @@ fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 { /// Where a hole feature drills (each on the surface) and in which direction: at its sketch /// points perpendicular to the sketch (into the material), else at its position. -fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { - let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; +fn hole_spots(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { + let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; let (spots, dir) = match points { Some(sp) => { let ss = @@ -2316,7 +2561,7 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { } None => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; - let at = hole_on_surface(st, position.0, dir); + let at = hole_on_surface(st, hole_position(vals, position, at)?, dir); let dir = hole_dir_into_face(st, at, dir); return Ok((vec![hole_on_surface(st, at, dir)], dir)); } @@ -2350,8 +2595,8 @@ fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option, depth } /// A hole's spots as given (not yet moved onto the surface) and its direction. -fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { - let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; +fn hole_spots_raw(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { + let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; match points { Some(sp) => { let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?; @@ -2363,16 +2608,29 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec, Vec3)> { } None => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; - Ok((vec![position.0], hole_dir_into_face(st, hole_on_surface(st, position.0, dir), dir))) + let p = hole_position(vals, position, at)?; + Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir), dir))) } } } +/// Where a hole placed by position is drilled: its `at` expressions evaluated, so it follows the +/// parameters (#1678), else the point it was given. +fn hole_position(vals: &BTreeMap, position: &crate::FacePick, at: &Option) -> Result { + let Some([x, y, z]) = at else { return Ok(position.0) }; + let p = + Vec3::new(Document::eval_in(vals, x, Kind::Length)?, Document::eval_in(vals, y, Kind::Length)?, Document::eval_in(vals, z, Kind::Length)?); + if ![p.x, p.y, p.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { + return Err(DocError::Invalid("the hole's position is out of range".into())); + } + Ok(p) +} + /// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a /// straight cylinder (see [`hole_tools_and_prisms`]). fn hole_feature_tools(vals: &BTreeMap, st: &ModelState, f: &Feature) -> Result)>> { let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) }; - let (spots, dir) = hole_spots(st, f)?; + let (spots, dir) = hole_spots(vals, st, f)?; if spots.is_empty() || spots.len() > 10_000 { return Err(DocError::Invalid("a hole needs 1…10000 points".into())); } @@ -2402,6 +2660,23 @@ fn sheet_hole_cut( let mut handled: Vec = Vec::new(); // Noted only when every tool was machined here. let mut parted = None; + // Many plain drills along one axis (a hole on each of a pattern's points): all in one pass. + let prisms: Option> = tools.iter().map(|(_, prism)| *prism).collect(); + if let Some(prisms) = prisms { + match more::sheet_drill_cuts(f, st, &prisms, &mut parted) { + Ok(Some(done)) => { + if let Some(note) = parted { + append_warning(warning, note); + } + return Ok(done); + } + // Not all of them that way, or refused at once: one by one, from the model as it was. + Ok(None) | Err(_) => { + (st.sheets, st.bodies) = saved.clone(); + parted = None; + } + } + } for (tool, prism) in tools { let tool = &tool.body; let done = match prism { @@ -2806,7 +3081,9 @@ fn feature_tools_with_constructor( // How far a tool grows past its profile at either end, at most. let mut growth: f64 = 0.0; // A face of the targets in the profile's plane (the same for every profile). - let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &crate::plain(targets)); + let plain_targets: Vec = crate::plain(targets); + let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets); + let surroundings = Surroundings::of(st, *operation, &plain_targets); let tools: Vec = regions .iter() .map(|r| { @@ -2814,16 +3091,40 @@ fn feature_tools_with_constructor( // sides standing on a face are curved where they meet it (along text, say), // and the boolean finds no result there. The sides keep their slope, so // the profile stays the section at the start. + let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }; + // A start that is a whole face of the target (a foot under a part's base) is sewn on + // along it, as it lies: run on, the sides would leave the face through its edges. + if on_face + && *operation == Operation::Join + && let Ok(exact) = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper) + && st + .bodies + .iter() + .filter(|t| targets.is_empty() || targets.iter().any(|x| *x == t.name)) + .any(|t| kernel::can_join_touching_faces(&t.body, &exact)) + { + growth = growth.max(len * taper.tan().abs()); + return Ok(roles.tool(exact, None)); + } let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) }; - let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, &crate::plain(targets)); - let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) }; + let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi)); + let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) }; + // A taper that narrows the tool away from the face widens it as it runs on into it, past + // the profile (where the profile is the whole face, out through the body's sides, #1534): + // then it starts on the face instead. + if a < 0.0 && a * taper.tan() > 0.0 { + let dir = n * sign; + if !taper_run_on_fits(st, &start_plane, dir, r, -a, a * taper.tan(), *operation, &plain_targets) { + a = 0.0; + } + } // A start that can't run on (the profile hangs over the face's edge): // the sides stand straight for a hair first, so the boolean can push the // face the tool stands on into the body. let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 }; let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?; growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs()); - Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None)) + Ok(roles.tool(body, None)) }) .collect::>()?; // Profiles apart by more than the tools grow (the letters of a text): the tools @@ -2877,6 +3178,8 @@ fn feature_tools_with_constructor( let mut footprints = Vec::with_capacity(regions.len()); // Each tool's extent along the normal (they differ where one is run on into a face). let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len()); + let plain_targets: Vec = crate::plain(targets); + let surroundings = Surroundings::of(st, *operation, &plain_targets); for r in ®ions { if *operation == Operation::Join { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; @@ -2894,14 +3197,32 @@ fn feature_tools_with_constructor( continue; } } - let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, &crate::plain(targets)); + let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off)); let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::>(); let original = r; - let plain_targets = crate::plain(targets); - let r = grow_profile_for_coplanar(st, r, *operation, &plain_targets, &probe); - let r = fill_void_holes(st, &r, *operation, &plain_targets, &ss.plane, (l2, h2)); + let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings { + Some(sur) => grow_profile_for_coplanar(sur, r, &probe, gains), + None => r.clone(), + }; + // A hole of the profile over empty space (a cut) or material (a join) is taken in. + let filled = |p: Region2| fill_void_holes(st, &p, *operation, &plain_targets, &ss.plane, (l2, h2)); + // A side's strip runs the tool's height, drawn back by a hair from its ends. + let mut r = filled(grown(&|pieces| { + surroundings.as_ref().is_some_and(|sur| sur.harmless(&ss.plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin)) + })); + let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss)); + if built.is_err() { + // A profile the kernel cannot build as drawn (a square some hundredths of a + // micrometre across, away from the origin, defeats truck's plane fit) grows + // wherever the samples find room, as it used to. + let loose = filled(grown(&|_| true)); + if loose != r { + built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss)); + r = loose; + } + } untrimmed &= l2 == lo + off && h2 == hi + off && &r == original; - tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?); + tools.extend(built?); footprints.push(r); extents.push((l2, h2)); } @@ -3030,6 +3351,7 @@ fn feature_tools_with_constructor( let (operation, targets) = feature_op(f); let axis_w = ss.plane.dir_to_world(d); let origin_w = ss.plane.to_world(o); + let surroundings = Surroundings::of(st, operation, &targets); let regions: Vec = regions .iter() .zip(®ion_extents) @@ -3049,7 +3371,11 @@ fn feature_tools_with_constructor( }) .collect::>() }; - Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe)) + // Around the axis the strips are rings, which the faces are not asked about yet. + Ok(match &surroundings { + Some(sur) => grow_profile_for_coplanar(sur, r, &probe, &|_| true), + None => r.clone(), + }) }) .collect::>>()?; // To a curved face: turn past the whole target body, then cut back to its surface @@ -3084,6 +3410,9 @@ fn feature_tools_with_constructor( { return build_curved(®ions, -axis_w, tb); } + // A new body is final: its planar faces come out whole. A boolean's tool is not + // healed (the boolean heals its result). + let revolve = if operation == Operation::NewBody { kernel::revolve_whole } else { kernel::revolve }; if *convention == crate::RevolveConvention::SketchNormal { let mut out = Vec::new(); for (region, region_extent) in regions.iter().zip(®ion_extents) { @@ -3092,7 +3421,7 @@ fn feature_tools_with_constructor( out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?); continue; } - let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?; + let bodies = revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?; let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start); for body in bodies { out.push(if region_extent.start.abs() < 1e-12 { @@ -3104,7 +3433,7 @@ fn feature_tools_with_constructor( } return Ok(out); } - let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?; + let bodies = revolve(&ss.plane, ®ions, o, d, ang)?; if start.abs() < 1e-12 { return Ok(regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect()); } @@ -3185,8 +3514,7 @@ fn feature_tools_with_constructor( let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() }; Ok(vec![roles.tool(body)]) } - FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => { - let ctl = sweep_control(vals, orientation, twist, scale)?; + FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, rails, rail_fit, .. } => { let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -3202,11 +3530,25 @@ fn feature_tools_with_constructor( let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?); let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default(); let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None); + let points = path_points(ps, path, start)?; + let segs = match points { + Some(_) => Vec::new(), + None => path_segments(ps, path, start)?, + }; + let path_start = points + .as_ref() + .and_then(|p| p.first().copied()) + .or_else(|| match segs.first() { + Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => Some(*a), + None => None, + }) + .unwrap_or(start); + let rails = rail_polylines(doc, st, rails, f.component, path_start)?; + let ctl = sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; // A path through 3D sketch curves follows them as points. - if let Some(pts) = path_points(ps, path, start)? { + if let Some(pts) = points { return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect(); } - let segs = path_segments(ps, path, start)?; regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect() } FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { @@ -3255,13 +3597,16 @@ fn feature_tools_with_constructor( } /// A chain of sketch curves as 3D path segments, starting at the end nearest `start`. -/// A sweep's options from its stored orientation and its twist and end scale expressions -/// (none: no twist, scale 1). Out-of-range values are refused by the kernel. +/// A sweep's options from its stored orientation, its twist and end scale expressions (none: no +/// twist, scale 1) and its guide rails as polylines (see [`rail_polylines`]). Out-of-range values +/// are refused by the kernel. pub(crate) fn sweep_control( vals: &BTreeMap, orientation: &crate::SweepOrientation, twist: &Option, scale: &Option, + rails: Vec>, + rail_fit: crate::RailFit, ) -> Result { let orientation = match *orientation { crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular, @@ -3276,11 +3621,40 @@ pub(crate) fn sweep_control( Some(k) => val(vals, k, Kind::Unitless)?, None => 1.0, }; - let ctl = kernel::SweepControl { orientation, twist, scale }; + let rail_fit = match rail_fit { + crate::RailFit::Scale => kernel::RailFit::Scale, + crate::RailFit::Stretch => kernel::RailFit::Stretch, + }; + let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit }; ctl.check()?; Ok(ctl) } +/// A sweep's guide rails as polylines in the feature's frame, each a chain of its sketch's curves +/// (planar or 3D) from the end nearest `start`, the start of the path. +pub(crate) fn rail_polylines(doc: &Document, st: &ModelState, rails: &[crate::SweepRail], consumer: u64, start: Vec3) -> Result>> { + if rails.len() > 2 { + return Err(DocError::Invalid("a sweep takes one or two guide rails".into())); + } + rails + .iter() + .map(|rail| { + if rail.curves.is_empty() { + return Err(DocError::Invalid("a guide rail needs its curves".into())); + } + let rs = st + .sketch(rail.sketch.0) + .ok_or_else(|| DocError::Unknown(format!("rail sketch {} (it must come earlier in the timeline)", rail.sketch)))?; + let mapped = path_in_frame(doc, rs, consumer)?; + let rs = mapped.as_ref(); + match path_points(rs, &rail.curves, start)? { + Some(pts) => Ok(pts), + None => Ok(kernel::path_polyline(&path_segments(rs, &rail.curves, start)?)?), + } + }) + .collect() +} + /// A pipe's section: a circle of radius `r`, hollow when it has a wall. fn pipe_region(vals: &BTreeMap, r: f64, wall: &Option) -> Result { let holes = match wall { @@ -3545,6 +3919,20 @@ fn mirror_matrix(pl: &Plane) -> Mat { const MAX_INSTANCES: usize = 1000; +/// Where a mirrored body is, in the frame of the mirror's component: through the placement it +/// was picked at and the feature's component frame. `None` when that is the body's own frame +/// (and in older designs, which saved no placement). +fn mirrored_placement(doc: &Document, f: &Feature, mb: &ModelBody, path: Option<&[u64]>) -> Result> { + let Some(path) = path else { return Ok(None) }; + let owner = doc.model_body_component(mb); + let lost = || DocError::Invalid(format!("the placement `{}` was mirrored at no longer exists", mb.name)); + let world = doc.path_transform(owner, path).ok_or_else(lost)?; + let world = doc.model_body_offset(mb).map(|offset| crate::mat_mul(&world, offset)).unwrap_or(world); + let inverse = crate::mat_inverse(&doc.component_transform(f.component)).ok_or_else(lost)?; + let rel = crate::mat_mul(&inverse, &world); + Ok((!crate::is_identity(&rel)).then_some(rel)) +} + /// The placements (column-major matrices) of a pattern's copies, the original excluded. pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result> { let (vals, _) = doc.param_values(); @@ -3754,7 +4142,7 @@ fn replay( // A hole is drilled again at each copy of its spot (found on the surface there), so // a blind hole's depth counts from the face at the copy, not from the bottom of the // original hole under the original spot. - let (spots, dir) = hole_spots_raw(st, src)?; + let (spots, dir) = hole_spots_raw(vals, st, src)?; for (k, m) in mats.iter().enumerate() { let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; for p in &spots { @@ -4148,6 +4536,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let anchor = crate::face_anchor::plane_face_anchor(plane); let fst = match anchor { Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?, + // A sketch that reads no geometry (a fixed plane, no projections) needs no other + // component's bodies moved into its frame: placing every body of a large + // assembly again is what made each sketch edit as slow as the design is big. + None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st), None => state_in_frame(doc, st, f.component), }; let (plane, face_warning) = match (anchor, plane) { @@ -4207,7 +4599,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: apply_traced(st, f, tools, *operation, &crate::plain(targets)) } FeatureKind::Hole { thread, diameter, .. } => { - let (spots, dir) = hole_spots(st, f)?; + let (spots, dir) = hole_spots(vals, st, f)?; let tools = hole_feature_tools(vals, st, f)?; let handled = sheet_hole_cut(st, f, &tools, warning)?; let tools: Vec = tools.into_iter().map(|(tool, _)| tool).collect(); @@ -4311,6 +4703,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?; doc.resolve_attached_plane(vals, plane, f.component, st, &fst)? } + // A plane of numbers alone reads no body: placing them all is the design's size. + None if plane_is_fixed(plane) => doc.resolve_plane_in(vals, plane, 0, Some(st))?, // Picks on bodies of other (moved) components are where those bodies are shown. None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?, }; @@ -4409,31 +4803,35 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: FeatureKind::Split { body, plane, tool, sketch, line } => { let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) }; + let input = mb.traced(); let parts = match tool { // A sketch line: the plane through it along the sketch's normal. _ if sketch.is_some() || line.is_some() => { let (Some(sketch), Some(line)) = (sketch, line) else { return Err(DocError::Invalid("a sketch line tool needs both its sketch and its line".into())); }; - kernel::split_by_plane(&mb.body, &sketch_line_plane(st, sketch.0, line)?)? + split_by_plane_traced(&input, &sketch_line_plane(st, sketch.0, line)?)? } // A face of a body (its surface extended). Some(t) => { let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?; let face = crate::appearance::face_index_at(tb, t.point.0) .ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?; + // The pieces keep the planar roles of the faces they share with the body (#565). kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })? + .into_iter() + .map(|part| Ok(Traced::step(part.with_preserved_planar_sources(&mb.body)?, &[&input]))) + .collect::>>()? } None => { let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; - kernel::split_by_plane(&mb.body, &pl)? + split_by_plane_traced(&input, &pl)? } }; if parts.len() < 2 { return Err(DocError::Invalid("the tool does not split the body".into())); } - let input = mb.traced(); - let mut it = parts.into_iter().map(|part| Traced::step(part, &[&input])); + let mut it = parts.into_iter(); if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) { *slot = ModelBody::traced_new(mb.name.clone(), first, mb.feature); } @@ -4464,17 +4862,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } - FeatureKind::Mirror { features, plane, bodies, combine } => { + FeatureKind::Mirror { features, plane, bodies, combine, placements } => { let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?; if bodies.is_empty() { return replay(doc, vals, f, st, &crate::plain(features), &[mirror_matrix(&pl)], warning); } let m = mirror_matrix(&pl); let mut output = 0; - for n in bodies { + for (k, n) in bodies.iter().enumerate() { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; - let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1); + // A body of a component placed elsewhere is mirrored where it is shown: the copy + // (in this feature's component) from its placement, a joined body in its own frame. + let placed = mirrored_placement(doc, f, &mb, placements.get(k).map(Vec::as_slice))?; + let (copy, own) = match placed { + Some(rel) => { + let inverse = crate::mat_inverse(&rel).ok_or_else(|| DocError::Invalid(format!("the placement of `{n}` is singular")))?; + let copy = crate::mat_mul(&m, &rel); + (copy, crate::mat_mul(&inverse, ©)) + } + None => (m, m), + }; + let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, if *combine { own } else { copy })?, 1); if *combine { let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)? .ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?; @@ -4550,8 +4959,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: }; st.blend_edges.insert(f.id, edges.clone()); let input = mb.traced(); + // A G2 corner is built by the corner construction alone, for a constant radius: any + // other style (or a fitted radius) has no corner patch to repeat. + if let FeatureKind::Fillet { corner: crate::FilletCorner::G2, style, fit, .. } = &f.kind + && (!matches!(style, crate::FilletStyle::Constant) || !matches!(fit, crate::FilletFit::Exact)) + { + return Err(DocError::Invalid("a G2 corner is built for a constant-radius fillet (type: constant, fit: exact) only".into())); + } // The blended body, and the lineage of what the last blend started from. let (nb, from) = match &f.kind { + FeatureKind::Fillet { corner: crate::FilletCorner::G2, .. } => (kernel::fillet_g2_corners(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Curvature, .. } => (kernel::fillet_g2(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input), FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => { @@ -4881,6 +5298,31 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: state_in_frame(doc, st, comp) } +/// Whether `plane` is given by the origin planes and numbers alone. +fn plane_is_fixed(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base), + _ => false, + } +} + +/// Whether resolving `plane` reads the model's bodies or sketches (a face, edges, points), +/// rather than only the origin planes, numbers and construction planes. +fn plane_reads_geometry(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base), + PlaneRef::Midplane { .. } + | PlaneRef::TwoEdges { .. } + | PlaneRef::ThreePoints { .. } + | PlaneRef::Tangent { .. } + | PlaneRef::AlongPath { .. } + | PlaneRef::Perpendicular { .. } + | PlaneRef::Face { .. } => true, + } +} + pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { state_in_frame_at(doc, st, comp, doc.component_transform(comp)) } @@ -4957,6 +5399,8 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram if !crate::is_identity(m) && let Ok(t) = kernel::transform_matrix(&b.body, *m) { + #[cfg(test)] + cost_tests::count(|c| c.moved += 1); // Moving a body keeps its faces (and their order): it keeps their names. let names = crate::naming::NameCell::same(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); @@ -5237,6 +5681,7 @@ mod chain_tests { body: None, style: crate::FilletStyle::Constant, fit: Default::default(), + corner: Default::default(), }, None, ) @@ -5251,6 +5696,10 @@ mod chain_tests { } } +#[cfg(test)] +#[path = "eval_cost_tests.rs"] +mod cost_tests; + #[cfg(test)] mod body_name_tests { use super::*; @@ -5281,6 +5730,117 @@ mod body_name_tests { } } +#[cfg(test)] +mod coplanar_growth_tests { + use super::*; + + fn state(bodies: Vec) -> ModelState { + ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() } + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Region2 { + let p = Vec2::new; + Region2 { outer: solidcraft_geom::Loop2::polygon(&[p(x0, y0), p(x1, y0), p(x1, y1), p(x0, y1)]), holes: Vec::new() } + } + + /// The rectangle `x0` … `x1` by `y0` … `y1` with its corners rounded to `rad`, counterclockwise. + fn rounded(x0: f64, y0: f64, x1: f64, y1: f64, rad: f64) -> Region2 { + use solidcraft_geom::Seg2; + let p = Vec2::new; + let quarter = std::f64::consts::FRAC_PI_2; + let arc = |cx: f64, cy: f64, k: f64| Seg2::Arc { center: p(cx, cy), radius: rad, start: k * quarter, sweep: quarter }; + let segs = vec![ + Seg2::Line { a: p(x0 + rad, y0), b: p(x1 - rad, y0) }, + arc(x1 - rad, y0 + rad, 3.0), + Seg2::Line { a: p(x1, y0 + rad), b: p(x1, y1 - rad) }, + arc(x1 - rad, y1 - rad, 0.0), + Seg2::Line { a: p(x1 - rad, y1), b: p(x0 + rad, y1) }, + arc(x0 + rad, y1 - rad, 1.0), + Seg2::Line { a: p(x0, y1 - rad), b: p(x0, y0 + rad) }, + arc(x0 + rad, y0 + rad, 2.0), + ]; + Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: Vec::new() } + } + + /// The extrude's growth of `r` (a cut from the plane z = `z`, `lo` … `hi` along +z), as + /// `feature_tools_with_constructor` asks for it. + fn grown(st: &ModelState, z: f64, r: &Region2, lo: f64, hi: f64) -> (Region2, f64, f64) { + let sur = Surroundings::of(st, Operation::Cut, &[]).unwrap(); + let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY }; + let (l2, h2) = extend_for_coplanar(&sur, &plane, r, lo, hi); + let probe = |p: Vec2| (1..4).map(|k| plane.to_world(p) + Vec3::Z * (lo + (hi - lo) * k as f64 / 4.0)).collect::>(); + let gains = |pieces: &[[Vec2; 3]]| sur.harmless(&plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin); + (grow_profile_for_coplanar(&sur, r, &probe, &gains), l2, h2) + } + + /// A cut's tool still grows past the faces it lies on where all it gains is empty space: a + /// notch 10 deep into the corner of a 100 × 60 × 30 block, flush with two of its sides, + /// grows out of those sides by 0.5 % of the block's size and up out of its top by 1 %. + /// Cut into the end wall of a tray of that size, flush with the wall's inner face, the + /// strip its side would gain holds the start of the long walls: there it stays as drawn. + #[test] + fn the_tool_grows_only_where_it_gains_nothing() { + let block = kernel::box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap(); + let st = state(vec![block.clone()]); + let notch = rect(0.0, 0.0, 20.0, 20.0); + let (r, lo, hi) = grown(&st, 30.0, ¬ch, -10.0, 0.0); + let step = block.size() * 0.005; + for p in [Vec2::new(-0.9 * step, 10.0), Vec2::new(10.0, -0.9 * step), Vec2::new(-0.9 * step, -0.9 * step)] { + assert!(r.contains(p) && !notch.contains(p), "{p:?}: {r:?}"); + } + assert!(!r.contains(Vec2::new(20.0 + 0.1 * step, 10.0)) && !r.contains(Vec2::new(10.0, 20.0 + 0.1 * step)), "{r:?}"); + assert_eq!(lo, -10.0); + assert!((hi - block.size() * 0.01).abs() < 1e-9, "{hi}"); + + let inner = kernel::box_solid(Vec3::new(2.0, 2.0, 2.0), Vec3::new(98.0, 58.0, 40.0)).unwrap(); + let tray = kernel::boolean(&block, &inner, BoolOp::Cut).unwrap().unwrap(); + let st = state(vec![tray]); + let end_wall = rect(-1.0, -1.0, 2.0, 70.0); + assert_eq!(grown(&st, 2.0, &end_wall, 0.0, 40.0), (end_wall, 0.0, 40.0)); + } + + /// Arcs grow too (#1275), on the same terms. A cut of the whole top face of a 40 × 40 × 20 + /// block with corners rounded to 8 grows out of every side, its corners' arcs by the same step + /// as its edges. A post standing 0.1 off one corner's arc, between the points the samples + /// look at, would lose a sliver to the grown arc: there the loop stays as drawn. + #[test] + fn arcs_grow_only_where_they_gain_nothing() { + let face = rounded(0.0, 0.0, 40.0, 40.0, 8.0); + let block = kernel::extrude(&Plane::XY, std::slice::from_ref(&face), 0.0, 20.0).unwrap().remove(0); + let step = block.size() * 0.005; + let st = state(vec![block.clone()]); + let (r, _, _) = grown(&st, 20.0, &face, -5.0, 0.0); + let corner = |k: f64| Vec2::new(8.0, 8.0) + Vec2::from_angle(1.25 * std::f64::consts::PI) * (8.0 + k * step); + for q in [Vec2::new(-0.9 * step, 20.0), Vec2::new(20.0, 40.0 + 0.9 * step), corner(0.9)] { + assert!(r.contains(q) && !face.contains(q), "{q:?}: {r:?}"); + } + assert!(!r.contains(corner(1.1)), "{r:?}"); + + // The post's corner nearest the arc's centre (8, 8) lies 8.1 from it, at 190 degrees; the + // samples look at 202.5, 225 and 247.5 degrees. + let post = kernel::box_solid(Vec3::new(-2.0, 5.0, 0.0), Vec3::new(8.0 - 7.977, 8.0 - 1.407, 20.0)).unwrap(); + let st = state(vec![block, post]); + assert_eq!(grown(&st, 20.0, &face, -5.0, 0.0).0, face); + } +} + +/// The parts of a body split by a plane, on its positive side first (only the non-empty ones). +/// Each part's cut face is named `section` after the half-space box that cut it, so it takes an +/// attachment that follows the plane (#565); the box's other faces never reach the part. +fn split_by_plane_traced(input: &Traced, plane: &Plane) -> Result> { + let mut parts = Vec::new(); + for positive in [true, false] { + let (part, tool) = kernel::half_space(&input.body, plane, positive)?; + let Some(part) = part else { continue }; + // The box's cap in the plane: its start cap on the positive side, its end cap on the other. + let section = Some("section".to_string()); + let caps = if positive { [section, None] } else { [None, section] }; + let tool = crate::lineage::ProfileRoles { sketch: None, region: None, caps }.tool(tool, None); + parts.push(Traced::step(part, &[input, &tool])); + } + Ok(parts) +} + /// The plane a straight sketch line sweeps along its sketch's normal: through the line, its x /// along the line and its y along the normal (a Split Body tool, #1552). pub fn sketch_line_plane(st: &ModelState, sketch: u64, line: &str) -> Result { @@ -5294,3 +5854,88 @@ pub fn sketch_line_plane(st: &ModelState, sketch: u64, line: &str) -> Result ModelState { + let mut st = ModelState::default(); + for i in 0..n { + let x = 20.0 * i as f64; + let cube = kernel::box_solid(Vec3::new(x, 0.0, 0.0), Vec3::new(x + 10.0, 10.0, 10.0)).unwrap(); + st.bodies.push(ModelBody::new(format!("Part{i}"), cube, 1)); + } + st + } + + /// The name of the face of `body` at `p`. + fn name_at(st: &ModelState, body: &str, p: Vec3) -> String { + let b = st.body(body).unwrap(); + let face = crate::appearance::face_index_at(b, p).unwrap(); + crate::naming::face_names(b)[face].clone() + } + + /// Everything a lookup found, to compare two lookups. + fn found(hit: Option) -> String { + format!("{hit:?}") + } + + /// A shell late in a long timeline spent seconds meshing every body of the model to find its + /// face by name (#287). A feature that names its body finds the face there without naming + /// the other bodies, and finds what looking over every body finds. + #[test] + fn a_face_is_found_on_the_body_the_feature_names_without_meshing_the_others() { + let st = parts(12); + let top = Vec3::new(145.0, 5.0, 10.0); + let name = name_at(&st, "Part7", top); + let hit = crate::name_lookup::locate_face_on_body(&st, Some("Part7"), &name, top); + for b in st.bodies.iter().filter(|b| b.name != "Part7") { + assert!(b.mesh.get().is_none(), "{} was meshed", b.name); + } + let h = hit.unwrap(); + assert_eq!((h.body, h.split, h.merged), (7, false, false)); + assert_eq!(found(hit), found(crate::naming::locate_face(&st, &name, top))); + // Looking over every body names (and so meshes) them all. + assert!(st.bodies.iter().all(|b| b.mesh.get().is_some())); + } + + /// A name the named body has no face of (it moved to another body: a combine took the face), + /// a body that is not there, no body, a name saved before format 14 or a name no face has: + /// looked for over every body, as without a body. + #[test] + fn a_name_not_on_the_named_body_is_looked_for_over_every_body() { + let st = parts(4); + let top = Vec3::new(45.0, 5.0, 10.0); + let name = name_at(&st, "Part2", top); + for name in [name.clone(), format!("{}{name}", crate::naming::LEGACY_MARK), "Part2:face:99".into()] { + let everywhere = found(crate::naming::locate_face(&st, &name, top)); + for body in [Some("Part1"), Some("Part2"), Some("Nope"), None] { + assert_eq!(found(crate::name_lookup::locate_face_on_body(&st, body, &name, top)), everywhere, "{name} on {body:?}"); + } + } + let moved = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap(); + assert_eq!((moved.body, moved.split), (2, false)); + } + + /// A copy of a body has its source's face names. Over every body the name finds the face on + /// both, and the one nearest the point is taken as a piece of a split face. A feature on the + /// copy takes the copy's face, whole. + #[test] + fn the_same_face_on_a_copy_is_not_a_piece_of_the_named_bodys_face() { + let mut st = parts(2); + let source = st.bodies[0].clone(); + st.bodies[1].names = crate::naming::NameCell::same(&source); + let top = Vec3::new(25.0, 5.0, 10.0); + let name = name_at(&st, "Part1", top); + assert_eq!(name, name_at(&st, "Part0", Vec3::new(5.0, 5.0, 10.0))); + let on_copy = crate::name_lookup::locate_face_on_body(&st, Some("Part1"), &name, top).unwrap(); + let everywhere = crate::naming::locate_face(&st, &name, top).unwrap(); + assert_eq!((on_copy.body, on_copy.face, on_copy.point), (everywhere.body, everywhere.face, everywhere.point)); + assert_eq!((on_copy.split, everywhere.split), (false, true)); + assert_eq!(on_copy.note(&name), None); + } +} diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs new file mode 100644 index 00000000..a598ea88 --- /dev/null +++ b/crates/doc/src/eval_cost_tests.rs @@ -0,0 +1,161 @@ +//! What an evaluation costs after an edit at the end of a large design: the features before +//! the edit are reused, so the edit's cost must not grow with the bodies and imported files +//! the design already holds. A sketch on a fixed plane once moved every body of every placed +//! component into its frame, and every evaluation wrote every imported STEP file out again to +//! fingerprint it, so each sketch command took longer the more parts were inserted. + +use std::cell::Cell; +use std::sync::Arc; + +use solidcraft_geom::{Plane, Vec2, Vec3}; +use solidcraft_kernel as kernel; +use solidcraft_sketch::Sketch; + +use crate::*; + +/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Counts { + pub moved: usize, + pub digested: usize, +} + +thread_local! { + static COUNTS: Cell = const { Cell::new(Counts { moved: 0, digested: 0 }) }; +} + +pub(super) fn count(f: impl FnOnce(&mut Counts)) { + COUNTS.with(|c| { + let mut n = c.get(); + f(&mut n); + c.set(n); + }); +} + +/// The work `f` does on this thread. +fn counted(f: impl FnOnce()) -> Counts { + COUNTS.with(|c| c.set(Counts::default())); + f(); + COUNTS.with(Cell::get) +} + +/// `parts` imported files of two boxes each, every one in its own component placed away from +/// the origin (as inserted parts are). +fn placed_imports(parts: usize) -> Document { + let mut doc = Document::new("placed imports"); + for i in 0..parts { + let x = 30.0 * i as f64; + let a = kernel::box_solid(Vec3::new(x, 0.0, 0.0), Vec3::new(x + 10.0, 10.0, 10.0)).unwrap(); + let b = kernel::box_solid(Vec3::new(x, 20.0, 0.0), Vec3::new(x + 5.0, 25.0, 5.0)).unwrap(); + let step: Arc = kernel::step_export(&[&a, &b], "test").unwrap().into(); + let comp = doc.add_component(Some(&format!("Part {i}")), 0).unwrap(); + let o = doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap(); + o.transform = rigid(Vec3::new(0.0, 0.0, 5.0 * i as f64), Vec3::ZERO, Vec3::Z, 0.05 * i as f64); + let kind = FeatureKind::Import { file: format!("part{i}.step"), step, components: Vec::new(), skip: Vec::new() }; + let id = doc.add_feature(kind, Some(&format!("Part {i}"))).unwrap(); + doc.feature_mut(id).unwrap().component = comp; + } + doc +} + +fn line_sketch(plane: PlaneRef, to: Vec2) -> FeatureKind { + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(to, None).unwrap(); + sk.add_line_pts(a, b, None).unwrap(); + FeatureKind::Sketch { plane, sketch: sk } +} + +#[test] +fn a_sketch_on_a_fixed_plane_costs_the_same_in_a_large_placed_design() { + let parts = 12; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert_eq!(model.state().bodies.len(), parts * 2); + + // A construction plane, then sketches on the origin planes, on an offset of one and on the + // construction plane, as commands make them one after another: no body is moved and no + // file digested again. + let plane = doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Origin { name: "XZ".into() } }, Some("Datum")).unwrap(); + assert_eq!(counted(|| model.evaluate(&doc)), Counts::default(), "construction plane"); + assert!(model.result(plane).unwrap().error.is_none()); + let datum = doc.feature(plane).unwrap().name.clone(); + let planes = [ + PlaneRef::Origin { name: "XY".into() }, + PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "YZ".into() }), distance: "5 mm".into() }, + PlaneRef::Construction { name: datum.into() }, + ]; + for (k, plane) in planes.into_iter().enumerate() { + let id = doc.add_feature(line_sketch(plane, Vec2::new(10.0, 1.0 + k as f64)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k}"); + assert_eq!(model.last_recomputed, 1); + let r = model.result(id).unwrap(); + assert!(r.error.is_none(), "{:?}", r.error); + assert_eq!(model.state().bodies.len(), parts * 2); + // Editing the sketch again recomputes it alone, for the same cost. + if let Ok(sk) = doc.sketch_mut(id) + && let Some(p) = sk.points.get_mut(1) + { + p.pos = Vec2::new(12.0, 2.0); + } + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k} edited"); + assert_eq!(model.last_recomputed, 1); + } +} + +#[test] +fn a_sketch_on_a_face_still_sees_the_placed_bodies() { + let parts = 3; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + // A face pick reads the model as the sketch's component sees it: the other components' + // bodies are moved into its frame, as before. + let top = Plane::new(Vec3::new(0.0, 0.0, 10.0), Vec3::X, Vec3::Y).unwrap(); + let face = PlaneRef::Face { plane: top, at: Vec3::new(5.0, 5.0, 10.0), name: None, anchor: None }; + doc.add_feature(line_sketch(face, Vec2::new(3.0, 3.0)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert!(work.moved >= parts * 2 - 2, "{work:?}"); + assert_eq!(work.digested, 0); +} + +#[test] +fn an_imported_file_is_fingerprinted_by_its_text() { + let doc = placed_imports(2); + let mut model = Model::new(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work.digested, 2); + let fingerprints = |m: &Model| m.results.iter().map(|r| r.fingerprint).collect::>(); + let before = fingerprints(&model); + + // The same text held by another copy (an undo state, a design opened again): the same + // fingerprints, and nothing evaluated again. + let mut copy = doc.clone(); + for f in &mut copy.features { + if let FeatureKind::Import { step, .. } = &mut f.kind { + *step = Arc::from(step.to_string()); + } + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 2); + assert_eq!(fingerprints(&model), before); + assert_eq!(model.last_recomputed, 0); + + // Other text: that feature and the ones after it are evaluated again. + let other = kernel::box_solid(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap(); + let text: Arc = kernel::step_export(&[&other], "test").unwrap().into(); + if let Some(FeatureKind::Import { step, .. }) = copy.features.last_mut().map(|f| &mut f.kind) { + *step = text; + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 1); + let after = fingerprints(&model); + assert_eq!(after.first(), before.first()); + assert_ne!(after.last(), before.last()); + assert_eq!(model.last_recomputed, 1); + assert_eq!(model.state().bodies.len(), 3); +} diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 2592cf2a..3ade16e1 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -5,7 +5,7 @@ use super::*; use crate::EmbossFace; use crate::apply_point; use crate::sheet; -use solidcraft_geom::{Loop2, Seg2}; +use solidcraft_geom::{Aabb3, Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { match &f.kind { @@ -833,11 +833,20 @@ pub(super) fn sheet_primitive_cut( if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { return Ok(Vec::new()); } - // The first sheet the primitive meets decides the machining direction. + // The first sheet the primitive meets decides the machining direction. Sheets clear of it + // by their boxes are passed over; the one sheet left needs no boolean to show it is met (that + // boolean came out empty against a sheet carrying a label's cut-outs, and the hole was then + // cut into the solid without a place in the flat pattern). + let tool_box = body_box(tool); + let near: Vec<&sheet::SheetBody> = st + .sheets + .iter() + .filter(|sh| sheet_eligible(f, st, &[], sh) && st.body(&sh.body).is_some_and(|mb| !boxes_apart(&mesh_box(mb), &tool_box))) + .collect(); let mut normal = None; - for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { + for sh in &near { if let Some(mb) = st.body(&sh.body) - && kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some() + && (near.len() == 1 || kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()) { normal = Some(match prism { PrimitivePrism::Cylinder { axis, .. } @@ -895,6 +904,70 @@ pub(super) fn sheet_bore_cut(f: &Feature, st: &mut ModelState, prism: PrimitiveP Ok(Some(handled)) } +/// The most hole tools machined in one pass by [`sheet_drill_cuts`]. +const MAX_DRILLS_AT_ONCE: usize = 1000; + +/// A hole feature's straight drills (`prisms`, all along one axis), machined into the sheets in +/// one pass for each depth they share: one rebuild of the sheet and of its flat pattern for all +/// of them, rather than one per drill, each on a sheet machined by the ones before. +/// The drills are the cut-outs and pockets [`sheet_primitive_cut`] makes of them one at a time. +/// None when they can't all be taken this way (they are not all cylinders along one axis that +/// every sheet they may machine takes square, or a sheet keeps one some other way): the caller +/// then puts the model back and drills them one by one. +pub(super) fn sheet_drill_cuts( + f: &Feature, + st: &mut ModelState, + prisms: &[PrimitivePrism], + warning: &mut Option, +) -> Result>> { + if prisms.len() < 2 || prisms.len() > MAX_DRILLS_AT_ONCE || !any_eligible_sheet(f, st, &[]) { + return Ok(None); + } + let Some(PrimitivePrism::Cylinder { base: base0, axis: axis0, .. }) = prisms.first().copied() else { return Ok(None) }; + let Some(axis) = axis0.normalized() else { return Ok(None) }; + let Some(plane) = Plane::from_normal(base0, axis) else { return Ok(None) }; + // Every sheet the drills may machine takes them square: along its normal, or (folded) square + // to one of its panels, as a single drill would be taken. + for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { + let square = sh.frame.normal().cross(axis).len() <= 1e-12 || (!sh.flat && !sh.parallel_panels(&plane).is_empty()); + if !square { + return Ok(None); + } + } + // Each drill in the one plane: its circle, and its depth interval along the axis. + let mut groups: Vec<((f64, f64), Vec)> = Vec::new(); + for prism in prisms { + let PrimitivePrism::Cylinder { base, axis: a, radius, height } = *prism else { return Ok(None) }; + let Some(a) = a.normalized() else { return Ok(None) }; + if a.cross(axis).len() > 1e-12 || ![base.x, base.y, base.z, radius, height].iter().all(|v| v.is_finite()) || !(radius > 0.0) { + return Ok(None); + } + let (sense, at) = (a.dot(axis).signum(), (base - base0).dot(axis)); + let (e0, e1) = (at + sense * height.min(0.0), at + sense * height.max(0.0)); + let interval = (e0.min(e1), e0.max(e1)); + let circle = Region2 { outer: Loop2::circle(plane.to_local(base), radius), holes: vec![] }; + let same = |(lo, hi): (f64, f64)| (lo - interval.0).abs() <= 1e-9 * (1.0 + lo.abs()) && (hi - interval.1).abs() <= 1e-9 * (1.0 + hi.abs()); + match groups.iter_mut().find(|(i, _)| same(*i)) { + Some((_, regions)) => regions.push(circle), + None => groups.push((interval, vec![circle])), + } + } + let mut handled: Vec = Vec::new(); + for ((lo, hi), regions) in groups { + let tools = kernel::extrude(&plane, ®ions, lo, hi)?; + let done = sheet_prism_cut(f, st, &plane, ®ions, Operation::Cut, &tools, &[], None, false, &[], warning)?; + if done.is_empty() { + return Ok(None); + } + for name in done { + if !handled.contains(&name) { + handled.push(name); + } + } + } + Ok(Some(handled)) +} + /// A primitive tool's exact geometry, for [`sheet_primitive_cut`]. #[derive(Clone, Copy, Debug)] pub(super) enum PrimitivePrism { @@ -1003,6 +1076,9 @@ fn sheet_prism_cut( } else { Vec::new() }; + // Boxes around the tools: a tool clear of a bend by its box needs no boolean to show it (a + // label's letters against every bend and flange of a tray). + let tool_boxes: Vec> = tools.iter().map(body_box).collect(); let mut plans = Vec::new(); let mut notes = Vec::new(); 'sheets: for original in &st.sheets { @@ -1018,12 +1094,16 @@ fn sheet_prism_cut( plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); continue; } - if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? { - plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); - continue; - } + // No 3D boolean of each tool with the whole folded sheet first: the flat machining below + // is exact, and a cut that machines nothing there is checked against the solid after it. + // (That boolean cost each label hundreds of milliseconds, and against a sheet already + // carrying a label's cut-outs it came out empty: the next cut was dropped.) for bends in sh.unmodeled_solids(plane)? { - for tool in tools { + let bends_box = body_box(&bends); + for (tool, tool_box) in tools.iter().zip(&tool_boxes) { + if boxes_apart(&bends_box, tool_box) { + continue; + } if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { return Err(DocError::Invalid( "this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(), @@ -1190,6 +1270,31 @@ fn sheet_prism_cut( Ok(handled) } +/// A box around `body`, grown past its mesh's deflection from the exact faces (and a hair +/// more): bodies whose boxes are apart do not meet. None when it can't be worked out. +fn body_box(body: &Body) -> Option { + let tol = (body.size() * 1e-3).max(1e-4); + padded_box(&body.tessellate(tol).ok()?, tol) +} + +/// [`body_box`] of a model body, from the mesh it keeps. +fn mesh_box(mb: &ModelBody) -> Option { + padded_box(&mb.mesh(), mb.display_tol()) +} + +fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option { + let b = mesh.bounds(); + let pad = 2.0 * tol + 1e-6; + let pad = Vec3::new(pad, pad, pad); + (!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| Aabb3 { min: b.min - pad, max: b.max + pad }) +} + +/// Whether two boxes are known to be apart (an unknown box is never apart). +fn boxes_apart(a: &Option, b: &Option) -> bool { + let (Some(a), Some(b)) = (a, b) else { return false }; + a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z +} + /// A cut's note on the pieces it parted from the base of `sh` (see /// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut, /// `before` the sheet before it): how many, their area and where they lie in the flat pattern. diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index d6c8a573..1464ecda 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -3,7 +3,7 @@ //! `surface_offset`, `surface_ruled`, `surface_untrim`). use super::*; -use crate::{RuledCorners, RuledMode}; +use crate::{RuledCorners, RuledMode, SheetSection}; use solidcraft_geom::Seg2; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { @@ -286,8 +286,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => { - let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?; + FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale, rails, rail_fit } => { let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; @@ -306,14 +305,33 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, let pts: Vec = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect(); let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64)); let body = match path_points(ps, path, start)? { - Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?, - None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?, + Some(pts) => { + let rails = crate::eval::rail_polylines(doc, st, rails, f.component, pts.first().copied().unwrap_or(start))?; + let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; + kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)? + } + None => { + let segs = path_segments(ps, path, start)?; + let path_start = match segs.first() { + Some(kernel::PathSeg::Line { a, .. } | kernel::PathSeg::Arc { a, .. }) => *a, + None => start, + }; + let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?; + let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?; + kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)? + } }; let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body); push_sheet(st, f, traced); Ok(()) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, continuity, crown } => { + // A loft with edge chains for sections, or one that has to leave its ends a particular + // way, is built through `loft_blend` from the frames of every section; the plain one + // (sketch chains only, both ends G0) is the loft of old. + if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) { + return loft_blend(st, f, sections, *continuity, *crown, doc); + } let mut secs = Vec::with_capacity(sections.len()); let mut sketches = Vec::with_capacity(sections.len()); for sec in sections { @@ -553,3 +571,118 @@ fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result>> { } Ok(chains) } + +/// A multi-section blend: every section as frames, the body ones read off the face their edge +/// chain bounds, and the first and last leaving with `continuity` where the caller asks for it. +fn loft_blend( + st: &mut ModelState, + f: &Feature, + sections: &[SheetSection], + continuity: [kernel::Continuity; 2], + crown: [f64; 2], + doc: &Document, +) -> Result<()> { + /// One section: a chain of sketch curves in its plane, or a body's edge chain. + enum Piece { + Sketch(solidcraft_geom::Plane, Vec), + Body(String, Vec), + } + let mut pieces = Vec::with_capacity(sections.len()); + let mut centres = Vec::with_capacity(sections.len()); + for sec in sections { + if let Some(name) = sec.body.as_deref() { + if sec.edges.is_empty() { + return Err(DocError::Invalid("a loft section picked from a body needs a chain of its edges".into())); + } + let n = sec.edges.len() as f64; + centres.push(sec.edges.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + **p) * (1.0 / n)); + pieces.push(Piece::Body(name.to_string(), crate::plain(&sec.edges))); + continue; + } + let ss = sketch_in_feature_frame( + doc, + st.sketch(sec.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?, + f.component, + )?; + let profiles = match (&sec.profiles, sec.curves.is_empty()) { + (None, true) => Some(&ProfileSel::All), + (p, _) => p.as_ref(), + }; + let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter(); + let (Some(chain), None) = (chains.next(), chains.next()) else { + return Err(DocError::Invalid("each loft section must be one chain: one profile without holes, or curves meeting end to end".into())); + }; + let pts: Vec = chain.iter().flat_map(|s| s.polyline(1e-2)).collect(); + let centre = pts.iter().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + ss.plane.to_world(*p)) * (1.0 / pts.len().max(1) as f64); + centres.push(centre); + pieces.push(Piece::Sketch(ss.plane, chain)); + } + // Each section leaves towards the one after it; the last one leaves back into the blend. + let stations = BLEND_STATIONS; + let last = pieces.len().saturating_sub(1); + let mut frames = Vec::with_capacity(pieces.len()); + for (i, piece) in pieces.iter().enumerate() { + let (from, to) = if i < last { (i, i + 1) } else { (last, last.saturating_sub(1)) }; + let leave = match (centres.get(from), centres.get(to)) { + (Some(a), Some(b)) => *b - *a, + _ => solidcraft_geom::Vec3::ZERO, + }; + match piece { + Piece::Body(name, edges) => { + let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?.body.clone(); + frames.push(kernel::loft_blend::section_frames(&b, edges, leave, stations)?); + } + Piece::Sketch(plane, chain) => frames.push(chain_frames(plane, chain, leave, stations)), + } + } + let body = kernel::loft_blend(&frames, continuity, crown, stations)?; + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::new(name, body, f.id)); + Ok(()) +} + +/// The frames of one sketch chain, sampled by arclength at `stations` stations: a sketch has no +/// face behind it, so the surface leaves along the run with no curvature to repeat there. +fn chain_frames( + plane: &solidcraft_geom::Plane, + chain: &[solidcraft_geom::Seg2], + leave: solidcraft_geom::Vec3, + stations: usize, +) -> Vec { + let mut pts: Vec = Vec::new(); + for seg in chain { + for p in seg.polyline(1e-2) { + if pts.last().is_none_or(|l: &Vec2| (*l - p).len() > 1e-12) { + pts.push(p); + } + } + } + if pts.len() < 2 || stations < 2 { + return Vec::new(); + } + let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum(); + if !(total > 1e-12) { + return Vec::new(); + } + let step = total / (stations - 1) as f64; + let (mut walked, mut k) = (0.0f64, 0usize); + let mut out = Vec::with_capacity(stations); + for i in 0..stations { + let want = step * i as f64; + while k + 1 < pts.len() && walked + (pts[k + 1] - pts[k]).len() < want { + walked += (pts[k + 1] - pts[k]).len(); + k += 1; + } + let (Some(a), Some(b)) = (pts.get(k).copied(), pts.get(k + 1).copied()) else { + if let Some(p) = pts.get(k).copied() { + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + continue; + }; + let span = (b - a).len(); + let t = if span > 1e-12 { ((want - walked) / span).clamp(0.0, 1.0) } else { 0.0 }; + let p = a * (1.0 - t) + b * t; + out.push(kernel::Frame { p: plane.to_world(p), leave, bend: solidcraft_geom::Vec3::ZERO, bend_change: solidcraft_geom::Vec3::ZERO }); + } + out +} diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index cb4b4d56..95f3958f 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -359,6 +359,15 @@ fn lex_spans(s: &str) -> Result, DocError> { } let t = s.get(at(st)..at(i)).unwrap_or_default(); let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?; + // A mixed number, as imperial sizes are written (`2 1/8"` is 2.125 in, #1802): a whole + // number, then a proper fraction of whole numbers, is one value, not `2 × 1/8`. + if t.chars().all(|c| c.is_ascii_digit()) + && let Some((fraction, end)) = mixed_fraction(&cs, i, depth) + { + out.push((Tok::Num(v + fraction), at(st), at(end))); + i = end; + continue; + } out.push((Tok::Num(v), at(st), at(i))); } else if c.is_alphabetic() || c == '_' || c == 'µ' { let st = i; @@ -404,6 +413,43 @@ fn lex_spans(s: &str) -> Result, DocError> { Ok(out) } +/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`, +/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a +/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the +/// parentheses open there (inside them a comma separates arguments: `max(2 1/8,1)`). +fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f64, usize)> { + let digits = |from: usize| cs.get(from..).map_or(0, |rest| rest.iter().take_while(|(_, c)| c.is_ascii_digit()).count()); + let space = i; + while cs.get(i).is_some_and(|(_, c)| *c == ' ' || *c == '\t') { + i += 1; + } + let numerator = digits(i); + if i == space || numerator == 0 || !cs.get(i + numerator).is_some_and(|(_, c)| *c == '/') { + return None; + } + let denominator = digits(i + numerator + 1); + let end = i + numerator + 1 + denominator; + let next = |k: usize| cs.get(end + k).map(|(_, c)| *c); + let continues = matches!(next(0), Some('.')) + || (depth == 0 && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit())) + || (matches!(next(0), Some('e' | 'E')) && next(1).is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+')); + if denominator == 0 || continues { + return None; + } + // Compared as digits, not as floats: long ones round to the same float (#1802 review). + let digits_of = |from: usize, len: usize| -> Option { + let s: String = cs.get(from..from + len)?.iter().map(|(_, c)| *c).collect(); + Some(s.trim_start_matches('0').to_string()) + }; + let (n, d) = (digits_of(i, numerator)?, digits_of(i + numerator + 1, denominator)?); + let proper = !d.is_empty() && (n.len(), n.as_str()) < (d.len(), d.as_str()); + let value = |t: &str| if t.is_empty() { Some(0.0) } else { t.parse::().ok() }; + if !proper { + return None; + } + Some((value(&n)? / value(&d)?, end)) +} + fn lex(s: &str) -> Result, DocError> { Ok(lex_spans(s)?.into_iter().map(|(t, _, _)| t).collect()) } @@ -965,6 +1011,29 @@ mod tests { assert_eq!(ev("\"", Kind::Length).ok(), ev("in", Kind::Length).ok()); } + #[test] + fn mixed_numbers() { + // #1802: `2 1/8"` is 2.125 in, as imperial sizes are written, not 2 × 1/8 in. + assert_eq!(ev("2 1/8\"", Kind::Length).unwrap(), 2.125 * 25.4); + assert_eq!(ev("2 1/8 in", Kind::Length).unwrap(), 2.125 * 25.4); + assert_eq!(ev("3 7/16\"", Kind::Length).unwrap(), 3.4375 * 25.4); + assert_eq!(ev("10 1/2mm", Kind::Length).unwrap(), 10.5); + assert_eq!(ev("width + 1 1/2", Kind::Length).unwrap(), 41.5); + assert_eq!(ev("max(2 1/8, 3)", Kind::Unitless).unwrap(), 3.0); + // Inside parentheses a comma after the fraction separates arguments; outside, a comma + // between digits is a decimal comma, which continues the number (as before). + assert_eq!(ev("max(2 1/8,1)", Kind::Unitless).unwrap(), 2.125); + assert_eq!(ev("2 1/8,5", Kind::Unitless).unwrap(), 2.0 / 8.5); + // Long numerators and denominators are compared as digits, not as rounded floats. + assert!((ev("2 9007199254740992/9007199254740993", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12); + assert_eq!(ev("2 007/8", Kind::Unitless).unwrap(), 2.875); + assert_eq!(ev("-2 1/4", Kind::Unitless).unwrap(), -2.25); + // Not a proper fraction of whole numbers after a whole number: as before. + for (s, v) in [("2 3/2", 3.0), ("2.5 1/2", 1.25), ("2 1/8.5", 2.0 / 8.5), ("2 1 / 8", 0.25), ("2 1/0", f64::INFINITY), ("1/8", 0.125)] { + assert_eq!(ev(s, Kind::Unitless).ok(), Some(v).filter(|v| v.is_finite()), "{s}"); + } + } + #[test] fn arithmetic_and_units() { assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); diff --git a/crates/doc/src/feature_geometry.rs b/crates/doc/src/feature_geometry.rs index 0994beeb..09a0a488 100644 --- a/crates/doc/src/feature_geometry.rs +++ b/crates/doc/src/feature_geometry.rs @@ -53,6 +53,26 @@ impl FeatureKind { v } + /// The body a feature that works on one body's faces names (shell, draft, split, offset, + /// delete and replace face, surface offset, the placed plastic features, sheet metal + /// conversion, ruled surface): its face points pick faces of that body. + pub fn face_body(&self) -> Option<&str> { + match self { + FeatureKind::Shell { body, .. } + | FeatureKind::Draft { body, .. } + | FeatureKind::SplitFace { body, .. } + | FeatureKind::OffsetFace { body, .. } + | FeatureKind::DeleteFace { body, .. } + | FeatureKind::ReplaceFace { body, .. } + | FeatureKind::SurfaceOffset { body, .. } + | FeatureKind::Boss { body, .. } + | FeatureKind::SnapFit { body, .. } + | FeatureKind::Rest { body, .. } => body.as_deref(), + FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceRuled { body, .. } => Some(body.as_ref()), + _ => None, + } + } + /// The feature's profile selections, each with its sketch. pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> { let mut v = Vec::new(); diff --git a/crates/doc/src/format/v26/cam-couplings.rs b/crates/doc/src/format/v26/cam-couplings.rs new file mode 100644 index 00000000..1f5c7917 --- /dev/null +++ b/crates/doc/src/format/v26/cam-couplings.rs @@ -0,0 +1,8 @@ +//! A motion link can carry a `cam` coupling (`joint.cam`): the cam's `profile` (a sketch's closed +//! profile by its outer loop's curve ids, or picked edges of the cam's bodies with their names), +//! the `follower` (`knife`, `roller` with a `radius`, or `flat`), an optional `tip` (a joint origin +//! where the follower touches) and `reverse` (the side it rides from). The link then also saves +//! `lift`, the follower's values over one turn of the cam, worked out from the profile again +//! whenever the design changes. Older designs need no transformation: a link without them is a +//! plain motion link, as it always was; 0.13.x asks for an update instead of reading a cam's +//! follower as a plain link of ratio 1. diff --git a/crates/doc/src/format/v26/fillet-corner-and-loft-continuity.rs b/crates/doc/src/format/v26/fillet-corner-and-loft-continuity.rs new file mode 100644 index 00000000..d736c40c --- /dev/null +++ b/crates/doc/src/format/v26/fillet-corner-and-loft-continuity.rs @@ -0,0 +1,7 @@ +//! The Class-A surface pass also lets a `Fillet` carry `corner` (`rolling`, the default and what +//! every older design meant, or `g2`: a patch repeating each blend's surface up to the second +//! order) and a `SurfaceLoft` carry `continuity` and `crown` and body edge-chain `sections` (a +//! section picked as a `body` with `edges` instead of a `sketch`). A design without them saves as +//! it always did — a loft without `continuity` reads and writes as `[g0, g0]`, the plain loft it +//! always was — so older designs need no transformation; 0.13.x asks for an update instead of +//! meeting a feature it can't read or dropping a corner style it does not round. diff --git a/crates/doc/src/format/v26/hole-position-expressions.rs b/crates/doc/src/format/v26/hole-position-expressions.rs new file mode 100644 index 00000000..8c5257ab --- /dev/null +++ b/crates/doc/src/format/v26/hole-position-expressions.rs @@ -0,0 +1,5 @@ +//! A Hole placed by position can keep that position as length expressions (`at`: `[x, y, z]`, +//! numbers as numbers, expressions as strings), and is drilled where they evaluate, so it follows +//! the design's parameters (#1678). `position` still holds the point it was made at. Older designs +//! need no transformation: a hole without `at` is drilled at `position` as before; 0.13.x asks for +//! an update instead of drilling a moved hole at its old point. diff --git a/crates/doc/src/format/v26/mirror-placements.rs b/crates/doc/src/format/v26/mirror-placements.rs new file mode 100644 index 00000000..36432dd6 --- /dev/null +++ b/crates/doc/src/format/v26/mirror-placements.rs @@ -0,0 +1,5 @@ +//! A Mirror of bodies keeps the placement each body was picked at (`placements`: one occurrence +//! path per body), and mirrors the body where that placement shows it, so a body of a moved or +//! inserted component, or of one of its further instances, is mirrored where it is seen. Older +//! designs need no transformation: a Mirror without `placements` mirrors the bodies' own +//! geometry as before; the last release asks for an update instead of mirroring them there. diff --git a/crates/doc/src/format/v26/sweep-guide-rails.rs b/crates/doc/src/format/v26/sweep-guide-rails.rs new file mode 100644 index 00000000..a5c37dec --- /dev/null +++ b/crates/doc/src/format/v26/sweep-guide-rails.rs @@ -0,0 +1,5 @@ +//! A `Sweep` and a `SurfaceSweep` can carry guide `rails` (one or two, each a `sketch` and its +//! `curves` in order) and `rail_fit` (`scale`, the default, or `stretch`): the profile follows +//! the rails instead of an orientation, twist and end scale (#771). A sweep without them saves as +//! it always did, so older designs need no transformation; 0.13.x asks for an update instead of +//! sweeping a railed profile without its rails. diff --git a/crates/doc/src/joints.rs b/crates/doc/src/joints.rs index f526d4c7..c8fae80a 100644 --- a/crates/doc/src/joints.rs +++ b/crates/doc/src/joints.rs @@ -1,8 +1,8 @@ //! Joints between occurrences: joint origins (snap frames on component geometry, re-found on //! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot, //! planar, ball) with their motion values and limits, motion links (gears, racks and screws are -//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from -//! the joint graph. +//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from +//! its profile, `joints_cam.rs`), and the solver that places occurrences from the joint graph. //! //! A joint makes B's joint frame coincide with A's frame moved by the joint's motion: //! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T` @@ -140,7 +140,8 @@ pub struct Joint { pub suppressed: bool, } -/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset. +/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a +/// cam, its `lift` table at a's value + offset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MotionLink { pub a: u64, @@ -152,11 +153,29 @@ pub struct MotionLink { pub ratio: f64, #[serde(default)] pub offset: f64, - /// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out - /// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and - /// the pitch; the last one worked out is kept in `ratio`. + /// Gears, a rack and pinion, a screw or a cam: what the ratio (or a cam's table) comes from. + /// It is worked out from it again whenever the design changes (`coupling_values`), so it + /// follows the radii, the pitch and the cam's profile; the last one worked out is kept in + /// `ratio` and `lift`. #[serde(default, skip_serializing_if = "Option::is_none")] pub coupling: Option, + /// A cam's follower values over one turn of a (`cam::CAM_SAMPLES` of them, in b's units, the + /// offset left out); empty for every other link. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lift: Vec, +} + +impl MotionLink { + /// b's value for a's value `x` (before b's limits). A cam's table that is not whole (a + /// hand-edited file) gives no value, so b keeps where it is; a table on any other link is + /// not read. + pub fn follow(&self, x: f64) -> f64 { + match &self.coupling { + Some(Coupling::Cam(_)) if self.lift.len() == cam::CAM_SAMPLES => cam::lift_at(&self.lift, x) + self.offset, + Some(Coupling::Cam(_)) => f64::NAN, + _ => self.ratio * x + self.offset, + } + } } /// A length a coupling's ratio comes from: millimetres, an expression over the design's @@ -199,14 +218,22 @@ pub enum Coupling { #[serde(default, skip_serializing_if = "std::ops::Not::not")] reverse: bool, }, + /// A cam on a revolute joint (a) and its follower (b, a slider or a revolute): b rides the + /// cam's profile (`joints_cam.rs`). + Cam(cam::Cam), } impl Coupling { - /// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch. + /// The lengths the ratio comes from: a's gear and b's, the pinion's radius, the pitch, or a + /// cam's roller radius. pub fn sizes(&self) -> Vec<&Size> { match self { Coupling::Gear { ra, rb, .. } => vec![ra, rb], Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s], + Coupling::Cam(c) => match &c.follower { + cam::Follower::Roller { radius } => vec![radius], + _ => Vec::new(), + }, } } @@ -214,6 +241,10 @@ impl Coupling { match self { Coupling::Gear { ra, rb, .. } => vec![ra, rb], Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s], + Coupling::Cam(c) => match &mut c.follower { + cam::Follower::Roller { radius } => vec![radius], + _ => Vec::new(), + }, } } } @@ -231,14 +262,18 @@ fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result { if v.is_finite() && v > 1e-9 && v <= MAX_SIZE { Ok(v) } else { - Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}"))) + Err(DocError::Invalid(format!("a gear, rack, screw or roller needs a positive length, not {v}"))) } } -/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from -/// the geometry and parameters it is defined by; `None` for a plain link. -pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option> { - l.coupling.as_ref().map(|c| coupled(doc, st, l, c)) +/// The ratio a link's coupling gives it (b's value per a's, in radians and mm) and a cam's table +/// (`MotionLink::lift`; empty for the others), worked out from the geometry and parameters it is +/// defined by; `None` for a plain link. +pub fn coupling_values(doc: &Document, st: &ModelState, l: &MotionLink) -> Option)>> { + l.coupling.as_ref().map(|c| match c { + Coupling::Cam(c) => cam::table(doc, st, l, c).map(|t| (1.0, t)), + c => coupled(doc, st, l, c).map(|r| (r, Vec::new())), + }) } fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result { @@ -260,6 +295,8 @@ fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Res } Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)), Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)), + // A cam's follower follows its table (`coupling_values`), not a ratio. + Coupling::Cam(_) => Ok(1.0), } } @@ -491,7 +528,11 @@ fn apply_links(asm: &Assembly, vals: &mut Values) { let before = linked(vals); for l in &asm.links { let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue }; - let mut next = l.ratio * src + l.offset; + let mut next = l.follow(src); + // A hand-edited table (or source) that gives no number leaves b where it is. + if !next.is_finite() { + continue; + } if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) { next = next.max(*lo).min(*hi); } @@ -1388,6 +1429,9 @@ impl Document { } } +#[path = "joints_cam.rs"] +pub mod cam; + #[path = "joints_reach.rs"] mod reach; pub use reach::{REACH_TOL, Reach, Reached, reach}; @@ -1425,7 +1469,8 @@ mod tests { suppressed: false, }) .collect(); - let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect(); + let links = + (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None, lift: Vec::new() }).collect(); Assembly { joints, links, ..Assembly::default() } } @@ -1477,8 +1522,8 @@ mod tests { } let st = ModelState::default(); let ratio = |a: u64, b: u64, c: Coupling| { - let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) }; - coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string())) + let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }; + coupling_values(&doc, &st, &l).map(|r| r.map(|(ratio, _)| ratio).map_err(|e| e.to_string())) }; let len = Size::Length; assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25))); diff --git a/crates/doc/src/joints_cam.rs b/crates/doc/src/joints_cam.rs new file mode 100644 index 00000000..6d8454ae --- /dev/null +++ b/crates/doc/src/joints_cam.rs @@ -0,0 +1,1024 @@ +//! Cams (`joint.cam`): a revolute joint (the cam) drives a follower joint, a slider (a translating +//! follower) or a revolute (an oscillating follower), so that the follower rides the cam's +//! profile. The follower's value over one turn of the cam is worked out from the profile (a closed +//! sketch profile or a closed loop of the cam body's edges) and the follower (a knife edge, a +//! roller or a flat face) and kept on the motion link as a table, which `MotionLink::follow` +//! interpolates; the table is worked out again whenever the design changes. +//! +//! Everything is worked out in the cam's fixed frame C: the frame of the cam joint's other side, +//! in which the cam's profile turns by the joint's value about z. The follower's line (or pivot) is +//! its joint's A-side frame, which stays put whichever side of that joint moves. The follower comes +//! toward the cam from where it starts (a translating follower from far along its line, an +//! oscillating one from its arm pointing away from the cam's axis) and stops where it first +//! touches the profile: the follower stays in contact, as a spring would hold it. + +use std::f64::consts::TAU; + +use serde::{Deserialize, Serialize}; +use solidcraft_geom::{Vec2, Vec3}; + +use super::{JointKind, JointOrigin, MotionLink, Size, component_bodies, component_of, flip_of, occurrence_world, size_of}; +use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul}; + +/// Samples of a cam's table over one turn of the cam (one per degree). +pub const CAM_SAMPLES: usize = 360; +/// Points a cam's profile is sampled into at most. +pub const MAX_PROFILE_POINTS: usize = 20_000; +/// Edges a cam's profile is picked from at most. +pub const MAX_PROFILE_EDGES: usize = 64; + +/// Where a cam's profile comes from. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CamProfile { + /// A closed profile of a sketch (feature id) in the cam's component, by the ids of its outer + /// loop's curves (the profile sharing most of them; with none given, the profile with the + /// largest outer loop): that outer loop. + Sketch { + sketch: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + curves: Vec, + }, + /// Edges of the cam's bodies forming one closed loop: points on them in the component's + /// coordinates and the edges' persistent names (found first). + Edges { + occurrence: u64, + picks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + names: Vec, + }, +} + +/// What touches the cam. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Follower { + /// A point (a knife edge seen along the cam's axis). + #[default] + Knife, + /// A roller of radius `radius` about the follower's point. + Roller { radius: Size }, + /// A flat face square to a translating follower's line. + Flat, +} + +/// A cam coupling's definition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Cam { + pub profile: CamProfile, + #[serde(default)] + pub follower: Follower, + /// An oscillating follower's knife tip or roller centre, on the follower. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tip: Option, + /// A translating follower rides the cam from the −z side of its line; an oscillating one + /// comes to the cam turning the other way. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reverse: bool, +} + +/// A value of a periodic table over one turn of `x` (radians): a monotone cubic through the +/// samples (Hermite, with central slopes limited as Fritsch and Carlson do), so it has no kinks +/// and never goes past its two samples (a follower on a dwell stays on it up to a rise). NaN for +/// an empty table or a non-finite `x`. +pub fn lift_at(table: &[f64], x: f64) -> f64 { + let n = table.len(); + let u = (x.rem_euclid(TAU) / TAU * n as f64).rem_euclid(n as f64); + if n == 0 || !u.is_finite() { + return f64::NAN; + } + let i = u.floor(); + let f = u - i; + let i = i as usize; + let at = |k: usize| table.get(k % n).copied().unwrap_or(f64::NAN); + let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2)); + // The slope at a sample from the steps either side: none where they disagree (an extremum or + // a flat), else their mean, at most three times the smaller. + let slope = |a: f64, b: f64| if a * b <= 0.0 { 0.0 } else { 0.5 * (a + b).signum() * (a + b).abs().min(6.0 * a.abs().min(b.abs())) }; + let (m1, m2) = (slope(p1 - p0, p2 - p1), slope(p2 - p1, p3 - p2)); + let (f2, f3) = (f * f, f * f * f); + (2.0 * f3 - 3.0 * f2 + 1.0) * p1 + (f3 - 2.0 * f2 + f) * m1 + (3.0 * f2 - 2.0 * f3) * p2 + (f3 - f2) * m2 +} + +fn rot(v: Vec2, a: f64) -> Vec2 { + let (s, c) = a.sin_cos(); + Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c) +} + +/// Where a translating follower coming along `-d` from far along its line through `g` first +/// touches the closed polygon `poly`: the largest `t` of its point `g + t·d` (its roller's centre +/// for `r > 0`; for `flat`, its face square to `d`). `None` when it misses the profile. +pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool) -> Option { + let n = d.perp(); + let mut best: Option = None; + let mut take = |t: f64| { + if t.is_finite() && best.is_none_or(|b| t > b) { + best = Some(t); + } + }; + if flat { + for p in poly { + take((*p - g).dot(d)); + } + return best; + } + let count = poly.len(); + for i in 0..count { + let (Some(&p), Some(&q)) = (poly.get(i), poly.get((i + 1) % count)) else { continue }; + let (a, b) = ((p - g).dot(n), (q - g).dot(n)); + // Edges wholly outside the band the follower sweeps. + if a.min(b) > r || a.max(b) < -r { + continue; + } + if r <= 0.0 { + if a == b { + // Along the line: both ends. + take((p - g).dot(d)); + take((q - g).dot(d)); + } else { + let k = a / (a - b); + take((p - g).dot(d) + (q - p).dot(d) * k); + } + continue; + } + // The roller's centre a radius from the edge: on its two parallels (the foot within the + // edge) or about its start (each vertex starts one edge). + let len = p.dist(q); + if let Some(e) = (q - p).normalized() { + let m = e.perp(); + let dm = d.dot(m); + if dm.abs() > 1e-15 { + for side in [r, -r] { + let t = (side - (g - p).dot(m)) / dm; + let foot = (g + d * t - p).dot(e); + if (0.0..=len).contains(&foot) { + take(t); + } + } + } + } + if a.abs() <= r { + take((p - g).dot(d) + (r * r - a * a).max(0.0).sqrt()); + } + } + best +} + +/// The angles in [0, 2π) where an oscillating follower's tip touches the closed polygon `poly`: +/// its tip (a roller's centre for `r > 0`) at `arm` from the pivot `pivot`, turned from the angle +/// `start` the way `turn` says (+1 counter-clockwise), sorted. +fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec { + let tip = |a: f64| pivot + Vec2::from_angle(a) * arm; + let count = poly.len(); + let edge = |i: usize| Some((*poly.get(i)?, *poly.get((i + 1) % count)?)); + let mut out = Vec::new(); + let mut take = |a: f64, keep: &dyn Fn(Vec2) -> bool| { + let turned = (turn * (a - start)).rem_euclid(TAU); + if turned.is_finite() && turned < TAU && keep(tip(a)) { + out.push(turned); + } + }; + // Angles of the tip's direction u where u·w = h: both solutions. + let meet = |w: Vec2, h: f64| -> Vec { + let len = w.len(); + if len <= 0.0 || !(h / len).is_finite() || (h / len).abs() > 1.0 { + return Vec::new(); + } + let (base, spread) = (w.angle(), (h / len).clamp(-1.0, 1.0).acos()); + vec![base + spread, base - spread] + }; + for (p, q) in (0..count).filter_map(edge) { + let near = dist_to_segment(pivot, p, q); + let far = (p - pivot).len().max((q - pivot).len()); + if near > arm + r || far < arm - r { + continue; + } + let len = p.dist(q); + let Some(e) = (q - p).normalized() else { continue }; + let m = e.perp(); + // On a parallel of the edge a radius away (the edge itself for a knife), the foot within it. + for side in if r > 0.0 { vec![r, -r] } else { vec![0.0] } { + for a in meet(m * arm, side - (pivot - p).dot(m)) { + take(a, &|t: Vec2| (0.0..=len).contains(&(t - p).dot(e))); + } + } + if r > 0.0 { + // A radius from the edge's start: |pivot + arm·u − p| = r. + let w = p - pivot; + for a in meet(w, (arm * arm + w.len2() - r * r) / (2.0 * arm)) { + take(a, &|_| true); + } + } + } + out.sort_by(f64::total_cmp); + out +} + +/// Whether an oscillating follower's tip, turned `turned` from `start` the way `turn` says, +/// overlaps the profile by more than `slack`. +fn overlaps_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool { + let at = pivot + Vec2::from_angle(start + turn * turned) * arm; + let n = poly.len(); + let gap = + (0..n).filter_map(|i| Some((*poly.get(i)?, *poly.get((i + 1) % n)?))).map(|(p, q)| dist_to_segment(at, p, q)).fold(f64::INFINITY, f64::min); + // Inside counts only clear of the boundary: a knife on an edge touches it. + gap < r - slack || (gap > slack && inside(poly, at)) +} + +/// Where an oscillating follower first touches the closed polygon `poly` turning from `start` +/// (the angle it turns through, in (0, 2π)): see `touches_turning`. `None` when it misses the +/// profile or starts on it. +pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option { + if overlaps_turning(poly, pivot, arm, r, (start, turn), 0.0, -f64::EPSILON) { + return None; + } + touches_turning(poly, pivot, arm, r, start, turn).into_iter().find(|t| *t > 0.0) +} + +/// Where an oscillating follower that touched the profile at `from` (turned from `start`) is +/// after the cam turned on a little: pushed back to where the profile now lets it be when its tip +/// overlaps the profile (by more than `slack`), else on to where it next touches. It stays on the +/// part of the profile it rides, however the profile crosses its tip's circle elsewhere. +pub fn next_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option { + let touches = touches_turning(poly, pivot, arm, r, start, turn); + // A touch where the tip is clear of the rest of the profile (a roller inside the profile is a + // radius from the parallels of the edges near the one it overlaps too). + let clear = |t: &f64| !overlaps_turning(poly, pivot, arm, r, (start, turn), *t, slack); + if overlaps_turning(poly, pivot, arm, r, (start, turn), from, slack) { + touches.into_iter().rev().filter(|t| *t < from).find(clear) + } else { + touches.into_iter().filter(|t| *t >= from - 1e-12).find(clear) + } +} + +fn dist_to_segment(x: Vec2, p: Vec2, q: Vec2) -> f64 { + let d = q - p; + let t = if d.len2() > 0.0 { ((x - p).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 }; + x.dist(p + d * t) +} + +/// Even-odd containment in a closed polygon. +fn inside(poly: &[Vec2], x: Vec2) -> bool { + let n = poly.len(); + let mut inside = false; + for i in 0..n { + let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue }; + if (a.y > x.y) != (b.y > x.y) && x.x < a.x + (x.y - a.y) / (b.y - a.y) * (b.x - a.x) { + inside = !inside; + } + } + inside +} + +/// Everything a cam's table is worked out from, in the cam's fixed frame C. +struct Setup { + /// The profile at cam value 0. + poly: Vec, + /// +1 when the cam turns by +value about C's z (it is the joint's b side), −1 otherwise. + turn: f64, + /// A translating follower's line (a point and its unit direction, the slider's +z), or an + /// oscillating follower's pivot. + at: Vec2, + dir: Vec2, + /// An oscillating follower's arm (pivot to tip) where it is now. + arm: Option, + /// The side a translating follower rides from (+1: from +dir), or the way an oscillating one + /// turns toward the cam (+1: counter-clockwise). + side: f64, + /// b's value per mm of the follower's travel along `dir`, or per radian of its turn about z. + gain: f64, + radius: f64, + flat: bool, + size: f64, + /// C in the world, and the occurrence the follower's joint moves. + frame: Mat, + moving: u64, +} + +fn joint(doc: &Document, id: u64) -> Result<&super::Joint> { + doc.assembly.joints.iter().find(|j| j.id == id).ok_or_else(|| DocError::Unknown(format!("joint {id}"))) +} + +fn world(doc: &Document, occ: u64) -> Mat { + if occ == 0 { IDENTITY } else { occurrence_world(doc, occ) } +} + +/// The profile's points in its component's coordinates, the occurrence placing them, and the +/// profile's size. `fa`, `fb`: the cam joint's frames (picked edges continue along the edges in +/// the plane square to the cam's axis). +fn profile_points(doc: &Document, st: &ModelState, p: &CamProfile, cam: &super::Joint, (fa, fb): (&Mat, &Mat)) -> Result<(Vec, u64, f64)> { + let bad = |m: &str| DocError::Invalid(format!("cam: {m}")); + match p { + CamProfile::Sketch { sketch, curves } => { + let sk = st.sketches.iter().find(|s| s.feature == *sketch).ok_or_else(|| bad(&format!("no sketch {sketch}")))?; + let comp = doc.feature(*sketch).map(|f| f.component).unwrap_or(0); + let sides: Vec = + [cam.b.occurrence, cam.a.occurrence].into_iter().filter(|o| component_of(doc, *o).is_ok_and(|c| c == comp)).collect(); + let occ = match sides.as_slice() { + [o] => *o, + [] => return Err(bad("the profile's sketch must be in the component the cam joint turns")), + _ => return Err(bad("both sides of the cam joint are that sketch's component: pick the cam's edges instead")), + }; + let lp = &sketch_profile(sk, curves).ok_or_else(|| bad("the sketch has no closed profile (or the cam's profile is gone)"))?.region.outer; + let too_many = || bad(&format!("the profile is too detailed (more than {MAX_PROFILE_POINTS} points)")); + let size = bounds_2d(&sampled(lp, 0.1).ok_or_else(too_many)?); + let pts = sampled(lp, tolerance(size)).ok_or_else(too_many)?; + let pl = &sk.plane; + Ok((pts.iter().map(|q| pl.origin + pl.x * q.x + pl.y * q.y).collect(), occ, size)) + } + CamProfile::Edges { occurrence, picks, names } => { + if *occurrence != cam.a.occurrence && *occurrence != cam.b.occurrence { + return Err(bad("the profile's edges must be on the part the cam joint turns")); + } + if picks.is_empty() || picks.len() > MAX_PROFILE_EDGES || !picks.iter().all(|p| p.is_finite()) { + return Err(bad(&format!("pick 1 to {MAX_PROFILE_EDGES} edges"))); + } + let comp = component_of(doc, *occurrence)?; + let bodies = component_bodies(doc, st, comp); + let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); + if bodies.is_empty() || !size.is_finite() || size <= 0.0 { + return Err(bad("the cam has no body")); + } + // Each body sampled to its own size (a large part beside the cam leaves the cam fine). + // Each body's edges with the faces beside them. + let meshes: Vec = + bodies.iter().map(|b| b.body.tessellate(tolerance(b.body.size()))).collect::>()?; + let edges: Vec<(usize, &Vec, &[u32])> = meshes + .iter() + .enumerate() + .flat_map(|(k, m)| m.edges.iter().enumerate().map(move |(i, e)| (k, e, m.edge_faces.get(i).map(Vec::as_slice).unwrap_or(&[])))) + .collect(); + let mut lines: Vec<&Vec> = Vec::new(); + let mut sides: Vec<(usize, &[u32])> = Vec::new(); + for (k, pick) in picks.iter().enumerate() { + let mut at = *pick; + // A named edge is looked for on the body that has it, and only there. + let mut on: Option = None; + if let Some(name) = names.get(k).filter(|n| !n.is_empty()) { + let (body, p) = bodies + .iter() + .enumerate() + .find_map(|(i, b)| crate::naming::find_edge(b, name, *pick).ok().map(|(p, _)| (i, p))) + .ok_or_else(|| bad(&format!("the edge `{name}` no longer exists (edit the cam to pick again)")))?; + (on, at) = (Some(body), p); + } + let nearest = edges + .iter() + .filter(|e| on.is_none_or(|b| e.0 == b)) + .map(|e| { + let d = e.1.windows(2).filter_map(|w| match w { + [p, q] => Some(at.dist_to_segment(*p, *q)), + _ => None, + }); + (d.fold(f64::MAX, f64::min), e) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .filter(|(d, _)| *d <= 1e-3 * size + 1e-3) + .ok_or_else(|| bad("no edge there"))?; + if !lines.iter().any(|l| std::ptr::eq(*l, nearest.1.1)) { + lines.push(nearest.1.1); + sides.push((nearest.1.0, nearest.1.2)); + } + } + // The other edges in the picked edges' plane square to the cam's axis, beside a face of + // theirs: a loop continues along them (a circle the kernel keeps as two arcs is picked + // with one click), and not onto another part touching it. + let f = if *occurrence == cam.b.occurrence { fb } else { fa }; + let axis = Vec3::new(f[2][0], f[2][1], f[2][2]); + let level = lines.first().and_then(|l| l.first()).map(|p| p.dot(axis)).unwrap_or(0.0); + // Joins and the plane to the picked edges' own size, not their bodies' (a small cam in a + // component with a large part must not close a gap of its own size). + let span = extent(lines.iter().flat_map(|l| l.iter())); + let (flat, join) = (1e-6 * span + 1e-9, 1e-6 * span + 1e-9); + let beside = |(k, faces): (usize, &[u32])| sides.iter().any(|(sk, sf)| *sk == k && sf.iter().any(|f| faces.contains(f))); + let pool: Vec<&Vec> = edges + .iter() + .filter(|(k, e, faces)| { + !lines.iter().any(|l| std::ptr::eq(*l, *e)) && beside((*k, faces)) && e.iter().all(|p| (p.dot(axis) - level).abs() <= flat) + }) + .map(|(_, e, _)| *e) + .collect(); + let loop_ = chain(&lines, &pool, join) + .ok_or_else(|| bad(&format!("the picked edges must form one closed loop (of at most {MAX_PROFILE_POINTS} points)")))?; + // The profile's own size (not its bodies'), for the checks on its plane and area. + let size = extent(loop_.iter()); + Ok((loop_, *occurrence, size)) + } + } +} + +/// Polylines joined end to end into one closed loop, without the repeated joints and the closing +/// point: every one of `lines` once, either way round, continued along `pool` where no line goes +/// on. `None` when they don't close, or close leaving a line out. +fn chain(lines: &[&Vec], pool: &[&Vec], tol: f64) -> Option> { + let (first, rest) = lines.split_first()?; + let mut out: Vec = first.to_vec(); + let (mut used, mut taken) = (vec![false; rest.len()], vec![false; pool.len()]); + // The pool's ends by their cell of a `tol` grid, so the next one is found among a few. + let cell = |p: Vec3| -> Option<[i64; 3]> { + let c = [p.x / tol, p.y / tol, p.z / tol]; + c.iter().all(|v| v.is_finite() && v.abs() < 1e15).then(|| c.map(|v| v.round() as i64)) + }; + let mut ends: std::collections::BTreeMap<[i64; 3], Vec<(usize, bool)>> = std::collections::BTreeMap::new(); + for (k, l) in pool.iter().enumerate() { + for (p, flip) in [(l.first(), false), (l.last(), true)] { + if let Some(c) = p.and_then(|p| cell(*p)) { + ends.entry(c).or_default().push((k, flip)); + } + } + } + let near_end = |end: Vec3, taken: &[bool]| -> Option<(usize, bool)> { + let c = cell(end)?; + for d in 0..27i64 { + let key = [c[0] + d % 3 - 1, c[1] + d / 3 % 3 - 1, c[2] + d / 9 - 1]; + for &(k, flip) in ends.get(&key).into_iter().flatten() { + let l = pool.get(k)?; + let p = if flip { l.last()? } else { l.first()? }; + if taken.get(k) == Some(&false) && p.dist(end) <= tol { + return Some((k, flip)); + } + } + } + None + }; + // The next picked polyline on from `end` (a few at most), and whether it runs backwards. + let next = |end: Vec3, used: &[bool]| { + rest.iter().enumerate().filter(|(k, _)| used.get(*k) == Some(&false)).find_map(|(k, l)| { + if l.first()?.dist(end) <= tol { + Some((k, false)) + } else if l.last()?.dist(end) <= tol { + Some((k, true)) + } else { + None + } + }) + }; + for _ in 0..rest.len() + pool.len() { + let end = *out.last()?; + if out.len() > 3 && out.first()?.dist(end) <= tol { + break; + } + if out.len() > MAX_PROFILE_POINTS { + return None; + } + let (l, flip) = match next(end, &used) { + Some((k, flip)) => { + *used.get_mut(k)? = true; + (*rest.get(k)?, flip) + } + None => { + let (k, flip) = near_end(end, &taken)?; + *taken.get_mut(k)? = true; + (*pool.get(k)?, flip) + } + }; + if flip { + out.extend(l.iter().rev().skip(1)); + } else { + out.extend(l.iter().skip(1)); + } + } + if out.len() < 4 || out.len() > MAX_PROFILE_POINTS + 1 || out.first()?.dist(*out.last()?) > tol || !used.iter().all(|u| *u) { + return None; + } + out.pop(); + Some(out) +} + +/// The profile of a solved sketch a cam's `curves` name: the one sharing most of them (the larger +/// on a tie), or with none named the one with the largest outer loop. `None` when no profile +/// shares at least half of them (most of the cam's outline gone: a neighbouring region sharing a +/// curve with it is not the cam). +pub fn sketch_profile<'a>(sk: &'a crate::SolvedSketch, curves: &[String]) -> Option<&'a solidcraft_sketch::Profile> { + if curves.len() > MAX_PROFILE_POINTS { + return None; + } + let named: std::collections::BTreeSet<&String> = curves.iter().collect(); + let shared = |p: &solidcraft_sketch::Profile| p.outer_curves.iter().filter(|c| named.contains(c)).count(); + let area = |p: &solidcraft_sketch::Profile| p.region.outer.signed_area().abs(); + sk.profiles + .iter() + .filter(|p| curves.is_empty() || 2 * shared(p) >= curves.len()) + .max_by(|a, b| shared(a).cmp(&shared(b)).then(area(a).total_cmp(&area(b)))) +} + +/// A loop's polyline within `tol`, segment by segment: `None` past `MAX_PROFILE_POINTS` points. +fn sampled(lp: &solidcraft_geom::Loop2, tol: f64) -> Option> { + let mut out = Vec::new(); + for seg in &lp.segs { + let p = seg.polyline(tol); + out.extend(p.iter().take(p.len().saturating_sub(1))); + if out.len() > MAX_PROFILE_POINTS { + return None; + } + } + Some(out) +} + +/// The diagonal of the box around some points (0 for none). +fn extent<'a>(pts: impl Iterator) -> f64 { + let (lo, hi) = pts.fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| { + (Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)), Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z))) + }); + if lo.x <= hi.x { (hi - lo).len() } else { 0.0 } +} + +fn bounds_2d(pts: &[Vec2]) -> f64 { + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in pts { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + if lo.x <= hi.x { (hi - lo).len() } else { 0.0 } +} + +/// The chord error a profile is sampled to: 0.1 µm per 100 mm of its size, at least 0.1 µm. +fn tolerance(size: f64) -> f64 { + 1e-4 * (size / 100.0).max(1.0) +} + +/// The joints, frames and profile a cam link's table is worked out from. +fn setup(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let bad = |m: &str| DocError::Invalid(format!("cam: {m}")); + let (cam, fol) = (joint(doc, l.a)?, joint(doc, l.b)?); + if cam.kind != JointKind::Revolute { + return Err(bad("the cam turns on a revolute joint")); + } + if !matches!(fol.kind, JointKind::Slider | JointKind::Revolute) { + return Err(bad("the follower slides (slider) or swings (revolute)")); + } + if cam.a.occurrence == cam.b.occurrence { + return Err(bad("the cam joint joins two parts")); + } + let (fa, fb) = (super::resolve_origin(doc, st, &cam.a)?, super::resolve_origin(doc, st, &cam.b)?); + let (pts, occ, size) = profile_points(doc, st, &c.profile, cam, (&fa, &fb))?; + if pts.len() < 3 || pts.len() > MAX_PROFILE_POINTS || !size.is_finite() || size <= 0.0 { + return Err(bad(&format!("the profile must have 3 to {MAX_PROFILE_POINTS} points"))); + } + let inv = |m: &Mat| mat_inverse(m).ok_or_else(|| bad("degenerate frame")); + // The cam's fixed frame C (world) and the profile at value 0 in it, as `through` places the + // joint's sides. + let on_b = occ == cam.b.occurrence; + let (frame, local, fixed) = if on_b { + (mat_mul(&world(doc, cam.a.occurrence), &fa), mat_mul(&flip_of(cam), &inv(&fb)?), cam.a.occurrence) + } else { + (mat_mul(&mat_mul(&world(doc, cam.b.occurrence), &fb), &inv(&flip_of(cam))?), inv(&fa)?, cam.b.occurrence) + }; + let v: Vec = pts.iter().map(|p| apply_point(&local, *p)).collect(); + let mean = v.iter().map(|p| p.z).sum::() / v.len() as f64; + if v.iter().any(|p| (p.z - mean).abs() > 1e-6 * size + 1e-9) { + return Err(bad("the profile must lie in a plane square to the cam's axis")); + } + let poly: Vec = v.iter().map(|p| Vec2::new(p.x, p.y)).collect(); + if !poly.iter().all(|p| p.is_finite()) || area(&poly).abs() <= 1e-12 * size * size { + return Err(bad("the profile encloses no area")); + } + // The follower's line or pivot in C: its joint's frame on the side that stays (the side away + // from the cam's fixed part moves; b when neither side is that part), as `through` places it. + // The moving side travels (or turns) by +value along its z when it is b, −value when it is a. + let c_inv = inv(&frame)?; + let moves_b = follower_is_b(doc, cam, fol, (fixed, occ))?; + let (ga, gb) = (super::resolve_origin(doc, st, &fol.a)?, super::resolve_origin(doc, st, &fol.b)?); + let guide = if moves_b { + mat_mul(&world(doc, fol.a.occurrence), &ga) + } else { + mat_mul(&mat_mul(&world(doc, fol.b.occurrence), &gb), &inv(&flip_of(fol))?) + }; + let to_c = mat_mul(&c_inv, &guide); + let (g, d) = (apply_point(&to_c, Vec3::ZERO), apply_vector(&to_c, Vec3::Z)); + let (moving, sign_f) = if moves_b { (fol.b.occurrence, 1.0) } else { (fol.a.occurrence, -1.0) }; + let (radius, flat) = match &c.follower { + Follower::Knife => (0.0, false), + Follower::Roller { radius } => (size_of(doc, st, radius)?, false), + Follower::Flat => (0.0, true), + }; + let side = if c.reverse { -1.0 } else { 1.0 }; + let turn = if on_b { 1.0 } else { -1.0 }; + let at = Vec2::new(g.x, g.y); + // Where the follower touches now (in C): its tip, or the roller's picked round face. + let face = match &c.follower { + Follower::Roller { radius: Size::Circle(o) } => Some(o), + _ => None, + }; + // A roller picked by its round face (as its radius or its tip) turns about the face's axis: + // square to the profile's plane. + let roller = matches!(c.follower, Follower::Roller { .. }); + for o in [c.tip.as_ref(), face].into_iter().flatten().filter(|o| roller && matches!(o.snap, super::Snap::CircleCenter { .. })) { + let at = mat_mul(&c_inv, &doc.origin_world(st, o)?); + if Vec3::new(at[2][0], at[2][1], 0.0).len() > 1e-6 { + return Err(bad("the roller's axis must be parallel to the cam's")); + } + } + let tip = match c.tip.as_ref().or(face) { + Some(o) => { + let t = apply_point(&mat_mul(&c_inv, &doc.origin_world(st, o)?), Vec3::ZERO); + Some(Vec2::new(t.x, t.y)) + } + None => None, + }; + if fol.kind == JointKind::Slider { + if d.z.abs() > 1e-6 { + return Err(bad("the follower must slide square to the cam's axis")); + } + let dir = Vec2::new(d.x, d.y).normalized().ok_or_else(|| bad("degenerate frame"))?; + // Its line: along the slider's axis, through its tip (where it is across the line, which + // doesn't change as it slides), else through the axis. + let at = tip.map(|t| at + dir.perp() * (t - at).dot(dir.perp())).unwrap_or(at); + return Ok(Setup { poly, turn, at, dir, arm: None, side, gain: sign_f, radius, flat, size, frame, moving }); + } + if flat { + return Err(bad("a flat-faced follower slides (use a slider)")); + } + if Vec2::new(d.x, d.y).len() > 1e-6 { + return Err(bad("the follower must swing about an axis parallel to the cam's")); + } + let t = tip.ok_or_else(|| bad("an oscillating follower needs its tip (the knife's point or the roller's centre)"))?; + if at.len() <= 1e-9 * size { + return Err(bad("the follower's pivot is on the cam's axis")); + } + let arm = t - at; + if arm.len() <= 1e-9 * size { + return Err(bad("the follower's tip is on its pivot")); + } + // b turns the follower by its value about the joint's z, which is C's z or against it. + Ok(Setup { poly, turn, at, dir: Vec2::X, arm: Some(arm), side, gain: sign_f * d.z.signum(), radius, flat, size, frame, moving }) +} + +/// How many moving joints part occurrence `from` from each occurrence it is joined to, over the +/// unsuppressed joints but `skip`: a rigid joint moves nothing, and the root and the grounded +/// occurrences are all fixed to the world (`u64::MAX` here), so they cost nothing either. +fn hops(doc: &Document, from: u64, skip: [u64; 2]) -> std::collections::BTreeMap { + const WORLD: u64 = u64::MAX; + let mut next: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut join = |a: u64, b: u64, cost: usize| { + next.entry(a).or_default().push((b, cost)); + next.entry(b).or_default().push((a, cost)); + }; + for j in doc.assembly.joints.iter().filter(|j| !j.suppressed && !skip.contains(&j.id)).take(super::MAX_JOINTS) { + join(j.a.occurrence, j.b.occurrence, usize::from(j.kind != JointKind::Rigid)); + } + join(0, WORLD, 0); + for o in doc.occurrences.iter().filter(|o| o.grounded) { + join(o.id, WORLD, 0); + } + // Breadth first with free steps taken first (each occurrence settled once, at its least cost). + let mut cost = std::collections::BTreeMap::from([(from, 0usize)]); + let mut done = std::collections::BTreeSet::new(); + let mut queue = std::collections::VecDeque::from([from]); + while let Some(o) = queue.pop_front() { + if !done.insert(o) { + continue; + } + let here = cost.get(&o).copied().unwrap_or(0); + for (other, step) in next.get(&o).into_iter().flatten() { + let d = here + step; + if cost.get(other).is_none_or(|c| d < *c) { + cost.insert(*other, d); + if *step == 0 { + queue.push_front(*other); + } else { + queue.push_back(*other); + } + } + } + } + cost +} + +/// Whether a follower joint's b side is the follower (its a side the guide): the guide is the side +/// fewer moving joints from the part the cam turns against (`frame`), through the other joints. A guide joined only to +/// the profile's part (`cam`) would turn with the profile: refused. +fn follower_is_b(doc: &Document, cam_joint: &super::Joint, fol: &super::Joint, (frame, cam): (u64, u64)) -> Result { + let skip = [cam_joint.id, fol.id]; + let from = hops(doc, frame, skip); + Ok(match (from.get(&fol.a.occurrence), from.get(&fol.b.occurrence)) { + (Some(a), Some(b)) => a <= b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => { + let from = hops(doc, cam, skip); + if from.contains_key(&fol.a.occurrence) || from.contains_key(&fol.b.occurrence) { + return Err(DocError::Invalid( + "cam: the follower's guide turns with the cam's profile (put it on the part the cam turns against)".into(), + )); + } + true + } + }) +} + +/// A polygon's signed area, from its first point (so a small profile far from the axis keeps its +/// digits). +fn area(poly: &[Vec2]) -> f64 { + let (n, o) = (poly.len(), poly.first().copied().unwrap_or(Vec2::ZERO)); + (0..n).filter_map(|i| Some((*poly.get(i)? - o).cross(*poly.get((i + 1) % n)? - o))).sum::() / 2.0 +} + +/// The follower's places at the samples 0 ..= `CAM_SAMPLES` of a turn, `place(u, prev)` giving +/// it with the cam `u` samples on (from `prev`, where it was, when it is `follows`ed from sample +/// to sample). A followed follower must come back where it started after the turn. A step much +/// larger than the steps either side of it is followed again in finer steps: a smooth rise +/// shrinks with them, a jump (the follower leaving the profile and landing on another part of it) +/// stays one step, and is refused. `floor`: steps no larger than it are not looked at. +fn round(place: &dyn Fn(f64, Option) -> Result, follows: bool, floor: f64) -> Result> { + let mut raw: Vec = Vec::with_capacity(CAM_SAMPLES + 1); + for k in 0..=CAM_SAMPLES { + let prev = if follows { raw.last().copied() } else { None }; + raw.push(place(k as f64, prev)?); + } + let at = |k: usize| raw.get(k).copied().unwrap_or(f64::NAN); + if follows && (at(0) - at(CAM_SAMPLES)).abs() > 1e-6 { + return Err(DocError::Invalid("cam: after a turn of the cam the follower does not come back where it started (it would jump)".into())); + } + let step = |k: usize| (at(k + 1) - at(k)).abs(); + for k in 0..CAM_SAMPLES { + let (d, around) = (step(k), step((k + CAM_SAMPLES - 1) % CAM_SAMPLES).max(step((k + 1) % CAM_SAMPLES))); + if d <= floor || d <= 3.0 * around { + continue; + } + let (mut here, mut most) = (at(k), 0.0f64); + for j in 1..=32 { + let next = place(k as f64 + f64::from(j) / 32.0, follows.then_some(here))?; + most = most.max((next - here).abs()); + here = next; + } + if most > 0.5 * d { + return Err(DocError::Invalid(format!("cam: the follower would jump near {k} degrees of the cam's turn (it leaves the profile there)"))); + } + } + Ok(raw) +} + +/// A cam link's table: b's values at a's values 2π·k / `CAM_SAMPLES`, offset left out. A +/// translating follower stands at its first contact coming along its line; an oscillating one +/// starts at its first contact turning from pointing away from the cam's axis and is followed from +/// sample to sample (`round`). +pub fn table(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result> { + let s = setup(doc, st, l, c)?; + let start = s.at.angle(); + let follows = s.arm.is_some(); + // Where the follower is with the cam `u` samples on: mm along its line, or the angle its arm + // turned through from pointing away from the cam's axis (the same direction in C at every + // sample), on from `prev` for an arm. + let place = |u: f64, prev: Option| -> Result { + // The follower turned back instead of the profile turned on. + let q = -s.turn * TAU * u / CAM_SAMPLES as f64; + match s.arm { + None => first_contact_along(&s.poly, rot(s.at, q), rot(s.dir * s.side, q), s.radius, s.flat) + .map(|t| t * s.side) + .ok_or_else(|| DocError::Invalid("cam: the follower's line misses the cam at some angles".into())), + Some(arm) => { + let (pivot, from) = (rot(s.at, q), (start + q, s.side)); + match prev { + None => first_contact_turning(&s.poly, pivot, arm.len(), s.radius, from.0, from.1), + Some(p) => next_contact_turning(&s.poly, pivot, arm.len(), s.radius, from, p, 1e-9 * s.size), + } + .ok_or_else(|| DocError::Invalid("cam: the follower's arm does not reach the cam at every angle (or starts on it)".into())) + } + } + }; + let raw = round(&place, follows, if follows { 1e-6 } else { 1e-6 * s.size })?; + let out: Vec = raw.iter().take(CAM_SAMPLES).map(|v| s.gain * if follows { start + s.side * v } else { *v }).collect(); + if !out.iter().all(|v| v.is_finite() && v.abs() <= 1e9 * s.size.max(1.0)) { + return Err(DocError::Invalid("cam: the follower's travel is out of range".into())); + } + Ok(out) +} + +/// The side a new cam's follower rides it from, as `Cam::reverse`: a translating follower from the +/// side of the cam's axis its bodies are on now, an oscillating one turning toward the cam the way +/// its arm is turned now from pointing away from the cam's axis. +pub fn default_reverse(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let s = setup(doc, st, l, &Cam { reverse: false, ..c.clone() })?; + if let Some(arm) = s.arm { + let off = (arm.angle() - s.at.angle() + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI; + return Ok(off < 0.0); + } + let comp = component_of(doc, s.moving)?; + let bb = component_bodies(doc, st, comp).iter().fold(solidcraft_geom::Aabb3::EMPTY, |bb, b| bb.union(&b.mesh().bounds())); + if bb.min.x > bb.max.x { + return Ok(false); + } + let inv = mat_inverse(&s.frame).ok_or_else(|| DocError::Invalid("cam: degenerate frame".into()))?; + let centre = apply_point(&mat_mul(&inv, &world(doc, s.moving)), bb.center()); + // The cam's axis is C's z: the follower is on the −dir side of it. + Ok(Vec2::new(centre.x, centre.y).dot(s.dir) < 0.0) +} + +/// How far a translating follower's line passes from the cam's axis (mm; 0 for an oscillating one). +pub fn follower_offset(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result { + let s = setup(doc, st, l, c)?; + Ok(if s.arm.is_some() { 0.0 } else { s.at.cross(s.dir).abs() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A circle of radius `r` about `c` as a polygon of `n` points (its sag is r·(1 − cos π/n)). + fn circle(c: Vec2, r: f64, n: usize) -> Vec { + (0..n).map(|k| c + Vec2::from_angle(TAU * k as f64 / n as f64) * r).collect() + } + + /// An eccentric circle (radius 30, its centre 10 from the axis) turned by θ against a + /// follower sliding along +x: a knife edge on a line `h` from the axis stands at + /// 10·cos θ + √(30² − (h − 10·sin θ)²), a roller of radius 5 as the knife on the circle of + /// radius 35, and a flat face at 10·cos θ + 30. + #[test] + fn a_translating_follower_rides_an_eccentric_circle() { + let (e, r) = (10.0, 30.0); + let poly = circle(Vec2::new(e, 0.0), r, 4096); + for k in 0..72 { + let th = TAU * k as f64 / 72.0 - 0.3; + // The follower turned back by θ, as `table` does. + let (d, at) = (rot(Vec2::X, -th), |h: f64| rot(Vec2::new(0.0, h), -th)); + for h in [0.0, 6.0, -12.0] { + let knife = first_contact_along(&poly, at(h), d, 0.0, false).unwrap_or(f64::NAN); + let want = e * th.cos() + (r * r - (h - e * th.sin()).powi(2)).sqrt(); + assert!((knife - want).abs() < 1e-4, "knife θ {th} h {h}: {knife} vs {want}"); + let roller = first_contact_along(&poly, at(h), d, 5.0, false).unwrap_or(f64::NAN); + let want = e * th.cos() + ((r + 5.0).powi(2) - (h - e * th.sin()).powi(2)).sqrt(); + assert!((roller - want).abs() < 1e-4, "roller θ {th} h {h}: {roller} vs {want}"); + } + let flat = first_contact_along(&poly, Vec2::ZERO, d, 0.0, true).unwrap_or(f64::NAN); + assert!((flat - (e * th.cos() + r)).abs() < 1e-4, "flat θ {th}: {flat}"); + } + // A line past the cam misses it. + assert_eq!(first_contact_along(&poly, Vec2::new(0.0, 50.0), Vec2::X, 0.0, false), None); + assert_eq!(first_contact_along(&poly, Vec2::new(0.0, 50.0), Vec2::X, 5.0, false), None); + } + + /// An arm of 50 about (−50, 40) with a roller of 5 on the same cam: the roller's centre is where + /// the arm's circle meets the circle of radius 35 about the cam's centre, the first of the two + /// met turning from the arm pointing away from the axis. + #[test] + fn an_oscillating_follower_meets_the_offset_circle() { + let (e, r, roller) = (10.0, 30.0, 5.0); + let (pivot, arm) = (Vec2::new(-50.0, 40.0), 50.0); + let start = pivot.angle(); + for k in 0..36 { + let th = TAU * k as f64 / 36.0; + let c = Vec2::from_angle(th) * e; + let poly = circle(c, r, 4096); + // The two circles meet at m ± n. + let (dv, l) = (c - pivot, (c - pivot).len()); + let a = (arm * arm - (r + roller).powi(2) + l * l) / (2.0 * l); + let m = pivot + dv * (a / l); + let n = dv.perp() * ((arm * arm - a * a).sqrt() / l); + for turn in [1.0, -1.0] { + let swept = |p: Vec2| (turn * ((p - pivot).angle() - start)).rem_euclid(TAU); + let want = swept(m + n).min(swept(m - n)); + let got = first_contact_turning(&poly, pivot, arm, roller, start, turn).unwrap_or(f64::NAN); + assert!((got - want).abs() < 1e-5, "θ {th} turn {turn}: {got} vs {want}"); + } + } + // Followed on from where it touches, it stays on the part of the profile it rides: a C + // whose two arms both cross the tip's circle, the tip resting on the second arm. + let c: Vec = [(-10.0, 20.0), (30.0, 20.0), (30.0, 14.0), (-4.0, 14.0), (-4.0, -14.0), (30.0, -14.0), (30.0, -20.0), (-10.0, -20.0)] + .into_iter() + .map(|(x, y)| Vec2::new(x, y)) + .collect(); + let (pv, l) = (Vec2::new(40.0, 0.0), 22.0); + let touches = touches_turning(&c, pv, l, 0.0, 0.0, 1.0); + let on = |t: f64| (pv + Vec2::from_angle(t) * l).y; + let first = first_contact_turning(&c, pv, l, 0.0, 0.0, 1.0).unwrap_or(f64::NAN); + let second = touches.iter().copied().find(|t| on(*t) < 0.0 && *t > first).unwrap_or(f64::NAN); + assert!(on(first) > 0.0 && second > first, "{touches:?}"); + let stay = next_contact_turning(&c, pv, l, 0.0, (0.0, 1.0), second, 1e-9).unwrap_or(f64::NAN); + assert!((stay - second).abs() < 1e-12, "{stay} vs {second}"); + // A knife on an edge touches it, it doesn't overlap it; just inside, it does. + let square: Vec = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].into_iter().map(|(x, y)| Vec2::new(x, y)).collect(); + let (pv, l) = (Vec2::new(0.0, -4.0), 3.0); + let up = std::f64::consts::FRAC_PI_2; + assert!(!overlaps_turning(&square, pv, l, 0.0, (up, 1.0), 0.0, 1e-9)); + assert!(overlaps_turning(&square, pv, l + 0.01, 0.0, (up, 1.0), 0.0, 1e-9)); + assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::>()) - 6.4e-5).abs() < 1e-12); + // An arm that can't reach, and one that starts on the cam. + let poly = circle(Vec2::new(e, 0.0), r, 512); + assert_eq!(first_contact_turning(&poly, pivot, 5.0, 0.0, start, 1.0), None); + assert_eq!(first_contact_turning(&poly, Vec2::new(10.0, 0.0), 10.0, 0.0, 0.0, 1.0), None); + } + + /// An arm whose first contact vanishes mid-turn and lands on another lobe jumps, though it + /// comes back after the turn: refused. On the eccentric circle it follows smoothly. + #[test] + fn a_follower_that_jumps_is_refused() { + let arm_round = |poly: &[Vec2], pivot: Vec2, arm: f64| { + let start = pivot.angle(); + let place = |u: f64, prev: Option| -> Result { + let q = -TAU * u / CAM_SAMPLES as f64; + let p = rot(pivot, q); + match prev { + None => first_contact_turning(poly, p, arm, 0.0, start + q, 1.0), + Some(v) => next_contact_turning(poly, p, arm, 0.0, (start + q, 1.0), v, 1e-9), + } + .ok_or_else(|| DocError::Invalid("misses".into())) + }; + round(&place, true, 1e-6) + }; + let lobes: Vec = (0..240) + .map(|k| { + let t = TAU * f64::from(k) / 240.0; + Vec2::from_angle(t) * (28.0 + 2.0 / 3.0 + 13.0 * (3.0 * t).cos()) + }) + .collect(); + let e = arm_round(&lobes, Vec2::new(35.0, 0.0), 20.0).map_err(|e| e.to_string()).err().unwrap_or_default(); + assert!(e.contains("jump"), "{e}"); + assert!(arm_round(&circle(Vec2::new(10.0, 0.0), 30.0, 2048), Vec2::new(-50.0, 40.0), 50.0).is_ok()); + } + + /// The table is read between its samples by a periodic cubic: a cosine sampled once a degree + /// reads back within a millionth anywhere, a turn either way round included; nothing reads + /// from an empty table or at a non-finite angle. + #[test] + fn a_table_reads_smoothly_between_its_samples() { + let t: Vec = (0..CAM_SAMPLES).map(|k| (TAU * k as f64 / CAM_SAMPLES as f64).cos()).collect(); + for x in [0.0, 0.0123, 1.0, 3.1, -2.5, 6.2, 40.0, -1e5] { + assert!((lift_at(&t, x) - x.cos()).abs() < 1e-6, "{x}: {}", lift_at(&t, x)); + } + assert!(lift_at(&[], 1.0).is_nan() && lift_at(&t, f64::NAN).is_nan() && lift_at(&t, f64::INFINITY).is_nan()); + // A dwell up to a rise reads flat up to the rise: never below the dwell, nor past the rise. + let mut step = vec![30.0; CAM_SAMPLES]; + for (k, v) in step.iter_mut().enumerate().skip(100).take(60) { + *v = 30.0 + 0.9 * (k - 99) as f64; + } + for k in 0..36_000 { + let v = lift_at(&step, TAU * k as f64 / 36_000.0); + assert!((30.0 - 1e-12..=30.0 + 0.9 * 60.0 + 1e-12).contains(&v), "{k}: {v}"); + } + assert!(lift_at(&[2.0], 1e300).is_finite()); + } + + /// The guide is the side of the follower's joint fewer moving joints from the cam's frame: a + /// guide on four rigid joints from it beats a piston three moving joints round a slider-crank; + /// a guide only on the cam's own side is refused. + #[test] + fn the_guide_is_the_side_fewer_moving_joints_from_the_frame() { + let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None } }; + let joint = |id: u64, kind: JointKind, a: u64, b: u64| super::super::Joint { + id, + name: format!("J{id}"), + kind, + a: JointOrigin { occurrence: a, ..origin.clone() }, + b: JointOrigin { occurrence: b, ..origin.clone() }, + values: Vec::new(), + limits: Vec::new(), + flip: false, + offset: 0.0, + angle: 0.0, + suppressed: false, + }; + let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded }; + // 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod. + let mut doc = Document { occurrences: (1..=10).map(|id| occ(id, id == 1)).collect(), ..Document::default() }; + let (r, v, s) = (JointKind::Rigid, JointKind::Revolute, JointKind::Slider); + doc.assembly.joints = vec![ + joint(1, v, 1, 2), + joint(2, r, 1, 3), + joint(3, r, 3, 4), + joint(4, r, 4, 5), + joint(5, r, 5, 7), + joint(6, s, 8, 7), + joint(7, v, 1, 9), + joint(8, v, 9, 10), + joint(9, v, 10, 8), + ]; + let (cam, fol) = (doc.assembly.joints[0].clone(), doc.assembly.joints[5].clone()); + // The piston (a) follows, the guide (b) stays. + assert!(matches!(follower_is_b(&doc, &cam, &fol, (1, 2)), Ok(false))); + // A guide joined only to the cam turns with it. + doc.assembly.joints[1] = joint(2, r, 2, 3); + doc.occurrences.iter_mut().for_each(|o| o.grounded = o.id == 2); + doc.assembly.joints.truncate(6); + assert!(follower_is_b(&doc, &cam, &fol, (1, 2)).is_err()); + } + + /// Edges join end to end either way round into one closed loop; a gap or a spare edge is no loop. + #[test] + fn edges_chain_into_one_closed_loop() { + let p = |x: f64, y: f64| Vec3::new(x, y, 0.0); + let (a, b, c) = (vec![p(0.0, 0.0), p(1.0, 0.0)], vec![p(0.0, 1.0), p(1.0, 0.0)], vec![p(0.0, 1.0), p(0.0, 0.0)]); + let loop3 = chain(&[&a, &b, &c], &[], 1e-9).unwrap_or_default(); + assert_eq!(loop3, vec![p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0)]); + assert_eq!(chain(&[&a, &b], &[], 1e-9), None); + let d = vec![p(5.0, 5.0), p(6.0, 6.0)]; + assert_eq!(chain(&[&a, &b, &c, &d], &[], 1e-9), None); + let ring = vec![p(1.0, 0.0), p(0.0, 1.0), p(-1.0, 0.0), p(0.0, -1.0), p(1.0, 0.0)]; + assert_eq!(chain(&[&ring], &[], 1e-9).map(|l| l.len()), Some(4)); + // One pick goes on along the other edges of its plane; a spare pick still fails. + assert_eq!(chain(&[&a], &[&d, &c, &b], 1e-9).map(|l| l.len()), Some(3)); + assert_eq!(chain(&[&a, &d], &[&c, &b], 1e-9), None); + // A loop of many short edges goes round from one of them, found by their ends; past the + // point cap it is refused, without going on round it. + let ring = |n: usize| -> Vec> { + let at = |k: usize| Vec3::new((TAU * k as f64 / n as f64).cos(), (TAU * k as f64 / n as f64).sin(), 0.0) * 100.0; + (0..n).map(|k| vec![at(k), at(k + 1)]).collect() + }; + let small = ring(10_000); + let rest: Vec<&Vec> = small.iter().skip(1).rev().collect(); + assert_eq!(chain(&[&small[0]], &rest, 1e-6).map(|l| l.len()), Some(10_000)); + let big = ring(100_000); + let rest: Vec<&Vec> = big.iter().skip(1).collect(); + assert_eq!(chain(&[&big[0]], &rest, 1e-7), None); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ae00b365..285b7d3f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -58,9 +58,9 @@ pub use assembly::{ pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, - edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices, - revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, - through_all_distance, world_state, world_state_reusing, + edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height, parse_metric_thread, + pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, + state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing, }; pub use expr::Expr; pub use names::{BodyName, FeatureName, SketchId}; diff --git a/crates/doc/src/name_lookup.rs b/crates/doc/src/name_lookup.rs index b65d25da..2b246530 100644 --- a/crates/doc/src/name_lookup.rs +++ b/crates/doc/src/name_lookup.rs @@ -271,6 +271,25 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option { locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p) } +/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a +/// draft…): the one face of that body with the very name, found without working out the names +/// of every other body (a body named from geometry is meshed for it: seconds in a large model). +/// Else as [`locate_face`], over every body: the name moved to another body (a combine took the +/// face), or on this body it names a piece of a split face or a merged one. The same face on +/// another body (a copy of this one) is not a piece of this body's face. +pub(crate) fn locate_face_on_body(st: &ModelState, body: Option<&str>, name: &str, p: Vec3) -> Option { + if legacy_name(name).is_none() + && let Some(i) = body.and_then(|n| st.bodies.iter().position(|b| b.name == n)) + && let Some(b) = st.bodies.get(i).filter(|b| !b.body.is_mesh()) + && let Some(hit) = locate_in(std::iter::once((i, b)), name, p) + && !hit.split + && naming::names(b).faces.get(hit.face).is_some_and(|n| n == name) + { + return Some(hit); + } + locate_face(st, name, p) +} + /// [`locate_face`] among `bodies` (with their indices). fn locate_in<'a>(bodies: impl Iterator, name: &str, p: Vec3) -> Option { if let Some(old) = legacy_name(name) { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index b8dcc0ec..f72a3dee 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -578,14 +578,22 @@ fn rev_edge(e: EdgeSpec) -> EdgeSpec { e.reversed() } -/// Largest distance between a folded bend edge and the cubic chain standing in for it. +/// Largest distance between a folded bend edge and the chain standing in for it. const BEND_CHAIN_TOL: f64 = 1e-7; -/// Most halvings of a bend edge's parameter while fitting its chain. -const BEND_CHAIN_DEPTH: usize = 10; +/// Degree of the pieces of a folded bend edge's chain. Hermite pieces of degree 7 match the +/// edge's point and first three derivatives at both ends, so one or two of them follow a helix +/// round a whole bend, where cubic pieces (point and tangent) took 32 and more. +const BEND_CHAIN_DEGREE: usize = 7; +/// Most pieces of a folded bend edge's chain. +const BEND_CHAIN_PIECES: usize = 256; -fn cubic_at(p0: Vec3, c1: Vec3, c2: Vec3, p1: Vec3, t: f64) -> Vec3 { - let u = 1.0 - t; - p0 * (u * u * u) + c1 * (3.0 * u * u * t) + c2 * (3.0 * u * t * t) + p1 * (t * t * t) +/// The point at `t` of the Bézier over `c` (de Casteljau). +fn bezier_at(c: &[Vec3], t: f64) -> Vec3 { + let mut w = c.to_vec(); + while w.len() > 1 { + w = w.windows(2).map(|p| p[0].lerp(p[1], t)).collect(); + } + w.first().copied().unwrap_or(Vec3::ZERO) } fn dedup(p: &[Vec2]) -> Vec { @@ -973,8 +981,10 @@ impl SheetBody { } /// A flat point of flange `f`'s bend zone folded onto the bend at height `z` (not clamped - /// to the zone), with its rates of change along the zone's flat `n` and `d` directions. - fn zone_point(&self, f: &SheetFlange, q: Vec2, z: f64) -> (Vec3, Vec3, Vec3) { + /// to the zone), with its first three derivatives along the zone's flat `n` direction and + /// its first along `d` (along `d` the bend is straight: the higher ones are zero, and so are + /// the mixed ones). + fn zone_point(&self, f: &SheetFlange, q: Vec2, z: f64) -> (Vec3, [Vec3; 3], Vec3) { let s = (q - f.p).dot(f.n); let u = (q - f.p).dot(f.d); let neutral = (f.radius + self.k * self.t).max(1e-12); @@ -987,58 +997,91 @@ impl SheetBody { let (sn, cs) = phi.sin_cos(); let w = self.world(f.parent); let local = axis_o + v3(f.d) * u + ez * cs + ek * sn; + // Each derivative along n turns the last a right angle about the axis and divides it by + // the neutral radius. let ds = (ez * -sn + ek * cs) * (1.0 / neutral); - (apply_point(&w, local), apply_vector(&w, ds), apply_vector(&w, v3(f.d))) + let dss = (ez * cs + ek * sn) * (-1.0 / (neutral * neutral)); + let dsss = ds * (-1.0 / (neutral * neutral)); + let along_n = [ds, dss, dsss].map(|v| apply_vector(&w, v)); + (apply_point(&w, local), along_n, apply_vector(&w, v3(f.d))) } /// A flat edge of flange `f`'s bend zone, folded at height `z`, at parameter `t`: the point - /// and its derivative. - fn zone_edge_at(&self, f: &SheetFlange, e: &Seg2, z: f64, t: f64) -> (Vec3, Vec3) { - let (p, ds, du) = self.zone_point(f, e.point_at(t), z); - let dq = e.derivative(t); - (p, ds * dq.dot(f.n) + du * dq.dot(f.d)) + /// and its first three derivatives. + fn zone_edge_at(&self, f: &SheetFlange, e: &Seg2, z: f64, t: f64) -> [Vec3; 4] { + let (p, [g1, g2, g3], du) = self.zone_point(f, e.point_at(t), z); + let [q1, q2, q3] = match e.derivatives(t, 3).as_deref() { + Some(&[q1, q2, q3]) => [q1, q2, q3], + _ => [e.derivative(t), Vec2::ZERO, Vec2::ZERO], + }; + let ([s1, s2, s3], [u1, u2, u3]) = ([q1, q2, q3].map(|q| q.dot(f.n)), [q1, q2, q3].map(|q| q.dot(f.d))); + // The chain rule (Faà di Bruno) through s(t) along n and u(t) along d. + [p, g1 * s1 + du * u1, g2 * (s1 * s1) + g1 * s2 + du * u2, g3 * (s1 * s1 * s1) + g2 * (3.0 * s1 * s2) + g1 * s3 + du * u3] } - /// Where to break a bend zone's flat edge so cubic Hermite pieces follow the folded edge - /// to within [`BEND_CHAIN_TOL`] on both faces. The breaks depend on the flat edge only, so - /// the top face, bottom face and wall of a cut-out share one chain per height. - fn zone_breaks(&self, f: &SheetFlange, e: &Seg2) -> Vec { - let mut out = vec![0.0]; - let mut todo = vec![(0.0, 1.0, 0usize)]; - while let Some((t0, t1, depth)) = todo.pop() { - let fits = depth >= BEND_CHAIN_DEPTH - || [0.0, self.t].iter().all(|&z| { - let h = t1 - t0; - let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1)); - let (c1, c2) = (p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0)); - [0.25, 0.5, 0.75].iter().all(|&x| cubic_at(p0, c1, c2, p1, x).dist(self.zone_edge_at(f, e, z, t0 + h * x).0) <= BEND_CHAIN_TOL) - }); - if fits { - out.push(t1); - } else { - let m = (t0 + t1) / 2.0; - // Depth first, right half last on the stack: breaks come out in order. - todo.push((m, t1, depth + 1)); - todo.push((t0, m, depth + 1)); + /// The control points of the Hermite piece of degree 7 of a folded bend edge from `t0` to + /// `t1`: it has the edge's point and first three derivatives at both ends. (The k-th + /// derivative of a Bézier of degree 7 at its start is 7!/(7 − k)! times the k-th forward + /// difference of its first control points, and the edge's k-th derivative scales by + /// (t1 − t0)^k to the piece's parameter; at its end the same, backwards.) + fn zone_piece(&self, f: &SheetFlange, e: &Seg2, z: f64, t0: f64, t1: f64) -> [Vec3; BEND_CHAIN_DEGREE + 1] { + let h = t1 - t0; + let ([p0, a1, a2, a3], [p1, b1, b2, b3]) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1)); + let (k1, k2, k3) = (h / 7.0, h * h / 42.0, h * h * h / 210.0); + [ + p0, + p0 + a1 * k1, + p0 + a1 * (2.0 * k1) + a2 * k2, + p0 + a1 * (3.0 * k1) + a2 * (3.0 * k2) + a3 * k3, + p1 - b1 * (3.0 * k1) + b2 * (3.0 * k2) - b3 * k3, + p1 - b1 * (2.0 * k1) + b2 * k2, + p1 - b1 * k1, + p1, + ] + } + + /// Into how many equal pieces (of its parameter) a bend zone's flat edge is folded, so that + /// the Hermite pieces are within [`BEND_CHAIN_TOL`] of the folded edge on both faces. The + /// count depends on the flat edge only, so the top face, bottom face and wall of a cut-out + /// share one chain per height; and as the pieces are equal, the chain's parameter runs evenly + /// with the edge's, and the pieces join with equal derivatives up to the third. + fn zone_pieces(&self, f: &SheetFlange, e: &Seg2) -> usize { + // The largest distance between the pieces and the folded edge, at points inside each. + let worst = |n: usize| { + let mut worst = 0.0f64; + for k in 0..n { + let (t0, t1) = (k as f64 / n as f64, (k + 1) as f64 / n as f64); + for z in [0.0, self.t] { + let c = self.zone_piece(f, e, z, t0, t1); + for j in 1..8 { + let t = t0 + (t1 - t0) * j as f64 / 8.0; + worst = worst.max(bezier_at(&c, j as f64 / 8.0).dist(self.zone_edge_at(f, e, z, t)[0])); + } + } } + worst + }; + let mut n = 1; + loop { + let off = worst(n); + if off <= BEND_CHAIN_TOL || !off.is_finite() || n >= BEND_CHAIN_PIECES { + return n; + } + // The distance falls as the eighth power of the pieces' length. + let guess = (n as f64 * (off / BEND_CHAIN_TOL).powf(1.0 / 8.0)).ceil(); + n = (guess as usize).clamp(n + 1, BEND_CHAIN_PIECES); } - out } - /// A bend zone's flat edge folded at height `z`: a chain of cubic Hermite pieces. + /// A bend zone's flat edge folded at height `z`: a chain of Hermite pieces of degree 7. fn zone_chain(&self, f: &SheetFlange, e: &Seg2, z: f64) -> EdgeSpec { - let breaks = self.zone_breaks(f, e); - let mut ctrl = Vec::with_capacity(breaks.len() * 3); - for w in breaks.windows(2) { - let (t0, t1) = (w[0], w[1]); - let h = t1 - t0; - let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1)); - if ctrl.is_empty() { - ctrl.push(p0); - } - ctrl.extend([p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0), p1]); + let n = self.zone_pieces(f, e); + let mut ctrl = Vec::with_capacity(BEND_CHAIN_DEGREE * n + 1); + for k in 0..n { + let c = self.zone_piece(f, e, z, k as f64 / n as f64, (k + 1) as f64 / n as f64); + ctrl.extend_from_slice(if k == 0 { &c } else { &c[1..] }); } - EdgeSpec::Chain { ctrl: ctrl.into() } + EdgeSpec::Chain { degree: BEND_CHAIN_DEGREE, ctrl: ctrl.into() } } /// A flat edge of a piece as a 3D edge at height `z`. @@ -1096,11 +1139,22 @@ impl SheetBody { cuts.push(fitted_region(&pocket)?); } let cuts = material_union(cuts, work)?; + let cut_boxes = cuts.iter().map(|c| loop_box(&c.outer)).collect::>>()?; let mut out = Vec::new(); let mut points = 0usize; for (piece, region) in pieces { let panel = fitted_region(&planar_region(®ion)?)?; - let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? }; + // Each piece meets only the cut-outs near it: arranging every cut-out of the sheet (a + // label's letters on its base) against each flange and bend zone too would charge the + // budget for all of them once per piece. + let (lo, hi) = loop_box(&panel.outer)?; + let near: Vec = cuts + .iter() + .zip(&cut_boxes) + .filter(|(_, (a, b))| a.x <= hi.x && b.x >= lo.x && a.y <= hi.y && b.y >= lo.y) + .map(|(c, _)| c.clone()) + .collect(); + let material = if near.is_empty() { vec![panel] } else { material_boolean(&[panel], &near, kernel::BoolOp::Cut, work)? }; for region in material { points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1); if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { @@ -1502,6 +1556,9 @@ impl SheetBody { } fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result { + if self.pockets.is_empty() { + return Ok(body); + } if self.pockets.len() > 1000 { return Err(flat_budget_error()); } @@ -4918,21 +4975,34 @@ mod tests { assert!(s.zone_point(&f, q, z).0.dist(s.map3(Piece::Zone(0), q, z)) < 1e-12); } // A flat line across the whole zone, 15 mm along the bend: the bend angle and the - // distance along the axis both grow evenly along it. + // distance along the axis both grow evenly along it. Two pieces of degree 7 follow it + // (cubic ones took 32). let e = Seg2::Line { a: f.p + f.d * 20.0, b: f.p + f.n * s.allowance(&f) + f.d * 35.0 }; - let breaks = s.zone_breaks(&f, &e); + let n = s.zone_pieces(&f, &e); + assert_eq!(n, 2); for z in [0.0, 2.0] { let helix = |t: f64| { let (phi, rho) = (angle * t, 3.0 - z); Vec3::new(e.point_at(t).x, setback - rho * phi.sin(), 3.0 - rho * phi.cos()) }; - let EdgeSpec::Chain { ctrl } = s.zone_chain(&f, &e, z) else { panic!("a slanted edge folds to a chain") }; - assert_eq!(ctrl.len(), 3 * (breaks.len() - 1) + 1); - for (k, w) in breaks.windows(2).enumerate() { - for x in [0.0, 0.1, 0.37, 0.5, 0.81, 1.0] { - let p = cubic_at(ctrl[3 * k], ctrl[3 * k + 1], ctrl[3 * k + 2], ctrl[3 * k + 3], x); - let exact = helix(w[0] + (w[1] - w[0]) * x); - assert!(p.dist(exact) < BEND_CHAIN_TOL, "height {z}, piece {k}: {}", p.dist(exact)); + let EdgeSpec::Chain { degree: 7, ctrl } = s.zone_chain(&f, &e, z) else { panic!("a slanted edge folds to a chain of degree 7") }; + assert_eq!(ctrl.len(), 7 * n + 1); + for k in 0..n { + for i in 0..=40 { + let x = i as f64 / 40.0; + let off = bezier_at(&ctrl[7 * k..=7 * k + 7], x).dist(helix((k as f64 + x) / n as f64)); + assert!(off < BEND_CHAIN_TOL, "height {z}, piece {k}, {x}: {off}"); + } + } + // The pieces are equal, so the chain is one smooth curve: on either side of a joint + // the first three differences of the control points are the same. + let diff = |p: &[Vec3]| p.windows(2).map(|w| w[1] - w[0]).collect::>(); + for k in 1..n { + let (mut before, mut after) = (ctrl[7 * k - 3..=7 * k].to_vec(), ctrl[7 * k..=7 * k + 3].to_vec()); + for order in 1..=3 { + (before, after) = (diff(&before), diff(&after)); + let (b, a) = (before[before.len() - 1], after[0]); + assert!(b.dist(a) < 1e-12, "height {z}, joint {k}, order {order}: {b:?} vs {a:?}"); } } } @@ -5597,7 +5667,7 @@ mod tests { assert!((s.base.area() - base).abs() < tol, "{what}: {} vs {base}", s.base.area()); assert!((flat_area(&s) - (base + len * (ba + panel))).abs() < 1e-6, "{what}: {}", flat_area(&s)); // (A sliver 0.0001 wide is below the flat pattern's shortest edge.) - if hair < 0.0 || hair > 1e-3 { + if !(0.0..=1e-3).contains(&hair) { holds_the_flat_material(&s, &what); } else { let b = s.solid().unwrap_or_else(|e| panic!("{what}: no solid: {e}")); @@ -7436,6 +7506,59 @@ mod tests { assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1); } + #[test] + fn a_torn_bend_end_folds_to_two_smooth_pieces_on_its_helix() { + // The tray of `a_tear_cuts_each_bend_from_the_corner_to_its_flanges_end` (T = 2, R = 1): + // each of its eight torn bend ends is the wall ruled between a helix on the bend's inner + // face and one on its outer face, `bend_end` at the turn φ from 0 to θ. Cubic pieces took + // 32 at either angle, which a viewer draws as 32 strips; two pieces of degree 7 hold the + // helix to within the chains' tolerance. + for deg in [90.0f64, 72.0] { + let what = format!("{deg}°"); + let (s, _) = tray(&torn(), deg, 38.6); + let mut work = FlatWork::new(); + let cells = s.cells_with_work(&mut work).unwrap(); + let faces = s.faces(&cells, 0.0, s.t, &mut work).unwrap(); + let walls: Vec<(&EdgeSpec, &EdgeSpec)> = faces + .iter() + .filter_map(|f| match &f.surface { + SurfSpec::Ruled { a, b } => Some((a, b)), + _ => None, + }) + .collect(); + assert_eq!(walls.len(), 8, "{what}"); + let th = deg.to_radians(); + for (bottom, top) in walls { + for (chain, z) in [(bottom, 0.0), (top, s.t)] { + let EdgeSpec::Chain { ctrl, .. } = chain else { panic!("{what}: a torn end folds to a chain") }; + assert_eq!(ctrl.len(), 15, "{what}: two pieces of degree 7"); + // The torn end this chain runs along, and which way. + let (first, last) = (ctrl[0], ctrl[ctrl.len() - 1]); + let end = s.flanges.iter().flat_map(|f| [(f, false), (f, true)]).find_map(|(f, at_end)| { + let at = |phi: f64| s.bend_end(f, at_end, phi, z); + if at(0.0).dist(first) < 1e-9 && at(th).dist(last) < 1e-9 { + Some((f, at_end, false)) + } else if at(th).dist(first) < 1e-9 && at(0.0).dist(last) < 1e-9 { + Some((f, at_end, true)) + } else { + None + } + }); + let Some((f, at_end, back)) = end else { panic!("{what}: {first:?} to {last:?} is no torn end") }; + for k in 0..2 { + for i in 0..=40 { + let t = (k as f64 + i as f64 / 40.0) / 2.0; + let exact = s.bend_end(f, at_end, th * if back { 1.0 - t } else { t }, z); + let off = bezier_at(&ctrl[7 * k..=7 * k + 7], i as f64 / 40.0).dist(exact); + assert!(off < BEND_CHAIN_TOL, "{what}: height {z}, {t}: {off}"); + } + } + } + } + holds_the_flat_material(&s, &what); + } + } + #[test] fn a_torn_bend_end_is_no_flat_face() { use std::f64::consts::{FRAC_PI_2, SQRT_2}; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3dab4b2..9dbaad6e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -113,6 +113,7 @@ fn box_fillet_cut_timeline() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -183,6 +184,7 @@ fn errors_are_reported_not_fatal() { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 7c571a1b..fa09250a 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -50,8 +50,10 @@ impl Walk for crate::document::Operation {} impl Walk for solidcraft_kernel::Continuity {} impl Walk for crate::document::Direction {} impl Walk for crate::document::FilletFit {} +impl Walk for crate::document::FilletCorner {} impl Walk for crate::document::MeshData {} impl Walk for crate::document::RevolveConvention {} +impl Walk for crate::document::RailFit {} impl Walk for crate::document::CoilSection {} impl Walk for crate::document::ThinSide {} impl Walk for crate::document::ExtendMode {} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d54bfd..58ab39c2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,3 +19,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +web-time = { workspace = true } diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 5f89fc15..6e299245 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -312,3 +312,166 @@ fn joined_loft_frames_plan_their_display_at_every_quality() { } } } + +/// A chamfered 240° ring cut by two prisms whose outlines have a corner exactly on its bore and +/// one in its end face, then notched into its top outer chamfer: each cut is exact (no retry +/// moved the tool), the two mirrored cuts take the same material, the notch takes what its +/// integral says, and the result meshes to its volume at a coarse tolerance too. The prism cuts +/// went to the last-resort nudge (seconds each, a face of no width left at each corner) and the +/// notch was refused for its volume: its hole in the chamfer meshed several mm² too large. +#[test] +fn oblique_cuts_through_a_chamfered_ring_are_exact() { + let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/oblique_cuts_through_chamfered_ring.json")).unwrap(); + let steps = script.get("commands").and_then(Value::as_array).unwrap(); + let mut s = Session::default(); + let mut volumes = Vec::new(); + for step in steps { + let reply = s.execute(step["command"].as_str().unwrap(), &step["params"]).unwrap(); + if step["command"] == "solid.extrude" { + assert!(reply["warning"].is_null(), "{reply}"); + } + if matches!(step["command"].as_str(), Some("solid.move" | "solid.extrude")) { + volumes.push(s.model.state().body("R").unwrap().body.tessellate(1e-5).unwrap().measure().volume); + } + } + // Pappus: the profile's moment about the axis turned through 240°. + let profile = [(16.6, 0.0), (17.5, 0.0), (18.5, 1.0), (18.5, 7.0), (17.5, 8.0), (16.6, 8.0), (16.4, 7.8), (16.4, 0.2)]; + let moment: f64 = (0..profile.len()) + .map(|i| { + let ((x0, y0), (x1, y1)) = (profile[i], profile[(i + 1) % profile.len()]); + (x0 * y1 - x1 * y0) * (x0 + x1) / 6.0 + }) + .sum(); + let ring = moment.abs() * 4.0 * std::f64::consts::PI / 3.0; + let [whole, first, second, notched] = volumes[..] else { panic!("{volumes:?}") }; + // Volumes from meshes at 1e-5 mm: within a few millionths of the ring's. + assert!((whole - ring).abs() < 1e-5 * ring, "{whole} vs {ring}"); + // The ring and the two prisms are mirror images across XZ. + let (cut1, cut2) = (whole - first, first - second); + assert!(cut1 > 5.0 && (cut1 - cut2).abs() < 1e-6, "{cut1} vs {cut2}"); + let notch = ring_notch(); + assert!(((second - notched) - notch).abs() < 1e-4, "{} vs {notch}", second - notched); + let state = s.model.state(); + let body = &state.body("R").unwrap().body; + assert!(body.validity().is_empty(), "{:?}", body.validity()); + // Faces meshed coarsely keep to their surfaces: chords cut a little area off round faces, + // and nothing adds any (triangles fanning across a chamfer from the notch's hole or from the + // cuts' trims, folded over each other, added a few mm² each). + let (coarse, fine) = (body.tessellate(0.05).unwrap().measure().area, body.tessellate(1e-5).unwrap().measure().area); + assert!(coarse < fine && coarse > fine - 2.0, "area meshed at 0.05: {coarse} vs {fine}"); + println!( + "ring {whole:.6} / {ring:.6} mm³; cuts {cut1:.6}, {cut2:.6}; notch {:.6} / {notch:.6}; area meshed at 0.05 {coarse:.4} / {fine:.4}", + second - notched + ); +} + +/// The ring's notch alone: its hole in the top outer chamfer meshed several mm² too large, and +/// the cut was refused for an implausible volume after more than a minute; it takes what its +/// integral says. +#[test] +fn notch_into_a_ring_chamfer_is_cut_exactly() { + let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/oblique_cuts_through_chamfered_ring.json")).unwrap(); + let steps = script.get("commands").and_then(Value::as_array).unwrap(); + let mut s = Session::default(); + let measure = |s: &Session| s.model.state().body("R").unwrap().body.tessellate(1e-5).unwrap().measure().volume; + s.run_script(&Value::Array(steps[..5].to_vec())).unwrap(); + let before = measure(&s); + // The notch's sketch and its cut. + for step in &steps[steps.len() - 4..] { + let reply = s.execute(step["command"].as_str().unwrap(), &step["params"]).unwrap(); + assert!(reply["warning"].is_null(), "{reply}"); + } + let (got, want) = (before - measure(&s), ring_notch()); + assert!((got - want).abs() < 1e-4, "{got} vs {want}"); +} + +/// What the ring's notch takes: a band 0.9 wide about a radius, from 17.6 out along it, and from +/// z 7.1 up to the top outer chamfer (r = 25.5 - z). Across the band (s), along it from 17.6 to +/// where r = 18.4, the height 18.4 - r: along it in closed form, across by Simpson's rule. +fn ring_notch() -> f64 { + let along = |s: f64| { + let x1 = (18.4f64 * 18.4 - s * s).sqrt(); + let g = |x: f64| { + let r = (x * x + s * s).sqrt(); + 18.4 * x - 0.5 * (x * r + s * s * (x + r).ln()) + }; + g(x1) - g(17.6) + }; + let n = 2000; + let h = 0.9 / n as f64; + let weight = |i: usize| { + if i == 0 || i == n { + 1.0 + } else if i % 2 == 1 { + 4.0 + } else { + 2.0 + } + }; + (0..=n).map(|i| weight(i) * along(-0.45 + i as f64 * h)).sum::() * h / 3.0 +} + +/// The wedge of #1617: a side profile in (y, z) extruded 138 mm along x, its back face leaning +/// by `lean` (the tangent of its angle), with the issue's rounds. The last one runs along the +/// outline of both ends. The issue asked for R1 with `fit: reduce`, which reduces it to 0.854 mm; +/// that radius is asked for here directly, which builds the same body without the fit's search +/// (about a minute). +fn raked_wedge(lean: f64) -> Session { + let mut s = Session::default(); + let top = 60.0 - 60.0 * lean; + let profile = [[-60.0, 0.0], [60.0, 0.0], [top, 60.0], [38.0, 60.0], [-22.0, 22.0], [-60.0, 22.0]]; + let back = (60.0 + top) / 2.0; + let ends: Vec<[f64; 3]> = [-69.0, 69.0].iter().flat_map(|&x| [[x, -41.0, 22.0], [x, 8.0, 41.0], [x, (38.0 + top) / 2.0, 60.0]]).collect(); + let steps = [ + ("sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Profile"})), + ("sketch.line", json!({"points": profile, "closed": true})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 138, "direction": "symmetric", "operation": "new", "body_name": "Wedge"})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.2, "edges": [[0, -60, 22]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.5, "edges": [[0, -22, 22]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 1.5, "edges": [[0, 38, 60]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 4, "edges": [[-69, -60, 12], [69, -60, 12], [-69, back, 30], [69, back, 30]]})), + ("solid.fillet", json!({"body": "Wedge", "radius": 0.854, "edges": ends})), + ]; + for (command, params) in steps { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")); + } + s +} + +/// A box intersect taking the end slice x >= 55 off the wedge of #1617 failed with "no result" +/// once its ends were rounded, when its back face leaned 10 degrees (a vertical one worked). +/// The leaning back shortens the top edge the end round runs along to 11.4 mm. Its strip there +/// borrowed sections from that short neighbour and folded back on itself past the joint, and +/// the boolean could not intersect the folded faces (fixed with #1667). The slice and the +/// rest, cut off by the same box, are sound and add up to the wedge, with the cut plane where +/// the issue had it and moved by 0.02 mm, as it also tried. +#[test] +fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() { + for lean in [10f64.to_radians().tan(), 0.0] { + let mut s = raked_wedge(lean); + let whole = volume(&s, "Wedge"); + for (i, x0) in [55.0, 55.02].into_iter().enumerate() { + let mut piece = |op: &str| -> (f64, solidcraft_geom::Aabb3) { + let copy = s.execute("solid.move", &json!({"bodies": ["Wedge"], "translate": [0, 0, 0], "copy": true})).unwrap(); + let copy = copy["created_bodies"][0].as_str().unwrap().to_string(); + let cap = format!("Cap{i}{op}"); + let length = 80.0 - x0; + let cap_box = json!({"center": [x0 + length / 2.0, 0, 40], "length": length, "width": 160, "height": 120, "operation": "new", "body_name": cap}); + s.execute("solid.box", &cap_box).unwrap(); + let (target, tool) = if op == "intersect" { (cap, copy) } else { (copy, cap) }; + s.execute("solid.combine", &json!({"target": target, "tools": [tool], "operation": op})) + .unwrap_or_else(|e| panic!("lean {lean}, x >= {x0}, {op}: {e}")); + let state = s.model.state(); + let body = &state.body(&target).unwrap().body; + assert!(body.validity().is_empty(), "lean {lean}, x >= {x0}, {op}: {:?}", body.validity()); + (solidcraft_kernel::measure(body).unwrap().volume, body.tessellate(1e-3).unwrap().bounds()) + }; + let (slice, at) = piece("intersect"); + let (rest, _) = piece("cut"); + println!("#1617 lean {lean:.4}, x >= {x0}: slice {slice:.6} + rest {rest:.6} = {:.6}, wedge {whole:.6} mm³", slice + rest); + assert!((slice + rest - whole).abs() < 1e-6 * whole, "{slice} + {rest} vs {whole}"); + assert!((at.min.x - x0).abs() < 1e-6 && (at.max.x - 69.0).abs() < 1e-3, "slice spans x {} to {}", at.min.x, at.max.x); + } + } +} diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 607cd25d..b8f93bf2 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -13,16 +13,16 @@ use crate::params::{bad, bool_, str_}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), + CommandSpec::new("body.rename", "Rename Body", rename_body).icon("rename").params("body: its name, name: the new name (features that use the body follow)"), CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) .icon("plane") .params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"), CommandSpec::new("browser.group", "New Group", group) .icon("folder") .params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"), - CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"), - CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"), - CommandSpec::new("browser.move", "Move Browser Items", move_items) + CommandSpec::new("browser.ungroup", "Ungroup", ungroup).icon("folder").params("group: id (its items go back to the folder)"), + CommandSpec::new("browser.rename_group", "Rename Group", rename_group).icon("rename").params("group: id, name"), + CommandSpec::new("browser.move", "Move Browser Items", move_items).icon("reorder") .params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"), CommandSpec::new("document.units", "Change Design Units", document_units) .icon("settings") @@ -30,13 +30,13 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("view.save", "New Named View", view_save) .icon("camera") .params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"), - CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"), - CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"), - CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(), + CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"), + CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"), + CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(), CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params( "view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step", ), - CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"), + CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"), CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params( "visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \ or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \ diff --git a/crates/engine/src/cmd/clearance.rs b/crates/engine/src/cmd/clearance.rs new file mode 100644 index 00000000..1752d56e --- /dev/null +++ b/crates/engine/src/cmd/clearance.rs @@ -0,0 +1,456 @@ +//! Clearance (`inspect.clearance`): in one call, which bodies overlap and by how much, which +//! come closer than a clearance and where, and each body's nearest neighbours. Pairs are pruned +//! by bounding boxes grown by the clearance, then by the bodies' meshes (which lie within their +//! chord tolerance of the faces); distances are measured on the exact faces and edges +//! ([`solidcraft_kernel::body_distance`]) and overlaps by intersecting the bodies. The pairs +//! that are left run in parallel. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use solidcraft_geom::mesh_distance::box_gap; +use solidcraft_geom::{Aabb3, Mesh}; +use solidcraft_kernel::{BodyDistance, DistanceShape}; + +use super::CommandSpec; +use crate::params::{bad, num, str_, string_list}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().params( + "bodies?: [names] (default: every solid body, in world placement, but those in `against`); against?: [names] (only pairs with one body in `bodies` and the other here); \ + min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \ + nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \ + \"component\" (pairs of bodies inside one placed component, its subcomponents included) | null; tolerance?: mm (default 1e-6: a gap \ + below it is contact, 0) → overlaps: [{a, b, volume_mm3, area_mm2, bbox, center}]; near: [{a, b, distance_mm, from, to, overlap_mm3?: \ + a contact's shared volume below min_volume, exact?: false when measured on the meshes}], nearest by body (the same for pairs that overlap: 0); \ + nearest: {body: [{body, distance_mm}]}; unchecked: [{a, b, reason}] (pairs whose overlap could not be worked out); pairs_checked; \ + pairs_pruned (ruled out by their boxes); ms. Distances are measured on the exact faces and edges, the meshes only rule pairs out", +)]; + +/// Most bodies one call takes. +const MAX_BODIES: usize = 20_000; +/// Most pairs whose grown boxes meet that one call measures. +const MAX_PAIRS: usize = 200_000; +/// Most neighbours a body's `nearest` list holds. +const MAX_NEAREST: usize = 1000; +/// Largest clearance (mm). +const MAX_CLEARANCE: f64 = 1e6; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SkipWithin { + Nothing, + Occurrence, + Component, +} + +/// What one pair came to. +#[derive(Default)] +struct PairResult { + /// Shared volume, area, box and centre. + overlap: Option<(f64, f64, Aabb3, solidcraft_geom::Vec3)>, + distance: Option, + unchecked: Option, +} + +fn clearance(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.clearance"; + let started = web_time::Instant::now(); + let param = |k: &str, default: f64, max: f64| -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(_) => match num(p, k) { + Some(v) if (0.0..=max).contains(&v) => Ok(v), + _ => Err(bad(cmd, format!("`{k}` must be a number from 0 to {max}"))), + }, + } + }; + let min_volume = param("min_volume", 0.01, f64::MAX)?; + let gap = param("clearance", 0.0, MAX_CLEARANCE)?; + let tolerance = param("tolerance", 1e-6, 1.0)?; + let nearest = param("nearest", 0.0, MAX_NEAREST as f64)?.floor() as usize; + let skip = match p.get("skip_within") { + None | Some(Value::Null) => SkipWithin::Nothing, + Some(_) => match str_(p, "skip_within") { + Some("occurrence") => SkipWithin::Occurrence, + Some("component") => SkipWithin::Component, + _ => return Err(bad(cmd, "`skip_within` must be \"occurrence\", \"component\" or null")), + }, + }; + + let st = s.world_state(); + let solid: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect(); + let named = |key: &str| -> Result> { + let names = string_list(p, key); + let mut out = Vec::new(); + for n in &names { + let i = solid.iter().position(|b| &b.name == n).ok_or_else(|| bad(cmd, format!("no solid body `{n}`")))?; + if !out.contains(&i) { + out.push(i); + } + } + Ok(out) + }; + let against = if p.get("against").is_none_or(Value::is_null) { None } else { Some(named("against")?) }; + // Without `bodies`: every body (every body not in `against`, when it is given). + let subjects = if p.get("bodies").is_none_or(Value::is_null) { + (0..solid.len()).filter(|i| against.as_ref().is_none_or(|a| !a.contains(i))).collect() + } else { + named("bodies")? + }; + if solid.len() > MAX_BODIES { + return Err(bad(cmd, format!("at most {MAX_BODIES} bodies"))); + } + let n = solid.len(); + let mut in_subjects = vec![false; n]; + for &i in &subjects { + if let Some(x) = in_subjects.get_mut(i) { + *x = true; + } + } + let mut in_against = vec![false; n]; + for &i in against.iter().flatten() { + if let Some(x) = in_against.get_mut(i) { + *x = true; + } + } + let used: Vec = (0..n).map(|i| in_subjects.get(i).copied().unwrap_or(false) || in_against.get(i).copied().unwrap_or(false)).collect(); + + // The occurrence path of each body, for `skip_within`. + let paths: HashMap> = s.doc.body_frames(&s.model.state()).into_iter().map(|(name, _, path, _)| (name, path)).collect(); + let path_by_body: Vec<&[u64]> = solid.iter().map(|b| paths.get(&b.name).map(Vec::as_slice).unwrap_or(&[])).collect(); + let path_of = |i: usize| path_by_body.get(i).copied().unwrap_or(&[]); + let flag = |v: &[bool], i: usize| v.get(i).copied().unwrap_or(false); + let allowed = |i: usize, j: usize| -> bool { + if i == j { + return false; + } + let paired = match &against { + None => flag(&in_subjects, i) && flag(&in_subjects, j), + Some(_) => (flag(&in_subjects, i) && flag(&in_against, j)) || (flag(&in_subjects, j) && flag(&in_against, i)), + }; + if !paired { + return false; + } + let (a, b) = (path_of(i), path_of(j)); + match skip { + SkipWithin::Nothing => true, + SkipWithin::Occurrence => a.is_empty() || a != b, + SkipWithin::Component => a.is_empty() || a.first() != b.first(), + } + }; + + // Meshes (made once, in parallel) and the distance shapes over them. + let idx: Vec = (0..n).filter(|&i| flag(&used, i)).collect(); + let meshes: Vec>> = { + let made = solidcraft_kernel::map_with_local_pools(&idx, usize::MAX, |&i| Ok(solid.get(i).map(|b| b.mesh()))); + let mut out = vec![None; n]; + for (&i, m) in idx.iter().zip(made) { + if let (Some(slot), Ok(m)) = (out.get_mut(i), m) { + *slot = m; + } + } + out + }; + let shapes: Vec> = (0..n) + .map(|i| match (solid.get(i), meshes.get(i).and_then(Option::as_ref)) { + (Some(b), Some(m)) if flag(&used, i) => Some(DistanceShape::new(&b.body, m, b.display_tol())), + _ => None, + }) + .collect(); + let shape = |i: usize| shapes.get(i).and_then(Option::as_ref); + let boxes: Vec = (0..n).map(|i| shape(i).map_or(Aabb3::EMPTY, DistanceShape::bounds)).collect(); + + // Pairs whose boxes, grown by half the clearance each, meet. + let half = solidcraft_geom::Vec3::new(1.0, 1.0, 1.0) * (gap / 2.0); + let grown: Vec = boxes.iter().map(|b| if b.is_empty() { *b } else { Aabb3 { min: b.min - half, max: b.max + half } }).collect(); + let (close, truncated) = super::joints::overlapping_pairs(&grown, MAX_PAIRS); + let close: Vec<(usize, usize)> = close.into_iter().filter(|&(i, j)| allowed(i, j) && shape(i).is_some() && shape(j).is_some()).collect(); + let mut total = 0usize; + for i in 0..n { + for j in i + 1..n { + if allowed(i, j) { + total += 1; + } + } + } + + let results = solidcraft_kernel::map_with_local_pools(&close, usize::MAX, |&(i, j)| { + let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(PairResult::default()) }; + let (Some(ba), Some(bb)) = (solid.get(i), solid.get(j)) else { return Ok(PairResult::default()) }; + Ok(measure_pair(ba, a, bb, b, gap, min_volume, tolerance)) + }); + + let mut known: HashMap<(usize, usize), f64> = HashMap::new(); + let (mut overlaps, mut near, mut unchecked) = (Vec::new(), Vec::new(), Vec::new()); + let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str()); + for (&(i, j), r) in close.iter().zip(results) { + let r = match r { + Ok(r) => r, + Err(e) => { + unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()})); + continue; + } + }; + if let Some(reason) = r.unchecked { + unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason})); + continue; + } + if let Some((volume, area, bbox, center)) = r.overlap + && volume >= min_volume + { + overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area, + "bbox": {"min": bbox.min, "max": bbox.max}, "center": center})); + known.insert((i, j), 0.0); + continue; + } + let Some(d) = r.distance else { continue }; + known.insert((i, j), d.distance); + if d.distance < gap { + let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to}); + if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) { + e["overlap_mm3"] = json!(v); + } + if !d.exact { + e["exact"] = json!(false); + } + near.push(e); + } + } + near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0))); + + let mut nearest_out = serde_json::Map::new(); + if nearest > 0 { + let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| { + solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| { + let (Some(a), Some(b)) = (shape(i), shape(j)) else { return Ok(None) }; + Ok(solidcraft_kernel::body_distance(a, b, f64::INFINITY)?.map(|d| if d.distance < tolerance { 0.0 } else { d.distance })) + }) + }); + for (i, list) in lists { + let rows: Vec = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect(); + nearest_out.insert(name(i).to_string(), Value::Array(rows)); + } + } + + Ok(json!({ + "overlaps": overlaps, + "near": near, + "nearest": nearest_out, + "unchecked": unchecked, + "bodies": subjects.len(), + "pairs_checked": close.len(), + "pairs_pruned": total.saturating_sub(close.len()), + "truncated": truncated > 0, + "ms": started.elapsed().as_secs_f64() * 1000.0, + })) +} + +/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance +/// when it may be closer than `gap`. +fn measure_pair( + ba: &solidcraft_doc::ModelBody, + a: &DistanceShape, + bb: &solidcraft_doc::ModelBody, + b: &DistanceShape, + gap: f64, + min_volume: f64, + tolerance: f64, +) -> PairResult { + let mut out = PairResult::default(); + let slack = 2.0 * (a.chord() + b.chord()); + let mesh = a.mesh_closest(b, gap + slack); + let holds = |outer: &solidcraft_doc::ModelBody, inner: &solidcraft_doc::ModelBody| { + let (o, i) = (outer.mesh(), inner.mesh()); + let (ob, ib) = (o.bounds(), i.bounds()); + let nested = ob.min.x <= ib.min.x + && ob.min.y <= ib.min.y + && ob.min.z <= ib.min.z + && ib.max.x <= ob.max.x + && ib.max.y <= ob.max.y + && ib.max.z <= ob.max.z; + nested && i.positions.first().is_some_and(|p| o.contains(*p)) + }; + let touching = mesh.is_some_and(|m| m.distance <= slack); + if touching || holds(ba, bb) || holds(bb, ba) { + // A pair whose shared part can't be worked out is reported, not passed as clear. + let region = shared_region(a, b); + let (ca, cb) = (clipped(&ba.body, region), clipped(&bb.body, region)); + let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| { + solidcraft_kernel::boolean(ca.as_ref().unwrap_or(&ba.body), cb.as_ref().unwrap_or(&bb.body), solidcraft_kernel::BoolOp::Intersect) + }); + match shared { + Err(e) => { + out.unchecked = Some(e.to_string()); + return out; + } + Ok(shared) => { + if let Some(reason) = report.unverified() { + out.unchecked = Some(reason); + return out; + } + if let Some(o) = shared { + match measure_near_origin(&o, region.center()) { + Ok(m) if !plausible(m.volume, m.centroid, m.bbox) => { + out.unchecked = Some(format!( + "the shared part came out malformed ({:.4} mm³ with its centre outside its own box), so it is not measured", + m.volume + )); + return out; + } + Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)), + Err(e) => { + out.unchecked = Some(e.to_string()); + return out; + } + } + } + } + } + if out.overlap.is_some_and(|o| o.0 >= min_volume) { + return out; + } + if out.overlap.is_some_and(|o| o.0 > 0.0) { + // Contact with a sliver of shared volume. + let at = out.overlap.map(|o| o.3).unwrap_or_default(); + out.distance = Some(BodyDistance { distance: 0.0, from: at, to: at, exact: true }); + return out; + } + } + if mesh.is_none() { + return out; + } + match solidcraft_kernel::body_distance(a, b, gap) { + Ok(Some(mut d)) => { + if d.distance < tolerance { + d.distance = 0.0; + } + out.distance = Some(d); + } + Ok(None) => {} + Err(e) => out.unchecked = Some(e.to_string()), + } + out +} + +/// The box where two bodies may share volume: where their boxes meet, grown by a margin so that +/// a cut there stays clear of both bodies' faces. +fn shared_region(a: &DistanceShape, b: &DistanceShape) -> Aabb3 { + let (x, y) = (a.bounds(), b.bounds()); + let min = x.min.max(y.min); + let max = x.max.min(y.max); + if x.is_empty() || y.is_empty() || !(min.x <= max.x && min.y <= max.y && min.z <= max.z) { + return Aabb3::EMPTY; + } + let grow = 1.0 + 0.05 * min.dist(max); + let g = solidcraft_geom::Vec3::new(grow, grow, grow); + Aabb3 { min: min - g, max: max + g } +} + +/// `body` cut down to `region` when the body is much larger than it. The boolean works to a +/// tolerance that grows with the bodies' size: a 0.01 mm overlap against a plate 1.7 m long +/// comes out a quarter too small, and thinner ones not at all. Cut to the region first, the +/// body is small and its faces there are the same faces. `None`: use the whole body. +fn clipped(body: &solidcraft_kernel::Body, region: Aabb3) -> Option { + if region.is_empty() || body.size() <= 4.0 * region.diagonal() { + return None; + } + let cut = solidcraft_kernel::box_solid(region.min, region.max).ok()?; + let (r, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(body, &cut, solidcraft_kernel::BoolOp::Intersect)); + match r { + Ok(Some(c)) if report.unverified().is_none() => Some(c), + _ => None, + } +} + +/// [`solidcraft_kernel::measure`] of `body` moved by `-at` and back: far from the origin, the +/// centre of a thin part loses its precision (its moments are large numbers that nearly cancel). +fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3) -> solidcraft_kernel::Result { + let Ok(moved) = solidcraft_kernel::transform(body, -at, solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 0.0) else { + return solidcraft_kernel::measure(body); + }; + let mut m = solidcraft_kernel::measure(&moved)?; + m.centroid += at; + m.bbox = Aabb3 { min: m.bbox.min + at, max: m.bbox.max + at }; + Ok(m) +} + +/// Is a measured shared part well formed? A part whose shell came out inside out or open can +/// carry a volume with its centre outside its own box, or more volume than its box holds. +fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool { + let slack = 1e-6 * bbox.diagonal().max(1.0); + let (lo, hi) = (bbox.min, bbox.max); + let inside = [(centre.x, lo.x, hi.x), (centre.y, lo.y, hi.y), (centre.z, lo.z, hi.z)].iter().all(|&(c, l, h)| c >= l - slack && c <= h + slack); + let s = bbox.size(); + volume.is_finite() && volume >= 0.0 && inside && volume <= s.x * s.y * s.z + slack +} + +/// Each subject's `count` nearest bodies, nearest first: pairs are measured in the order of their +/// boxes' gaps (a lower bound of their distance), in rounds, until the boxes of the next ones +/// are farther apart than the last neighbour kept. `known` holds distances already measured. +fn nearest_lists( + subjects: &[usize], + n: usize, + count: usize, + boxes: &[Aabb3], + allowed: &dyn Fn(usize, usize) -> bool, + known: &mut HashMap<(usize, usize), f64>, + measure: impl Fn(&[(usize, usize)]) -> Vec>>, +) -> BTreeMap> { + let key = |i: usize, j: usize| (i.min(j), i.max(j)); + let gap_of = |i: usize, j: usize| match (boxes.get(i), boxes.get(j)) { + (Some(a), Some(b)) => box_gap(a, b), + _ => f64::INFINITY, + }; + // Each subject's candidates, by box gap. + let mut order: BTreeMap, usize)> = BTreeMap::new(); + for &i in subjects { + let mut c: Vec<(f64, usize)> = (0..n).filter(|&j| allowed(i, j)).map(|j| (gap_of(i, j), j)).filter(|(g, _)| g.is_finite()).collect(); + c.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.cmp(&y.1))); + order.insert(i, (c, 0)); + } + let mut failed: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); + let measured = |known: &HashMap<(usize, usize), f64>, i: usize, c: &[(f64, usize)]| -> Vec<(usize, f64)> { + let mut l: Vec<(usize, f64)> = c.iter().filter_map(|&(_, j)| known.get(&key(i, j)).map(|d| (j, *d))).collect(); + l.sort_by(|x, y| x.1.total_cmp(&y.1).then(x.0.cmp(&y.0))); + l + }; + // Each round measures, for every subject not settled yet, the next `count` candidates. + for _ in 0..n.max(1) { + let mut todo: Vec<(usize, usize)> = Vec::new(); + for (&i, (c, next)) in order.iter_mut() { + let have = measured(known, i, c); + let mut added = 0; + while let Some(&(g, j)) = c.get(*next) { + let bound = if have.len() >= count { have.get(count - 1).map_or(f64::INFINITY, |x| x.1) } else { f64::INFINITY }; + if g > bound || added >= count { + break; + } + *next += 1; + let k = key(i, j); + if !known.contains_key(&k) && !failed.contains(&k) && !todo.contains(&k) { + todo.push(k); + added += 1; + } + } + } + if todo.is_empty() { + break; + } + for (k, r) in todo.iter().zip(measure(&todo)) { + match r { + Ok(Some(d)) => { + known.insert(*k, d); + } + _ => { + failed.insert(*k); + } + } + } + } + order.iter().map(|(&i, (c, _))| (i, measured(known, i, c).into_iter().take(count).collect())).collect() +} + +#[cfg(test)] +#[path = "clearance_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/clearance_tests.rs b/crates/engine/src/cmd/clearance_tests.rs new file mode 100644 index 00000000..5f1876e7 --- /dev/null +++ b/crates/engine/src/cmd/clearance_tests.rs @@ -0,0 +1,253 @@ +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn cube(s: &mut Session, name: &str, size: f64, corner: [f64; 3]) { + run(s, "solid.box", json!({"length": size, "width": size, "height": size, "corner": corner, "operation": "new", "body_name": name})); +} + +fn f(v: &Value) -> f64 { + v.as_f64().unwrap_or(f64::NAN) +} + +/// The pair `{a, b}` of a list entry, sorted. +fn pair(v: &Value) -> [String; 2] { + let mut p = [v["a"].as_str().unwrap_or("").to_string(), v["b"].as_str().unwrap_or("").to_string()]; + p.sort(); + p +} + +/// Two 10 mm cubes sharing a 1 mm corner cube overlap by 1 mm³; a third, far off, is pruned. +#[test] +fn an_overlap_of_one_cubic_millimetre_is_reported() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "B", 10.0, [9.0, 9.0, 9.0]); + cube(&mut s, "Far", 10.0, [100.0, 0.0, 0.0]); + let r = run(&mut s, "inspect.clearance", json!({})); + let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default(); + assert_eq!(overlaps.len(), 1, "{r}"); + assert_eq!(pair(&overlaps[0]), ["A", "B"]); + assert!((f(&overlaps[0]["volume_mm3"]) - 1.0).abs() < 1e-6, "{r}"); + // Three faces of 1 x 1 from each cube: 6 mm². + assert!((f(&overlaps[0]["area_mm2"]) - 6.0).abs() < 1e-6, "{r}"); + assert!((f(&overlaps[0]["center"][0]) - 9.5).abs() < 1e-6, "{r}"); + assert_eq!(r["pairs_checked"], 1, "{r}"); + assert_eq!(r["pairs_pruned"], 2, "{r}"); + assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}"); + // An overlap under min_volume is contact, not an overlap: with a clearance it is near, at 0. + let r = run(&mut s, "inspect.clearance", json!({"min_volume": 2, "clearance": 0.1})); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + assert_eq!(f(&r["near"][0]["distance_mm"]), 0.0, "{r}"); + assert!((f(&r["near"][0]["overlap_mm3"]) - 1.0).abs() < 1e-6, "{r}"); +} + +/// Two cubes 0.5 mm apart are near with a clearance of 1 mm; 2 mm apart they are not. +#[test] +fn pairs_closer_than_the_clearance_are_near() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "B", 10.0, [10.5, 2.0, 3.0]); + cube(&mut s, "C", 10.0, [-12.0, 0.0, 0.0]); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 1})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + assert_eq!(near.len(), 1, "{r}"); + assert_eq!(pair(&near[0]), ["A", "B"]); + assert!((f(&near[0]["distance_mm"]) - 0.5).abs() < 1e-9, "{r}"); + assert!(near[0].get("exact").is_none(), "measured on the exact faces: {r}"); + // The points lie on the facing faces x = 10 and x = 10.5. + let xs = [f(&near[0]["from"][0]), f(&near[0]["to"][0])]; + assert!(xs.iter().any(|x| (x - 10.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.5).abs() < 1e-9), "{r}"); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + // With no clearance nothing is near; with 2.5 mm C (2 mm from A) is near too. + let r = run(&mut s, "inspect.clearance", json!({})); + assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}"); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 2.5})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + assert_eq!(near.len(), 2, "{r}"); + assert_eq!(pair(&near[1]), ["A", "C"]); + assert!((f(&near[1]["distance_mm"]) - 2.0).abs() < 1e-9, "{r}"); +} + +/// Distances where the nearest points are on edges, and on curved faces whose meshes are far +/// coarser than the answer: cube edge to cube edge, a cylinder's side to a cube's face, and a +/// large sphere (mesh chord about 1 mm) to a cube. +#[test] +fn distances_are_exact_on_edges_and_curved_faces() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + // Its vertical edge at (12, 13) faces A's at (10, 10): √(2² + 3²). + cube(&mut s, "B", 10.0, [12.0, 13.0, 0.0]); + run(&mut s, "solid.cylinder", json!({"base": [-30, 5, 0], "radius": 5, "height": 10, "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.sphere", json!({"radius": 500, "center": [5, 5, 510.7], "operation": "new", "body_name": "Ball"})); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"], "bodies": ["B", "Pin", "Ball"]})); + let near = r["near"].as_array().cloned().unwrap_or_default(); + let d = |other: &str| near.iter().find(|n| pair(n).contains(&other.to_string())).map(|n| (f(&n["distance_mm"]), n.get("exact").is_none())); + assert_eq!(near.len(), 3, "{r}"); + let (edge, exact) = d("B").unwrap_or((f64::NAN, false)); + assert!((edge - 13f64.sqrt()).abs() < 1e-6 && exact, "{r}"); + // The pin's side (x = -25) is 25 from A's face x = 0. + let (side, exact) = d("Pin").unwrap_or((f64::NAN, false)); + assert!((side - 25.0).abs() < 1e-6 && exact, "{r}"); + // The ball's lowest point, (5, 5, 10.7), is 0.7 above A's top. + let (ball, exact) = d("Ball").unwrap_or((f64::NAN, false)); + assert!((ball - 0.7).abs() < 1e-6 && exact, "{r}"); + // `against` leaves out the pairs among `bodies`, which are by default every other body. + assert!(near.iter().all(|n| pair(n).contains(&"A".to_string())), "{r}"); + let all = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"]})); + assert_eq!(all["near"].as_array().map(Vec::len), Some(3), "{all}"); +} + +/// A pin in a hole 0.2 mm wider all round, in a plate whose mesh is coarser than that gap: the +/// plate's flat faces reach over the hole on their surfaces but not on the faces, so the +/// distance is the gap to the hole's wall. +#[test] +fn a_pin_in_a_hole_is_as_far_as_the_holes_wall() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "operation": "new", "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 3.2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [10, 10, -5], "operation": "new", "body_name": "Pin"})); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 1})); + assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}"); + assert!((f(&r["near"][0]["distance_mm"]) - 0.2).abs() < 1e-6, "{r}"); + let r = run(&mut s, "inspect.clearance", json!({"clearance": 0.15})); + assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}"); +} + +/// Three spheres' nearest neighbours against their distances worked out by hand. +#[test] +fn nearest_distances_of_three_spheres() { + let mut s = Session::default(); + for (name, r, c) in [("S1", 5.0, [0.0, 0.0, 0.0]), ("S2", 3.0, [12.0, 0.0, 0.0]), ("S3", 2.0, [3.0, 20.0, 4.0])] { + run(&mut s, "solid.sphere", json!({"radius": r, "center": c, "operation": "new", "body_name": name})); + } + let r = run(&mut s, "inspect.clearance", json!({"nearest": 2})); + let d12 = 12.0 - 8.0; + let d13 = (9.0f64 + 400.0 + 16.0).sqrt() - 7.0; + let d23 = (81.0f64 + 400.0 + 16.0).sqrt() - 5.0; + let want = [("S1", [("S2", d12), ("S3", d13)]), ("S2", [("S1", d12), ("S3", d23)]), ("S3", [("S1", d13), ("S2", d23)])]; + for (body, list) in want { + let got = r["nearest"][body].as_array().cloned().unwrap_or_default(); + assert_eq!(got.len(), 2, "{body}: {r}"); + for (g, (other, d)) in got.iter().zip(list) { + assert_eq!(g["body"], other, "{body}: {r}"); + assert!((f(&g["distance_mm"]) - d).abs() < 1e-6, "{body} to {other}: {} against {d}", g["distance_mm"]); + } + } + // One neighbour each. + let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"]})); + assert_eq!(r["nearest"]["S3"].as_array().map(Vec::len), Some(0), "only S3 is in `bodies`, so it has no partner: {r}"); + let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"], "against": ["S1", "S2"]})); + assert_eq!(r["nearest"]["S3"][0]["body"], "S1", "{r}"); +} + +/// `skip_within` leaves out the pairs inside one component (two bodies made to touch, as the +/// parts of a bought component do) and keeps those between components. +#[test] +fn skip_within_leaves_out_a_components_own_pairs() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Bought"})); + cube(&mut s, "Housing", 10.0, [0.0, 0.0, 0.0]); + cube(&mut s, "Insert", 4.0, [8.0, 3.0, 3.0]); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Bracket"})); + cube(&mut s, "Plate", 10.0, [-9.5, 0.0, 0.0]); + let all = run(&mut s, "inspect.clearance", json!({})); + let pairs: Vec<[String; 2]> = all["overlaps"].as_array().into_iter().flatten().map(pair).collect(); + assert!(pairs.contains(&["Housing".into(), "Insert".into()]) && pairs.contains(&["Housing".into(), "Plate".into()]), "{all}"); + for mode in ["component", "occurrence"] { + let r = run(&mut s, "inspect.clearance", json!({"skip_within": mode})); + let pairs: Vec<[String; 2]> = r["overlaps"].as_array().into_iter().flatten().map(pair).collect(); + assert_eq!(pairs, vec![["Housing".to_string(), "Plate".to_string()]], "{mode}: {r}"); + assert!((f(&r["overlaps"][0]["volume_mm3"]) - 50.0).abs() < 1e-6, "{r}"); + } +} + +/// Parameters out of range are refused, not guessed at. +#[test] +fn bad_parameters_are_refused() { + let mut s = Session::default(); + cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]); + for p in [ + json!({"clearance": -1}), + json!({"clearance": "wide"}), + json!({"min_volume": -0.1}), + json!({"nearest": 1e9}), + json!({"tolerance": 5}), + json!({"skip_within": "body"}), + json!({"bodies": ["Nope"]}), + json!({"against": ["Nope"]}), + ] { + assert!(s.execute("inspect.clearance", &p).is_err(), "{p}"); + } + let r = run(&mut s, "inspect.clearance", json!({"nearest": 3, "skip_within": null})); + assert_eq!(r["pairs_checked"], 0, "{r}"); + assert_eq!(r["nearest"]["A"].as_array().map(Vec::len), Some(0), "{r}"); +} + +/// A pin sunk 0.01 mm into a plate 1.7 m long: the shared ring holds π (4.43² − 2.5²) × 0.01 mm³ +/// wherever the plate stands, as on a plate of 100 mm (the boolean of the whole plate gave 0.31). +#[test] +fn a_thin_overlap_with_a_large_plate_is_measured_in_full() { + let ring = std::f64::consts::PI * (4.43f64.powi(2) - 2.5f64.powi(2)) * 0.01; + for (length, far) in [(100.0, false), (1700.0, false), (1700.0, true)] { + let mut s = Session::default(); + let corner = [-length / 2.0, -length / 4.0, 0.0]; + run(&mut s, "solid.box", json!({"length": length, "width": length / 2.0, "height": 5, "corner": corner, "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 2.5, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 7, "base": [0, 0, -1.99], "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.cylinder", json!({"radius": 4.43, "height": 2, "base": [0, 0, 4.99], "operation": "join", "targets": ["Pin"]})); + let shift = if far { [-326.3, 13.5, 1278.3] } else { [0.0; 3] }; + if far { + run(&mut s, "solid.move", json!({"bodies": ["Plate", "Pin"], "translate": shift})); + } + let r = run(&mut s, "inspect.clearance", json!({"min_volume": 1e-4})); + let o = &r["overlaps"][0]; + assert!((f(&o["volume_mm3"]) - ring).abs() < 1e-3 * ring, "{length} {far}: {r}"); + // The ring's centre is on the axis, 0.005 below the plate's face. + assert!((f(&o["center"][2]) - (4.995 + shift[2])).abs() < 1e-4, "{length} {far}: {r}"); + assert!((f(&o["center"][0]) - shift[0]).abs() < 1e-4, "{length} {far}: {r}"); + } +} + +/// A shared part whose centre lies outside its own box, or which holds more than its box, is +/// malformed: the pair is reported unchecked rather than with that volume. +#[test] +fn a_malformed_shared_part_is_not_measured() { + use solidcraft_geom::{Aabb3, Vec3}; + let bbox = Aabb3 { min: Vec3::new(0.0, 0.0, 0.0), max: Vec3::new(10.0, 10.0, 1.0) }; + assert!(super::plausible(50.0, Vec3::new(5.0, 5.0, 0.5), bbox)); + assert!(!super::plausible(50.0, Vec3::new(5.0, 5.0, 3.0), bbox)); + assert!(!super::plausible(1239.0, Vec3::new(5.0, 5.0, 0.5), bbox)); + assert!(!super::plausible(f64::NAN, Vec3::new(5.0, 5.0, 0.5), bbox)); +} + +/// Two plates 0.2 mm apart, the upper one set 10 mm in from the lower one's corner, and two set +/// side by side with a 0.2 mm gap: 0.2, not 0 (a point past a corner, on the line of one of its +/// edges, was taken for a point of the face). Gaps under twice the meshes' chord went wrong. +#[test] +fn a_small_gap_past_a_corner_is_not_contact() { + let mut s = Session::default(); + let plate = |s: &mut Session, name: &str, size: [f64; 3], corner: [f64; 3]| { + run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner, "operation": "new", "body_name": name})); + }; + plate(&mut s, "A", [100.0, 100.0, 2.0], [0.0, 0.0, 0.0]); + plate(&mut s, "B", [100.0, 100.0, 2.0], [10.0, 10.0, 2.2]); + plate(&mut s, "C", [100.0, 100.0, 2.0], [0.0, 0.0, 10.0]); + plate(&mut s, "D", [100.0, 100.0, 2.0], [100.2, 0.0, 10.0]); + // 3 mm apart in x and 0.2 in z, along their long sides: √(3² + 0.2²). + plate(&mut s, "E", [1000.0, 100.0, 2.0], [0.0, 300.0, 0.0]); + plate(&mut s, "F", [1000.0, 100.0, 2.0], [1003.0, 300.0, 2.2]); + for (a, b, want) in [("A", "B", 0.2), ("C", "D", 0.2), ("E", "F", (9.0f64 + 0.04).sqrt())] { + let r = run(&mut s, "inspect.clearance", json!({"bodies": [a], "against": [b], "clearance": 5, "nearest": 4})); + assert!((f(&r["near"][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}"); + assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}"); + } +} diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index eff00a90..cf924e89 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -12,8 +12,9 @@ use crate::params::{bad, string_list, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"), + CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"), CommandSpec::new("timeline.paste", "Paste Features", paste) + .icon("paste") .params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"), ]; diff --git a/crates/engine/src/cmd/compare.rs b/crates/engine/src/cmd/compare.rs index be888746..439198c1 100644 --- a/crates/engine/src/cmd/compare.rs +++ b/crates/engine/src/cmd/compare.rs @@ -12,7 +12,7 @@ use super::CommandSpec; use crate::params::bad; use crate::{Result, Sel, Session}; -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params( +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params( "a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \ surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \ more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \ diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ffe5510f..76733d61 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -29,8 +29,8 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies) .icon("component") .params("bodies: [names] → one new component per body, named after it"), - CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), - CommandSpec::new("component.list", "List Components", list).noundo().params( + CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"), + CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params( "compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \ identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)", ), @@ -40,20 +40,20 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence) .icon("measure").noundo() .params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"), - CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"), - CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"), + CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"), + CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"), CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params( "component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \ move and take joints one by one → joints (the ids removed)", ), - CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"), - CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"), - CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence) + CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params("bodies: [names], component"), + CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"), + CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy") .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), - CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), - CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence) + CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), + CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move") .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), - CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"), + CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"), CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"), ]; @@ -214,10 +214,18 @@ fn rename(s: &mut Session, p: &Value) -> Result { let id = component(s, p, "component", cmd)?; let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + let d = s.doc_mut(); if id == 0 { - s.doc_mut().name = name.to_string(); - } else if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) { - c.name = name.to_string(); + d.name = name.to_string(); + } else if let Some(c) = d.components.iter_mut().find(|c| c.id == id) { + let old = std::mem::replace(&mut c.name, name.to_string()); + // Placements still named after the component ("Old:2") follow it (#1665); others keep theirs. + for o in d.occurrences.iter_mut().filter(|o| o.component == id) { + let k = o.name.strip_prefix(old.as_str()).and_then(|r| r.strip_prefix(':')); + if let Some(k) = k.filter(|k| !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit())) { + o.name = format!("{name}:{k}"); + } + } } Ok(json!({"component": id, "name": name})) } diff --git a/crates/engine/src/cmd/component_clone_assembly.rs b/crates/engine/src/cmd/component_clone_assembly.rs index 88a2f2c9..7cf3a027 100644 --- a/crates/engine/src/cmd/component_clone_assembly.rs +++ b/crates/engine/src/cmd/component_clone_assembly.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use solidcraft_doc::Document; +use solidcraft_doc::joints::cam::CamProfile; use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap}; use crate::{EngineError, Result}; @@ -102,9 +103,31 @@ fn budget(assembly: &Assembly) -> Result<()> { return Err(invalid("exploded view contains non-finite moves")); } } - if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) { + if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite() && l.lift.iter().all(|v| v.is_finite())) { return Err(invalid("motion link contains non-finite values")); } + for cam in assembly.links.iter().filter_map(|l| match &l.coupling { + Some(Coupling::Cam(c)) => Some(c), + _ => None, + }) { + add_references(&mut references, cam.tip.iter().count())?; + if let Some(tip) = &cam.tip { + finite_origin(tip)?; + } + match &cam.profile { + CamProfile::Sketch { curves, .. } => { + add_references(&mut references, curves.len())?; + curves.iter().try_for_each(|c| text(c))?; + } + CamProfile::Edges { picks, names, .. } => { + add_references(&mut references, picks.len() + names.len())?; + if !picks.iter().all(|p| p.is_finite()) { + return Err(invalid("motion link contains non-finite values")); + } + names.iter().try_for_each(|n| text(n))?; + } + } + } for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) { match size { Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")), @@ -300,12 +323,26 @@ pub(super) fn clone_assembly( continue; } let mut item = old.clone(); - // A gear's picked round faces are on occurrences of the copy too. + // A gear's picked round faces are on occurrences of the copy too, and so are a cam's + // profile and its follower's tip. let faces = item.coupling.as_mut().is_none_or(|c| { - c.sizes_mut().into_iter().all(|size| match size { + let sizes = c.sizes_mut().into_iter().all(|size| match size { Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(), _ => true, - }) + }); + let Coupling::Cam(cam) = c else { return sizes }; + let tip = match &mut cam.tip { + Some(t) => origin(t, occurrences, features, bodies).map(|new| *t = new).is_some(), + None => true, + }; + let profile = match &mut cam.profile { + CamProfile::Sketch { sketch, .. } => features.get(sketch).map(|new| *sketch = *new).is_some(), + CamProfile::Edges { occurrence, names, .. } => { + occurrences.get(occurrence).map(|new| *occurrence = *new).is_some() + && names.iter_mut().all(|n| n.is_empty() || face_name(n, features, bodies).map(|new| *n = new).is_some()) + } + }; + sizes && tip && profile }); match (joints.get(&old.a), joints.get(&old.b)) { (Some(aa), Some(bb)) if faces => { diff --git a/crates/engine/src/cmd/component_clone_assembly_tests.rs b/crates/engine/src/cmd/component_clone_assembly_tests.rs index f9157eab..63f11036 100644 --- a/crates/engine/src/cmd/component_clone_assembly_tests.rs +++ b/crates/engine/src/cmd/component_clone_assembly_tests.rs @@ -44,8 +44,8 @@ fn documents() -> (Document, Document) { NamedOrigin { name: "Outside".into(), origin: at(9) }, ]; source.assembly.links = vec![ - MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None }, - MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None }, + MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None, lift: Vec::new() }, + MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() }, ]; source.assembly.contact_sets = vec![ ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true }, @@ -106,8 +106,8 @@ fn copied_gears_take_their_picked_faces_along() { let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } }); let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false }; source.assembly.links = vec![ - MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) }, - MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) }, + MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))), lift: Vec::new() }, + MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))), lift: Vec::new() }, ]; let mut destination = source.clone(); destination.occurrences.extend([101, 102].map(occurrence)); diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index 8dd29104..c5e3d6ba 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -20,12 +20,14 @@ pub static COMMANDS: &[CommandSpec] = &[ "path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \ name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]", ), - CommandSpec::new("component.update_links", "Update Links", update_links).params( + CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params( "component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed", ), CommandSpec::new("component.break_link", "Break Link", break_link) + .icon("break") .params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"), CommandSpec::new("component.links", "List Links", list_links) + .icon("chain") .noundo() .params("→ linked components: component, name, path, file (resolved), found"), ]; diff --git a/crates/engine/src/cmd/config.rs b/crates/engine/src/cmd/config.rs index 49835ff7..ca170e5a 100644 --- a/crates/engine/src/cmd/config.rs +++ b/crates/engine/src/cmd/config.rs @@ -14,17 +14,17 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("config.table", "Display Configuration Table", show) .at("SOLID", "CONFIGURE") - .icon("params") + .icon("configuration_table") .noundo() .params("→ columns (param: or suppress:), rows, the active row and whether the design still matches it"), - CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"), - CommandSpec::new("config.row", "Configuration Row", row).params( + CommandSpec::new("config.column", "Configuration Column", column).icon("configuration_table").params("param? | feature? | material? | appearance? (target name); delete?: bool"), + CommandSpec::new("config.row", "Configuration Row", row).icon("configuration_table").params( "name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool", ), - CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"), - CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"), - CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), - CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"), + CommandSpec::new("config.activate", "Activate Configuration", activate).icon("activate").params("row: name — sets its parameter values and suppressions"), + CommandSpec::new("config.preview", "Preview Configuration", preview).icon("eye").noundo().params("row: name — validates a rebuild without changing the design or history"), + CommandSpec::new("config.rule", "Configuration Rule", rule).icon("configuration_table").params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"), + CommandSpec::new("config.rules", "Configuration Rules", show).icon("list").noundo().params("ordered typed rules, rows and active state"), ]; fn column_of(s: &Session, p: &Value, cmd: &str) -> Result { diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index 460cbbdd..4c42c43f 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -21,69 +21,69 @@ const REF: &str = pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_through_cylinder") .params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"), CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_normal_to_face") .params("face: ref; point: ref (the axis goes through it, square to the face); name?"), CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_two_planes") .params("a, b: plane or planar face refs; name?"), CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_two_points") .params("a, b: point refs (vertices, sketch points, points); name?"), CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge) .at("SOLID", "CONSTRUCT") - .icon("axis") + .icon("axis_edge") .params("edge: ref (a straight edge or sketch line); name?"), CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_vertex") .params("point: ref (vertex, sketch point, point); name?"), CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_two_edges") .params("a, b: edge or line refs (where they meet, or come closest); name?"), CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_three_planes") .params("a, b, c: plane or planar face refs; name?"), CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_center") .params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"), CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_edge_plane") .params("edge: edge or line ref; plane: plane or planar face ref; name?"), CommandSpec::new("construct.point.along_path", "Point Along Path", point_along) .at("SOLID", "CONSTRUCT") - .icon("point") + .icon("point_along_path") .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"), CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_midplane") .params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"), CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_two_edges") .params("a, b: straight edge or line refs in one plane; name?"), CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_three_points") .params("a, b, c: point refs; name?"), CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_tangent") .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"), CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_along_path") .params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"), - CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params( + CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane_perpendicular").params( "plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane", ), ]; diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index b1875e22..19a0e4ca 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -27,9 +27,13 @@ struct CachedBody { size: f64, } +/// A member of a contact set: (occurrence id, name). +pub(super) type Member = (u64, String); + pub(super) struct ContactGeometry { bodies: Vec, - pairs: Vec<(usize, usize, String, String)>, + /// Body indices, and the set members (occurrence id, name) they are under. + pairs: Vec<(usize, usize, Member, Member)>, step: f64, } @@ -41,6 +45,12 @@ impl ContactGeometry { } else { assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect() }; + Self::of_sets(s, &sets) + } + + /// The geometry of the bodies under `sets` (occurrence ids), paired as contact sets pair + /// them: bodies under different members of one set. + pub(super) fn of_sets(s: &Session, sets: &[Vec]) -> Result { let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY }; if sets.is_empty() { return Ok(out); @@ -120,7 +130,7 @@ impl ContactGeometry { } } let mut pairs = BTreeSet::new(); - for set in &sets { + for set in sets { let tagged: Vec<(usize, u64)> = out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect(); for (i, (a, oa)) in tagged.iter().enumerate() { @@ -132,7 +142,7 @@ impl ContactGeometry { } } let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default(); - out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect(); + out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, (oa, name(oa)), (ob, name(ob)))).collect(); Ok(out) } @@ -140,6 +150,21 @@ impl ContactGeometry { !self.pairs.is_empty() } + /// Keeps only the pairs of set members (occurrence ids) `keep` takes. + pub(super) fn keep_pairs(&mut self, keep: impl Fn(u64, u64) -> bool) { + self.pairs.retain(|(_, _, a, b)| keep(a.0, b.0)); + } + + /// How many mesh points are placed for each check. + pub(super) fn vertex_count(&self) -> usize { + self.bodies.iter().map(|b| b.mesh.positions.len()).fold(0, usize::saturating_add) + } + + /// How many pairs of set members are checked against each other. + pub(super) fn pair_count(&self) -> usize { + self.pairs.iter().map(|(_, _, a, b)| (a.0.min(b.0), a.0.max(b.0))).collect::>().len() + } + fn frame(body: &CachedBody, s: &Session) -> Result { let mut frame = IDENTITY; for id in &body.path { @@ -205,6 +230,16 @@ impl ContactGeometry { } pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result> { + Ok(self.hits(s, work, MAX_WORK, false)?.into_iter().next().map(|(a, b, depth)| (a.1, b.1, depth))) + } + + /// The pairs of set members whose bodies pass through each other in `s`, with how deep the + /// first two bodies found go (every pair, or the first found when `all` is false). `work` + /// adds up the cost, refused past `budget`. + pub(super) fn hits(&self, s: &Session, work: &mut usize, budget: usize, all: bool) -> Result> { + let mut out: Vec<(Member, Member, f64)> = Vec::new(); + // The member pairs found so far (lower id first). + let mut found: BTreeSet<(u64, u64)> = BTreeSet::new(); let meshes: Vec = self .bodies .iter() @@ -214,11 +249,19 @@ impl ContactGeometry { Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() }) }) .collect::>()?; - for (a, b, aname, bname) in &self.pairs { + // Each mesh's box once, not once per pair it is in. + let boxes: Vec = meshes.iter().map(Mesh::bounds).collect(); + for (a, b, oa, ob) in &self.pairs { + // Members already found colliding need no more of their bodies tested. + if found.contains(&(oa.0.min(ob.0), oa.0.max(ob.0))) { + continue; + } let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else { return Err(limit("invalid contact geometry pair")); }; - let (aabb, bbb) = (ma.bounds(), mb.bounds()); + let (Some(aabb), Some(bbb)) = (boxes.get(*a), boxes.get(*b)) else { + return Err(limit("invalid contact geometry pair")); + }; if aabb.max.x < bbb.min.x || bbb.max.x < aabb.min.x || aabb.max.y < bbb.min.y @@ -235,14 +278,18 @@ impl ContactGeometry { .saturating_mul(mb.triangles.len()) .saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len())); *work = work.saturating_add(cost); - if *work > MAX_WORK { + if *work > budget { return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments")); } if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) { - return Ok(Some((aname.clone(), bname.clone(), depth))); + found.insert((oa.0.min(ob.0), oa.0.max(ob.0))); + out.push((oa.clone(), ob.clone(), depth)); + if !all { + break; + } } } - Ok(None) + Ok(out) } } diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs index 716b3bd8..25a4756d 100644 --- a/crates/engine/src/cmd/diagnostics.rs +++ b/crates/engine/src/cmd/diagnostics.rs @@ -8,20 +8,21 @@ use crate::params::{bad, str_}; use crate::{EngineError, Result, Session, crash}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).noundo().params( + CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).icon("bug_report").noundo().params( "limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \ command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \ Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay `", ), - CommandSpec::new("diagnostics.crash_report", "Crash Report", read).noundo().params( + CommandSpec::new("diagnostics.crash_report", "Crash Report", read).icon("bug_report").noundo().params( "id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \ crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \ (`solidcraft-cli run --in `)", ), CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear) + .icon("delete") .noundo() .params("id?: one report (default: all). Returns {deleted}"), - CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).noundo().enabled(crash_test_enabled).params( + CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params( "mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \ the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \ at the next start). Only when the SOLIDCRAFT_CRASH_TEST environment variable is set", diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ac6a07c..58e33c55 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -12,11 +12,11 @@ use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(), CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(), - CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint) + CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).icon("checkpoint") .noundo() .doc_only() .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"), - CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint) + CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).icon("restore") .noundo() .doc_only() .params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"), @@ -26,48 +26,48 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("delete") .key("Delete") .params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"), - CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"), + CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"), CommandSpec::new("timeline.group", "New Feature Folder", group) .icon("folder") .doc_only() .params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"), - CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup) + CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder") .doc_only() .params("group: id | groups: [ids] (features remain in the timeline)"), - CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"), - CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group) + CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name"), + CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder") .doc_only() .params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"), - CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params( + CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params( "features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block", ), - CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), - CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback) + CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active (omit = end)"), + CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback") .params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"), - CommandSpec::new("timeline.reorder", "Reorder Feature", reorder) + CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder") .params("feature: id|name, position: its new index; refused when a feature that worked would fail"), - CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params( + CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params( "features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail", ), - CommandSpec::new("timeline.redefine", "Edit Feature", redefine) + CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit") .params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"), - CommandSpec::new("timeline.dependents", "Feature Dependents", dependents) + CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph") .noundo() .params("feature: id|name → features deleted with it, and features that would fail without it"), - CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), - CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params( + CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params("feature: id|name, suppressed?: bool (default toggles)"), + CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params( "feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \ e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \ expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused", ), - CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params( + CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params( "feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \ inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning", ), - CommandSpec::new("select.set", "Select", select_set) + CommandSpec::new("select.set", "Select", select_set).icon("select") .noundo() .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), - CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(), + CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(), ]; fn undo(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 923ac485..20becc82 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -2,7 +2,8 @@ use serde_json::{Value, json}; use solidcraft_doc::{ - AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr, + AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall, + expr::Kind, point_expr, }; use solidcraft_doc::{BodyName, FeatureName, SketchId}; use solidcraft_doc::{Dir, FacePick, Position}; @@ -25,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.sweep", "Sweep", sweep) .at("SOLID", "CREATE") .icon("sweep") - .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"), + .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"), @@ -35,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("fillet") .key("F") - .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) @@ -48,21 +49,21 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"), CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path) .at("SOLID", "CREATE") - .icon("pattern_rect") + .icon("pattern_path") .params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"), CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (origin coordinates may be length expressions; the mirror follows them, or a construction plane)"), CommandSpec::new("solid.hole", "Hole", hole) .at("SOLID", "CREATE") .icon("hole") .key("H") - .params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), + .params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"), CommandSpec::new("solid.pipe", "Pipe", pipe) .at("SOLID", "CREATE") .icon("pipe") .params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"), CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid) .at("SOLID", "CREATE") - .icon("box") + .icon("bounding_solid") .params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"), CommandSpec::new("solid.scale", "Scale", scale) .at("SOLID", "MODIFY") @@ -70,7 +71,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"), CommandSpec::new("face.delete", "Delete Face", delete_face) .at("SOLID", "MODIFY") - .icon("delete") + .icon("delete_face") .params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"), CommandSpec::new("solid.offset_face", "Offset Face", offset_face) .at("SOLID", "MODIFY") @@ -79,7 +80,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.thread", "Thread", thread) .at("SOLID", "CREATE") .icon("thread") - .params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"), + .params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"), CommandSpec::new("solid.shell", "Shell", shell) .at("SOLID", "MODIFY") .icon("shell") @@ -94,17 +95,17 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"), CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle) .at("SOLID", "CONSTRUCT") - .icon("plane") + .icon("plane_angle") .params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"), CommandSpec::new("solid.split_face", "Split Face", split_face) .at("SOLID", "MODIFY") - .icon("split") + .icon("split_face") .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)", ), CommandSpec::new("solid.split_body", "Split Body", split_body) .at("SOLID", "MODIFY") - .icon("split") + .icon("split_body") .params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"), CommandSpec::new("solid.press_pull", "Press Pull", press_pull) .at("SOLID", "MODIFY") @@ -141,6 +142,20 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { Some((ss.plane.to_world(q), ss.plane.normal())) } +/// How far an extrude with these parameters goes from its profile along the profile's normal +/// (negative: the other way). To an object, the target's height, on whichever side it is: the +/// extrude goes there and its `direction` does not count (#1633). Otherwise its distance, turned +/// round for the negative direction. +pub fn extrude_reach(s: &Session, p: &Value) -> Option { + if let Some(t) = p.get("to").and_then(vec3) { + let (base, n) = extrude_base(s, p)?; + return Some((t - base).dot(n)).filter(|h| h.is_finite()); + } + let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; + let dir = str_(p, "direction").map(str::to_ascii_lowercase); + Some(if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d }) +} + /// How far past the profile [`auto_operation`] looks for material it starts in. const START_PROBE_MM: f64 = 0.1; @@ -152,10 +167,8 @@ const START_PROBE_MM: f64 = 0.1; /// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far /// past a body's far side read as a Join (#1257). pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { - let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; - let dir = str_(p, "direction").map(str::to_ascii_lowercase); - let symmetric = dir.as_deref() == Some("symmetric"); - let d = if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d }; + let d = extrude_reach(s, p)?; + let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric")); let (base, n) = extrude_base(s, p)?; let st = s.model.state(); if st.bodies.is_empty() || !d.is_finite() { @@ -1051,10 +1064,26 @@ fn fillet(s: &mut Session, p: &Value) -> Result { if !fit.is_exact() && !style.is_constant() { return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius")); } + // What fills the corners where three blended edges meet. + let corner = match str_(p, "corner").map(str::to_ascii_lowercase).as_deref() { + None | Some("rolling") => solidcraft_doc::FilletCorner::Rolling, + Some("g2") => solidcraft_doc::FilletCorner::G2, + Some(o) => return Err(bad(cmd, format!("unknown corner `{o}` (rolling or g2)"))), + }; + if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) { + return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only")); + } let mut out = add_feature( s, p, - FeatureKind::Fillet { edges: edges.into_iter().map(Position).collect(), radius, body: str_(p, "body").map(BodyName::from), style, fit }, + FeatureKind::Fillet { + edges: edges.into_iter().map(Position).collect(), + radius, + body: str_(p, "body").map(BodyName::from), + style, + fit, + corner, + }, )?; // The radii the fillet used, when `fit: reduce` reduced it. if let Some(id) = out.get("feature").and_then(Value::as_u64) @@ -1329,6 +1358,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result { let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let plane = plane_param(s, p.get("plane"), cmd)?; let combine = bool_(p, "combine").unwrap_or(false); + let (bodies, placements) = placed_bodies(s, bodies, cmd)?; add_feature( s, p, @@ -1337,10 +1367,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result { plane, bodies: bodies.into_iter().map(BodyName).collect(), combine, + placements, }, ) } +/// Bodies picked where the viewport shows them: each world name ("Body1", or "Body1 (Occ:2)" +/// for a further instance) becomes its component's body name, with the occurrence path of the +/// placement it was picked at. Names stay as they are (and no placement is kept) while every +/// component is in place, or when one of them is not a body. +fn placed_bodies(s: &Session, names: Vec, cmd: &str) -> Result<(Vec, Vec>)> { + if names.is_empty() || !crate::frames::placed(s) { + return Ok((names, Vec::new())); + } + let frames = s.doc.body_frames_with_source(&s.model.state()); + let mut picked = Vec::with_capacity(names.len()); + for name in &names { + let Some((_, own, _, path, _)) = frames.iter().find(|(world, ..)| world == name) else { return Ok((names, Vec::new())) }; + if path.iter().any(|id| s.pending_moves.contains_key(id)) { + return Err(bad(cmd, format!("`{name}` has a position that is not captured yet: Capture Position (component.capture_position) first"))); + } + picked.push((own.clone(), path.clone())); + } + Ok(picked.into_iter().unzip()) +} + /// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one. pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> { match name.trim().to_ascii_uppercase().as_str() { @@ -1605,10 +1656,36 @@ fn hole(s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)"))); } - let position = match (&points, p.get("position").and_then(vec3)) { + // A position given as length expressions (#1678), say `["leg_h*0.4", "width/2", "t"]`: kept, + // so the hole follows the parameters; drilled where they evaluate now. + let at: Option = match p.get("position").and_then(Value::as_array) { + Some(a) if points.is_none() && a.len() == 3 && a.iter().any(Value::is_string) => { + let mut coords = Vec::with_capacity(3); + for v in a { + let e = match v { + Value::String(x) => x.trim().to_string(), + Value::Number(n) => n.to_string(), + _ => return Err(bad(cmd, "`position` must be [x, y, z]: numbers or length expressions")), + }; + check_expr(s, &e, Kind::Length, cmd, "position")?; + coords.push(solidcraft_doc::Expr::new(e)); + } + let [x, y, z]: [solidcraft_doc::Expr; 3] = coords.try_into().map_err(|_| bad(cmd, "`position` must be [x, y, z]"))?; + Some([x, y, z]) + } + _ => None, + }; + let at_value = match &at { + Some([x, y, z]) => { + let c = |e: &solidcraft_doc::Expr| s.doc.eval(e, Kind::Length).map_err(|e| bad(cmd, format!("`position`: {e}"))); + Some(Vec3::new(c(x)?, c(y)?, c(z)?)).filter(|v| v.is_finite()) + } + None => None, + }; + let position = match (&points, at_value.or_else(|| p.get("position").and_then(vec3))) { (Some((_, first, _)), None) => *first, (_, Some(x)) => x, - (None, None) => return Err(bad(cmd, "`position` must be [x, y, z] (or give `sketch` and `points`)")), + (None, None) => return Err(bad(cmd, "`position` must be [x, y, z], numbers or length expressions (or give `sketch` and `points`)")), }; let direction = match (p.get("direction").and_then(vec3), &points) { (None, Some((_, _, d))) => *d, @@ -1678,6 +1755,7 @@ fn hole(s: &mut Session, p: &Value) -> Result { &p, FeatureKind::Hole { position: FacePick(position), + at, direction: Dir(direction), diameter, depth, @@ -1795,6 +1873,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`path` must list curve ids")); } let (orientation, twist, scale) = sweep_options(s, p, cmd)?; + let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?; add_feature( s, p, @@ -1806,6 +1885,8 @@ fn sweep(s: &mut Session, p: &Value) -> Result { orientation, twist, scale, + rails, + rail_fit, operation: operation(p, cmd)?, targets: string_list(p, "targets").into_iter().map(BodyName).collect(), }, @@ -1855,6 +1936,44 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO Ok((orientation, twist, scale)) } +/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two +/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or +/// stretch; and no orientation, twist or scale besides, since the rails set those. +pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -> Result<(Vec, RailFit)> { + let rail_fit = match p.get("rail_fit") { + None | Some(Value::Null) => RailFit::Scale, + Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale, + Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch, + Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")), + }; + let given = match p.get("rails") { + None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, "`rails` must list one or two rails: [{sketch?, curves: [ids]}]")), + }; + if !(1..=2).contains(&given.len()) { + return Err(bad(cmd, "a sweep takes one or two guide rails")); + } + for key in ["orientation", "twist", "scale"] { + if p.get(key).is_some_and(|v| !v.is_null()) { + return Err(bad(cmd, format!("guide rails set the profile's turn and size along the path: leave out `{key}`"))); + } + } + let mut rails = Vec::with_capacity(given.len()); + for rail in given { + let sketch = match rail.get("sketch") { + None | Some(Value::Null) => path_sketch, + Some(_) => sketch_id(s, rail.get("sketch"), cmd, "rails[].sketch")?, + }; + let curves = string_list(rail, "curves"); + if curves.is_empty() || curves.len() > 1000 { + return Err(bad(cmd, "each rail's `curves` must list 1…1000 curve ids in order")); + } + rails.push(SweepRail { sketch: SketchId(sketch), curves }); + } + Ok((rails, rail_fit)) +} + fn loft(s: &mut Session, p: &Value) -> Result { let cmd = "solid.loft"; let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?; @@ -2004,6 +2123,10 @@ mod revolve_convention_tests; #[path = "sweep_options_tests.rs"] mod sweep_options_tests; +#[cfg(test)] +#[path = "sweep_rails_tests.rs"] +mod sweep_rails_tests; + #[cfg(test)] #[path = "offset_topology_tests.rs"] mod offset_topology_tests; @@ -2042,6 +2165,55 @@ mod thin_extrude_tests; #[cfg(test)] #[path = "extrude_convention_tests.rs"] mod extrude_convention_tests; +/// `solid.fillet corner?: rolling|g2`: the default keeps the rolling ball's sphere corner, `g2` +/// is accepted and refuses (item 10's patch is built but not sewn in yet, see `corner_g2`), and an +/// unknown word is refused. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod corner_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: &Value) -> Value { + s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) + } + + /// A 20 mm box with its three edges at one corner, filleted by 4 with `corner`. + fn cornered(corner: Option<&str>) -> (Session, Value) { + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let mut p = json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4}); + if let Some(c) = corner { + p["corner"] = json!(c); + } + let reply = run(&mut s, "solid.fillet", &p); + (s, reply) + } + + #[test] + fn the_default_corner_is_the_rolling_ball_and_g2_refuses_clearly() { + let (default, reply) = cornered(None); + assert_eq!(reply["warning"], json!(null), "{reply}"); + let (rolling, _) = cornered(Some("rolling")); + let ball = default.world_state().body("B").unwrap().body.clone(); + let same = rolling.world_state().body("B").unwrap().body.clone(); + // `corner: rolling` is the default: the same body, to the last bit of its area. + assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area); + assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0); + // `corner: g2`: accepted, and refused with a clear message until the patch sews in. + let mut s = Session::default(); + run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})); + let err = + s.execute("solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2"})).unwrap_err().to_string(); + eprintln!("corner g2: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + // An unknown word, and a style the G2 corner has no patch for, are refused as parameters. + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "round"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "type": "chord"})).is_err()); + assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "fit": "reduce"})).is_err()); + } +} #[cfg(test)] #[path = "fillet_connected_rounds_tests.rs"] mod fillet_connected_rounds_tests; diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 556a0319..7a255925 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -28,7 +28,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"), CommandSpec::new("solid.web", "Web", web) .at("SOLID", "CREATE") - .icon("rib") + .icon("web") .params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"), CommandSpec::new("solid.emboss", "Emboss", emboss) .at("SOLID", "CREATE") @@ -36,11 +36,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"), CommandSpec::new("solid.replace_face", "Replace Face", replace_face) .at("SOLID", "MODIFY") - .icon("offset_face") + .icon("replace_face") .params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"), CommandSpec::new("solid.align", "Align", align) .at("SOLID", "MODIFY") - .icon("move") + .icon("align") .params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"), CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"), ]; @@ -161,6 +161,8 @@ fn emboss(s: &mut Session, p: &Value) -> Result { let at = solidcraft_doc::apply_point(&s.active_component_frame(), local); let state = s.world_state(); let mut candidates = Vec::new(); + // The nearest face off the point: (distance, body), for the error when none holds it. + let mut nearest: Option<(f64, &str)> = None; for body in &state.bodies { let definition = body.definition_name(); if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) { @@ -179,14 +181,30 @@ fn emboss(s: &mut Session, p: &Value) -> Result { inside.then_some(((at - a).dot(n).abs(), *face as usize)) }) .min_by(|a, b| a.0.total_cmp(&b.0)); - if let Some((distance, index)) = closest - && distance < (body.body.size() * 1e-3).max(1e-3) - { - candidates.push((body, index)); + match closest { + Some((distance, index)) if distance < (body.body.size() * 1e-3).max(1e-3) => candidates.push((body, index)), + Some((distance, _)) if nearest.is_none_or(|(d, _)| distance < d) => nearest = Some((distance, body.name.as_str())), + _ => {} } } - if candidates.len() != 1 { - return Err(bad(cmd, "`face` must identify exactly one target body surface")); + // #392: say what is wrong with the point, not only that it picks no single face. + let point = format!("[{}, {}, {}]", local.x, local.y, local.z); + match (candidates.as_slice(), nearest) { + ([], Some((d, body))) => { + return Err(bad( + cmd, + format!("`face` {point} lies on no face: the nearest, on `{body}`, is {d:.3} mm away; give a point on the face"), + )); + } + ([], None) => return Err(bad(cmd, format!("`face` {point} lies on no face of the target bodies"))), + ([_], _) => {} + (more, _) => { + let names: Vec<&str> = more.iter().map(|(b, _)| b.name.as_str()).collect(); + return Err(bad( + cmd, + format!("`face` {point} lies on faces of {} bodies ({}): name one in `targets`", names.len(), names.join(", ")), + )); + } } let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?; let name = selected.definition_name(); diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 582ae1fa..f21bd390 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -92,6 +92,23 @@ fn emboss_and_deboss_on_a_face() { assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err()); } +#[test] +fn emboss_face_off_the_part_says_how_far() { + // #392: a point beside the face was refused with "`face` must identify exactly one target + // body surface", which didn't say the point was off the face. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"})); + run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]})); + run(&mut s, "sketch.finish", json!({})); + let e = s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "face": [20, 15, 11.25]})).unwrap_err().to_string(); + assert!(e.contains("[20, 15, 11.25] lies on no face: the nearest, on `A`, is 1.250 mm away"), "{e}"); + run(&mut s, "solid.box", json!({"length": 10, "width": 30, "height": 10, "corner": [40, 0, 0], "operation": "new_body", "body_name": "B"})); + let e = s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "face": [40, 15, 5]})).unwrap_err().to_string(); + assert!(e.contains("lies on faces of 2 bodies (A, B): name one in `targets`"), "{e}"); + run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 1, "face": [20, 15, 10]})); +} + fn cylinder_badge() -> Session { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"})); @@ -765,6 +782,60 @@ fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() { } } +/// A tapered foot extruded 3 mm down from a part's bottom face and joined (#1534). Run on into +/// the part with their slope, sides that narrow the foot away from the face widened it past the +/// face: a box got a lip round its walls (0.16 mm wide, and nothing said), a cylinder's join found +/// no result, and sides that widen the foot met the face's edge there. A foot on the whole face is +/// sewn on along it; one drawn just inside the face's edge starts on it. Either way: the part plus +/// the frustum, and nothing beside the part's walls. +#[test] +fn a_tapered_foot_under_a_part_joins_as_drawn() { + // (round, the foot's half width: the whole face, or just inside its edge) + for (round, half) in [(false, 15.0), (true, 15.0), (false, 14.9)] { + for taper in [-20.0f64, 20.0] { + let t = taper.to_radians().tan(); + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + if round { + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":15})); + } else { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,-15],"p1":[15,15]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"B"})); + let taper_deg = format!("{taper} deg"); + if half < 15.0 { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-half,-half],"p1":[half,half]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":3,"direction":"negative","operation":"join","taper":taper_deg})); + } else { + let foot = json!({"face":[5,1,0],"face_body":"B","distance":3,"direction":"positive","operation":"join","taper":taper_deg}); + run(&mut s, "solid.extrude", foot); + } + let (part, frustum) = if round { + let r = 15.0 + 3.0 * t; + (std::f64::consts::PI * 225.0 * 10.0, std::f64::consts::PI * (225.0 + 15.0 * r + r * r)) + } else { + let side = 2.0 * half + 6.0 * t; + (9000.0, 4.0 * half * half + 2.0 * half * side + side * side) + }; + let v = volume(&mut s); + // The cylinder is measured on its mesh; a foot that starts on the face stands straight + // for a hair first. + let tol = if round || half < 15.0 { 2e-4 } else { 1e-9 } * (part + frustum); + assert!((v - (part + frustum)).abs() < tol, "round {round}, half {half}, taper {taper}: {v} for {}", part + frustum); + assert_eq!(s.world_state().bodies.len(), 1); + let state = s.model.state(); + let mesh = state.body("B").unwrap().mesh(); + // Beside the part, above the foot's start: nothing. + let out = |x: f64, y: f64| if round { x.hypot(y) } else { x.abs().max(y.abs()) } > 15.0 + 1e-6; + let beside = mesh.positions.iter().filter(|p| p.z > 1e-6 && out(p.x, p.y)).count(); + assert_eq!(beside, 0, "round {round}, half {half}, taper {taper}: {:?}", mesh.bounds()); + } + } +} + /// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top /// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front /// face and in a knife-edge tip where it runs into the back face. @@ -1151,3 +1222,58 @@ fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() { assert_eq!(pieces, 1); assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one)); } + +/// The same line of text cut again, deeper: the new walls lie on the letters' walls. The 2D route +/// takes it as one exact boolean: a point a hair from a segment's end is that end, a sliver of a +/// letter's curve shorter than the tolerance is dropped, and a piece of a letter so short that the +/// engraved walls read it as an arc while the tool has the curve itself is one edge. Before, the +/// sewing found a degenerate edge, then the faces did not close, and the cut went to the B-rep +/// boolean's retries (90 s). +#[test] +fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":40})); + let cut = |s: &mut Session, depth: f64| { + let before = volume(s); + run(s, "sketch.create", json!({"plane":"XY","offset":40})); + run(s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5})); + run(s, "sketch.finish", json!({})); + let id = run(s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{depth}: {r:?}"); + before - volume(s) + }; + let first = cut(&mut s, 1.0); + // Cut again 2 mm deep: one more millimetre of the letters. + let again = cut(&mut s, 2.0); + assert!(rel(again, first) < 1e-3, "{first} {again}"); +} + +/// Text cut through a large plate (2 m) is decided in 2D in one exact boolean, as it is in a +/// small one. The 2D route's tolerances grow with the part: a font's curve that is merely near a +/// circle was read as an arc ending a micron off the next curve, so the letter's outline did not +/// close, and where the curves of a 3 come close the arrangement left a sliver that closed up to +/// nothing. The cut went to the B-rep boolean instead, which took tens of seconds and moved the +/// tool to find a result at all. +#[test] +fn text_cut_through_a_large_plate_is_one_exact_boolean() { + let taken = |w: f64, h: f64, at: [f64; 2]| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":w,"width":h,"height":1.5,"corner":[0,0,0]})); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane":"XY","offset":1.5})); + run(&mut s, "sketch.text", json!({"text":"PART-23","at":at,"height":7})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "solid.extrude", json!({"distance":-2,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{w} x {h}: {r:?}"); + before - volume(&mut s) + }; + let (small, large) = (taken(200.0, 120.0, [60.0, 40.0]), taken(2000.0, 1200.0, [860.0, 125.0])); + // The letters right through the plate, their counters left as pieces of their own: the same + // material in either plate (measured on meshes, which are coarser on the larger one). + assert!(small > 10.0 && rel(large, small) < 5e-2, "{small} {large}"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 78147187..cc137984 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -38,23 +38,23 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("save") .noundo() .params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"), - CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list) + CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore") .noundo() .params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"), - CommandSpec::new("doc.recover", "Recover Design", recover) + CommandSpec::new("doc.recover", "Recover Design", recover).icon("restore") .noundo() .params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"), - CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"), + CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"), CommandSpec::new("file.export", "Export", export) .icon("export") .noundo() .params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|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; 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)"), CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh) .at("MESH", "MODIFY") - .icon("export") + .icon("mesh_export") .noundo() .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, name or program path from file.slicers (open the saved file there)"), - CommandSpec::new("file.slicers", "Installed Slicers", slicers) + CommandSpec::new("file.slicers", "Installed Slicers", slicers).icon("export") .noundo() .params("→ the slicers found on this machine: [{id, name, icon?}]; pass an id or name as file.save_mesh's `slicer`"), ]; @@ -278,6 +278,19 @@ fn default_name_for(name: &str, path: &str) -> Option { (!stem.is_empty()).then_some(stem) } +/// The name a design takes when saved to `path`, if it changes: its file's name while it has +/// none of its own ([`default_name_for`]), or while it still has the name it took from the file +/// it was saved as before, so Save As under another name renames it too (#1710). A name the +/// user chose is kept. +fn saved_name(name: &str, old_path: Option<&str>, path: &str) -> Option { + if let Some(stem) = default_name_for(name, path) { + return Some(stem); + } + let stem = |p: &str| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().trim().to_string()).filter(|x| !x.is_empty()); + let (old, new) = (stem(old_path?)?, stem(path)?); + (name.trim() == old && new != old).then_some(new) +} + /// Rename the design, and the undo and redo states that still carry the old name, so undo /// does not bring the default name back and leave the design looking changed. fn rename_design(s: &mut Session, from: &str, to: &str) { @@ -291,8 +304,9 @@ fn rename_design(s: &mut Session, from: &str, to: &str) { fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result { solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?; - // Saving an unnamed design names it after its file (not an undoable edit). - let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem)); + // Saving an unnamed design, or one named after the file it was saved as, names it after its + // file (not an undoable edit). + let renamed = saved_name(&s.doc.name, s.path.as_deref(), path).map(|stem| (s.doc.name.clone(), stem)); if let Some((from, to)) = &renamed { rename_design(s, from, to); } @@ -883,9 +897,20 @@ mod tests { // Undoing the box does not bring the old name back. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc.name, "bracket"); - // Saving under another file keeps the name it now has. - s.execute("file.save_as", &json!({"path":dir.join("copy.solidcraft")})).unwrap(); - assert_eq!(s.doc.name, "bracket"); + // Saved under another file, a name taken from the old file follows it (#1710), in the + // file too, so reopening shows it. + let copy = dir.join("copy.solidcraft"); + s.execute("file.save_as", &json!({"path":copy})).unwrap(); + assert_eq!(s.doc.name, "copy"); + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path":copy})).unwrap(); + assert_eq!(reopened.doc.name, "copy"); + // Saving again to the same file changes nothing. + s.execute("file.save", &json!({})).unwrap(); + assert_eq!(s.doc.name, "copy"); + // As in the issue: a design opened from its file, then saved as another. + reopened.execute("file.save_as", &json!({"path":dir.join("third.solidcraft")})).unwrap(); + assert_eq!(reopened.doc.name, "third"); // A failed save leaves the name alone. let mut failed = Session::default(); assert!(failed.execute("file.save_as", &json!({"path":dir.join("missing-dir").join("plate.solidcraft")})).is_err()); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 410c6c43..1d215957 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -14,14 +14,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("I") .noundo() .params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"), - CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"), - CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"), - CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"), - CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params( + CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements)"), + CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"), + CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"), + CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params( "body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \ plane_normal, point (a point on the face that picks it), cylinder? ({origin, axis, radius} of a cylindrical face)", ), - CommandSpec::new("model.threads", "List Threads", model_threads).noundo(), + CommandSpec::new("model.threads", "List Threads", model_threads).icon("inspect").noundo(), CommandSpec::new("material.assign", "Physical Material", physical_material) .at("SOLID", "MODIFY") .icon("material") @@ -31,17 +31,17 @@ pub static COMMANDS: &[CommandSpec] = &[ appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \ pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material", ), - CommandSpec::new("appearance.library", "Appearance Library", appearance_library) + CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library") .noundo() .params("→ the built-in appearances (name, colour, opacity)"), - CommandSpec::new("appearance.list", "List Appearances", appearance_list) + CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list") .noundo() .params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"), - CommandSpec::new("material.list", "List Materials", material_list) + CommandSpec::new("material.list", "List Materials", material_list).icon("list") .noundo() .doc_only() .params("→ the built-in materials, then the design's own (user: true, with their look)"), - CommandSpec::new("engine.commands", "List Commands", commands).noundo(), + CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(), ]; pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value { diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 4b8fdbfe..962af8f8 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; use solidcraft_doc::expr::Kind; +use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower}; use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap}; use solidcraft_geom::{Aabb3, Vec3}; @@ -36,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[ reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \ the occurrence named)", ), - CommandSpec::new("joint.reach", "Reach Point", reach).params( + CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params( "occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move", ), CommandSpec::new("joint.motion_link", "Motion Link", motion_link) @@ -60,16 +61,19 @@ pub static COMMANDS: &[CommandSpec] = &[ travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \ ratio (mm per degree, or degrees per mm); b keeps where it is", ), - CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), - CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), - CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"), - CommandSpec::new("joint.list", "List Joints", list).noundo().params( + CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params( + "a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is", + ), + CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), + CommandSpec::new("joint.edit", "Edit Joint", edit).icon("edit").params("joint; type?, flip?, offset?, angle?, suppressed?, name?"), + CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"), + CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params( "→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \ (joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)", ), - CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"), + CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"), CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params( - "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", + "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)", ), ]; @@ -80,29 +84,30 @@ pub fn resolve(s: &mut Session) -> joints::Solution { resolve_holding(s, &BTreeMap::new()) } -/// Work out the ratios of the coupled motion links again (a radius or a parameter may have -/// changed). A coupling that can't be worked out (its face gone) keeps its last ratio; -/// `joint.list` says why. +/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter +/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone) +/// keeps its last ratio and table; `joint.list` says why. fn recouple(s: &mut Session) { if s.doc.assembly.links.iter().all(|l| l.coupling.is_none()) { return; } let st = s.model.state(); - let changed: Vec<(usize, f64)> = s + let changed: Vec<(usize, (f64, Vec))> = s .doc .assembly .links .iter() .enumerate() - .filter_map(|(i, l)| joints::coupling_ratio(&s.doc, &st, l)?.ok().filter(|r| *r != l.ratio).map(|r| (i, r))) + .filter_map(|(i, l)| joints::coupling_values(&s.doc, &st, l)?.ok().filter(|(r, t)| *r != l.ratio || *t != l.lift).map(|v| (i, v))) .collect(); if changed.is_empty() { return; } let doc = s.doc_mut(); - for (i, r) in changed { + for (i, (r, t)) in changed { if let Some(l) = doc.assembly.links.get_mut(i) { l.ratio = r; + l.lift = t; } } } @@ -305,8 +310,29 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result Ok(o), + // A circle given by its centre, where no face lies (#393). + Err(e) => match (what == "circle").then(|| circle_centre(s, occurrence, local(world))).flatten() { + Some(snap) => Ok(JointOrigin { occurrence, snap }), + None => Err(bad(cmd, e.to_string())), + }, + } +} + +/// The round end of a cylindrical face of the occurrence's bodies centred at `pick`: a point on +/// its axis there, pointing out of that end (as picking its circular edge gives). +fn circle_centre(s: &Session, occurrence: u64, pick: Vec3) -> Option { + let component = if occurrence == 0 { 0 } else { s.doc.occurrences.iter().find(|o| o.id == occurrence)?.component }; + let st = s.model.state(); + let c = st + .bodies + .iter() + .filter(|b| s.doc.model_body_component(b) == component) + .find_map(|b| solidcraft_doc::kernel::cylinder_end_at(&b.body, pick))?; + let along = (pick - c.axis_point).dot(c.axis); + let (at, dir) = if (along - c.start).abs() <= (c.end - along).abs() { (c.start, -1.0) } else { (c.end, 1.0) }; + Some(Snap::Point { point: c.axis_point + c.axis * at, z: Some(c.axis * dir) }) } pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { @@ -688,7 +714,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result { None => 0.0, }; let ratio = ratio * ratio_scale(s, a, ia, b, ib); - s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None }); + s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() }); let sol = resolve(s); Ok(sol_json(&sol, p)) } @@ -699,7 +725,10 @@ fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { match p.get(key) { Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))), Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())), - Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)), + Some(o @ Value::Object(m)) if m.contains_key("circle") => match origin_param(s, Some(o), cmd)? { + o @ JointOrigin { snap: Snap::CircleCenter { .. }, .. } => Ok(Size::Circle(o)), + _ => Err(bad(cmd, format!("`{key}`: give a point on the round face, not its centre"))), + }, _ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))), } } @@ -742,7 +771,7 @@ fn gear(s: &mut Session, p: &Value) -> Result { internal: bool_(p, "internal").unwrap_or(false), reverse: reverse(p), }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) } fn rack_pinion(s: &mut Session, p: &Value) -> Result { @@ -750,7 +779,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result { let (a, b) = joint_pair(s, p, cmd)?; let (ia, ib) = turn_and_travel(s, a, b, cmd)?; let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) } fn screw(s: &mut Session, p: &Value) -> Result { @@ -770,7 +799,120 @@ fn screw(s: &mut Session, p: &Value) -> Result { } }; let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) }; - couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c) }) + couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() }) +} + +fn cam_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "joint.cam"; + let (a, b) = joint_pair(s, p, cmd)?; + let kind = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind); + if kind(a) != Some(JointKind::Revolute) { + return Err(bad(cmd, "the cam (a) turns on a revolute joint")); + } + let oscillating = match kind(b) { + Some(JointKind::Slider) => false, + Some(JointKind::Revolute) => true, + _ => return Err(bad(cmd, "the follower (b) is a slider or a revolute joint")), + }; + let follower = match str_(p, "follower").unwrap_or("knife") { + "knife" => Follower::Knife, + "roller" => Follower::Roller { radius: size_param(s, p, "radius", cmd)? }, + "flat" => Follower::Flat, + _ => return Err(bad(cmd, "`follower` is knife, roller or flat")), + }; + if oscillating && p.get("tip").is_none() && !matches!(follower, Follower::Roller { radius: Size::Circle(_) }) { + return Err(bad(cmd, "an oscillating follower needs `tip`: its knife's point or its roller's centre ({circle: [x, y, z]} on the roller)")); + } + let tip = match p.get("tip") { + Some(v) => Some(origin_param(s, Some(v), cmd)?), + None => None, + }; + let mut c = Cam { profile: cam_profile(s, p, cmd)?, follower, tip, reverse: false }; + let probe = MotionLink { a, ia: 0, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() }; + let st = s.model.state(); + c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false); + let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?; + let reverse = c.reverse; + let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?; + let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX); + if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) { + o.remove("ratio"); + o.insert("lift".into(), lift_json(l, oscillating)); + o.insert("reverse".into(), json!(reverse)); + if !oscillating { + o.insert("follower_offset".into(), json!(offset)); + } + } + Ok(out) +} + +/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their +/// difference, in degrees (`angle`) or mm. +fn lift_json(l: &MotionLink, angle: bool) -> Value { + // A table that is not whole (a hand-edited file) moves nothing, and says so. + if l.lift.len() != cam::CAM_SAMPLES { + return Value::Null; + } + let unit = |v: f64| if angle { v.to_degrees() } else { v }; + let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset)); + json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }}) +} + +/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's +/// other profiles may change), or picked edges of the cam's bodies (kept in the component's +/// coordinates with the edges' names). +fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result { + let v = p.get("profile").ok_or_else(|| bad(cmd, "`profile` is required: {sketch: id|name} or {edges: [[x, y, z], …]}"))?; + if let Some(sk) = v.get("sketch") { + let sketch = super::features::sketch_id(s, Some(sk), cmd, "profile.sketch")?; + let st = s.model.state(); + let solved = + st.sketches.iter().find(|x| x.feature == sketch).ok_or_else(|| bad(cmd, "that sketch is not evaluated (it is after the marker?)"))?; + let picked = match v.get("index") { + None => cam::sketch_profile(solved, &[]), + Some(i) => i.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| solved.profiles.get(i)), + }; + let profile = picked.ok_or_else(|| bad(cmd, "the sketch has no such closed profile (an open curve makes none)"))?; + return Ok(CamProfile::Sketch { sketch, curves: profile.outer_curves.clone() }); + } + let Some(Value::Array(pts)) = v.get("edges") else { + return Err(bad(cmd, "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}")); + }; + if pts.is_empty() || pts.len() > cam::MAX_PROFILE_EDGES { + return Err(bad(cmd, format!("pick 1 to {} edges", cam::MAX_PROFILE_EDGES))); + } + let world: Vec = pts.iter().map(vec3).collect::>().ok_or_else(|| bad(cmd, "`edges` are finite [x, y, z] points"))?; + let occurrence = match v.get("occurrence") { + Some(o) => occurrence_id(s, Some(o), cmd)?, + None => world.first().and_then(|w| occurrence_at(s, *w)).ok_or_else(|| bad(cmd, "no body there"))?, + }; + let t = if occurrence == 0 { solidcraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) }; + let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?; + let picks: Vec = world.iter().map(|w| solidcraft_doc::apply_point(&inv, *w)).collect(); + let comp = s.doc.occurrences.iter().find(|o| o.id == occurrence).map(|o| o.component).unwrap_or(0); + let st = s.model.state(); + let bodies: Vec<&solidcraft_doc::ModelBody> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp).collect(); + // Each pick's edge name on the body whose edges pass nearest it. + let names = picks + .iter() + .map(|q| { + let near = |b: &&solidcraft_doc::ModelBody| { + b.mesh() + .edges + .iter() + .flat_map(|e| e.windows(2)) + .filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))) + .fold(f64::MAX, f64::min) + }; + bodies + .iter() + .min_by(|x, y| near(x).total_cmp(&near(y))) + .and_then(|b| solidcraft_doc::naming::edge_names_at(b, &[*q]).into_iter().next()) + .unwrap_or_default() + }) + .collect(); + Ok(CamProfile::Edges { occurrence, picks, names }) } /// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its @@ -802,14 +944,18 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result break, } } - l.ratio = joints::coupling_ratio(&s.doc, &s.model.state(), &l).unwrap_or(Ok(l.ratio)).map_err(|e| bad(cmd, e.to_string()))?; + (l.ratio, l.lift) = + joints::coupling_values(&s.doc, &s.model.state(), &l).unwrap_or(Ok((l.ratio, Vec::new()))).map_err(|e| bad(cmd, e.to_string()))?; let vals = joints::joint_values(&s.doc.assembly); let value = |id: u64, i: usize| vals.get(&id).and_then(|q| q.get(i)).copied().unwrap_or(0.0); // A link put in place of another keeps its offset, as a radius edited in the design does. l.offset = match replace.and_then(|i| links.get(i)) { Some(old) => old.offset, - None => value(l.b, l.ib) - l.ratio * value(l.a, l.ia), + None => value(l.b, l.ib) - MotionLink { offset: 0.0, ..l.clone() }.follow(value(l.a, l.ia)), }; + if !l.offset.is_finite() { + return Err(bad(cmd, "the coupling gives the driven joint no value where it is")); + } let shown = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib); let doc = s.doc_mut(); let index = match replace { @@ -970,10 +1116,17 @@ fn list(s: &mut Session, p: &Value) -> Result { json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }}); if let Some(c) = &l.coupling { v["coupling"] = json!(c); - if let Some(Err(e)) = joints::coupling_ratio(&s.doc, &st, l) { + if let Some(Err(e)) = joints::coupling_values(&s.doc, &st, l) { v["error"] = json!(e.to_string()); } } + // A cam's follower follows its table, not a ratio: its travel instead. + if matches!(l.coupling, Some(Coupling::Cam(_))) + && let Some(o) = v.as_object_mut() + { + o.remove("ratio"); + o.insert("lift".into(), lift_json(l, angle)); + } v }) .collect(); @@ -1003,6 +1156,16 @@ fn interference(s: &mut Session, p: &Value) -> Result { let (mut hits, mut unchecked) = (Vec::new(), Vec::new()); for (i, j) in pairs { let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue }; + let threshold = 1e-9 * a.body.size().max(1.0).powi(3); + // A pin a hair off the axis of its hole: the crescent the walls share, which the + // boolean can't intersect. + if let Some(o) = solidcraft_kernel::coaxial_overlap(&a.body, &b.body) { + if o.volume > threshold { + hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid, + "bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}})); + } + continue; + } // A pair whose shared part can't be worked out is reported, not passed as clear. let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect)); @@ -1025,7 +1188,7 @@ fn interference(s: &mut Session, p: &Value) -> Result { continue; } }; - if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) { + if m.volume > threshold { hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}})); } } @@ -1038,7 +1201,7 @@ const MAX_INTERFERENCE_PAIRS: usize = 20_000; /// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most /// `limit` of them, plus how many more there were. -fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) { +pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) { let mut order: Vec = (0..boxes.len()).collect(); order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) { (Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x), diff --git a/crates/engine/src/cmd/joints_cam_tests.rs b/crates/engine/src/cmd/joints_cam_tests.rs new file mode 100644 index 00000000..3ed46a74 --- /dev/null +++ b/crates/engine/src/cmd/joints_cam_tests.rs @@ -0,0 +1,435 @@ +//! Cams: a revolute joint drives a follower that rides the cam's profile. Expected values are +//! analytic: an eccentric circle of radius R whose centre is e from its axis puts a knife-edge +//! follower on a line h from the axis at e·cos θ + √(R² − (h − e·sin θ)²), a roller of radius r +//! where a knife on the circle of radius R + r would be, a flat face at e·cos θ + R; an +//! oscillating follower's roller sits where its arm's circle meets the circle of radius R + r. + +use super::*; +use solidcraft_doc::joints::Coupling; +use solidcraft_doc::joints::cam::CamProfile; + +/// The cam's eccentricity and radius (mm). +const E: f64 = 10.0; +const R: f64 = 30.0; + +fn joint_id(s: &Session, name: &str) -> u64 { + s.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0) +} + +/// A joint's value as `joint.list` gives it (degrees or mm). +fn shown(s: &mut Session, joint: &str) -> f64 { + let l = run(s, "joint.list", json!({"quiet": true})); + let j = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == joint).cloned().unwrap_or_default(); + j["values"][0]["value"].as_f64().unwrap_or(f64::NAN) +} + +/// Where a knife edge on a line `h` from the axis stands on the circle of radius `r` about the +/// cam's centre turned by `deg`. +fn knife(deg: f64, h: f64, r: f64) -> f64 { + let t = deg.to_radians(); + E * t.cos() + (r * r - (h - E * t.sin()).powi(2)).sqrt() +} + +/// A grounded base, and a cam: a disc of radius `rc` (a parameter, 30 mm) about (10, 0), 5 mm thick, +/// drawn in sketch `CamSketch` of its own component, on a revolute joint `Cam` about Z through the +/// origin (the cam is its b side). +fn cam_on_base() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "rc", "expression": "30 mm"})); + run(&mut s, "parameters.add", json!({"name": "th", "expression": "5 mm"})); + part(&mut s, "Base", [-90.0, -90.0, -20.0]); + let g = occ(&s, "Base"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Cam"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"})); + run(&mut s, "sketch.circle.center", json!({"center": [E, 0], "radius": R, "id": "c1"})); + run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": "rc"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": "th"})); + pin(&mut s, "revolute", "Cam", "Base", "Cam", Vec3::ZERO, Vec3::Z); + s +} + +/// A tappet (a 20 mm bar, 4 wide) sliding along +X on the line y = `h`, its end at x = `tip`, on a +/// slider `Tappet`. +fn tappet(s: &mut Session, h: f64, tip: f64) { + run(s, "component.activate", json!({"component": "root"})); + run(s, "component.create", json!({"name": "Tappet"})); + run(s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]})); + pin(s, "slider", "Tappet", "Base", "Tappet", Vec3::new(0.0, h, 2.5), Vec3::X); +} + +fn close(got: f64, want: f64, tol: f64) -> bool { + (got - want).abs() < tol +} + +/// A knife-edge tappet on the eccentric circle, its line through the axis and 6 mm past it, rises +/// and falls as e·cos θ + √(R² − (h − e·sin θ)²); the tappet's body goes with it. +#[test] +fn a_knife_edge_tappet_rides_an_eccentric_circle() { + for h in [0.0, 6.0] { + let mut s = cam_on_base(); + let tip = knife(0.0, h, R); + tappet(&mut s, h, tip); + let r = run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + assert_eq!((r["link"].clone(), r["reverse"].clone()), (json!(0), json!(false)), "{r}"); + assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}"); + // It stays where it is, and its travel over a turn is the cam's. + assert!(close(shown(&mut s, "Tappet"), 0.0, 1e-9), "{r}"); + let over_a_turn: Vec = (0..3600).map(|k| knife(f64::from(k) / 10.0, h, R)).collect(); + let rise = over_a_turn.iter().fold(f64::MIN, |a, b| a.max(*b)) - over_a_turn.iter().fold(f64::MAX, |a, b| a.min(*b)); + assert!(close(r["lift"]["rise"].as_f64().unwrap_or(f64::NAN), rise, 2e-3), "{r} vs {rise}"); + for deg in [30.0f64, 90.0, 135.0, 200.0, 300.0, -45.0, 400.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let want = knife(deg, h, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "h {h} θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5)); + assert!(close(end.x, tip + want, 1e-3) && close(end.y, h, 1e-9), "{end:?}"); + } + } +} + +/// The follower's line is through its tip, wherever its slider's origin is across the line; a +/// guide of its own (grounded, the slider's b side) makes the tappet the slider's a side, and it +/// rides the same way. +#[test] +fn a_tappet_on_a_guide_of_its_own_rides_its_tip_s_line() { + let h = 6.0; + let tip = knife(0.0, h, R); + let mut s = cam_on_base(); + part(&mut s, "Guide", [70.0, 20.0, 0.0]); + let g = occ(&s, "Guide"); + run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Tappet"})); + run(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [tip, h - 2.0, 0]})); + // The slider's origins 9 mm across from the tappet's line, a the tappet, b the guide. + pin(&mut s, "slider", "Tappet", "Tappet", "Guide", Vec3::new(0.0, h + 9.0, 2.5), Vec3::X); + let r = run( + &mut s, + "joint.cam", + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"occurrence": "Tappet", "point": [tip, h, 2.5]}}), + ); + assert!(close(r["follower_offset"].as_f64().unwrap_or(f64::NAN), h, 1e-9), "{r}"); + for deg in [70.0f64, 160.0, 290.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let end = place(&s, "Tappet", Vec3::new(tip, h, 2.5)); + assert!(close(end.x, knife(deg, h, R), 1e-3), "θ {deg}: {end:?} vs {}", knife(deg, h, R)); + } +} + +/// A roller of radius 5 rides the circle of radius R + 5; a flat face e·cos θ + R, and when the +/// cam's radius parameter grows by 2 the flat face stands 2 further out at every angle. +#[test] +fn rollers_and_flat_faces_ride_their_offset_circles() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R + 5.0); + tappet(&mut s, 0.0, tip); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "5 mm"})); + for deg in [60.0f64, 150.0, 250.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = knife(deg, 0.0, R + 5.0) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "roller θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + let mut s = cam_on_base(); + tappet(&mut s, 3.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"})); + for deg in [60.0f64, 150.0, 250.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = E * deg.to_radians().cos() - E; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "flat θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + // Editing the profile: the table is worked out again. + run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"})); + let want = E * 250f64.to_radians().cos() - E + 2.0; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "after the edit: {} vs {want}", shown(&mut s, "Tappet")); +} + +/// The cam's top edge picked on its body gives the table its sketch gives, and follows the +/// design the same way. +#[test] +fn picked_edges_give_the_sketch_s_table() { + let table = |profile: Value| { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": profile})); + s.doc.assembly.links.first().map(|l| (l.lift.clone(), l.offset)).unwrap_or_default() + }; + let (sketch, a) = table(json!({"sketch": "CamSketch"})); + let (edges, b) = table(json!({"edges": [[E + R, 0, 5]]})); + assert_eq!((sketch.len(), edges.len()), (360, 360)); + assert!(sketch.iter().zip(&edges).all(|(x, y)| close(*x, *y, 1e-3)) && close(a, b, 1e-3)); + // A second disc of the cam's component touching it in the same plane is not part of its loop. + let mut s = cam_on_base(); + run(&mut s, "component.activate", json!({"component": "Cam"})); + run(&mut s, "solid.cylinder", json!({"base": [E + R + 20.0, 0, 0], "axis": [0, 0, 1], "radius": 20, "height": 5})); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E - R, 0, 5]]}})); + let touching = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + assert!(touching.len() == 360 && touching.iter().zip(&sketch).all(|(x, y)| close(*x, *y, 1e-3)), "{touching:?}"); + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5]]}})); + assert!(matches!( + s.doc.assembly.links.first().and_then(|l| l.coupling.clone()), + Some(Coupling::Cam(c)) if matches!(&c.profile, CamProfile::Edges { names, .. } if names.first().is_some_and(|n| !n.is_empty())) + )); + run(&mut s, "parameters.change", json!({"name": "rc", "expression": "32 mm"})); + // A thicker cam: the picked edge is found at its new height, and its loop with it. + run(&mut s, "parameters.change", json!({"name": "th", "expression": "7 mm"})); + let l = run(&mut s, "joint.list", json!({"quiet": true})); + assert!(l["links"][0].get("error").is_none(), "{l}"); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90})); + let want = knife(90.0, 0.0, 32.0) - (E + R); + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "{} vs {want}", shown(&mut s, "Tappet")); +} + +/// An arm of 50 about (−50, 40) with a roller of 5: its roller's centre is where the arm's circle +/// meets the circle of radius R + 5 about the cam's centre, the meeting it turns into first from +/// pointing away from the cam's axis. +#[test] +fn an_oscillating_roller_follower_swings_to_the_offset_circle() { + let (pivot, arm, roller) = (Vec3::new(-50.0, 40.0, 2.5), 50.0, 5.0); + let away = pivot.y.atan2(pivot.x); + let wrap = |a: f64| (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let angle = |p: Vec3| (p.y - pivot.y).atan2(p.x - pivot.x); + // Where the roller's centre is at cam angle `deg`, turning the way `turn` says. + let centre = |deg: f64, turn: f64| { + let c = Vec3::new(E * deg.to_radians().cos(), E * deg.to_radians().sin(), 2.5); + let [p, q] = meet(pivot, arm, c, R + roller); + let swept = |x: Vec3| (turn * (angle(x) - away)).rem_euclid(std::f64::consts::TAU); + if swept(p) < swept(q) { p } else { q } + }; + let [p, q] = meet(pivot, arm, Vec3::new(E, 0.0, 2.5), R + roller); + let start = if wrap(angle(p) - away).abs() < wrap(angle(q) - away).abs() { p } else { q }; + let turn = wrap(angle(start) - away).signum(); + let mut s = cam_on_base(); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Arm"})); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [start.x - 2.0, start.y - 2.0, 0]})); + pin(&mut s, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z); + // An oscillating follower needs its tip. + assert!(s.execute("joint.cam", &json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5})).is_err()); + let r = run( + &mut s, + "joint.cam", + json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5, + "tip": {"occurrence": "Arm", "point": [start.x, start.y, 2.5]}}), + ); + assert_eq!((r["reverse"].clone(), r["lift"]["unit"].clone()), (json!(turn < 0.0), json!("deg")), "{r}"); + for deg in [40.0f64, 120.0, 210.0, 330.0] { + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + let want = wrap(angle(centre(deg, turn)) - angle(start)).to_degrees(); + assert!(close(shown(&mut s, "Arm"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Arm")); + let at = place(&s, "Arm", start); + assert!(at.dist(centre(deg, turn)) < 1e-3, "θ {deg}: {at:?}"); + } + // The roller's round face picked on its arm is where it touches, and its radius: the same table. + let table = s.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + let mut t = cam_on_base(); + run(&mut t, "component.activate", json!({"component": "root"})); + run(&mut t, "component.create", json!({"name": "Arm"})); + run(&mut t, "solid.cylinder", json!({"base": [start.x, start.y, 0], "axis": [0, 0, 1], "radius": roller, "height": 5})); + pin(&mut t, "revolute", "Arm", "Base", "Arm", pivot, Vec3::Z); + let face = [start.x + roller, start.y, 2.5]; + run( + &mut t, + "joint.cam", + json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"occurrence": "Arm", "circle": face}}), + ); + let picked = t.doc.assembly.links.first().map(|l| l.lift.clone()).unwrap_or_default(); + assert!(picked.len() == 360 && picked.iter().zip(&table).all(|(x, y)| close(*x, *y, 1e-6))); +} + +/// A stationary cam: the profile on the grounded part, a rotor turning about it with the tappet's +/// guide on it. Turned by +θ against the cam, the tappet stands where it would on a cam turned by +/// −θ. A guide on the cam's own (grounded) side would turn with the profile: refused. +#[test] +fn a_stationary_cam_drives_a_follower_on_a_turning_rotor() { + let h = 6.0; + let tip = knife(0.0, h, R); + let mut s = cam_on_base(); + // The cam is the fixed part: ground it, and take the base off its joint. + let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base")); + run(&mut s, "joint.delete", json!({"joint": "Cam"})); + run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true})); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false})); + pin(&mut s, "revolute", "Turret", "Cam", "Base", Vec3::ZERO, Vec3::Z); + tappet(&mut s, h, tip); + run(&mut s, "joint.cam", json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + for deg in [50.0f64, 140.0, 260.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Turret", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let want = knife(-deg, h, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "θ {deg}: {} vs {want}", shown(&mut s, "Tappet")); + } + let mut s = cam_on_base(); + let (cam, base) = (occ(&s, "Cam"), occ(&s, "Base")); + run(&mut s, "joint.delete", json!({"joint": "Cam"})); + run(&mut s, "occurrence.ground", json!({"occurrence": cam, "grounded": true})); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": false})); + part(&mut s, "Rotor", [-90.0, 60.0, -20.0]); + pin(&mut s, "revolute", "Turret", "Cam", "Rotor", Vec3::ZERO, Vec3::Z); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true})); + tappet(&mut s, h, tip); + let e = s.execute("joint.cam", &json!({"a": "Turret", "b": "Tappet", "profile": {"sketch": "CamSketch"}})).map_err(|e| e.to_string()).err(); + assert!(e.as_deref().is_some_and(|e| e.contains("turns with the cam")), "{e:?}"); +} + +/// A motion study turning the cam plays the tappet through its analytic travel. +#[test] +fn a_cam_plays_in_a_motion_study() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R); + tappet(&mut s, 0.0, tip); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 10, "keys": [{"joint": "Cam", "points": [[0, 0], [10, 360]]}]})); + for step in [0, 2, 5, 7, 10] { + run(&mut s, "motion.play", json!({"study": "Turn", "step": step})); + let want = knife(36.0 * f64::from(step), 0.0, R) - tip; + assert!(close(shown(&mut s, "Tappet"), want, 1e-3), "step {step}: {} vs {want}", shown(&mut s, "Tappet")); + } +} + +/// The tappet is the piston of a slider-crank: a 40 mm rod from its end to a 20 mm crank about +/// (95, 0). Turning the cam moves the tappet, and the loop turns the crank to where the rod's +/// circle meets the crank's, on the branch it started on. A stop in a contact set with the cam and +/// the tappet halts the cam where the tappet reaches it; the cam and the flat tappet only touch. +#[test] +fn a_cam_drives_a_closed_loop_and_stops_at_a_contact() { + let mut s = cam_on_base(); + let tip = knife(0.0, 0.0, R); + tappet(&mut s, 0.0, tip); + let (q, end) = (Vec3::new(95.0, 0.0, 2.5), Vec3::new(tip + 20.0, 0.0, 2.5)); + let crank_pin = |x: f64| meet(q, 20.0, Vec3::new(x, 0.0, 2.5), 40.0).into_iter().find(|p| p.y > 0.0).unwrap_or(Vec3::ZERO); + let pin0 = crank_pin(end.x); + for (name, at) in [("Rod", end), ("Crank", pin0)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [at.x - 1.0, at.y - 1.0, 10]})); + } + pin(&mut s, "revolute", "Crank", "Base", "Crank", q, Vec3::Z); + pin(&mut s, "revolute", "Wrist", "Tappet", "Rod", end, Vec3::Z); + pin(&mut s, "revolute", "Big end", "Rod", "Crank", pin0, Vec3::Z); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + for deg in [45.0f64, 120.0, 180.0] { + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": deg})); + assert_eq!(d["conflicts"].as_array().map(Vec::len), Some(0), "{d}"); + let x = end.x + knife(deg, 0.0, R) - tip; + let at = place(&s, "Crank", pin0); + assert!(at.dist(crank_pin(x)) < 1e-3, "θ {deg}: {at:?} vs {:?}", crank_pin(x)); + } + // A flat tappet, a stop 5 mm past where its end is at the top, and contact on. + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "flat"})); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 180})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Stop"})); + run(&mut s, "solid.box", json!({"length": 3, "width": 4, "height": 5, "corner": [E + R + 15.0, -2, 0]})); + let st = occ(&s, "Stop"); + run(&mut s, "occurrence.ground", json!({"occurrence": st, "grounded": true})); + run(&mut s, "contact.create", json!({"occurrences": ["Cam", "Tappet", "Stop"]})); + // Touching the cam stops nothing: half a turn moves the tappet 20 mm back up, as far as the stop. + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 90})); + assert!(d["stopped_by_contact"].is_null(), "{d}"); + let d = run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 0})); + assert!(d["stopped_by_contact"].is_object(), "{d}"); + let reached = shown(&mut s, "Tappet"); + assert!(reached > -5.5 && reached < -4.5, "{reached}"); +} + +/// What a cam can't be made from is refused, and a table edited by hand never puts a NaN into a +/// joint's value. +#[test] +fn hostile_cams_are_refused() { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + let refused = |s: &mut Session, p: Value| s.execute("joint.cam", &p).map_err(|e| e.to_string()).err().unwrap_or_else(|| panic!("accepted {p}")); + // An open curve makes no closed profile. + run(&mut s, "component.activate", json!({"component": "Cam"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Open"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 5], [10, 20]]})); + run(&mut s, "sketch.finish", json!({})); + let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Open"}})); + assert!(e.contains("closed profile"), "{e}"); + // Two closed edges are not one loop; a line past the cam misses it. + let e = refused(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[E + R, 0, 5], [E + R, 0, 0]]}})); + assert!(e.contains("closed loop"), "{e}"); + let mut far = cam_on_base(); + tappet(&mut far, 50.0, 0.0); + let e = refused(&mut far, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + assert!(e.contains("misses"), "{e}"); + for p in [ + json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}}), + json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}}), + json!({"a": "Cam", "b": "Tappet"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, "x", 0]]}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[E + R, 0.0, 5.0]; 65]}}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": -1}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": "nope"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 1e300}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "spring"}), + json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3}), + ] { + refused(&mut s, p); + } + assert!(s.doc.assembly.links.is_empty()); + // A table edited by hand: NaN leaves the follower where it is, any length reads. + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}})); + let before = solidcraft_doc::joints::joint_values(&s.doc.assembly); + for lift in [vec![f64::NAN; 360], vec![1.0, 2.0, 3.0], vec![f64::INFINITY]] { + let mut d = (*s.doc).clone(); + if let Some(l) = d.assembly.links.first_mut() { + l.lift = lift; + } + let vals = solidcraft_doc::joints::joint_values(&d.assembly); + assert!(vals.values().flatten().all(|v| v.is_finite()), "{vals:?}"); + // The follower keeps where it is. + assert_eq!(vals.get(&joint_id(&s, "Tappet")), d.assembly.joints.iter().find(|j| j.name == "Tappet").map(|j| &j.values), "{before:?}"); + } + // A table on a plain link is not read. + let mut d = (*s.doc).clone(); + if let Some(l) = d.assembly.links.first_mut() { + (l.coupling, l.ratio, l.offset) = (None, 0.0, 3.0); + } + assert_eq!(solidcraft_doc::joints::joint_values(&d.assembly).get(&joint_id(&s, "Tappet")).and_then(|q| q.first()).copied(), Some(3.0)); + // A roller's picked face must turn about an axis parallel to the cam's, a tip given or not. + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Pin"})); + run(&mut s, "solid.cylinder", json!({"base": [60, 30, 0], "axis": [1, 0, 0], "radius": 3, "height": 10})); + run(&mut s, "joint.delete", json!({"link": 0})); + let tilted = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", + "radius": {"occurrence": "Pin", "circle": [65, 33, 0]}, "tip": {"occurrence": "Tappet", "point": [E + R, 0, 2.5]}}); + let e = refused(&mut s, tilted); + assert!(e.contains("roller's axis"), "{e}"); +} + +/// A cam saves and opens with its definition and table; a link saved before cams reads as a +/// plain link. +#[test] +fn cams_save_and_open_with_their_table() { + let mut s = cam_on_base(); + tappet(&mut s, 0.0, E + R); + run(&mut s, "joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 2})); + run(&mut s, "joint.drive", json!({"joint": "Cam", "value": 30})); + let text = s.doc.to_json(); + let back = solidcraft_doc::format::read(&text).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(back.assembly.links, s.doc.assembly.links); + assert_eq!(back.assembly.links.first().map(|l| l.lift.len()), Some(360)); + let l = run(&mut s, "joint.list", json!({})); + let link = &l["links"][0]; + assert!(link.get("ratio").is_none() && link["lift"]["unit"] == "mm" && link["coupling"]["type"] == "cam", "{link}"); + assert!(link["coupling"].get("lift").is_none(), "{link}"); + let mut v: Value = serde_json::from_str(&text).unwrap_or_default(); + for l in v["assembly"]["links"].as_array_mut().into_iter().flatten() { + l.as_object_mut().map(|o| (o.remove("coupling"), o.remove("lift"))); + } + let old = solidcraft_doc::format::read(&v.to_string()).unwrap_or_else(|e| panic!("{e}")); + assert!(old.assembly.links.iter().all(|l| l.coupling.is_none() && l.lift.is_empty())); +} diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index e86571f8..4c5e4a6c 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -34,6 +34,40 @@ fn leaf(s: &mut Session, name: &str) { run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); } +#[test] +fn circle_given_by_its_centre_takes_that_round_end() { + // #393: `circle` at the centre of a round end, as the parameters document, was refused with + // "no cylindrical face there" (no face lies at a centre). + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 6, "corner": [-15, -10, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Knob"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 0, 6], "axis": [0, 0, 1], "radius": 10, "height": 10, "operation": "new"})); + let r = run( + &mut s, + "joint.create", + json!({"type": "cylindrical", "a": {"occurrence": "Plate", "face": [0, 0, 6]}, "b": {"occurrence": "Knob", "circle": [0, 0, 6]}, "name": "J"}), + ); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + // The knob's bottom circle already sits on the plate's top: its axis stays put (it may turn + // about it, to line up the two frames' x). + for z in [6.0, 16.0] { + assert!(near(place(&s, "Knob", Vec3::new(0.0, 0.0, z)), Vec3::new(0.0, 0.0, z)), "{:?}", place(&s, "Knob", Vec3::new(0.0, 0.0, z))); + } + // The top end's centre: its axis points up, out of the knob. + run(&mut s, "joint.origin", json!({"occurrence": "Knob", "circle": [0, 0, 16], "name": "Top"})); + let top = s.doc.assembly.origins.iter().find(|o| o.name == "Top").map(|o| o.origin.snap.clone()); + assert!( + matches!(top, Some(solidcraft_doc::joints::Snap::Point { point, z: Some(z) }) if near(point, Vec3::new(0.0, 0.0, 16.0)) && near(z, Vec3::Z)), + "{top:?}" + ); + // Off the axis is still refused, and so is a coupling size (it follows a face). + assert!(s.execute("joint.origin", &json!({"occurrence": "Knob", "circle": [3, 0, 6]})).is_err()); + let e = s.execute("joint.screw", &json!({"joint": "J", "pitch": {"occurrence": "Knob", "circle": [0, 0, 6]}})).unwrap_err(); + assert!(e.to_string().contains("not its centre"), "{e}"); +} + #[test] fn revolute_hinge_turns_about_the_knuckle() { let mut s = Session::default(); @@ -1411,3 +1445,38 @@ fn hostile_trace_params_are_refused() { #[path = "joints_coupling_tests.rs"] mod coupling; + +#[path = "joints_cam_tests.rs"] +mod cam; + +// Plots and interference of motion studies, on the four-bar and slider-crank above. +#[path = "motion_analysis_tests.rs"] +mod motion_analysis; + +/// A pin through a hole of its own radius whose axis is a hair off the hole's (parts placed from +/// rounded coordinates): the walls are all but tangent along two lines, which the boolean can't +/// intersect. Each offset gets a checked answer: clear on the axis and within the kernel's +/// same-surface tolerance, and otherwise the crescent the pin shares with the plate, against +/// the textbook lens of two circles. The pin is chamfered, so it is not a plain prism. +#[test] +fn interference_of_a_pin_a_hair_off_its_hole() { + let crescent = |r: f64, e: f64| std::f64::consts::PI * r * r - (2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt()); + for offset in [0.0, 1e-6, 5.9e-4, 1e-2] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 5, "corner": [-10, -10, 0], "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]})); + run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 9, "base": [offset, 0, -2], "operation": "new", "body_name": "Pin"})); + run(&mut s, "solid.chamfer", json!({"edges": [[offset + 3.0, 0, 7]], "distance": 0.5, "body": "Pin"})); + let i = run(&mut s, "inspect.interference", json!({"bodies": ["Plate", "Pin"]})); + assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{offset}: {i}"); + if offset < 1e-5 { + assert_eq!(i["count"], 0, "{offset}: {i}"); + continue; + } + assert_eq!(i["count"], 1, "{offset}: {i}"); + let want = 5.0 * crescent(3.0, offset); + let got = i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0); + assert!((got - want).abs() < 1e-6 * want, "{offset}: {got} against {want}: {i}"); + assert!((i["interferences"][0]["near_coaxial"]["offset_mm"].as_f64().unwrap_or(0.0) - offset).abs() < 1e-9, "{i}"); + } +} diff --git a/crates/engine/src/cmd/libraries.rs b/crates/engine/src/cmd/libraries.rs index 585d5f10..ad64fef6 100644 --- a/crates/engine/src/cmd/libraries.rs +++ b/crates/engine/src/cmd/libraries.rs @@ -6,11 +6,11 @@ use serde_json::{Value, json}; use solidcraft_io::catalog::{Catalog, Definition, Provenance}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("library.inspect", "Inspect Local Library", inspect).noundo().doc_only() + CommandSpec::new("library.inspect", "Inspect Local Library", inspect).icon("inspect").noundo().doc_only() .params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"), - CommandSpec::new("library.export", "Export Design Library", export).noundo().doc_only() + CommandSpec::new("library.export", "Export Design Library", export).icon("export").noundo().doc_only() .params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"), - CommandSpec::new("library.apply", "Apply Local Library Entry", apply).enabled(super::not_in_sketch) + CommandSpec::new("library.apply", "Apply Local Library Entry", apply).icon("import").enabled(super::not_in_sketch) .params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"), ]; diff --git a/crates/engine/src/cmd/lineage_face_tests.rs b/crates/engine/src/cmd/lineage_face_tests.rs index 6eac284e..680dfb1f 100644 --- a/crates/engine/src/cmd/lineage_face_tests.rs +++ b/crates/engine/src/cmd/lineage_face_tests.rs @@ -309,7 +309,6 @@ fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() { /// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three /// joined down (the inverse elbow). #[test] -#[ignore = "the join of the elbow down fails until #679 is fixed"] fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"})); @@ -327,3 +326,76 @@ fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() { ); every_flat_face_takes_a_sketch(&mut s); } + +/// #565: Split Body by a plane a parameter moves (a raw plane, a construction plane or a sketch +/// line). The face the split cuts on each part is named after the half-space that cut it +/// (`F:section`): a sketch and an offset plane attach to it and follow the plane. +#[test] +fn the_face_a_split_cuts_takes_a_sketch_and_an_offset_plane_that_follow_the_plane() { + for tool in ["plane", "construction", "line"] { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "split_x", "expression": "15 mm"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10, "body_name": "Block"})); + let split = match tool { + "plane" => json!({"body": "Block", "plane": {"origin": ["split_x", 0, 0], "normal": [1, 0, 0]}}), + "construction" => { + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": "split_x", "name": "Cut"})); + json!({"body": "Block", "plane": "Cut"}) + } + _ => { + let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.line", json!({"points": [[15, -10], [15, 30]], "ids": ["cut"], "connect": false})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "cut", "mode": "vertical"})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "cut.start"], "type": "horizontal", "value": "split_x"})); + run(&mut s, "sketch.finish", json!({})); + json!({"body": "Block", "sketch": id, "line": "cut"}) + } + }; + run(&mut s, "solid.split_body", split); + no_errors(&s); + // Each part's cut face (the two lie on one another, so each is picked by body and index). + let cut_face = |s: &Session, body: &str, x: f64| { + let state = s.model.state(); + let b = &state.body(body).unwrap().body; + let index = (0..b.face_count()) + .find(|i| b.planar_face(*i).is_some_and(|p| p.normal().x.abs() > 0.999 && (p.origin.x - x).abs() < 1e-9)) + .unwrap_or_else(|| panic!("{tool}: no cut face on `{body}`")); + json!({"face": [x, 10, 5], "body": body, "index": index}) + }; + let mut normals = Vec::new(); + for (k, body) in ["Block", "Block (1)"].into_iter().enumerate() { + let pick = cut_face(&s, body, 15.); + run(&mut s, "sketch.create", json!({"plane": pick, "name": format!("OnCut{k}")})); + run(&mut s, "sketch.finish", json!({})); + let (plane, certificate) = healthy(&s, &format!("OnCut{k}")); + assert_eq!(certificate, Some(FaceCertificate::Lineage), "{tool} {body}"); + assert!((plane.origin.x - 15.).abs() < 1e-9 && plane.normal().x.abs() > 0.999, "{tool} {body}: {plane:?}"); + normals.push(plane.normal().x); + let pick = cut_face(&s, body, 15.); + run(&mut s, "construct.plane.offset", json!({"base": pick, "offset": 2, "name": format!("Off{k}")})); + } + assert!(normals[0] * normals[1] < 0., "{tool}: the two cut faces face each other's way: {normals:?}"); + no_errors(&s); + + run(&mut s, "parameters.change", json!({"name": "split_x", "expression": "20 mm"})); + no_errors(&s); + let offset = |s: &Session, name: &str| { + let feature = s.doc.find_feature(name).map(|f| f.id).unwrap(); + let state = s.model.state(); + match state.construct.iter().find(|c| c.feature == feature).map(|c| &c.geom) { + Some(solidcraft_doc::construct::ConstructGeom::Plane(p)) => *p, + other => panic!("{tool} {name}: {other:?}"), + } + }; + for (k, n) in normals.iter().enumerate() { + let (plane, _) = healthy(&s, &format!("OnCut{k}")); + assert!((plane.origin.x - 20.).abs() < 1e-9 && (plane.normal().x - n).abs() < 1e-9, "{tool} OnCut{k}: {plane:?}"); + let off = offset(&s, &format!("Off{k}")); + assert!((off.origin.x - (20. + 2. * n)).abs() < 1e-9, "{tool} Off{k}: {off:?}"); + } + let t = reopened(&s); + for k in 0..2 { + assert!((healthy(&t, &format!("OnCut{k}")).0.origin.x - 20.).abs() < 1e-9, "{tool} reopened OnCut{k}"); + } + } +} diff --git a/crates/engine/src/cmd/materials.rs b/crates/engine/src/cmd/materials.rs index c96376a2..0c27d46b 100644 --- a/crates/engine/src/cmd/materials.rs +++ b/crates/engine/src/cmd/materials.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params( "material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look", ), - CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params( + CommandSpec::new("material.delete", "Delete Physical Material", delete).icon("delete").enabled(has_user_materials).params( "material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)", ), ]; diff --git a/crates/engine/src/cmd/metadata.rs b/crates/engine/src/cmd/metadata.rs index 451d7949..de1b7809 100644 --- a/crates/engine/src/cmd/metadata.rs +++ b/crates/engine/src/cmd/metadata.rs @@ -22,6 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[ reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]", ), CommandSpec::new("document.metadata.remove", "Remove Design Property", remove) + .icon("delete") .doc_only() .params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"), ]; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a37f226a..4d8626b5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ macro_rules! planned { } mod browser; +mod clearance; mod clipboard; mod compare; mod component; @@ -33,7 +34,7 @@ mod edit; mod face; mod features; mod features_more; -pub use features::auto_operation; +pub use features::{auto_operation, extrude_reach}; mod component_link; mod config; mod file; @@ -59,6 +60,7 @@ mod render; #[cfg(test)] mod script_tests; mod sheet; +mod silhouette; mod sketch; pub(crate) use sketch::author_path_for_rebind; pub(crate) use sketch::created as sketch_created; @@ -74,6 +76,7 @@ mod sketch_modify; mod sketch_project; mod surface; mod surface_analysis; +mod surface_boundary; mod surface_match; mod view_dxf; mod view_navigation; @@ -236,6 +239,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_canvas::COMMANDS.iter()); v.extend(sketch_3d::COMMANDS.iter()); v.extend(surface_analysis::COMMANDS.iter()); + v.extend(surface_boundary::COMMANDS.iter()); v.extend(surface::COMMANDS.iter()); v.extend(surface_match::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); @@ -249,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(component::COMMANDS.iter()); v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); + v.extend(clearance::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); v.extend(parts::COMMANDS.iter()); @@ -263,6 +268,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(metadata::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); v.extend(compare::COMMANDS.iter()); + v.extend(silhouette::COMMANDS.iter()); v.extend(materials::COMMANDS.iter()); v.extend(material_properties::COMMANDS.iter()); v.extend(libraries::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 2e575911..03f01798 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -14,6 +14,10 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, reso use crate::params::{bad, bool_, str_, vec3}; use crate::{EngineError, Result, Session}; +#[path = "motion_analysis.rs"] +mod analysis; +#[path = "motion_animation.rs"] +mod animation; #[path = "contact_sweep.rs"] mod contact_sweep; @@ -26,55 +30,67 @@ macro_rules! trace_doc { } pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("contact_sets").params( "enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other", ), CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all) .at("SOLID", "ASSEMBLE") - .icon("joint") + .icon("contact_all") .params("every occurrence takes part in contact (one set of all)"), - CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"), + CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("contact_off").params("contact off"), CommandSpec::new("contact.create", "New Contact Set", new_set) .at("SOLID", "ASSEMBLE") - .icon("joint") + .icon("contact_new") .params("occurrences: [ids or names] (two or more); name?"), - CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), - CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"), - CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"), - CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("contact.edit", "Edit Contact Set", edit_set) + .icon("edit") + .params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"), + CommandSpec::new("contact.list", "List Contact Sets", list_sets).icon("list").noundo().doc_only().params("→ sets, enabled, all"), + CommandSpec::new("contact.check", "Check Contact", check) + .icon("interference") + .noundo() + .params("→ pairs in contact sets that pass through each other now"), + CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("play").params( "name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \ (values: degrees for angles, mm for distances, or expressions)", ), - CommandSpec::new("motion.play", "Play Motion Study", play).params( + CommandSpec::new("motion.play", "Play Motion Study", play).icon("play").params( "study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them); quiet?: bool \ (positions of only the occurrences that moved) → values: [{joint, values: [{name, value, unit}]}], positions, moved: whether \ any occurrence moved, unmoved: [{joint, name, reason}] (keyed joints that can move nothing, such as a chain joined to \ nothing grounded)", ), CommandSpec::new("motion.export", "Export Motion Study", export) + .icon("export") .noundo() .params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"), + CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).icon("export").noundo().params( + "study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?; path: a .gif file, or a folder for PNG frames (frame-0001.png, frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, or 100); width?, height? (16…4096 pixels, default 800 × 500); camera?: {target, yaw, pitch, roll?, distance, fov?, up?} (ui.camera's reply; fields left out keep the default view: view.apply's camera, or the home view framing the model); fps? (1…50, default 20); grid?, axes? (default true: the ground grid and the origin's axes) - draws the study as it plays, or the joint as it is driven, with the headless renderer → path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the design is not changed)", + ), CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!( trace_doc!(), " → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \ ends there), stopped_by_loop, stopped_by_contact (the design is not changed)" )), - CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).params(concat!( + CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).icon("trace").params(concat!( trace_doc!(), "; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \ on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points" )), - CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(), - CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"), - CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params( + CommandSpec::new("motion.plot", "Plot Motion Study", analysis::plot).icon("graph").noundo().params(analysis::PLOT_DOC), + CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).icon("interference").noundo().params(analysis::INTERFERENCE_DOC), + CommandSpec::new("motion.list", "List Motion Studies", list_studies).icon("list").noundo(), + CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).icon("delete").params("study"), + CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").params( "name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \ moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)", ), CommandSpec::new("explode.show", "Show Exploded View", explode_show) + .icon("explode") .noundo() .params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"), - CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(), - CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"), + CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(), + CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"), ]; const MAX_ITEMS: usize = 10_000; @@ -467,12 +483,15 @@ fn check(s: &mut Session, _p: &Value) -> Result { // ---- Motion studies ---- fn study_index(s: &Session, p: &Value, cmd: &str) -> Result { - let key = match p.get("study") { - Some(Value::Number(n)) => n.to_string(), - Some(Value::String(x)) => x.clone(), + // A number is an id first, text a name first: a study may be named like another's id. + let (key, id_first) = match p.get("study") { + Some(Value::Number(n)) => (n.to_string(), true), + Some(Value::String(x)) => (x.clone(), false), _ => return Err(bad(cmd, "`study` must be an id or a name")), }; - s.doc.assembly.studies.iter().position(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| bad(cmd, format!("no motion study `{key}`"))) + let studies = &s.doc.assembly.studies; + let (by_id, by_name) = (studies.iter().position(|m| m.id.to_string() == key), studies.iter().position(|m| m.name == key)); + if id_first { by_id.or(by_name) } else { by_name.or(by_id) }.ok_or_else(|| bad(cmd, format!("no motion study `{key}`"))) } fn study(s: &mut Session, p: &Value) -> Result { @@ -683,13 +702,11 @@ const MAX_TRACE: usize = solidcraft_sketch::MAX_FIT_POINTS; /// The joint values a trace drives to at sample `k` of `n`. type Plan = Box Vec<(u64, Vec)>>; -/// Where a point of an occurrence goes as a study plays or a joint is driven: its world -/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one -/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at -/// the sample where the motion stops short. Returns the points and the answer without them. -fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> { - // The placement traced: an occurrence path through nested sub-assemblies (one instance of - // each), or an occurrence at its first placement (none: the root). +/// The point `p` names (`occurrence` and `point`): the occurrence path placing it and the point +/// in that placement's frame. The occurrence is an occurrence path through nested sub-assemblies +/// (one instance of each), or an occurrence at its first placement (none: the root). The point +/// is a world point where the parts are placed (moves only shown are left out). +fn traced_point(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Vec3)> { let path: Vec = match p.get("occurrence") { Some(Value::Array(ids)) if ids.len() <= 64 => { ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, "an occurrence path lists occurrence ids"))).collect::>()? @@ -705,32 +722,68 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> }; let at = p.get("point").and_then(vec3).filter(|v| v.len() < 1e7).ok_or_else(|| bad(cmd, "`point` must be [x, y, z], a world point"))?; let mut t = s.scratch(); + t.pending_moves.clear(); + let local = solidcraft_doc::mat_inverse(&path_world(&t, &path, cmd)?) + .map(|m| solidcraft_doc::apply_point(&m, at)) + .ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?; + Ok((path, local)) +} + +/// The world placement of an occurrence path (empty: the root). +fn path_world(t: &Session, path: &[u64], cmd: &str) -> Result { + if path.is_empty() { Ok(solidcraft_doc::IDENTITY) } else { t.occurrence_placement(path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string())) } +} + +/// Where a point of an occurrence goes as a study plays or a joint is driven: its world +/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one +/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at +/// the sample where the motion stops short. Returns the points and the answer without them. +fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> { + let (path, local) = traced_point(s, p, cmd)?; + let mut t = s.scratch(); // Traced from where the parts are placed: moves only shown (an exploded view, a move not // captured yet) are not where the joints are solved. t.pending_moves.clear(); - let world = |t: &Session| -> Result { - if path.is_empty() { - Ok(solidcraft_doc::IDENTITY) - } else { - t.occurrence_placement(&path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string())) + let world = |t: &Session| path_world(t, &path, cmd); + let (default, given, plan) = motion_plan(s, p, cmd)?; + let n = sample_count(p, default.min(MAX_TRACE), MAX_TRACE, cmd)?; + let mut points = Vec::new(); + let mut stop = (Value::Null, Value::Null, Value::Null); + for k in 0..n { + let want = plan(&t, k, n); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + points.push(solidcraft_doc::apply_point(&world(&t)?, local)); + if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { + stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); + break; } - }; - let local = solidcraft_doc::mat_inverse(&world(&t)?) - .map(|m| solidcraft_doc::apply_point(&m, at)) - .ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?; - let samples = |default: usize| match p.get("samples") { + } + let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); + let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2}); + Ok((points, out)) +} + +/// `samples`, 2…`max`, or `default` without it. +fn sample_count(p: &Value, default: usize, max: usize, cmd: &str) -> Result { + match p.get("samples") { Some(v) => v .as_f64() - .filter(|n| n.is_finite() && *n >= 2.0 && *n <= MAX_TRACE as f64) + .filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64) .map(|n| n.round() as usize) - .ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))), - None => Ok(default), - }; - let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) { + .ok_or_else(|| bad(cmd, format!("`samples` must be 2…{max}"))), + None => Ok(default.max(2)), + } +} + +/// The motion a trace or an animation samples: a study played from start to end, or one joint +/// value driven from `from` to `to`. Returns the default sample count (the study's steps + 1, or +/// 100), the values driven and what they are at sample `k` of `n`. +fn motion_plan(s: &Session, p: &Value, cmd: &str) -> Result<(usize, BTreeSet<(u64, usize)>, Plan)> { + Ok(match (p.get("study"), p.get("joint")) { (Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")), (Some(_), None) => { let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; - let n = samples((m.steps as usize).saturating_add(1).clamp(2, MAX_TRACE))?; + let n = (m.steps as usize).saturating_add(1); (n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64))) } (None, Some(j)) => { @@ -750,7 +803,7 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> }; let end = |key: &str| match p.get(key) { Some(v) => value(s, v, kind.is_angle(index), cmd), - None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the trace runs between (no `{key}`)"))), + None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)"))), }; let (from, to) = (end("from")?, end("to")?); let plan: Plan = Box::new(move |t, k, n| { @@ -761,24 +814,10 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> } vec![(id, q)] }); - (samples(100)?, BTreeSet::from([(id, index)]), plan) + (100, BTreeSet::from([(id, index)]), plan) } (None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")), - }; - let mut points = Vec::new(); - let mut stop = (Value::Null, Value::Null, Value::Null); - for k in 0..n { - let want = plan(&t, k, n); - let (r, _) = drive_with_contact(&mut t, &want, &given)?; - points.push(solidcraft_doc::apply_point(&world(&t)?, local)); - if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { - stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); - break; - } - } - let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); - let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2}); - Ok((points, out)) + }) } fn trace(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/motion_analysis.rs b/crates/engine/src/cmd/motion_analysis.rs new file mode 100644 index 00000000..d842d9c9 --- /dev/null +++ b/crates/engine/src/cmd/motion_analysis.rs @@ -0,0 +1,456 @@ +//! Motion analysis: a study played sample by sample on a copy of the design, the way +//! `motion.play` places it (closed loops following on their branch). `motion.plot` answers the +//! joint values and a point's path with their velocities and accelerations; `motion.interference` +//! answers the pairs of parts that pass through each other along the way. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use solidcraft_doc::joints; +use solidcraft_doc::motion::MotionStudy; +use solidcraft_doc::{Document, Mat, apply_point, mat_mul}; +use solidcraft_geom::{Aabb3, Vec3}; + +use super::contact_sweep::{ContactGeometry, Member}; +use super::{drive_with_contact, joint_id, keyed, occurrences, path_world, study_index, traced_point, values_at}; +use crate::params::{bad, bool_}; +use crate::{EngineError, Result, Session}; + +pub const PLOT_DOC: &str = "study: id|name; joints?: [id|name] (default: every joint that moves); occurrence? and point? (a point \ + of a part to follow, as motion.trace takes them); samples? (3…1000, default the study's steps + 1); duration?: seconds the \ + whole study takes, 0.001 or more (velocities and accelerations then per second, else per step) → step: [the study step of each sample], \ + time?: [s], joints: [{joint, name, index, dof, unit, value, velocity, acceleration, velocity_unit, acceleration_unit}] \ + (degrees for angles, mm for distances; angles unwrapped), point?: {occurrence, position: [[x,y,z]…], velocity, \ + acceleration (vectors), speed, acceleration_norm, unit, velocity_unit, acceleration_unit}, samples (taken), stopped_at, \ + stopped_by_loop, stopped_by_contact. The study plays as motion.play plays it, each sample driven from the one before, on \ + a copy (the design is not changed); velocities and accelerations are finite differences between samples (central, \ + one-sided at the ends)"; + +pub const INTERFERENCE_DOC: &str = "study: id|name; occurrences?: [ids or names] (two or more, checked against each other; \ + default every occurrence), with against?: [ids or names] (then each of `occurrences` is checked only against these, such \ + as an arm against the parts it must miss), or contact_sets?: true (the pairs of the design's contact sets); samples? \ + (2…1000, default the study's steps + 1) → collisions: [{a, b, first_step, last_step, steps: [[from, to]…] (the step ranges where they overlap), \ + depth_mm (how far into each other, the most at deepest_step), volume_mm3 (their overlap there; null when it was not \ + cheap to work out)}], clear, \ + pairs (checked), samples (taken), truncated (the work budget ran out; checked_to: the last step checked), stopped_at, \ + stopped_by_loop. The study plays as motion.play plays it but contact sets do not stop it, on a copy (the design is not \ + changed). Parts are tested at the samples only: one that passes wholly through another between two samples is missed"; + +/// Most samples a plot or an interference check takes: each is a solve. +const MAX_SAMPLES: usize = 1000; +/// Most joint values one plot answers. +const MAX_SERIES: usize = 256; +/// Most collision work over a whole study (see `ContactGeometry::hits`). +const MAX_WORK: usize = 500_000_000; +/// Most colliding pairs whose overlap volume is worked out, and most body booleans that takes. +const MAX_VOLUMES: usize = 16; +const MAX_BOOLEANS: usize = 64; + +/// How many samples `p` asks for (`min`…`MAX_SAMPLES`, default the study's steps + 1). +fn samples(p: &Value, m: &MotionStudy, min: usize, cmd: &str) -> Result { + if m.steps == 0 { + return Err(bad(cmd, "the study has no steps")); + } + match p.get("samples") { + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && *n >= min as f64 && *n <= MAX_SAMPLES as f64) + .map(|n| n.round() as usize) + .ok_or_else(|| bad(cmd, format!("`samples` must be {min}…{MAX_SAMPLES}"))), + None => Ok((m.steps as usize).saturating_add(1).clamp(min, MAX_SAMPLES)), + } +} + +/// The study step sample `k` of `n` is at. +fn step_of(m: &MotionStudy, k: usize, n: usize) -> f64 { + f64::from(m.steps) * k as f64 / n.saturating_sub(1).max(1) as f64 +} + +/// Plays study `m` on a copy of the design in `n` samples, each driven from the one before (so +/// closed loops follow on their branch, as `motion.play` places them; contact sets stop it only +/// when `contact`). `each` sees the copy at every sample and answers whether to go on. Returns +/// where the motion stopped short (all null when it did not). +fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result) -> Result { + let mut t = s.scratch(); + // Played from where the parts are placed: moves only shown are not where joints are solved. + t.pending_moves.clear(); + if !contact { + t.doc_mut().assembly.contact_enabled = false; + } + let given = keyed(m); + for k in 0..n { + let want = values_at(&t, m, step_of(m, k, n)); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + let go = each(&t, k)?; + if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { + return Ok(json!({"stopped_at": k, "stopped_by_loop": r["stopped_by_loop"], "stopped_by_contact": r["stopped_by_contact"]})); + } + if !go { + break; + } + } + Ok(json!({"stopped_at": null, "stopped_by_loop": null, "stopped_by_contact": null})) +} + +/// The derivative of samples `v` spaced `h` apart: central differences inside, second-order +/// one-sided at the ends (a plain difference with two samples, 0 with one). +fn derivative(v: &[f64], h: f64) -> Vec { + let n = v.len(); + let at = |i: usize| v.get(i).copied().unwrap_or(0.0); + (0..n) + .map(|k| match n { + 0 | 1 => 0.0, + 2 => (at(1) - at(0)) / h, + _ if k == 0 => (-3.0 * at(0) + 4.0 * at(1) - at(2)) / (2.0 * h), + _ if k + 1 == n => (3.0 * at(k) - 4.0 * at(k - 1) + at(k - 2)) / (2.0 * h), + _ => (at(k + 1) - at(k - 1)) / (2.0 * h), + }) + .collect() +} + +/// The second derivative of samples `v` spaced `h` apart: central differences inside, +/// second-order one-sided at the ends (the one difference there is with three samples, 0 with +/// fewer). +fn second_derivative(v: &[f64], h: f64) -> Vec { + let n = v.len(); + let at = |i: usize| v.get(i).copied().unwrap_or(0.0); + let h2 = h * h; + (0..n) + .map(|k| match n { + 0..=2 => 0.0, + 3 => (at(0) - 2.0 * at(1) + at(2)) / h2, + _ if k == 0 => (2.0 * at(0) - 5.0 * at(1) + 4.0 * at(2) - at(3)) / h2, + _ if k + 1 == n => (2.0 * at(k) - 5.0 * at(k - 1) + 4.0 * at(k - 2) - at(k - 3)) / h2, + _ => (at(k + 1) - 2.0 * at(k) + at(k - 1)) / h2, + }) + .collect() +} + +/// Angles in degrees made continuous: a jump of more than half a turn between samples is taken +/// as the angle going round (half a turn exactly is a real step). +fn unwrap_degrees(v: &mut [f64]) { + let (mut turns, mut prev) = (0.0, None::); + for x in v.iter_mut() { + let raw = *x; + if let Some(p) = prev + && (raw - p).abs() > 180.0 + { + turns -= 360.0 * ((raw - p) / 360.0).round(); + } + prev = Some(raw); + *x = raw + turns; + } +} + +/// The joint values a plot follows: (joint id, value index). +fn plotted(s: &Session, p: &Value, cmd: &str) -> Result> { + let joints = &s.doc.assembly.joints; + let ids: Vec = match p.get("joints") { + None | Some(Value::Null) => joints.iter().filter(|j| !j.suppressed).map(|j| j.id).collect(), + Some(Value::Array(list)) if list.len() <= MAX_SERIES => list.iter().map(|v| joint_id(s, Some(v), cmd)).collect::>()?, + Some(_) => return Err(bad(cmd, format!("`joints` must list at most {MAX_SERIES} joints"))), + }; + let mut out = Vec::new(); + for id in ids { + let dofs = joints.iter().find(|j| j.id == id).map_or(0, |j| j.kind.dofs().len()); + for i in 0..dofs { + if !out.contains(&(id, i)) { + out.push((id, i)); + } + } + } + if out.len() > MAX_SERIES { + return Err(bad(cmd, format!("more than {MAX_SERIES} joint values: list the `joints` to plot"))); + } + Ok(out) +} + +pub(super) fn plot(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.plot"; + let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; + let n = samples(p, &m, 3, cmd)?; + let duration = match p.get("duration") { + None | Some(Value::Null) => None, + Some(v) => { + // At least a millisecond: shorter sample spacings square to nothing. + Some(v.as_f64().filter(|d| (1e-3..=1e9).contains(d)).ok_or_else(|| bad(cmd, "`duration` must be seconds, 0.001 or more"))?) + } + }; + let series = plotted(s, p, cmd)?; + let given = keyed(&m); + let point = if p.get("point").is_some() || p.get("occurrence").is_some() { Some(traced_point(s, p, cmd)?) } else { None }; + let mut values: Vec> = vec![Vec::new(); series.len()]; + let mut path: Vec = Vec::new(); + let mut taken = 0; + let stop = play(s, &m, n, true, |t, _| { + taken += 1; + let now = joints::joint_values(&t.doc.assembly); + for ((id, i), out) in series.iter().zip(values.iter_mut()) { + out.push(now.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)); + } + if let Some((occ, local)) = &point { + path.push(apply_point(&path_world(t, occ, cmd)?, *local)); + } + Ok(true) + })?; + // Sample spacing in steps, or in seconds when the study has a duration. + let seconds_per_step = duration.map(|d| d / f64::from(m.steps)); + let h = f64::from(m.steps) / (n - 1) as f64 * seconds_per_step.unwrap_or(1.0); + let per = if duration.is_some() { "s" } else { "step" }; + let steps: Vec = (0..taken).map(|k| step_of(&m, k, n)).collect(); + let mut out = json!({"study": m.name, "steps": m.steps, "samples": taken, "step": steps}); + if let Some(sps) = seconds_per_step { + out["time"] = steps.iter().map(|st| st * sps).collect(); + } + let joints_out: Vec = series + .iter() + .zip(values.iter_mut()) + .map(|((id, i), v)| { + let j = s.doc.assembly.joints.iter().find(|j| j.id == *id); + let angle = j.is_some_and(|j| j.kind.is_angle(*i)); + let unit = if angle { "deg" } else { "mm" }; + if angle { + v.iter_mut().for_each(|x| *x = x.to_degrees()); + // Keyed values are as the study sets them, and values beyond half a turn + // (through motion links) already run on; a loop can hand back a follower's + // angle within half a turn, a turn round. + if !given.contains(&(*id, *i)) && v.iter().all(|x| x.abs() <= 180.0 + 1e-9) { + unwrap_degrees(v); + } + } + json!({"joint": id, "name": j.map(|j| j.name.clone()), "index": i, "dof": j.and_then(|j| j.kind.dofs().get(*i).copied()), + "unit": unit, "velocity_unit": format!("{unit}/{per}"), "acceleration_unit": format!("{unit}/{per}²"), + "value": v, "velocity": derivative(v, h), "acceleration": second_derivative(v, h)}) + }) + .collect(); + out["joints"] = json!(joints_out); + if let Some((occ, _)) = &point { + let axis = |c: usize| -> Vec { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() }; + let d: Vec<(Vec, Vec)> = (0..3).map(|c| (derivative(&axis(c), h), second_derivative(&axis(c), h))).collect(); + let vec_at = |k: usize, second: bool| -> Vec3 { + let c = |i: usize| d.get(i).and_then(|(v, a)| if second { a.get(k) } else { v.get(k) }).copied().unwrap_or(0.0); + Vec3::new(c(0), c(1), c(2)) + }; + let (vel, acc): (Vec, Vec) = (0..path.len()).map(|k| (vec_at(k, false), vec_at(k, true))).unzip(); + let xyz = |list: &[Vec3]| -> Value { list.iter().map(|v| json!([v.x, v.y, v.z])).collect() }; + let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == occ.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); + out["point"] = json!({"occurrence": name, "unit": "mm", "velocity_unit": format!("mm/{per}"), "acceleration_unit": format!("mm/{per}²"), + "position": xyz(&path), "velocity": xyz(&vel), "acceleration": xyz(&acc), + "speed": vel.iter().map(|v| v.len()).collect::>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::>()}); + } + for k in ["stopped_at", "stopped_by_loop", "stopped_by_contact"] { + out[k] = stop[k].clone(); + } + Ok(out) +} + +/// One pair of parts that collides along a study. +struct Collision { + names: (String, String), + /// The samples where they overlap, in order. + samples: Vec, + depth: f64, + deepest: usize, + /// The design at the deepest sample (its placements). + at_deepest: Arc, +} + +/// The volume the parts under occurrences `a` and `b` share, placed as in `doc` (`None`: not +/// worked out). Only bodies whose boxes overlap are intersected, at most `booleans` of them. +fn shared_volume(s: &Session, doc: &Document, a: u64, b: u64, booleans: &mut usize) -> Option { + let st = s.model.state(); + // The bodies under each (and not under the other), with their world frames and boxes. + let mut sides: [Vec<(Mat, Aabb3, &solidcraft_doc::ModelBody)>; 2] = Default::default(); + for (comp, path, m) in doc.placements() { + // A body belongs to the innermost of the two on its path, as contact sets pair them. + let side = match path.iter().rev().find(|o| **o == a || **o == b) { + Some(o) if *o == a => 0, + Some(_) => 1, + None => continue, + }; + for body in st.bodies.iter().filter(|x| doc.model_body_component(x) == comp && !x.body.is_mesh()) { + let frame = doc.model_body_offset(body).map(|o| mat_mul(&m, o)).unwrap_or(m); + let local = body.mesh().bounds(); + let mut world = Aabb3::EMPTY; + for c in 0..8 { + let pick = |bit: usize, lo: f64, hi: f64| if c & bit == 0 { lo } else { hi }; + let corner = Vec3::new(pick(1, local.min.x, local.max.x), pick(2, local.min.y, local.max.y), pick(4, local.min.z, local.max.z)); + world.add(apply_point(&frame, corner)); + } + sides.get_mut(side)?.push((frame, world, body)); + } + } + let mut total = 0.0; + for (fa, xa, ba) in sides.first()? { + for (fb, xb, bb) in sides.get(1)? { + if xa.max.x < xb.min.x || xb.max.x < xa.min.x || xa.max.y < xb.min.y || xb.max.y < xa.min.y || xa.max.z < xb.min.z || xb.max.z < xa.min.z + { + continue; + } + *booleans = booleans.checked_sub(1)?; + let (pa, pb) = (solidcraft_kernel::transform_matrix(&ba.body, *fa).ok()?, solidcraft_kernel::transform_matrix(&bb.body, *fb).ok()?); + if let Some(shared) = solidcraft_kernel::boolean(&pa, &pb, solidcraft_kernel::BoolOp::Intersect).ok()? { + total += solidcraft_kernel::measure(&shared).ok()?.volume; + } + } + } + Some(total) +} + +/// Contiguous runs of samples, as study step ranges. +fn ranges(m: &MotionStudy, samples: &[usize], n: usize) -> Vec<[f64; 2]> { + let mut out: Vec<(usize, usize)> = Vec::new(); + for &k in samples { + match out.last_mut() { + Some(run) if run.1 + 1 == k => run.1 = k, + _ => out.push((k, k)), + } + } + out.into_iter().map(|(a, b)| [step_of(m, a, n), step_of(m, b, n)]).collect() +} + +pub(super) fn interference(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.interference"; + let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; + let n = samples(p, &m, 2, cmd)?; + let everything = || vec![s.doc.occurrences.iter().map(|o| o.id).collect::>()]; + // Each of `occurrences` only against these. + let against = match p.get("against") { + None | Some(Value::Null) => None, + Some(_) if p.get("occurrences").is_none() => return Err(bad(cmd, "`against` checks the `occurrences` against other parts: list them too")), + Some(v) => Some(occurrences(s, &json!({"occurrences": v}), cmd)?), + }; + let sets: Vec> = match (p.get("occurrences"), bool_(p, "contact_sets").unwrap_or(false)) { + (Some(_), true) => return Err(bad(cmd, "give `occurrences` or `contact_sets`, not both")), + (Some(_), false) => { + let mut list = occurrences(s, p, cmd)?; + if list.len() + against.as_ref().map_or(0, Vec::len) < 2 { + return Err(bad(cmd, "list two or more occurrences to check against each other")); + } + for o in against.iter().flatten() { + if !list.contains(o) { + list.push(*o); + } + } + vec![list] + } + (None, true) => { + let a = &s.doc.assembly; + let sets: Vec> = + if a.contact_all { everything() } else { a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect() }; + if sets.is_empty() { + return Err(bad(cmd, "the design has no contact sets")); + } + sets + } + (None, false) => everything(), + }; + let mut geometry = ContactGeometry::of_sets(s, &sets).map_err(|e| match e { + EngineError::BadParams { msg, .. } => bad(cmd, format!("{msg} (check fewer parts: list the `occurrences`)")), + e => e, + })?; + if let Some(b) = &against { + let a = occurrences(s, p, cmd)?; + geometry.keep_pairs(|x, y| (a.contains(&x) && b.contains(&y)) || (a.contains(&y) && b.contains(&x))); + } + let mut found: BTreeMap<(u64, u64), Collision> = BTreeMap::new(); + let (mut work, mut checked, mut truncated) = (0usize, 0usize, false); + let stop = play(s, &m, n, false, |t, k| { + // Placing the meshes is work too, also where nothing comes near. + work = work.saturating_add(geometry.vertex_count()); + if work > MAX_WORK { + truncated = true; + return Ok(false); + } + let hits: Vec<(Member, Member, f64)> = match geometry.hits(t, &mut work, MAX_WORK, true) { + Ok(h) => h, + Err(_) if work > MAX_WORK => { + truncated = true; + return Ok(false); + } + Err(e) => return Err(e), + }; + checked = k + 1; + for ((a, an), (b, bn), depth) in hits { + // Each pair once, the older occurrence (the lower id) first. + let ((a, an), (b, bn)) = if a <= b { ((a, an), (b, bn)) } else { ((b, bn), (a, an)) }; + let c = found.entry((a, b)).or_insert_with(|| Collision { + names: (an, bn), + samples: Vec::new(), + depth: 0.0, + deepest: k, + at_deepest: t.doc.clone(), + }); + if c.samples.last() != Some(&k) { + c.samples.push(k); + } + if depth > c.depth { + (c.depth, c.deepest, c.at_deepest) = (depth, k, t.doc.clone()); + } + } + Ok(true) + })?; + // Nothing checked is not clear. + if truncated && checked == 0 { + return Err(bad(cmd, "the check ran out of its work budget before its first step: check fewer parts (occurrences, against)")); + } + let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect(); + list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX)); + let mut booleans = MAX_BOOLEANS; + let collisions: Vec = list + .iter() + .enumerate() + .map(|(i, ((a, b), c))| { + let volume = if i < MAX_VOLUMES { shared_volume(s, &c.at_deepest, *a, *b, &mut booleans) } else { None }; + let first = c.samples.first().map(|k| step_of(&m, *k, n)); + let last = c.samples.last().map(|k| step_of(&m, *k, n)); + json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n), + "depth_mm": c.depth, "deepest_step": step_of(&m, c.deepest, n), "volume_mm3": volume}) + }) + .collect(); + let mut out = json!({"study": m.name, "steps": m.steps, "clear": collisions.is_empty(), "collisions": collisions, + "pairs": geometry.pair_count(), "samples": checked, "truncated": truncated}); + if truncated { + out["checked_to"] = json!(checked.checked_sub(1).map(|k| step_of(&m, k, n))); + } + out["stopped_at"] = stop["stopped_at"].clone(); + out["stopped_by_loop"] = stop["stopped_by_loop"].clone(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The differences are exact for a quadratic: its velocity and acceleration everywhere, + /// the ends too. + #[test] + fn finite_differences_follow_a_polynomial() { + let h = 0.5; + let f = |x: f64| 2.0 * x * x - 3.0 * x + 1.0; + let v: Vec = (0..7).map(|k| f(k as f64 * h)).collect(); + let d = derivative(&v, h); + let a = second_derivative(&v, h); + for (k, (dk, ak)) in d.iter().zip(&a).enumerate() { + let x = k as f64 * h; + assert!((dk - (4.0 * x - 3.0)).abs() < 1e-9, "{k}: {dk}"); + assert!((ak - 4.0).abs() < 1e-9, "{k}: {ak}"); + } + assert_eq!(derivative(&[1.0, 3.0], 2.0), vec![1.0, 1.0]); + assert_eq!(second_derivative(&[1.0, 3.0], 2.0), vec![0.0, 0.0]); + assert_eq!(derivative(&[5.0], 1.0), vec![0.0]); + assert!(derivative(&[], 1.0).is_empty()); + } + + #[test] + fn angles_unwrap_across_half_turns() { + let mut v = vec![170.0, 179.0, -172.0, -160.0, 175.0]; + unwrap_degrees(&mut v); + assert_eq!(v, vec![170.0, 179.0, 188.0, 200.0, 175.0]); + // Half a turn a sample is a step, not a wrap. + let mut v = vec![0.0, 180.0, 360.0]; + unwrap_degrees(&mut v); + assert_eq!(v, vec![0.0, 180.0, 360.0]); + } +} diff --git a/crates/engine/src/cmd/motion_analysis_tests.rs b/crates/engine/src/cmd/motion_analysis_tests.rs new file mode 100644 index 00000000..88c2cd41 --- /dev/null +++ b/crates/engine/src/cmd/motion_analysis_tests.rs @@ -0,0 +1,211 @@ +//! `motion.plot` against the analytic motion of a four-bar and a slider-crank, and +//! `motion.interference` on two blocks pushed through each other at a known step. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::{four_bar, occ, part, pin, rocker_pin, run, slider_crank}; +use crate::Session; + +/// A joint's series in a plot, by joint name. +fn series<'a>(r: &'a Value, name: &str) -> &'a Value { + r["joints"].as_array().and_then(|list| list.iter().find(|j| j["name"] == name)).unwrap_or(&Value::Null) +} + +fn nums(v: &Value) -> Vec { + v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() +} + +/// The crank turns at a constant 50°/s (a full turn in 7.2 s). The rocker turns at +/// ω4 = ω2·a·sin(θ2 − θ3) / (c·sin(θ4 − θ3)) (crank a = 20, rocker c = 40; θ2, θ3 and θ4 the +/// crank, coupler and rocker angles), from differentiating the loop a·e^iθ2 + b·e^iθ3 = d + c·e^iθ4. +#[test] +fn four_bar_rocker_velocity_matches_its_analytic_derivative() { + let (mut s, _, q0) = four_bar(); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]})); + let before = s.doc.clone(); + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank", "Rocker"], "samples": 181, "duration": 7.2})); + assert_eq!(*s.doc, *before, "a plot leaves the design as it was"); + assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]); + assert_eq!(r["joints"].as_array().map(Vec::len), Some(2)); + let (crank, rocker) = (series(&r, "Crank"), series(&r, "Rocker")); + assert_eq!((rocker["unit"].as_str(), rocker["velocity_unit"].as_str()), (Some("deg"), Some("deg/s"))); + let time = nums(&r["time"]); + assert!((time.last().copied().unwrap_or(0.0) - 7.2).abs() < 1e-9 && (time[1] - 0.04).abs() < 1e-12, "{time:?}"); + // The crank: exactly as keyed, at a constant speed. + for ((v, w), a) in nums(&crank["value"]).iter().zip(nums(&crank["velocity"])).zip(nums(&crank["acceleration"])) { + assert!((w - 50.0).abs() < 1e-6 && a.abs() < 1e-6, "{v}°: {w} deg/s, {a} deg/s²"); + } + let (value, velocity) = (nums(&rocker["value"]), nums(&rocker["velocity"])); + let start = q0.y.atan2(q0.x - 45.0); + let (mut q, mut peak, mut worst) = (q0, 0.0f64, 0.0f64); + for k in 0..181 { + let v = 2.0 * k as f64; + if k > 0 { + q = rocker_pin(v - 2.0, v, q); + } + let th2 = (90.0 + v).to_radians(); + let p = Vec3::new(20.0 * th2.cos(), 20.0 * th2.sin(), 0.0); + let (th3, th4) = ((q - p).y.atan2((q - p).x), q.y.atan2(q.x - 45.0)); + // The rocker's value is its turn from where it was assembled, made continuous. + let turned = (th4 - start).to_degrees(); + assert!(((value[k] - turned + 540.0).rem_euclid(360.0) - 180.0).abs() < 1e-6, "{v}°: {} wants {turned}", value[k]); + let w4 = 50.0 * 20.0 * (th2 - th3).sin() / (40.0 * (th4 - th3).sin()); + peak = peak.max(w4.abs()); + worst = worst.max((velocity[k] - w4).abs()); + } + // Central differences 0.04 s apart: within 0.2 % of the fastest swing. + assert!(peak > 40.0 && worst < 2e-3 * peak, "worst {worst} deg/s of {peak} deg/s"); +} + +/// The piston of a slider-crank (crank a = 15, rod b = 40) turning at 100°/s sits at +/// x = a·cos θ + S with S = √(b² − a²·sin²θ), so it accelerates at +/// ω²·(−a·cos θ − a²·cos 2θ / S − a⁴·sin²θ·cos²θ / S³). +#[test] +fn slider_crank_piston_acceleration_matches_its_analytic_form() { + let (mut s, _, s0) = slider_crank(); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]})); + let p = json!({"study": "Turn", "joints": ["Bore"], "occurrence": "Piston", "point": [s0.x, s0.y, s0.z], "samples": 181, "duration": 3.6}); + let r = run(&mut s, "motion.plot", p); + assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]); + let point = &r["point"]; + assert_eq!((point["occurrence"].as_str(), point["acceleration_unit"].as_str()), (Some("Piston:1"), Some("mm/s²"))); + let at = |key: &str, k: usize, c: usize| point[key][k][c].as_f64().unwrap_or(f64::NAN); + let (a, b, w) = (15.0f64, 40.0f64, 100.0f64.to_radians()); + let (mut peak, mut worst) = (0.0f64, 0.0f64); + for k in 0..181 { + let th = (90.0 + 2.0 * k as f64).to_radians(); + let root = (b * b - (a * th.sin()).powi(2)).sqrt(); + assert!((at("position", k, 0) - (a * th.cos() + root)).abs() < 1e-6, "{k}"); + let want = w * w * (-a * th.cos() - a * a * (2.0 * th).cos() / root - a.powi(4) * (th.sin() * th.cos()).powi(2) / root.powi(3)); + peak = peak.max(want.abs()); + // The ends take one-sided differences; inside, central ones. + let tolerance = if k == 0 || k == 180 { 2e-2 } else { 2e-3 }; + worst = worst.max((at("acceleration", k, 0) - want).abs() / tolerance); + assert!(at("acceleration", k, 1).abs() < 1e-6 && at("acceleration", k, 2).abs() < 1e-6, "it stays on its axis"); + } + assert!(peak > 40.0 && worst < peak, "worst {worst} of {peak} mm/s² (scaled by tolerance)"); + // The bore's value is the piston's travel, so its acceleration is the piston's along x. + let bore = series(&r, "Bore"); + assert_eq!(bore["acceleration_unit"], "mm/s²"); + let along = |k: usize| nums(&bore["value"])[k] - nums(&bore["value"])[0]; + let sign = if along(90) * (at("position", 90, 0) - at("position", 0, 0)) < 0.0 { -1.0 } else { 1.0 }; + for (k, acc) in nums(&bore["acceleration"]).iter().enumerate() { + assert!((acc - sign * at("acceleration", k, 0)).abs() < 1e-6, "{k}: {acc}"); + } + // Per step without a duration: 2 steps between samples, 1° a step. + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank"], "samples": 181})); + let crank = series(&r, "Crank"); + assert!(r["time"].is_null() && crank["velocity_unit"] == "deg/step", "{crank}"); + assert!(nums(&crank["velocity"]).iter().all(|v| (v - 1.0).abs() < 1e-9)); +} + +/// A dial linked 2:1 to the crank turns on past a turn: its values are not taken a turn round, +/// even half a turn a sample. +#[test] +fn a_linked_value_past_half_a_turn_runs_on() { + let (mut s, _, _) = four_bar(); + part(&mut s, "Dial", [80.0, 0.0, -5.0]); + pin(&mut s, "revolute", "Dial", "Ground", "Dial", Vec3::new(80.0, 0.0, 0.0), Vec3::Z); + run(&mut s, "joint.motion_link", json!({"a": "Crank", "b": "Dial", "ratio": 2})); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 2, "keys": [{"joint": "Crank", "points": [[0, 0], [2, 360]]}]})); + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Dial"], "samples": 3})); + let v: Vec = nums(&series(&r, "Dial")["value"]).iter().map(|x| x.abs()).collect(); + assert!((v[1] - 360.0).abs() < 1e-6 && (v[2] - 720.0).abs() < 1e-6, "{r}"); +} + +/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x, B 10 mm away at value 10. +fn rail() -> Session { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 20)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})); + } + run(&mut s, "component.activate", json!({"component": "root"})); + let a = occ(&s, "A"); + run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10})); + s +} + +/// B is pushed 1 mm a step from 10 mm away to 5 mm into A: the faces touch at step 10 and the +/// blocks overlap from step 11 to the end, 100 mm³ more each step. +#[test] +fn interference_names_the_step_two_parts_start_to_collide() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})); + // A contact set would stop the motion where the faces touch; the check plays through. + run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]})); + let before = s.doc.clone(); + for p in [json!({"study": "Push"}), json!({"study": "Push", "occurrences": ["A", "B"]}), json!({"study": "Push", "contact_sets": true})] { + let r = run(&mut s, "motion.interference", p.clone()); + assert_eq!(*s.doc, *before, "the design is left as it was"); + assert!(r["clear"] == false && r["samples"] == 16 && r["pairs"] == 1 && r["truncated"] == false, "{p}: {r}"); + let c = &r["collisions"][0]; + assert_eq!((c["a"].as_str(), c["b"].as_str()), (Some("A:1"), Some("B:1")), "{r}"); + assert_eq!((c["first_step"].as_f64(), c["last_step"].as_f64()), (Some(11.0), Some(15.0)), "{r}"); + assert_eq!(c["steps"], json!([[11.0, 15.0]]), "{r}"); + let deepest = c["deepest_step"].as_f64().unwrap_or(f64::NAN); + assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && (11.0..=15.0).contains(&deepest), "{r}"); + let volume = c["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((volume - 100.0 * (deepest - 10.0)).abs() < 1e-6, "{r}"); + } + // B against a far block C only: B's pass through A is not asked about. + run(&mut s, "component.create", json!({"name": "C"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 50, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["C"]})); + assert!(r["clear"] == true && r["pairs"] == 1, "{r}"); + let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["A", "C"]})); + assert!(r["pairs"] == 2 && r["collisions"].as_array().map(Vec::len) == Some(1) && r["collisions"][0]["first_step"] == 11.0, "{r}"); + assert!(s.execute("motion.interference", &json!({"study": "Push", "against": ["A"]})).is_err()); + // Pushed only to touching, they never pass through each other. + run(&mut s, "motion.study", json!({"study": "Push", "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]})); + let r = run(&mut s, "motion.interference", json!({"study": "Push"})); + assert!(r["clear"] == true && r["collisions"] == json!([]), "{r}"); +} + +/// A number names a study by its id first, text by its name first: a study may be named like +/// another's id. +#[test] +fn studies_by_id_or_name() { + let mut s = rail(); + let keys = json!([{"joint": "Rail", "points": [[0, 10], [15, -5]]}]); + let first = run(&mut s, "motion.study", json!({"name": "Late", "steps": 15, "keys": keys}))["study"].as_u64().unwrap_or(0); + run(&mut s, "motion.study", json!({"name": first.to_string(), "steps": 15, "keys": keys})); + assert_eq!(run(&mut s, "motion.plot", json!({"study": first, "samples": 3}))["study"], "Late"); + assert_eq!(run(&mut s, "motion.plot", json!({"study": first.to_string(), "samples": 3}))["study"], first.to_string()); +} + +/// Hostile parameters are refused, never a panic. +#[test] +fn motion_analysis_refuses_what_it_cannot_do() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})); + let refused = [ + ("motion.plot", json!({"study": "Nope"})), + ("motion.plot", json!({"study": "Push", "samples": 2})), + ("motion.plot", json!({"study": "Push", "samples": 1e9})), + ("motion.plot", json!({"study": "Push", "samples": "many"})), + ("motion.plot", json!({"study": "Push", "duration": 0})), + ("motion.plot", json!({"study": "Push", "duration": -3})), + ("motion.plot", json!({"study": "Push", "duration": 1e-200})), + ("motion.plot", json!({"study": "Push", "joints": ["Nope"]})), + ("motion.plot", json!({"study": "Push", "joints": vec![json!("Rail"); 300]})), + ("motion.plot", json!({"study": "Push", "point": [1, 2]})), + ("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [1e300, 0, 0]})), + ("motion.interference", json!({"study": "Push", "samples": 1})), + ("motion.interference", json!({"study": "Push", "samples": f64::MAX})), + ("motion.interference", json!({"study": "Push", "occurrences": ["A"]})), + ("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "contact_sets": true})), + ("motion.interference", json!({"study": "Push", "contact_sets": true})), + ]; + for (cmd, p) in refused { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + } + // A point of a part that does not move has no velocity. + let r = run(&mut s, "motion.plot", json!({"study": "Push", "occurrence": "A", "point": [5, 5, 5]})); + assert!(r["point"]["speed"].as_array().is_some_and(|v| v.len() == 16 && v.iter().all(|x| x.as_f64() == Some(0.0))), "{r}"); +} diff --git a/crates/engine/src/cmd/motion_animation.rs b/crates/engine/src/cmd/motion_animation.rs new file mode 100644 index 00000000..53bd0795 --- /dev/null +++ b/crates/engine/src/cmd/motion_animation.rs @@ -0,0 +1,154 @@ +//! Motion animations (`motion.export_animation`): a study played, or one joint driven, drawn +//! frame by frame by the headless renderer the host installed ([`crate::capture`]) and written as +//! an animated GIF or a folder of PNG frames. Sampled on a copy, as a trace is: the design is not +//! changed. + +use serde_json::{Value, json}; + +use super::{drive_with_contact, motion_plan, sample_count}; +use crate::capture::{self, Renderer}; +use crate::params::{bad, str_}; +use crate::render::Camera; +use crate::{Result, Session}; + +/// Most frames of one animation. +const MAX_FRAMES: usize = 1000; +/// Largest frame side, in pixels. +const MAX_SIDE: u32 = 4096; +/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the +/// palette is chosen, so 256 MB at most. +const MAX_GIF_PIXELS: usize = 1 << 26; +/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about +/// 500 frames of 1920 × 1080. +const MAX_PNG_PIXELS: usize = 1 << 30; +/// The camera fields `camera` may set: those of `ui.camera`'s reply. +const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"]; + +pub(super) fn export(s: &mut Session, p: &Value) -> Result { + export_with(s, p, capture::installed().as_deref()) +} + +/// [`export`] drawn by `renderer`. +pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -> Result { + let cmd = "motion.export_animation"; + let path = str_(p, "path") + .filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control)) + .ok_or_else(|| bad(cmd, "`path` must name a .gif file, or a folder for PNG frames"))?; + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".png") { + return Err(bad(cmd, "`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png")); + } + let gif = lower.ends_with(".gif"); + // In the browser written files stay in memory until the host takes them, and the host is not + // told of removed ones: a folder of frames would pile up there. + if !gif && cfg!(target_arch = "wasm32") { + return Err(bad(cmd, "a folder of PNG frames is not available in the browser: give a .gif path")); + } + let side = |key: &str, default: u32| match p.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n)) + .map(|n| n.round() as u32) + .ok_or_else(|| bad(cmd, format!("`{key}` must be 16…{MAX_SIDE} pixels"))), + }; + let (width, height) = (side("width", 800)?, side("height", 500)?); + let fps = match p.get("fps") { + None => 20.0, + Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or_else(|| bad(cmd, "`fps` must be 1…50"))?, + }; + let cam = camera(s, p.get("camera"), cmd)?; + let shown = |key: &str| match p.get(key) { + None => Ok(true), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + }; + let guides = crate::view::Guides { grid: shown("grid")?, axes: shown("axes")? }; + let (default, given, plan) = motion_plan(s, p, cmd)?; + // At least 4 frames fit even at the largest size. + let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize)); + let n = sample_count(p, default.min(max), max, cmd).map_err(|_| bad(cmd, format!("`samples` must be 2…{max} for {width} × {height} frames")))?; + let renderer = renderer.ok_or_else(|| bad(cmd, "no renderer is installed to draw pictures in this process"))?; + // A destination outside an agent's folders is refused before anything is drawn. + let io = |e: std::io::Error| bad(cmd, format!("{path}: {e}")); + solidcraft_io::vfs::check(path).map_err(io)?; + let frame_path = |k: usize| std::path::Path::new(path).join(format!("frame-{k:04}.png")).to_string_lossy().to_string(); + if !gif { + solidcraft_io::vfs::create_dir_all(path).map_err(io)?; + // The frames of an earlier export here go first, so the folder never mixes two + // animations: not after a shorter one, nor when this one fails part way. + for stale in (1..=MAX_FRAMES).map(frame_path).filter(|f| solidcraft_io::vfs::exists(f)) { + solidcraft_io::vfs::remove(&stale).map_err(|e| bad(cmd, format!("{stale}: {e}")))?; + } + } + let mut t = s.scratch(); + // From where the parts are placed: moves only shown (an exploded view, the pose an open + // Motion Study dialog shows) are not where the joints are solved. + t.pending_moves.clear(); + let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, ""); + let mut stop = (Value::Null, Value::Null, Value::Null); + for k in 0..n { + let want = plan(&t, k, n); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) { + stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone()); + } + let scene = crate::view::scene_with_guides(&t, &cam, s.section, guides); + let picture = capture::rgba_with(Some(renderer), &scene, &cam, width, height).map_err(|e| bad(cmd, e))?; + drawn_by = picture.renderer; + if gif { + frames.push(picture.rgba); + } else { + let png = crate::render::png(width as usize, height as usize, picture.rgba).ok_or_else(|| bad(cmd, "PNG encoding failed"))?; + let file = frame_path(k + 1); + solidcraft_io::vfs::write(&file, &png).map_err(|e| bad(cmd, format!("{file}: {e}")))?; + bytes += png.len(); + } + } + let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height}); + if gif { + // GIF counts in hundredths of a second, and viewers slow anything under 2 down. + let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16; + let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?; + solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?; + bytes = gif.len(); + out["fps"] = json!(100.0 / f64::from(delay)); + } + out["bytes"] = json!(bytes); + out["renderer"] = json!(drawn_by); + if let Some(w) = capture::warning(drawn_by) { + out["warning"] = json!(w); + } + out["stopped_at"] = stop.0; + out["stopped_by_loop"] = stop.1; + out["stopped_by_contact"] = stop.2; + Ok(out) +} + +/// The camera the frames are drawn from: the session's (`view.apply`), or the home view framing +/// the model, with the fields `given` sets (`ui.camera`'s reply sets them all). +fn camera(s: &Session, given: Option<&Value>, cmd: &str) -> Result { + let base = s.camera.unwrap_or_else(|| crate::view::home_camera(s)); + let Some(given) = given else { return Ok(base) }; + let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?; + let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + for (k, x) in fields { + if !FIELDS.contains(&k.as_str()) { + return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", ")))); + } + if let Some(o) = v.as_object_mut() { + o.insert(k.clone(), x.clone()); + } + } + let mut cam: Camera = serde_json::from_value(v).map_err(|e| bad(cmd, format!("bad camera: {e}")))?; + if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) { + return Err(bad( + cmd, + "the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians", + )); + } + // Straight up or down keeps a heading, as ui.camera does. + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + cam.pitch = cam.pitch.clamp(-lim, lim); + Ok(cam) +} diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index 6ee3215a..44974d82 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -173,3 +173,235 @@ fn exploded_views_show_without_changing_the_design() { ); assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2)); } + +/// Pictures drawn by the CPU rasterizer (the engine has no GPU): real views of the scene. +fn cpu(scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32) -> Result { + Ok(crate::capture::Picture { rgba: crate::render::render(scene, cam, w as usize, h as usize).rgba, renderer: "cpu" }) +} + +/// A folder of its own for one test's files. +fn out_dir(test: &str) -> std::path::PathBuf { + let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-anim-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A GIF's logical screen size and the number of its frames (image descriptors). +fn gif_frames(gif: &[u8]) -> (usize, usize, usize) { + let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]])); + assert_eq!(&gif[..6], b"GIF89a"); + let mut at = 13 + if gif[10] & 0x80 != 0 { 3 << ((gif[10] & 7) + 1) } else { 0 }; + let mut frames = 0; + // Each block: a size byte and that many bytes, up to a 0. + let skip_blocks = |mut at: usize| { + while gif[at] != 0 { + at += usize::from(gif[at]) + 1; + } + at + 1 + }; + loop { + match gif[at] { + 0x3B => return (u16_at(6), u16_at(8), frames), + 0x21 => at = skip_blocks(at + 2), + 0x2C => { + frames += 1; + at = skip_blocks(at + 11); + } + b => panic!("unexpected GIF block {b:#x}"), + } + } +} + +/// A PNG's width and height (its IHDR). +fn png_size(png: &[u8]) -> (u32, u32) { + let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]); + (be(16), be(20)) +} + +#[test] +fn an_animation_has_the_frames_and_the_size_asked_for() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]})); + let dir = out_dir("size"); + let before = s.doc.clone(); + let gif = dir.join("push.gif"); + let p = json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 6, "width": 64, "height": 40, "fps": 25}); + let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_eq!( + (r["format"].as_str(), r["frames"].as_u64(), r["width"].as_u64(), r["height"].as_u64()), + (Some("gif"), Some(6), Some(64), Some(40)), + "{r}" + ); + assert_eq!(r["fps"], 25.0, "{r}"); + let bytes = std::fs::read(&gif).unwrap(); + assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64)); + assert_eq!(gif_frames(&bytes), (64, 40, 6)); + // Without samples: the study's steps + 1. + let r = + super::animation::export_with(&s, &json!({"study": "Push", "path": gif.to_string_lossy(), "width": 32, "height": 32}), Some(&cpu)).unwrap(); + assert_eq!(gif_frames(&std::fs::read(&gif).unwrap()), (32, 32, 11), "{r}"); + // A folder of PNG frames, made where missing; a joint driven instead of a study. + let frames = dir.join("frames").join("rail"); + let p = json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 3, "width": 48, "height": 30}); + let r = super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_eq!((r["format"].as_str(), r["frames"].as_u64()), (Some("png"), Some(3)), "{r}"); + let mut names: Vec = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, ["frame-0001.png", "frame-0002.png", "frame-0003.png"]); + assert!(names.iter().all(|n| png_size(&std::fs::read(frames.join(n)).unwrap()) == (48, 30))); + // A shorter export into the same folder leaves no frames of a longer one behind, past a gap + // too; other files stay. + std::fs::write(frames.join("notes.txt"), "mine").unwrap(); + std::fs::write(frames.join("frame-0005.png"), "older").unwrap(); + super::animation::export_with(&s, &json!({"joint": "Rail", "from": 10, "to": 0, "path": frames.to_string_lossy(), "samples": 2}), Some(&cpu)) + .unwrap(); + let mut names: Vec = std::fs::read_dir(&frames).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, ["frame-0001.png", "frame-0002.png", "notes.txt"]); + assert_eq!(*s.doc, *before, "the design is not changed"); + // Through the registry, with the renderer the host installed. + crate::capture::install_test_renderer(); + let r = run(&mut s, "motion.export_animation", json!({"study": "Push", "path": gif.to_string_lossy(), "samples": 2, "width": 16, "height": 16})); + assert_eq!(r["frames"], 2, "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_two_frame_drive_gives_two_different_frames() { + let s = rail(); + let dir = out_dir("two"); + let frames = |name: &str, to: f64| { + let folder = dir.join(name); + let p = json!({"joint": "Rail", "from": 10, "to": to, "path": folder.to_string_lossy(), "samples": 2, "width": 120, "height": 80}); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + (std::fs::read(folder.join("frame-0001.png")).unwrap(), std::fs::read(folder.join("frame-0002.png")).unwrap()) + }; + let (a, b) = frames("moved", -5.0); + assert_ne!(a, b, "B moved 15 mm between the frames"); + // Held still, the two frames are the same picture: the difference is the motion. + let (c, d) = frames("still", 10.0); + assert_eq!(c, d); + assert_eq!(a, c, "both start at 10 mm"); + // Without the ground grid and the axes, the same pose is another picture. + let bare = dir.join("bare"); + let p = json!({"joint": "Rail", "from": 10, "to": 10, "path": bare.to_string_lossy(), "samples": 2, "width": 120, "height": 80, "grid": false, "axes": false}); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + assert_ne!(std::fs::read(bare.join("frame-0001.png")).unwrap(), c); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_animation_is_drawn_from_the_camera_given() { + let mut s = rail(); + let dir = out_dir("camera"); + // A renderer is 'static (hosts install it for the process): it shares what it saw. + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = { + let seen = seen.clone(); + move |scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32| { + seen.lock().unwrap().push(*cam); + cpu(scene, cam, w, h) + } + }; + let path = dir.join("cam.gif"); + let base = json!({"joint": "Rail", "from": 10, "to": 0, "path": path.to_string_lossy(), "samples": 3, "width": 40, "height": 30}); + let with = |camera: Value| { + let mut p = base.clone(); + p["camera"] = camera; + p + }; + // ui.camera's reply, every field. + let reply = json!({"target": [1.0, 2.0, 3.0], "yaw": 0.5, "pitch": -0.25, "roll": 0.1, "distance": 300.0, "fov": 0.6, "up": "y"}); + super::animation::export_with(&s, &with(reply.clone()), Some(&record)).unwrap(); + let cams = std::mem::take(&mut *seen.lock().unwrap()); + assert_eq!(cams.len(), 3); + assert!(cams.iter().all(|c| json!(c) == reply), "{cams:?}"); + // Fields left out keep the default view: view.apply's camera. + run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80}})); + super::animation::export_with(&s, &with(json!({"yaw": 2.0})), Some(&record)).unwrap(); + let cams = std::mem::take(&mut *seen.lock().unwrap()); + assert!(cams.iter().all(|c| c.yaw == 2.0 && c.pitch == 0.2 && c.distance == 80.0), "{cams:?}"); + // Without one: view.apply's camera as it is; and another camera draws another picture. + super::animation::export_with(&s, &base, Some(&record)).unwrap(); + assert!(seen.lock().unwrap().iter().all(|c| Some(*c) == s.camera)); + let pictures = |camera: Value| { + let folder = dir.join(format!("p{}", camera["yaw"])); + let mut p = with(camera); + p["path"] = json!(folder.to_string_lossy()); + super::animation::export_with(&s, &p, Some(&cpu)).unwrap(); + std::fs::read(folder.join("frame-0001.png")).unwrap() + }; + assert_ne!(pictures(json!({"yaw": 0.3})), pictures(json!({"yaw": 1.3}))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn hostile_animation_params_are_refused() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 10, "keys": [{"joint": "Rail", "points": [[0, 10], [10, 0]]}]})); + let dir = out_dir("hostile"); + let gif = dir.join("x.gif").to_string_lossy().to_string(); + let base = json!({"study": "Push", "path": gif, "samples": 2, "width": 16, "height": 16}); + let refused = |change: Value| { + let mut p = base.clone(); + if let (Some(p), Some(change)) = (p.as_object_mut(), change.as_object()) { + for (k, v) in change { + if v.is_null() { + p.remove(k); + } else { + p.insert(k.clone(), v.clone()); + } + } + } + super::animation::export_with(&s, &p, Some(&cpu)).err().map(|e| e.to_string()) + }; + for change in [ + json!({"samples": 0}), + json!({"samples": 1}), + json!({"samples": 1e9}), + json!({"samples": "many"}), + json!({"samples": 1001}), + json!({"width": 1e9}), + json!({"width": -16}), + json!({"height": 3}), + json!({"height": f64::MAX}), + json!({"fps": 0}), + json!({"fps": 1e9}), + json!({"path": ""}), + json!({"path": null}), + json!({"path": "one.png"}), + json!({"path": "a\u{0}b.gif"}), + json!({"path": "x".repeat(5000)}), + json!({"camera": "iso"}), + json!({"camera": {"distance": -1}}), + json!({"camera": {"distance": 1e300}}), + json!({"camera": {"pitch": 4}}), + json!({"camera": {"fov": 9}}), + json!({"camera": {"target": [1, 2]}}), + json!({"camera": {"fit": true}}), + json!({"grid": "no"}), + json!({"axes": 1}), + json!({"study": null}), + json!({"study": "Nope"}), + json!({"study": null, "joint": "Rail"}), + // 4096 × 4096 frames: a GIF holds four of them. + json!({"width": 4096, "height": 4096, "samples": 5}), + ] { + assert!(refused(change.clone()).is_some(), "{change} was accepted"); + } + let e = refused(json!({"width": 4096, "height": 4096, "samples": 5})).unwrap(); + assert!(e.contains("2…4 for 4096 × 4096"), "{e}"); + assert!(super::animation::export_with(&s, &base, None).unwrap_err().to_string().contains("no renderer")); + assert!(!std::path::Path::new(&gif).exists(), "nothing refused was written"); + // An agent's export lands only inside its folders. + let root = dir.join("root"); + std::fs::create_dir_all(&root).unwrap(); + solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([root.clone()])), || { + let e = refused(json!({"path": dir.join("out").to_string_lossy()})).unwrap(); + assert!(e.contains("outside the folders this agent may use"), "{e}"); + assert!(refused(json!({"path": root.join("in.gif").to_string_lossy()})).is_none()); + }); + assert!(root.join("in.gif").exists() && !dir.join("out").exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 31a1bed8..c88c7f8c 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -66,6 +66,22 @@ fn instance_selection_survives_rename_move_capture_and_serde() { assert_eq!(roundtrip, s.selection); } +#[test] +fn component_rename_carries_into_placement_names() { + // #1665: the browser row shows the placement's name, so a rename has to reach it. + let mut s = Session::default(); + let (component, first) = created(&mut s, "Block"); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap(); + let third = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [40,0,0]}))["occurrence"].as_u64().unwrap(); + let name = |s: &Session, id: u64| s.doc.occurrences.iter().find(|o| o.id == id).unwrap().name.clone(); + assert_eq!(name(&s, second), "Block:2"); + // A placement named otherwise (an import's own name) keeps it. + s.doc_mut().occurrences.iter_mut().find(|o| o.id == third).unwrap().name = "Blockade:3".into(); + run(&mut s, "component.rename", json!({"component": component, "name": "box"})); + assert_eq!([name(&s, first), name(&s, second), name(&s, third)], ["box:1", "box:2", "Blockade:3"]); + assert_eq!(run(&mut s, "component.list", json!({}))["components"][0]["children"][0]["name"], "box:1"); +} + #[test] fn instance_selection_delete_removes_only_selected_instance() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 47756fe7..daa74cbc 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -15,34 +15,44 @@ pub static COMMANDS: &[CommandSpec] = &[ favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \ restored: features whose earlier result for the same inputs was reused (e.g. a value set back)", ), - CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params( + CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params( "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ source, feature/input, comment, favorite, uses, used_by, error) and cycles", ), - CommandSpec::new("parameters.add", "Add User Parameter", add).params("name, expression, unit?, comment?, favorite?: bool"), + CommandSpec::new("parameters.add", "Add User Parameter", add).icon("new").params("name, expression, unit?, comment?, favorite?: bool"), CommandSpec::new("parameters.rename", "Rename Parameter", rename) + .icon("rename") .params("name, new_name (expressions, feature inputs and dimensions that use it follow)"), - CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"), - CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"), + CommandSpec::new("parameters.delete", "Delete Parameter", delete) + .icon("delete") + .params("name (refused while something uses it; the error lists the users)"), + CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).icon("star").params("name, favorite?: bool (default true)"), CommandSpec::new("parameters.move", "Move Parameter", move_param) + .icon("reorder") .params("name, before | after: another user parameter; the user parameter is listed just before or after it"), - CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"), + CommandSpec::new("parameters.comment", "Comment Parameter", comment).icon("text").params("name, comment"), CommandSpec::new("parameters.users", "Parameter Users", users) + .icon("graph") .noundo() .params("name → users (parameters, feature inputs, dimensions) and the features it drives"), CommandSpec::new("parameters.graph", "Parameter Graph", graph) + .icon("graph") .noundo() .params("→ nodes, edges [user, used] (users include Feature:), cycles, errors"), CommandSpec::new("parameters.export", "Export Parameters", export) + .icon("export") .noundo() .params("format?: csv|json (default csv, or from the path), path?: write to a file → text"), CommandSpec::new("parameters.import", "Import Parameters", import) + .icon("import") .params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"), CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate) + .icon("params") .noundo() .doc_only() .params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"), CommandSpec::new("parameters.complete", "Complete Expression", complete) + .icon("params") .noundo() .doc_only() .params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"), diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index b0a60e4b..ffdb3e09 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -15,7 +15,7 @@ use crate::params::{bad, num, str_, vec3}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("parts.library", "Standard Parts", library) + CommandSpec::new("parts.library", "Standard Parts", library).icon("library") .noundo() .params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"), CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params( @@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[ length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?", ), - CommandSpec::new("parts.resize", "Change Part Size", resize) + CommandSpec::new("parts.resize", "Change Part Size", resize).icon("scale") .params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"), ]; diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index e825ab75..b6012139 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params( "face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?", ), - CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params( + CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap_fit").params( "position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?", ), CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params( @@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params( "bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults", ), - CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), + CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).icon("plastic_rule").noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), ]; fn rule_json(s: &Session, r: &PlasticRule) -> Value { diff --git a/crates/engine/src/cmd/render.rs b/crates/engine/src/cmd/render.rs index 301cd5dd..e516b985 100644 --- a/crates/engine/src/cmd/render.rs +++ b/crates/engine/src/cmd/render.rs @@ -13,27 +13,27 @@ use crate::{Result, Session}; mod jobs; pub static COMMANDS: &[CommandSpec] = &[ -CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only() +CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only() .params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"), -CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box") +CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("render_image") .params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"), -CommandSpec::new("render.status", "Render Progress", jobs::status).noundo().doc_only() +CommandSpec::new("render.status", "Render Progress", jobs::status).icon("list").noundo().doc_only() .params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."), -CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only() +CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).icon("delete").noundo().doc_only() .params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."), -CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only() +CommandSpec::new("render.export", "Export Render Image", jobs::export).icon("export").noundo().doc_only() .params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"), -CommandSpec::new("render.backends", "Render Backends", backends) +CommandSpec::new("render.backends", "Render Backends", backends).icon("list") .noundo() .doc_only() .params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"), -CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo().doc_only() +CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only() .params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"), -CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).doc_only() +CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only() .params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background). Creates from defaults, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"), -CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("box") +CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene") .params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"), -CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).noundo().doc_only() +CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).icon("camera").noundo().doc_only() .params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."), ]; diff --git a/crates/engine/src/cmd/script.rs b/crates/engine/src/cmd/script.rs index 768d4f54..5eb55b50 100644 --- a/crates/engine/src/cmd/script.rs +++ b/crates/engine/src/cmd/script.rs @@ -14,12 +14,12 @@ use crate::params::{bad, str_}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("timeline.add", "Add Recorded Feature", add).params( + CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params( "feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; id and name are kept when free, \ so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \ dimensions use); the feature goes at the marker → feature, name, error, warning", ), - CommandSpec::new("timeline.export_script", "Export Command Script", export_script).noundo().doc_only().params( + CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params( "path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \ steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \ are not supported yet", diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 630a028d..423a5bd9 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[ Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \ and so is the corner relief on a base or contour flange", ), - CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params( + CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("perforate").params( "diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)", ), CommandSpec::new("sheet.hem", "Hem", hem) @@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[ `corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; default notch); \ active?: bool; delete?: bool", ), - CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"), + CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"), CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern) .at("SHEET METAL", "CREATE") .icon("flat") @@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params( "body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table", ), - CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) + CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf).icon("dxf") .noundo() .params( "body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those), \ @@ -77,7 +77,7 @@ pub static COMMANDS: &[CommandSpec] = &[ → DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \ POCKET__Z_TO__MM (one per side and depth) and PERFORATION; inner contours come before the outline", ), - CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table) + CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table).icon("bendtable") .noundo() .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"), ]; @@ -893,6 +893,10 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result { #[path = "sheet_tests.rs"] mod tests; +#[cfg(test)] +#[path = "sheet_batch_cuts_tests.rs"] +mod batch_cuts_tests; + #[cfg(test)] #[path = "sheet_convert_tests.rs"] mod convert_tests; diff --git a/crates/engine/src/cmd/sheet_batch_cuts_tests.rs b/crates/engine/src/cmd/sheet_batch_cuts_tests.rs new file mode 100644 index 00000000..621a80c6 --- /dev/null +++ b/crates/engine/src/cmd/sheet_batch_cuts_tests.rs @@ -0,0 +1,204 @@ +//! Many cuts through one sheet: a hole feature's drills machined in one pass, labels in one +//! sketch or one feature each, all keeping the geometry and flat pattern of cutting them one by one. + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &mut Session) -> f64 { + run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +/// A `w` x `h` tray of 2.5 mm sheet: a base flange on XY with a 15 mm flange on each edge. +fn tray(s: &mut Session, w: f64, h: f64) { + run(s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]})); + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"sketch": "Base"})); + let edges = json!([[w / 2.0, h, 2.5], [w, h / 2.0, 2.5], [w / 2.0, 0, 2.5], [0, h / 2.0, 2.5]]); + run(s, "sheet.flange", json!({"edges": edges, "height": 15})); +} + +fn through_cut(s: &mut Session, sketch: &str) { + run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "symmetric", "operation": "cut"})); +} + +/// A label cut on its own: one sketch, one cut. +fn label_cut(s: &mut Session, name: &str, text: &str, at: [f64; 2]) { + run(s, "sketch.create", json!({"plane": "XY", "name": name})); + run(s, "sketch.text", json!({"text": text, "at": at, "height": 6})); + run(s, "sketch.finish", json!({})); + through_cut(s, name); +} + +/// The labels in one sketch, cut at once. +fn labels_cut(s: &mut Session, labels: &[(String, [f64; 2])]) { + run(s, "sketch.create", json!({"plane": "XY", "name": "Labels"})); + for (text, at) in labels { + run(s, "sketch.text", json!({"text": text, "at": at, "height": 6})); + } + run(s, "sketch.finish", json!({})); + through_cut(s, "Labels"); +} + +/// One hole feature drilling at every point (one sketch of points). +fn holes_at_once(s: &mut Session, points: &[[f64; 2]]) { + run(s, "sketch.create", json!({"plane": "XY", "name": "HolePoints"})); + for p in points { + run(s, "sketch.point", json!({"point": p})); + } + run(s, "sketch.finish", json!({})); + run(s, "solid.hole", json!({"sketch": "HolePoints", "diameter": 4})); +} + +/// A hole feature for each point, drilled into the base's top face. +fn holes_one_by_one(s: &mut Session, points: &[[f64; 2]]) { + for p in points { + run(s, "solid.hole", json!({"position": [p[0], p[1], 2.5], "diameter": 4})); + } +} + +fn polyline(v: &Value) -> Vec<[f64; 2]> { + v.as_array().map(|a| a.iter().map(|p| [p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)]).collect()).unwrap_or_default() +} + +fn area(p: &[[f64; 2]]) -> f64 { + let n = p.len(); + (0..n).map(|i| p[i][0] * p[(i + 1) % n][1] - p[(i + 1) % n][0] * p[i][1]).sum::() / 2.0 +} + +fn sorted_loops(v: &Value) -> Vec> { + let mut loops: Vec> = v.as_array().map(|a| a.iter().map(polyline).collect()).unwrap_or_default(); + loops.sort_by(|a, b| { + let (a, b) = (a.first().copied().unwrap_or_default(), b.first().copied().unwrap_or_default()); + a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])) + }); + loops +} + +/// The flat pattern's outline loops and cut-outs (each a polyline, ordered by where they start, +/// so loops made in another order compare alike) and the area they enclose. +fn flat(s: &mut Session) -> (Vec>, f64) { + let f = run(s, "sheet.flat_pattern", json!({})); + let mut loops = sorted_loops(&f["outline"]); + let cutouts = sorted_loops(&f["cutouts"]); + let enclosed = loops.iter().map(|l| area(l).abs()).sum::() - cutouts.iter().map(|c| area(c).abs()).sum::(); + loops.extend(cutouts); + (loops, enclosed) +} + +fn cutout_count(s: &mut Session) -> usize { + run(s, "sheet.flat_pattern", json!({}))["cutouts"].as_array().map_or(0, Vec::len) +} + +/// The same volume (within a millionth) and the same flat pattern (its material area within a +/// millionth, every point of its outline and cut-outs within 1e-6 mm). +fn assert_same(a: &mut Session, b: &mut Session) { + let (va, vb) = (volume(a), volume(b)); + assert!((va - vb).abs() <= 1e-6 * va.abs(), "volume {va} vs {vb}"); + let (la, ma) = flat(a); + let (lb, mb) = flat(b); + assert!(ma.is_finite() && (ma - mb).abs() <= 1e-6 * ma.abs().max(1.0), "flat area {ma} vs {mb}"); + let close = |p: &[[f64; 2]], q: &[[f64; 2]]| { + p.len() == q.len() && p.iter().zip(q).all(|(x, y)| (x[0] - y[0]).abs() <= 1e-6 && (x[1] - y[1]).abs() <= 1e-6) + }; + assert_eq!(la.len(), lb.len(), "flat loop count"); + for (x, y) in la.iter().zip(&lb) { + assert!(close(x, y), "flat loop differs: {:?} vs {:?}", x.first(), y.first()); + } +} + +fn hole_grid(n: usize, columns: usize, at: [f64; 2], pitch: f64) -> Vec<[f64; 2]> { + (0..n).map(|i| [at[0] + (i % columns) as f64 * pitch, at[1] + (i / columns) as f64 * pitch]).collect() +} + +#[test] +fn a_hole_feature_drills_a_sheet_in_one_pass_like_holes_one_by_one() { + let points = hole_grid(12, 6, [15.0, 20.0], 15.0); + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 120.0, 80.0); + tray(&mut each, 120.0, 80.0); + let before = volume(&mut once); + holes_at_once(&mut once, &points); + holes_one_by_one(&mut each, &points); + // Each hole takes π r² t from the base (the measure follows the walls' facets). + let want = before - 12.0 * std::f64::consts::PI * 4.0 * 2.5; + assert!((volume(&mut once) - want).abs() < 1e-5 * want, "{} {want}", volume(&mut once)); + assert_eq!(cutout_count(&mut once), 12); + assert_same(&mut once, &mut each); +} + +/// A second cut after a text cut was dropped without a word, and a hole after it missed the flat +/// pattern: the 3D test whether their tools met the sheet came out empty against the sheet +/// rebuilt with the letters' cut-outs. +#[test] +fn a_cut_after_a_text_cut_still_cuts() { + let mut s = Session::default(); + tray(&mut s, 120.0, 80.0); + label_cut(&mut s, "Label", "AB", [15.0, 50.0]); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 20], "p1": [90, 30]})); + run(&mut s, "sketch.finish", json!({})); + let r = run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}"); + let want = before - 20.0 * 10.0 * 2.5; + assert!((volume(&mut s) - want).abs() < 1e-6 * want, "{} {want}", volume(&mut s)); + assert_eq!(cutout_count(&mut s), 3, "A, B and the slot"); + // A hole drilled after it is a cut-out of the flat pattern too (it was cut into the solid + // only, so unfolding would have lost it). + run(&mut s, "solid.hole", json!({"position": [100, 60, 2.5], "diameter": 4})); + assert_eq!(cutout_count(&mut s), 4, "A, B, the slot and the hole"); +} + +#[test] +fn labels_in_one_sketch_and_holes_in_one_feature_match_cutting_them_one_by_one() { + let labels: Vec<(String, [f64; 2])> = (0..3).map(|i| (format!("A{i}"), [15.0 + 30.0 * i as f64, 55.0])).collect(); + let points = hole_grid(6, 6, [15.0, 20.0], 15.0); + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 120.0, 80.0); + tray(&mut each, 120.0, 80.0); + labels_cut(&mut once, &labels); + holes_at_once(&mut once, &points); + for (i, (text, at)) in labels.iter().enumerate() { + label_cut(&mut each, &format!("Label{i}"), text, *at); + } + holes_one_by_one(&mut each, &points); + assert_same(&mut once, &mut each); +} + +/// The full size: a flanged sheet with 10 labels and 50 holes, cut at once and one by one. +/// `cargo test --release -p solidcraft-engine --lib sheet_many_cuts_benchmark -- --ignored --nocapture` +#[test] +#[ignore = "benchmark: run with --release --ignored --nocapture"] +fn sheet_many_cuts_benchmark() { + let labels: Vec<(String, [f64; 2])> = + (0..10).map(|i| (format!("AB{i}"), [20.0 + 55.0 * (i % 5) as f64, if i < 5 { 150.0 } else { 170.0 }])).collect(); + let points = hole_grid(50, 10, [20.0, 30.0], 20.0); + let time = |f: &mut dyn FnMut()| { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }; + let (mut once, mut each) = (Session::default(), Session::default()); + tray(&mut once, 300.0, 200.0); + tray(&mut each, 300.0, 200.0); + let labels_once = time(&mut || labels_cut(&mut once, &labels)); + let holes_once = time(&mut || holes_at_once(&mut once, &points)); + let labels_each = time(&mut || { + for (i, (text, at)) in labels.iter().enumerate() { + label_cut(&mut each, &format!("Label{i}"), text, *at); + } + }); + let holes_each = time(&mut || holes_one_by_one(&mut each, &points)); + println!("labels: one sketch {labels_once:.0} ms, one feature each {labels_each:.0} ms"); + println!("holes: one feature {holes_once:.0} ms, one feature each {holes_each:.0} ms"); + assert_same(&mut once, &mut each); +} diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 96f53ba0..0e30dcaa 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2816,6 +2816,64 @@ fn a_pocket_around_a_cut_out_rebuilds_on_a_folded_sheet() { } } +/// Engraving a long channel leaves its corner rounds round. The channel (1200 long, 1 thick) has +/// 2 mm rounds where its legs end, and two 5.95 x 5 pockets 0.1 apart are cut 0.1 deep into a leg +/// halfway along. Their walls in one plane leave the second pocket to the exact planar fallback, +/// whose mesh (a thousandth of the body, 1.2 mm) is flat across a 2 mm round: taken for a plane, +/// the rounds came back flat chamfers beside a round hole, and without one the cut failed. +#[test] +fn engraving_a_long_channel_keeps_its_corner_rounds() { + for hole in [false, true] { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Thin", "active": true, "thickness": "1 mm", "bend_radius": "1 mm", "k_factor": "0.44"})); + // Along z, 20 wide, its legs 65 and 25 high from the web at y = 65. + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, -600], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Profile"})); + run(&mut s, "sketch.line", json!({"points": [[-10, 0], [-10, 65], [10, 65], [10, 40]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Profile", "curves": ["l1", "l2", "l3"], "distance": 1200})); + // Each leg's free edge rounded at both ends: the corner outside a quarter circle, cut through. + let legs = [(-10.0_f64, 0.0_f64), (10.0, 40.0)]; + for (k, (x, y)) in legs.iter().enumerate() { + let name = format!("Rounds{k}"); + run(&mut s, "sketch.create", json!({"plane": {"origin": [x, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": name})); + for end in [-600.0, 600.0_f64] { + let c = end - 2.0 * end.signum(); + let far = end + 2.0 * end.signum(); + run(&mut s, "sketch.line", json!({"points": [[c, y], [c, y - 2.0], [far, y - 2.0], [far, y + 2.0], [end, y + 2.0]]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [c, y + 2.0], "start": [end, y + 2.0], "sweep": -90.0 * end.signum()})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": name, "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + if hole { + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [360, 57], "diameter": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 4, "direction": "symmetric", "operation": "cut"})); + } + let v0 = volume(&mut s); + // Into the outer face of the leg at x = -10 (its material from -10 to -9). + run(&mut s, "sketch.create", json!({"plane": {"origin": [-10, 0, 0], "x_dir": [0, 0, 1], "y_dir": [0, 1, 0]}, "name": "Label"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-6, 30], "p1": [-0.05, 35]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.05, 30], "p1": [6, 35]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Label", "distance": 0.1, "direction": "negative", "operation": "cut"})); + // 2 x 5.95 x 5 x 0.1. + let removed = v0 - volume(&mut s); + assert!((removed - 5.95).abs() < 1e-3, "hole {hole}: removed {removed}"); + // A point on each round, halfway through the leg: 2 - 2 cos 45 deg in from both edges. + let d = 2.0 - std::f64::consts::SQRT_2; + let faces: Vec<[f64; 3]> = + legs.iter().flat_map(|(x, y)| [-600.0, 600.0_f64].map(|end| [x + 0.5 * -x.signum(), y + d, end - d * end.signum()])).collect(); + let radii = run(&mut s, "inspect.minimum_radius", json!({"faces": faces})); + let items = radii["items"].as_array().unwrap(); + assert_eq!(items.len(), 4); + for item in items { + assert!(item["min_radius"].as_f64().is_some_and(|r| (r - 2.0).abs() < 1e-6), "hole {hole}: {item}"); + } + } +} + /// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working: /// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263). #[test] diff --git a/crates/engine/src/cmd/silhouette.rs b/crates/engine/src/cmd/silhouette.rs new file mode 100644 index 00000000..97d34544 --- /dev/null +++ b/crates/engine/src/cmd/silhouette.rs @@ -0,0 +1,234 @@ +//! A tightly framed, view-aligned union of the projected body meshes. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::bad; +use crate::render::{Camera, StandardView}; +use crate::{Result, Session}; + +const CMD: &str = "inspect.silhouette"; +const MAX_PIXELS: usize = 4 * 1024 * 1024; + +// For agents comparing the model with a picture; nothing to show a person, so no toolbar button. +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().params( + "view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts", +)]; + +fn run(s: &mut Session, p: &Value) -> Result { + let camera = camera(p)?; + let rows = rows(p)?; + let wanted = body_names(p)?; + let state = s.world_state(); + if let Some(names) = &wanted { + for name in names { + if !state.bodies.iter().any(|b| b.name == *name) { + return Err(bad(CMD, format!("unknown body name `{name}`"))); + } + } + } + let bodies: Vec<_> = state + .bodies + .iter() + .filter(|b| wanted.as_ref().map_or_else(|| s.body_visible(&b.name), |names| names.iter().any(|name| name == &b.name))) + .collect(); + if bodies.is_empty() { + return Err(bad(CMD, "nothing visible to outline")); + } + + let (right, up, back) = camera.basis(); + let perspective = camera.fov > 0.0; + let origin_u = camera.target.dot(right); + let origin_v = camera.target.dot(up); + let project = |point: Vec3| -> Result<[f64; 2]> { + let delta = point - camera.target; + let scale = if perspective { + let depth = camera.distance - delta.dot(back); + if !depth.is_finite() || depth <= 0.0 { + return Err(bad( + CMD, + "part of the model is at or behind the camera: move the camera back, or name the bodies in front of it in `bodies`", + )); + } + camera.distance / depth + } else { + 1.0 + }; + let uv = [origin_u + delta.dot(right) * scale, origin_v + delta.dot(up) * scale]; + if uv.iter().any(|v| !v.is_finite()) { + return Err(bad(CMD, "projected model coordinates are not finite")); + } + Ok(uv) + }; + + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + let mut meshes = Vec::with_capacity(bodies.len()); + let mut triangle_count = 0usize; + for body in &bodies { + let mesh = body.mesh(); + triangle_count = triangle_count.checked_add(mesh.triangles.len()).ok_or_else(|| bad(CMD, "too many triangles"))?; + let mut projected = Vec::with_capacity(mesh.positions.len()); + for &point in &mesh.positions { + let uv = project(point)?; + for axis in 0..2 { + lo[axis] = lo[axis].min(uv[axis]); + hi[axis] = hi[axis].max(uv[axis]); + } + projected.push(uv); + } + meshes.push((mesh, projected)); + } + let u_span = hi[0] - lo[0]; + let v_span = hi[1] - lo[1]; + if !u_span.is_finite() || !v_span.is_finite() || (u_span <= 0.0 && v_span <= 0.0) { + return Err(bad(CMD, "nothing visible to outline")); + } + let mm_per_px = (v_span / rows as f64).max(u_span / (4 * rows) as f64); + if !mm_per_px.is_finite() || mm_per_px <= 0.0 { + return Err(bad(CMD, "outline scale is invalid")); + } + let width = dimension(u_span, mm_per_px)?; + let height = dimension(v_span, mm_per_px)?; + let pixels = width.checked_mul(height).filter(|n| *n <= MAX_PIXELS).ok_or_else(|| bad(CMD, "outline exceeds the pixel limit"))?; + if width == 0 || height == 0 { + return Err(bad(CMD, "nothing visible to outline")); + } + + let mut covered = vec![false; pixels]; + for (mesh, projected) in &meshes { + for indices in &mesh.triangles { + // Meshes can come from imported data; never trust triangle indices. + let (Some(a), Some(b), Some(c)) = + (projected.get(indices[0] as usize), projected.get(indices[1] as usize), projected.get(indices[2] as usize)) + else { + continue; + }; + let tri = [*a, *b, *c].map(|q| [(q[0] - lo[0]) / mm_per_px, (hi[1] - q[1]) / mm_per_px]); + if tri.iter().flat_map(|p| p.iter()).any(|v| !v.is_finite()) { + return Err(bad(CMD, "projected triangle coordinates are not finite")); + } + let [a, b, c] = tri; + let area = edge(a, b, c); + if area == 0.0 || !area.is_finite() { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().clamp(0.0, width as f64) as usize; + let x1 = a[0].max(b[0]).max(c[0]).ceil().clamp(0.0, width as f64) as usize; + let y0 = a[1].min(b[1]).min(c[1]).floor().clamp(0.0, height as f64) as usize; + let y1 = a[1].max(b[1]).max(c[1]).ceil().clamp(0.0, height as f64) as usize; + for y in y0..y1 { + for x in x0..x1 { + let centre = [x as f64 + 0.5, y as f64 + 0.5]; + let e = [edge(a, b, centre), edge(b, c, centre), edge(c, a, centre)]; + if (e.iter().all(|v| *v >= -1e-9) || e.iter().all(|v| *v <= 1e-9)) + && let Some(i) = y.checked_mul(width).and_then(|base| base.checked_add(x)) + && let Some(pixel) = covered.get_mut(i) + { + *pixel = true; + } + } + } + } + } + + let mask: Vec> = covered.chunks_exact(width).map(spans).collect(); + Ok(json!({ + "width": width, + "height": height, + "mm_per_px": round4(mm_per_px), + "box_mm": [[round4(lo[0]), round4(lo[1])], [round4(hi[0]), round4(hi[1])]], + "right": [clean(right.x), clean(right.y), clean(right.z)], + "up": [clean(up.x), clean(up.y), clean(up.z)], + "projection": if perspective { "perspective" } else { "parallel" }, + "bodies": bodies.len(), + "triangles": triangle_count, + "mask": mask, + })) +} + +fn camera(p: &Value) -> Result { + if p.get("view").is_some() && p.get("camera").is_some() { + return Err(bad(CMD, "give `view` or `camera`, not both")); + } + let camera = if let Some(value) = p.get("camera") { + let named: solidcraft_doc::NamedView = serde_json::from_value(value.clone()).map_err(|e| bad(CMD, format!("bad camera: {e}")))?; + Camera { target: named.target, yaw: named.yaw, pitch: named.pitch, distance: named.distance, fov: named.fov, ..Default::default() } + } else { + let name = match p.get("view") { + None => "front", + Some(Value::String(name)) => name, + _ => return Err(bad(CMD, "`view` must be a standard view name")), + }; + let view = StandardView::parse(name).ok_or_else(|| bad(CMD, format!("unknown standard view `{name}`")))?; + let (yaw, pitch) = view.angles(); + Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, ..Default::default() } + }; + if !camera.is_valid() || !camera.fov.is_finite() || camera.fov < 0.0 { + return Err(bad(CMD, "the camera must be finite with a positive distance")); + } + Ok(camera) +} + +fn body_names(p: &Value) -> Result>> { + match p.get("bodies") { + None => Ok(None), + Some(Value::String(name)) => Ok(Some(vec![name.clone()])), + Some(Value::Array(names)) if names.len() <= 10_000 => names + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(CMD, "`bodies` must contain names"))) + .collect::>>() + .map(Some), + _ => Err(bad(CMD, "`bodies` must be a list of names")), + } +} + +fn rows(p: &Value) -> Result { + match p.get("rows") { + None => Ok(240), + Some(v) => { + let n = v.as_i64().or_else(|| v.as_u64().map(|n| n.min(i64::MAX as u64) as i64)).ok_or_else(|| bad(CMD, "`rows` must be an integer"))?; + Ok(n.clamp(16, 1024) as usize) + } + } +} + +fn dimension(span: f64, mm_per_px: f64) -> Result { + let ratio = span / mm_per_px; + if !ratio.is_finite() || ratio > MAX_PIXELS as f64 { + return Err(bad(CMD, "outline exceeds the pixel limit")); + } + // Exact axis views pick up a few ulps from sin(π) and cos(π/2). + let nearest = ratio.round(); + let stable = if (ratio - nearest).abs() < 1e-9 { nearest } else { ratio }; + Ok(stable.ceil() as usize) +} + +fn edge(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 { + (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) +} + +fn spans(row: &[bool]) -> Vec<[usize; 2]> { + let mut out = Vec::new(); + let mut start = None; + for (x, &on) in row.iter().enumerate() { + if on && start.is_none() { + start = Some(x); + } else if !on && let Some(first) = start.take() { + out.push([first, x]); + } + } + if let Some(first) = start { + out.push([first, row.len()]); + } + out +} + +fn round4(value: f64) -> f64 { + if value.abs() < 1e12 { (value * 10_000.0).round() / 10_000.0 } else { value } +} + +fn clean(value: f64) -> f64 { + if value.abs() < 1e-12 { 0.0 } else { value } +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 4cf5e4b0..504f1838 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("L") .enabled(in_sketch) .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())), - CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) + CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension") .enabled(in_sketch) .params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) @@ -91,12 +91,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed) .at("SKETCH", "CREATE") - .icon("polygon") + .icon("polygon_circumscribed") .enabled(in_sketch) .params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge) .at("SKETCH", "CREATE") - .icon("polygon") + .icon("polygon_edge") .enabled(in_sketch) .params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c) @@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall) .at("SKETCH", "CREATE") - .icon("slot") + .icon("slot_overall") .enabled(in_sketch) .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())), @@ -174,27 +174,27 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("X") .enabled(in_sketch) .params("curves: [ids], value?: bool (default: construction unless all of them already are)"), - CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete) + CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete") .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), - CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), - CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params( + CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"), + CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params( "entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag", ), - CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params( + CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params( "constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom", ), - CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text) + CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit") .enabled(in_sketch) .params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"), CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params( "sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all", ), - CommandSpec::new("sketch.chain", "Chain", chain_cmd) + CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain") .enabled(in_sketch) .noundo() .params("curve: id: the curves joined to it end to end (what a double-click selects)"), - CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd) + CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute") .enabled(in_sketch) .noundo() .params("reports the solver status and degrees of freedom"), @@ -1463,10 +1463,10 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], if !second_locked { ordered(first, second); } - if !first_locked { - for f in first { - ordered(&[second], *f); - } + // Each first entity that can move may (a free symmetry line between two fixed lines, #1796), + // even when another of them can't. + for f in first.iter().filter(|f| !ent_locked(sk, rep, **f)) { + ordered(&[second], *f); } const TEMP: &str = "__keep_size"; for (h, extra) in tries { diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index 07f865c5..d6202511 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -12,17 +12,20 @@ use crate::params::{bad, req_vec3, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.line3d", "3D Line", line3d).enabled(in_sketch).params(concat!( + CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch).params(concat!( "points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)", created_doc!() )), CommandSpec::new("sketch.spline3d", "3D Spline", spline3d) + .icon("spline") .enabled(in_sketch) .params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())), CommandSpec::new("sketch.point3d", "3D Point", point3d) + .icon("point") .enabled(in_sketch) .params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())), CommandSpec::new("sketch.move3d", "Move 3D Point", move3d) + .icon("sketch_move") .enabled(in_sketch) .params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"), ]; diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index b884e784..08f16be3 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -18,9 +18,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("decal") .params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"), CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).at("SKETCH", "INSERT").icon("canvas").params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"), - CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate) + CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate).icon("measure") .params("canvas: id or name; a, b: two points on the canvas plane [x,y]; distance: their true distance (mm): scales the image about a"), - CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).params("canvas: id or name"), + CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).icon("delete").params("canvas: id or name"), ]; fn image_bytes(p: &Value, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs index 364f1851..fb9c4d19 100644 --- a/crates/engine/src/cmd/sketch_clipboard.rs +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -17,14 +17,14 @@ use crate::params::{bad, string_list, vec2}; use crate::{Result, Sel, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy) + CommandSpec::new("sketch.copy", "Copy Sketch Entities", copy).icon("copy") .noundo() .enabled(in_sketch) .params("entities?: [curve or point ids] (default: the selected sketch geometry); the constraints and dimensions among them come along"), - CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut) + CommandSpec::new("sketch.cut", "Cut Sketch Entities", cut).icon("cut") .enabled(in_sketch) .params("entities?: [curve or point ids] (default: the selected sketch geometry): copies them as sketch.copy does, then deletes them (one undo step)"), - CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).enabled(in_sketch).params( + CommandSpec::new("sketch.paste", "Paste Sketch Entities", paste).icon("paste").enabled(in_sketch).params( "at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)", ), ]; diff --git a/crates/engine/src/cmd/sketch_constraints.rs b/crates/engine/src/cmd/sketch_constraints.rs index 260c8cfb..862dcd53 100644 --- a/crates/engine/src/cmd/sketch_constraints.rs +++ b/crates/engine/src/cmd/sketch_constraints.rs @@ -41,14 +41,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("constrainer") .enabled(in_sketch) .params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"), - CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven) + CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension") .enabled(in_sketch) .params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"), - CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs) + CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye") .enabled(in_sketch) .noundo() .params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"), - CommandSpec::new("sketch.snap", "Snap Inference", snap) + CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap") .enabled(in_sketch) .noundo() .params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"), diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 4e3efc03..c68343ff 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -283,3 +283,23 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true})); assert_eq!(d["unit"], "mm", "{d}"); } + +#[test] +fn symmetry_moves_a_free_line_between_two_fixed_lines() { + // #1796: with both mirrored lines fixed, the symmetry line between them (a free construction + // line) is the one to move; it was refused as "neither entity can move". + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 20]], "ids": ["left"]})); + run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]], "ids": ["right"]})); + run(&mut s, "sketch.line", json!({"points": [[3, 0], [3, 20]], "construction": true, "ids": ["mid"]})); + run(&mut s, "sketch.constraint.fix", json!({"entities": ["left", "right"]})); + run(&mut s, "sketch.constraint.symmetry", json!({"a": "left", "b": "right", "line": "mid"})); + let sk = sketch(&s); + let at = |r: &str| sk.point(sk.resolve_point(r).unwrap()).unwrap(); + for end in ["mid.start", "mid.end"] { + assert!((at(end).x - 5.0).abs() < 1e-7, "{end} at {:?}", at(end)); + } + for (end, x) in [("left.start", 0.0), ("left.end", 0.0), ("right.start", 10.0), ("right.end", 10.0)] { + assert!((at(end).x - x).abs() < 1e-9, "{end} moved to {:?}", at(end)); + } +} diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index c4eebc0d..fbc538b1 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -47,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())), CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point) .at("SKETCH", "CREATE") - .icon("slot") + .icon("slot_center") .enabled(in_sketch) .params(concat!("center: slot centre, end: one arc centre, width", created_doc!())), CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three) @@ -57,7 +57,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("start, through, end: points on the centre arc, width", created_doc!())), CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center) .at("SKETCH", "CREATE") - .icon("arc_slot") + .icon("arc_slot_center") .enabled(in_sketch) .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())), CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!( @@ -88,22 +88,25 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("blend") .enabled(in_sketch) - .params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())), + .params(concat!( + "a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)", + created_doc!() + )), CommandSpec::new("sketch.centerline", "Centerline", centerline) .at("SKETCH", "CREATE") .icon("centerline") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), - CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line) + CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"), - CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!( + CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!( "text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \ font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ", text_style_doc!(), created_doc!() )), - CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) + CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit") .enabled(in_sketch) .live(edit_text_live) .params(concat!( @@ -399,8 +402,68 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> { sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d))) } +/// The continuity a blend asks for: the number of derivatives of each reference curve it +/// matches at both joints (g1 is the tangent construction the tool has always made). +fn blend_order(p: &Value, cmd: &str) -> Result { + match p.get("continuity") { + None | Some(Value::Null) => Ok(1), + Some(Value::String(v)) => match v.trim().to_ascii_lowercase().as_str() { + "" | "g1" => Ok(1), + "g2" => Ok(2), + "g3" => Ok(3), + "g4" => Ok(4), + _ => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + }, + Some(_) => Err(bad(cmd, "`continuity` must be g1, g2, g3 or g4")), + } +} + +/// Negate the odd-order derivatives: how a curve's derivatives change when it is read backwards. +fn flip_odd(d: &mut [Vec2]) { + for (i, v) in d.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } +} + +/// Derivatives 1…`order` of curve `ci`'s continuation through its end point `pi`, in the +/// parameterisation the blend needs at that joint: at the blend's start (`leaving`) the curve's +/// continuation runs away from it, at the blend's end the blend runs into the curve instead, which +/// is the same curve read backwards. The derivatives are those of the one exact piece that ends +/// at `pi`, evaluated at that piece's own end, so an arc gives its circular-motion terms (zero +/// curvature rate included) and a conic the quotient rule's terms. Errors when the curve has no +/// exact piece, when a derivative is not finite (a corrupt or degenerate reference), or when it +/// has no tangent at all: no blend can meet the last case. +fn joint_derivatives(sk: &Sketch, ci: usize, pi: usize, order: usize, leaving: bool, what: &str, cmd: &str) -> Result> { + let id = sk.curves.get(ci).map(|c| c.id.as_str()).unwrap_or("?"); + let (start, end) = sk.curves.get(ci).and_then(|c| c.kind.ends()).ok_or_else(|| bad(cmd, format!("`{id}` has no end points")))?; + let at_end = end == pi; + if !at_end && start != pi { + return Err(bad(cmd, format!("`{what}` is not an end point of `{id}`"))); + } + let pieces = sk.exact_pieces(ci); + let seg = if at_end { pieces.last() } else { pieces.first() }.ok_or_else(|| bad(cmd, format!("`{id}` has no exact geometry")))?; + let mut d = seg + .derivatives(if at_end { 1.0 } else { 0.0 }, order) + .ok_or_else(|| bad(cmd, format!("`{id}` has no finite derivatives up to order {order} at `{what}`")))?; + // Reading the piece against its own direction, or reading the continuation into the curve + // rather than out of it, each reverse the parameterisation once and so flip the odd + // derivatives: the blend's start wants the continuation away from the reference's end (no + // flip) or backwards out of its start (flip); its end wants the mirror image of that, so a + // joint needs a flip exactly when its two readings disagree. + if at_end != leaving { + flip_odd(&mut d); + } + if !d.first().is_some_and(|t| t.len() > 0.0) { + return Err(bad(cmd, format!("`{id}` has no tangent at `{what}`"))); + } + Ok(d) +} + fn blend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.blend_curve"; + let order = blend_order(p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?; let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?; @@ -412,10 +475,34 @@ fn blend(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the ends must differ")); } let first_new = sk.points.len(); - let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; - let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; - let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; - let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + let (sp, cons) = if order <= 1 { + let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; + let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; + let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; + (sp, cons) + } else { + // One Bézier of degree 2·order + 1 whose controls are the Hermite interpolation of the + // two references' derivatives, so its 1st…order-th derivatives are theirs at both + // joints. Tangency comes from the construction, so it needs no tangent constraints. + let da = joint_derivatives(sk, ca, pa, order, true, "a", cmd)?; + let db = joint_derivatives(sk, cb, pb, order, false, "b", cmd)?; + let mut pts: Vec = Vec::with_capacity(2 * order + 2); + let cps = solidcraft_geom::hermite_bezier(qa, qb, &da, &db) + .ok_or_else(|| bad(cmd, "the blend curve could not be built from the derivatives of `a` and `b`"))?; + for (i, q) in cps.iter().enumerate() { + pts.push(if i == 0 { + pa + } else if i + 1 == cps.len() { + pb + } else { + sk.add_point(*q, None)? + }); + } + let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?; + let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false }, None)?; + (sp, Vec::new()) + }; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) })?; @@ -860,3 +947,391 @@ pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) { } } } + +#[cfg(test)] +mod blend_tests { + use serde_json::{Value, json}; + use solidcraft_geom::{Seg2, Vec2}; + use solidcraft_sketch::CurveKind; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn ids(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + } + + fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s + } + + fn sketch(s: &Session) -> solidcraft_sketch::Sketch { + let id = s.active_sketch.unwrap(); + s.model.state().sketch(id).unwrap().sketch.clone() + } + + fn factorial(n: usize) -> f64 { + (1..=n).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` of exact piece `seg` at its end (`at_end`), from the piece's own + /// geometry: what a blend must match, computed here independently of `Seg2::derivatives`. + fn piece_derivatives(seg: &Seg2, at_end: bool, order: usize) -> Vec { + let t = if at_end { 1.0 } else { 0.0 }; + let p = seg.point_at(t); + let mut d = match *seg { + Seg2::Line { a, b } => vec![b - a, Vec2::ZERO, Vec2::ZERO, Vec2::ZERO], + Seg2::Arc { center, sweep, .. } => { + let (v, s2) = (seg.derivative(t), sweep * sweep); + vec![v, (p - center) * -s2, v * -s2, (p - center) * (s2 * s2)] + } + Seg2::Cubic { p0, p1, p2, p3 } => { + let third = (p3 - p2 * 3.0 + p1 * 3.0 - p0) * 6.0; + if at_end { + vec![(p3 - p2) * 3.0, (p3 - p2 * 2.0 + p1) * 6.0, third, Vec2::ZERO] + } else { + vec![(p1 - p0) * 3.0, (p0 - p1 * 2.0 + p2) * 6.0, third, Vec2::ZERO] + } + } + Seg2::Conic { a, apex, b, w } => { + // The rational quadratic's numerator N and weight D as quadratics in the power + // basis; dividing N's series by D's (constant term 1 at both ends) has to give the + // Taylor coefficients of the curve. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let h = 1e-3; + let series = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = series(&|x| n0 + n1 * x + n2 * (x * x)); + let den = [1.0 + e1 * t + e2 * (t * t), e1 + e2 * (2.0 * t), e2]; + let mut x = vec![n[0] / den[0]]; + for j in 1..=order { + let mut v = n.get(j).copied().unwrap_or(Vec2::ZERO); + for i in 1..=j { + v -= x[j - i] * den.get(i).copied().unwrap_or(0.0); + } + x.push(v / den[0]); + } + x.remove(0); + x.into_iter().enumerate().map(|(i, v)| v * factorial(i + 1)).collect() + } + Seg2::Bezier { .. } => panic!("a test piece is not a high-degree Bézier"), + }; + d.truncate(order); + d + } + + /// The derivatives 1…`order` the blend's own control polygon carries at an end: the j-th + /// difference of the controls, scaled by `n!/(n − j)!` (the Bézier endpoint identity). + fn blend_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + /// The blend curve's control points, its degree and the one exact piece they make. + fn blend_of(s: &Session, id: &str) -> (Vec, usize, Seg2) { + let sk = sketch(s); + let ci = sk.curve_index(id).unwrap(); + let ctrl: Vec = sk.curves[ci].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect(); + let degree = match sk.curves[ci].kind { + CurveKind::Spline { degree, .. } => usize::from(degree), + ref other => panic!("{other:?}"), + }; + let pieces = sk.exact_pieces(ci); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + (ctrl, degree, pieces[0]) + } + + /// The reference pair the tests build: a line `(0, 0)`–`(6, 0)` ending at `a` and a three-point + /// control spline starting at `b`, so the blend runs out of the first and into the second with + /// no reversal at either joint. + fn two_references(s: &mut Session) -> (String, String, [Vec2; 4]) { + let a = ids(&run(s, "sketch.line", json!({"points": [[0, 0], [6, 0]]}))["curves"])[0].clone(); + let bctrl = [Vec2::new(20.0, 10.0), Vec2::new(24.0, 14.0), Vec2::new(30.0, 9.0), Vec2::new(34.0, 6.0)]; + let b = ids(&run(s, "sketch.spline.control_point", json!({"points": [[20, 10], [24, 14], [30, 9], [34, 6]]}))["curves"])[0].clone(); + (a, b, bctrl) + } + + #[test] + fn blend_curve_matches_both_references_through_the_requested_order() { + for order in 1..=4usize { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1, "g{order} is one Bézier of degree 2k+1"); + assert_eq!(ctrl.len(), 2 * order + 2, "g{order} control count"); + assert!(r["sketch"]["solved"].as_bool().unwrap_or(false), "{r}"); + assert!(piece.start().dist(Vec2::new(6.0, 0.0)) < 1e-12 && piece.end().dist(bctrl[0]) < 1e-12, "the blend joins `a` and `b`"); + + // What each reference supplies at the joint: the line's own derivative at its end (and + // nothing higher) and the cubic's at its start. + let line = piece_derivatives(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(6.0, 0.0) }, true, 4); + let cubic = piece_derivatives(&Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }, false, 4); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + // g1 is the tangent construction of the tool: the first derivative is parallel to the + // reference's (that is what G1 is), with the tool's own chord-scaled magnitude. g2 and + // up match the reference's derivative vectors themselves. + let tan_line = line[0].normalized().unwrap(); + let tan_cubic = cubic[0].normalized().unwrap(); + assert!(got0[0].cross(tan_line).abs() < 1e-9 && got0[0].dot(tan_line) > 0.0, "g{order} leaves `a` along {tan_line:?}, not {:?}", got0[0]); + assert!( + got1[0].cross(tan_cubic).abs() < 1e-9 && got1[0].dot(tan_cubic) > 0.0, + "g{order} enters `b` along {tan_cubic:?}, not {:?}", + got1[0] + ); + for j in 1..order { + assert!(got0[j].dist(line[j]) < 1e-9, "g{order} at a, order {}: {:?} vs {:?}", j + 1, got0[j], line[j]); + assert!(got1[j].dist(cubic[j]) < 1e-9, "g{order} at b, order {}: {:?} vs {:?}", j + 1, got1[j], cubic[j]); + } + if order >= 2 { + for (got, want) in [(got0[0], line[0]), (got1[0], cubic[0])] { + assert!(got.dist(want) < 1e-9, "g{order} matches the reference's own derivative: {got:?} vs {want:?}"); + } + } + // The next order up is not met: the line has no derivative there at all, while a blend of + // degree 2k+1 has. + if order < 4 { + let next = got0[order]; + assert!(next.dist(line[order]) > 1e-6, "g{order} matched one order too many at a: {next:?}"); + } + // Curvature itself (orientation-free, from finite differences of the samples) agrees at + // both joints once the order asks for it. + if order >= 2 { + let curve = |x: f64| piece.point_at(x.clamp(0.0, 1.0)); + let line_f = |x: f64| Vec2::new(6.0 * x, 0.0); + let cubic_f = |x: f64| Seg2::Cubic { p0: bctrl[0], p1: bctrl[1], p2: bctrl[2], p3: bctrl[3] }.point_at(x); + let k0 = solidcraft_sketch::end_curvature(&curve, false); + let ka = solidcraft_sketch::end_curvature(&line_f, true); + let k1 = solidcraft_sketch::end_curvature(&curve, true); + let kb = solidcraft_sketch::end_curvature(&cubic_f, false); + assert!(k0.dist(ka) < 1e-3, "g{order} curvature at a: {k0:?} vs {ka:?}"); + assert!(k1.dist(kb) < 1e-3, "g{order} curvature at b: {k1:?} vs {kb:?}"); + } + } + } + + #[test] + fn blend_curve_g1_is_the_tangent_construction_with_its_constraints() { + let mut s = new_sketch(); + let (a, b, bctrl) = two_references(&mut s); + let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")})); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 3, "the default is the cubic tangent spline"); + let (qa, qb) = (Vec2::new(6.0, 0.0), bctrl[0]); + let l = qa.dist(qb); + let tb = (qb - bctrl[1]).normalized().unwrap(); + let want = [qa, qa + Vec2::X * (l / 3.0), qb + tb * (l / 3.0), qb]; + for (i, q) in want.iter().enumerate() { + assert!(ctrl[i].dist(*q) < 1e-9, "g1 control {i}: {:?} vs {q:?}", ctrl[i]); + } + assert_eq!(ids(&r["constraints"]).len(), 2, "the two tangents are kept: {r}"); + // g2…g4 meet the derivatives by construction and need no constraint of their own. + for order in 2..=4usize { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + assert!(ids(&r["constraints"]).is_empty(), "g{order}: {r}"); + } + } + + #[test] + fn blend_curve_between_two_lines_is_curvature_free_to_the_requested_order() { + // Two lines meeting at a right angle: from G2 on, the blend has the lines' own zero + // curvature at both joints (a corner with no curvature jump). + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + let got0 = blend_derivatives(&ctrl, false, 4); + let got1 = blend_derivatives(&ctrl, true, 4); + assert!(got0[0].dist(Vec2::new(2.0, 0.0)) < 1e-12, "g{order} leaves `a` along +x: {:?}", got0[0]); + assert!(got1[0].dist(Vec2::new(0.0, 2.0)) < 1e-12, "g{order} enters `b` along +y: {:?}", got1[0]); + for j in 1..order { + assert!(got0[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + assert!(got1[j].len() < 1e-12, "g{order}: a line has no order-{} derivative", j + 1); + } + if order < 4 { + assert!(got0[order].len() > 1e-6, "g{order}: the blend curves, the line does not"); + } + assert!(piece.start().dist(Vec2::new(2.0, 0.0)) < 1e-12 && piece.end().dist(Vec2::new(20.0, 10.0)) < 1e-12); + } + } + + #[test] + fn blend_curve_matches_a_conic_reference() { + for order in 2..=4usize { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.conic", json!({"start": [20, 10], "apex": [26, 16], "end": [30, 10], "rho": 0.7}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": format!("g{order}"), + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, _) = blend_of(&s, &sp); + assert_eq!(degree, 2 * order + 1); + // The conic's own derivatives at its start: `w` comes from the sketch's `rho`, and the + // series division in `piece_derivatives` is the expectation. + let w = 0.7 / (1.0 - 0.7); + let conic = Seg2::Conic { a: Vec2::new(20.0, 10.0), apex: Vec2::new(26.0, 16.0), b: Vec2::new(30.0, 10.0), w }; + let want = piece_derivatives(&conic, false, order); + let got = blend_derivatives(&ctrl, true, order); + for j in 0..order { + assert!(got[j].dist(want[j]) < 1e-8 * want[j].len().max(1.0), "g{order} order {}: {:?} vs {:?}", j + 1, got[j], want[j]); + } + } + } + + #[test] + fn blend_curve_refuses_hostile_orders_and_degenerate_references() { + let mut s = new_sketch(); + let (a, b, _) = two_references(&mut s); + let ends = json!({"a": format!("{a}.end"), "b": format!("{b}.start")}); + for (order, want) in [("g0", true), ("g9", true), ("G9", true), ("curvature", true), ("", false), ("g2", false), ("G4", false)] { + let mut p = ends.clone(); + p["continuity"] = json!(order); + let r = s.execute("sketch.blend_curve", &p); + assert_eq!(r.is_err(), want, "continuity {order:?}: {r:?}"); + assert!(r.is_ok() || r.unwrap_err().to_string().contains("continuity"), "continuity {order:?} needs a clear error"); + } + let mut numbered = ends.clone(); + numbered["continuity"] = json!(3); + assert!(s.execute("sketch.blend_curve", &numbered).is_err(), "a number is not a continuity"); + // A point that is not an end of a curve, and the same end twice. + let p = ids(&run(&mut s, "sketch.point", json!({"point": [50, 50]}))["created"]["points"])[0].clone(); + let e = s.execute("sketch.blend_curve", &json!({"a": p, "b": format!("{b}.start")})).unwrap_err().to_string(); + assert!(e.contains("no curve ends at `a`"), "{e}"); + let e = s.execute("sketch.blend_curve", &json!({"a": format!("{a}.end"), "b": format!("{a}.end")})).unwrap_err().to_string(); + assert!(e.contains("ends must differ"), "{e}"); + // The helper behind the command refuses a corrupt (non-finite) reference and a reference + // with no tangent at all, never guessing at them. + let mut broken = solidcraft_sketch::Sketch::new(); + let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap(); + let p1 = broken.add_point(Vec2::ZERO, None).unwrap(); + let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false }, None).unwrap(); + broken.points[p0].pos = Vec2::new(f64::NAN, 4.0); + assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry"); + let mut flat = solidcraft_sketch::Sketch::new(); + let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap(); + let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false }, None).unwrap(); + let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string(); + assert!(e.contains("no tangent"), "{e}"); + assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4"); + } + + #[test] + fn blend_curve_with_g4_closes_a_profile_that_measures_exactly() { + // A quarter-turn blend from (2, 0) into a line at (20, 10), closed by the chord between its + // ends: the region's area comes from the degree-9 curve, not from its chord. + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [2, 0]]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 12]]}))["curves"])[0].clone(); + let r = run( + &mut s, + "sketch.blend_curve", + json!({ + "a": format!("{a}.end"), + "b": format!("{b}.start"), + "continuity": "g4", + }), + ); + let sp = ids(&r["curves"])[0].clone(); + let (ctrl, degree, piece) = blend_of(&s, &sp); + assert_eq!(degree, 9, "G4 is one degree-9 Bézier"); + assert_eq!(ctrl.len(), 10); + // The command still reports what it added, with stable ids: ten controls of which the two + // joints are the references' own points, so eight new points, and no new constraint. + assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}"); + assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}"); + assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}"); + run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"})); + // Drop the two references: what is left is the blend and its chord, one closed region. + run(&mut s, "sketch.delete", json!({"entities": [a, b]})); + let id = s.active_sketch.unwrap(); + let state = s.model.state(); + let profiles = &state.sketch(id).unwrap().profiles; + assert_eq!(profiles.len(), 1, "the blend and the chord close one region"); + let area = profiles[0].area; + // A fine sampling of the exact piece plus the straight chord reproduces the area; the chord + // alone (what a polyline approximation would give) is exactly zero. + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + sampled += 0.5 * piece.point_at(t0).cross(piece.point_at(t1)); + } + // The chord back from (20, 10) to (2, 0) contributes its own shoelace term; together they + // are the region. + sampled += 0.5 * Vec2::new(20.0, 10.0).cross(Vec2::new(2.0, 0.0)); + assert!((area - sampled.abs()).abs() < 1e-4, "{area} vs {sampled}"); + assert!(area > 1e-2, "the blend curves away from the chord: {area}"); + // And the degree-9 profile still extrudes to its area times the height (the kernel's + // volume of a free-form body is a numerical integration, so this is the same 2e-4 the + // neighbouring free-form extrusion test allows). + run(&mut s, "solid.extrude", json!({"distance": 5})); + let m = run(&mut s, "inspect.measure", json!({})); + let volume = m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((volume - area * 5.0).abs() / (area * 5.0) < 2e-4, "volume {volume} vs {}", area * 5.0); + assert!(m["bodies"][0]["faces"].as_u64().unwrap_or(0) >= 3, "a solid with the blend as a side: {m}"); + } +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 785c9ec8..83632df1 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -15,13 +15,13 @@ use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf) .at("SKETCH", "INSERT") - .icon("dxf") + .icon("sketch_dxf") .params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg) .at("SKETCH", "INSERT") .icon("svg") .params(concat!("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), - CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf) + CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf") .noundo() .params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"), ]; diff --git a/crates/engine/src/cmd/sketch_inspect.rs b/crates/engine/src/cmd/sketch_inspect.rs index b387218b..10e63152 100644 --- a/crates/engine/src/cmd/sketch_inspect.rs +++ b/crates/engine/src/cmd/sketch_inspect.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "INSPECT") .icon("curvature_comb") .noundo() - .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"), + .params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges] | faces?: [[x,y,z] points on faces] with count?: isoparametric curves each way (default 4; combs along the face's iso grid); density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"), CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius) .at("SKETCH", "INSPECT") .icon("min_radius") @@ -113,8 +113,25 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result { combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth_3d(&pts))); } } + if let Some(faces) = p.get("faces") { + let faces = faces.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + let count = match p.get("count") { + None | Some(Value::Null) => 4, + Some(_) => { + num(p, "count").filter(|c| c.is_finite()).map(|c| c.clamp(1.0, 50.0) as usize).ok_or_else(|| bad(cmd, "`count` must be a number"))? + } + }; + for v in faces.iter().take(100) { + let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?; + let (bi, f) = face_at(&st, at).ok_or_else(|| bad(cmd, "no face there"))?; + let m = st.bodies.get(bi).map(|b| b.mesh()).ok_or_else(|| bad(cmd, "that face's body is not evaluated"))?; + for (k, w) in solidcraft_doc::iso_grid(&m, f, at, count).iter().enumerate() { + combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_3d(w))); + } + } + } if combs.is_empty() { - return Err(bad(cmd, "give `curves` or `edges`")); + return Err(bad(cmd, "give `curves`, `edges` or `faces`")); } // One scale for all combs: the longest tooth is a fifth of the largest curve's size. let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max); @@ -348,6 +365,15 @@ mod tests { let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap(); assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}"); assert!(s.execute("inspect.curvature_comb", &json!({})).is_err()); + // Combs along the side face's isocurves: rings of radius 8 and straight lines along it. + let r = s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": 3})).unwrap(); + let combs = r["combs"].as_array().unwrap(); + assert!(combs.len() >= 6, "{}", combs.len()); + let rings = combs.iter().filter(|c| c["min_radius"].as_f64().is_some_and(|m| (m - 8.0).abs() < 0.5)).count(); + let straight = combs.iter().filter(|c| c["min_radius"].is_null() || c["min_radius"].as_f64().is_some_and(|m| m > 1e3)).count(); + assert!(rings >= 3 && straight >= 3, "{rings} rings, {straight} straight: {r}"); + assert!(s.execute("inspect.curvature_comb", &json!({"faces": []})).is_err()); + assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": "x"})).is_err()); } #[test] diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 43898ad9..cd9eeaf2 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -31,12 +31,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("point | a, b: a corner of two lines; distance", created_doc!())), CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle) .at("SKETCH", "MODIFY") - .icon("sketch_chamfer") + .icon("sketch_chamfer_angle") .enabled(in_sketch) .params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())), CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two) .at("SKETCH", "MODIFY") - .icon("sketch_chamfer") + .icon("sketch_chamfer_two_distances") .enabled(in_sketch) .params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())), CommandSpec::new("sketch.offset", "Offset", offset) @@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[ "distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions", created_doc!() )), - CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) + CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break") .enabled(in_sketch) .params("curve: derived offset curve id (retains geometry and makes its points editable)"), CommandSpec::new("sketch.trim", "Trim", trim) @@ -66,17 +66,17 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"), CommandSpec::new("sketch.break", "Break", break_cmd) .at("SKETCH", "MODIFY") - .icon("break") + .icon("sketch_break") .enabled(in_sketch) .params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())), CommandSpec::new("sketch.scale", "Sketch Scale", scale) .at("SKETCH", "MODIFY") - .icon("scale") + .icon("sketch_scale") .enabled(in_sketch) .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) .at("SKETCH", "MODIFY") - .icon("move") + .icon("sketch_move") .enabled(in_sketch) .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index f92adc81..7d4649c7 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("fit_section") .enabled(in_sketch) .params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())), - CommandSpec::new("sketch.break_link", "Break Link", break_link) + CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break") .enabled(in_sketch) .params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"), - CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project) + CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project") .noundo() .params("value?: bool (default toggles): project a face's edges when a sketch is created on it"), ]; diff --git a/crates/engine/src/cmd/sketch_support.rs b/crates/engine/src/cmd/sketch_support.rs index 1f3b1543..bb8afe8c 100644 --- a/crates/engine/src/cmd/sketch_support.rs +++ b/crates/engine/src/cmd/sketch_support.rs @@ -5,6 +5,11 @@ use solidcraft_sketch::{Shape, intersections}; use crate::{Result, params::bad}; +/// Degrees the exact extend intersection handles: a round support squares the piece's degree in +/// homogeneous form, and both the root finder and its `evaluate` buffer hold at most 15 +/// coefficients, so 2·7 + 1 fits. +const MAX_EXACT_EXTEND_DEGREE: u8 = 7; + fn choose(n: usize, k: usize) -> f64 { (0..k.min(n - k)).fold(1.0, |value, i| value * (n - i) as f64 / (i + 1) as f64) } @@ -113,7 +118,7 @@ pub(super) fn hits(support: &Shape, seg: &Seg2, budget: &mut usize) -> Result, Vec) = match *seg { Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=MAX_EXACT_EXTEND_DEGREE).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), _ => return Err(bad("sketch.extend", "invalid exact curve piece")), }; if points.iter().any(|p| !p.is_finite()) { @@ -195,8 +200,8 @@ mod tests { elevated.push(y[n - 1]); y = elevated; } - let mut points = [Vec2::ZERO; 8]; - for (i, p) in points.iter_mut().enumerate() { + let mut points = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; + for (i, p) in points.iter_mut().take(8).enumerate() { *p = Vec2::new(10.0 + i as f64 * 20.0 / 7.0, y[i]); } let target = Seg2::Bezier { n: 7, p: points }; diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 4e76e0d4..ea161f8e 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -18,23 +18,23 @@ use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.patch", "Patch", patch) .at("SURFACE", "CREATE") - .icon("patch") + .icon("surface_patch") .params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"), CommandSpec::new("surface.stitch", "Stitch", stitch) .at("SURFACE", "MODIFY") - .icon("stitch") + .icon("surface_stitch") .params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"), CommandSpec::new("surface.thicken", "Thicken", thicken) .at("SURFACE", "CREATE") - .icon("thicken") + .icon("surface_thicken") .params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"), CommandSpec::new("surface.extend", "Extend", extend) .at("SURFACE", "MODIFY") - .icon("extend") + .icon("surface_extend") .params("body: surface name; edges: [[x,y,z]] (points on boundary edges to push out); distance: expr (unless up_to); mode?: natural (the surface itself continued: exact for planes and surfaces of revolution, a B-spline's end spans continued; the default) | tangent (a ruled run-off face along each edge, G1) | curvature (a run-off face keeping the surface's curvature across the edge, G2); up_to?: plane (XY|XZ|YZ|plane name|{origin, normal}) | {body, point} (a face, its surface extended) | \"bounds\" (natural: back to the surface's own bounds, undoing a trim); neighbours?: bool (default true: the edges next to an extended edge are lengthened to meet it; false: the extension's sides run square to the edge)"), CommandSpec::new("surface.trim", "Trim", trim) .at("SURFACE", "MODIFY") - .icon("trim") + .icon("surface_trim") .params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"), CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude) .at("SURFACE", "CREATE") @@ -47,18 +47,18 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep) .at("SURFACE", "CREATE") .icon("surface_sweep") - .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale? (as solid.sweep); name?, body_name? (a surface body, no caps)"), + .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"), CommandSpec::new("surface.loft", "Surface Loft", surface_loft) .at("SURFACE", "CREATE") .icon("surface_loft") - .params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), + .params("sections: [{sketch, curves?: [curve ids], profiles?} | {body, edges: [[x,y,z]]}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed; a body edge chain takes the face the chain bounds as the surface the end repeats); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g0: how much of the surface at the first and last section the loft repeats — the tangent plane, the curvature, or how that curvature changes); crown?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), CommandSpec::new("surface.offset", "Offset Surface", offset) .at("SURFACE", "CREATE") .icon("surface_offset") .params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"), CommandSpec::new("surface.ruled", "Ruled Surface", ruled) .at("SURFACE", "CREATE") - .icon("ruled") + .icon("surface_ruled") .params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back). Lines and arcs give exact planes, cylinders and cones, other edges a fitted ruled B-spline. A new surface body"), CommandSpec::new("surface.blend", "Blend Surface", blend) .at("SURFACE", "CREATE") @@ -66,19 +66,19 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"), CommandSpec::new("surface.untrim", "Untrim", untrim) .at("SURFACE", "MODIFY") - .icon("untrim") + .icon("surface_untrim") .params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"), CommandSpec::new("surface.unstitch", "Unstitch", unstitch) .at("SURFACE", "MODIFY") - .icon("unstitch") + .icon("surface_unstitch") .params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"), CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal) .at("SURFACE", "MODIFY") - .icon("reverse_normal") + .icon("surface_reverse_normal") .params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"), CommandSpec::new("surface.fair", "Fair Surface", fair) .at("SURFACE", "MODIFY") - .icon("spline_cv") + .icon("surface_fair") .params("body: surface name; faces?: [[x,y,z]] points on the faces to fair (default: every face); strength?: number (0..1 of the full fairing step, default 1); iterations?: number (1..1000 steps, default 1); keep_edges?: bool (default true: hold the boundary row and the row next to it, so a face keeps meeting its neighbours in position and in tangent); tolerance?: expr mm (how far a control vertex may move, default 0.001 of the body's size). Moves the interior control vertices of each B-spline face to flatten its curvature variation while the boundary stays put: the face keeps its edges and the body its shape within the tolerance, and the warning in the reply carries the energy before and after; name?"), planned!("surface.fillet", "Surface Fillet", "769") .at("SURFACE", "MODIFY") @@ -354,10 +354,22 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`path` must list 1…1000 curve ids")); } let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?; + let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_sketch)?; add_feature( s, p, - FeatureKind::SurfaceSweep { sketch: SketchId(sketch), profiles, curves, path_sketch: SketchId(path_sketch), path, orientation, twist, scale }, + FeatureKind::SurfaceSweep { + sketch: SketchId(sketch), + profiles, + curves, + path_sketch: SketchId(path_sketch), + path, + orientation, + twist, + scale, + rails, + rail_fit, + }, ) } @@ -369,15 +381,57 @@ fn surface_loft(s: &mut Session, p: &Value) -> Result { } let mut sections = Vec::with_capacity(given.len()); for sec in given { + // A section is a chain of sketch curves, or an edge chain of a body whose face the end of + // the loft repeats. + if let Some(body) = str_(sec, "body") { + let edges: Vec = + sec.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default(); + if edges.is_empty() || edges.len() > 10_000 { + return Err(bad(cmd, "`sections.edges` must list 1 to 10000 points on the edges of a chain")); + } + if sec.get("sketch").is_some() { + return Err(bad(cmd, "a section is either a sketch chain or a body edge chain")); + } + sections.push(solidcraft_doc::SheetSection { + sketch: SketchId(0), + profiles: None, + curves: Vec::new(), + body: Some(BodyName::from(body.to_string())), + edges: edges.into_iter().map(Position).collect(), + }); + continue; + } let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?; let curves = string_list(sec, "curves"); if curves.len() > 1000 { return Err(bad(cmd, "at most 1000 curves a section")); } let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None }; - sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves }); + sections.push(solidcraft_doc::SheetSection { sketch: SketchId(sketch), profiles, curves, body: None, edges: Vec::new() }); } - add_feature(s, p, FeatureKind::SurfaceLoft { sections }) + let continuity = match p.get("continuity") { + None => [Continuity::G0, Continuity::G0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?; + let word = |x: Option<&Value>| -> Result { + x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3")) + }; + [word(list.first())?, word(list.get(1))?] + } + }; + let crown = match p.get("crown") { + None | Some(Value::Null) => [0.0, 0.0], + Some(v) => { + let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`crown` must be two numbers, one per end"))?; + let one = |x: Option<&Value>| -> Result { + x.and_then(Value::as_f64) + .filter(|x| x.is_finite() && (-0.9..=3.0).contains(x)) + .ok_or_else(|| bad(cmd, "each `crown` entry must be between -0.9 (flattest) and 3 (fullest)")) + }; + [one(list.first())?, one(list.get(1))?] + } + }; + add_feature(s, p, FeatureKind::SurfaceLoft { sections, continuity, crown }) } fn offset(s: &mut Session, p: &Value) -> Result { @@ -1212,7 +1266,7 @@ mod tests { /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { - let planned = ["surface.fillet", "inspect.continuity"]; + let planned = ["surface.fillet"]; let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); let before = s.doc.features.len(); @@ -1588,4 +1642,68 @@ mod tests { assert!(s.execute("surface.fair", &json!({"body": "Shell", "iterations": 0, "tolerance": 1.0})).is_err()); assert!(s.execute("surface.fair", &json!({"body": "Shell", "tolerance": 0})).is_err()); } + + /// A loft through three sections whose first and last are edge chains of two boxes, with + /// `continuity: ["g1","g1"]`: it leaves each box's top face tangentially (the run direction + /// lies in that face) and passes through the sketch between them. + #[test] + fn surface_loft_leaves_a_box_face_tangentially() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Low"})); + run(&mut s, "solid.box", json!({"corner": [40, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "High"})); + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 30, "name": "Mid"})); + run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "Bump"})); + run(&mut s, "sketch.line", json!({"points": [[0, 14], [20, 14]], "ids": ["b"]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "surface.loft", + json!({ + "sections": [ + {"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, + {"sketch": "Bump", "curves": ["b"]}, + {"body": "High", "edges": [[40, 5, 10], [40, 15, 10]]} + ], + "continuity": ["g1", "g1"], + "crown": [0, 0], + "body_name": "Blend" + }), + ); + let body = s.world_state().body("Blend").unwrap().body.clone(); + // A fine grid in the run direction: the seams sit at u = 0, 1/2 and 1, and the first step + // off each one is the tangent there (a thousandth of the run, so it is the tangent). + let grid = solidcraft_kernel::sample_grid(&body, 1001, 9).unwrap(); + // The ends lie on the two boxes' top faces, and the sketch is met exactly between them. + for p in &grid[0] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 20.0).abs() < 1e-9, "the first seam is the box edge: {p:?}"); + } + for p in &grid[1000] { + assert!((p.z - 10.0).abs() < 1e-9 && (p.x - 40.0).abs() < 1e-9, "the last seam is the box edge: {p:?}"); + } + for p in &grid[500] { + assert!((p.z - 14.0).abs() < 1e-9 && (p.x - 30.0).abs() < 1e-9, "the blend meets the sketch: {p:?}"); + } + // The seam leaves along the box's top face: the run direction the blend is given is what + // the face holds, so the tangent there is horizontal and along the run. (The step the grid + // takes is a thousandth of the run, and a chord over that step carries an O(h²) bias of + // about 0.011 here, so 1e-2 on the rise off the face is what the grid can measure; the + // kernel's own tests pin the tangent to the frame's leave direction exactly.) + for j in [0usize, 4, 8] { + let d = (grid[1][j] - grid[0][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend leaves the low box's top face: {d:?}"); + let d = (grid[1000][j] - grid[999][j]).normalized().unwrap(); + assert!(d.z.abs() < 2e-2 && d.x > 0.999, "the blend arrives along the high box's top face: {d:?}"); + } + // Hostile parameters are refused, never panicking. + let bad = [ + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10], [20, 15, 10]]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1"]}), + json!({"sections": [{"body": "Low", "edges": []}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g1", "g1"]}), + json!({"sections": [{"body": "Low", "edges": [[20, 5, 10]], "sketch": "Bump"}, {"sketch": "Bump", "curves": ["b"]}]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "crown": [5, 0]}), + json!({"sections": [{"sketch": "Bump", "curves": ["b"]}, {"sketch": "Bump", "curves": ["b"]}], "continuity": ["g9", "g1"]}), + ]; + for p in bad { + assert!(s.execute("surface.loft", &p).is_err(), "hostile loft params: {p}"); + } + } } diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index dbcea9f0..9753f8d9 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -5,8 +5,19 @@ use serde_json::{Value, json}; use solidcraft_geom::Vec3; use super::CommandSpec; -use crate::params::{bad, bool_, num, vec3}; -use crate::{Result, Session, SurfaceAnalysis}; +use crate::params::{bad, bool_, num, str_, vec3}; +use crate::{ContinuitySeam, Result, Session, SurfaceAnalysis, kernel}; + +/// Stations along each edge when the call does not say (`samples`). +const DEFAULT_SAMPLES: usize = 33; +/// Tolerances a seam is judged against when the call does not say: a micron of position gap, a +/// degree of normal angle, and a curvature jump of 1/100 mm. +const DEFAULT_GAP_MM: f64 = 1e-3; +const DEFAULT_ANGLE_DEG: f64 = 1.0; +const DEFAULT_CURVATURE_PER_MM: f64 = 0.01; +/// Most edges one call may pick, and most bodies one call may analyse at once. +const MAX_PICKS: usize = 512; +const MAX_BODIES: usize = 64; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra) @@ -34,11 +45,15 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("curvature_map") .noundo() .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), - planned!("inspect.continuity", "Continuity Analysis", "770") - .at("SURFACE", "INSPECT") - .icon("continuity") - .noundo() - .params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"), + CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params( + "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ + a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \ + gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \ + add?: true keeps the seams already shaded and adds these (measuring an edge again replaces it); \ + clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \ + {value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \ + the seams are shaded in the viewport", + ), ]; fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { @@ -94,12 +109,220 @@ fn curvature_map(s: &mut Session, p: &Value) -> Result { set(s, p, SurfaceAnalysis::Curvature { radius }) } +/// `inspect.continuity`: measure how far the two faces meeting at each picked edge (or at every +/// edge of a body) depart from sharing a curve (G0), a tangent plane (G1) and a curvature (G2), +/// and keep the seams so the viewport can shade them. View state only: the design and its undo +/// history are untouched (`noundo`), as with the other analyses. +fn continuity(s: &mut Session, p: &Value) -> Result { + let cmd = "inspect.continuity"; + if bool_(p, "clear").unwrap_or(false) { + s.continuity.clear(); + s.revision += 1; + return Ok(json!({ "analysis": "continuity", "cleared": true })); + } + let samples = match p.get("samples") { + None => DEFAULT_SAMPLES, + Some(v) => match v.as_f64().filter(|n| n.is_finite() && n.fract() == 0.0 && (3.0..=257.0).contains(n)) { + Some(n) => n as usize, + None => return Err(bad(cmd, "`samples` must be 3…257 stations along each edge")), + }, + }; + let tolerances = kernel::ContinuityTolerances { + gap_mm: tolerance(p, "gap_tolerance_mm", DEFAULT_GAP_MM)?, + angle_deg: tolerance(p, "angle_tolerance_deg", DEFAULT_ANGLE_DEG)?, + curvature_per_mm: tolerance(p, "curvature_tolerance_per_mm", DEFAULT_CURVATURE_PER_MM)?, + }; + let picks = edge_picks(p)?; + let st = s.world_state(); + let named = str_(p, "body"); + let candidates: Vec<&solidcraft_doc::ModelBody> = match named { + Some(name) => vec![st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?], + None => st.bodies.iter().filter(|b| s.body_visible(&b.name)).collect(), + }; + if candidates.is_empty() { + return Err(bad(cmd, "there are no visible bodies to analyse: name a `body`")); + } + if candidates.len() > MAX_BODIES { + return Err(bad(cmd, format!("more than {MAX_BODIES} bodies are shown: name the `body` to analyse"))); + } + let mut targets: Vec<(&solidcraft_doc::ModelBody, Vec)> = Vec::new(); + if picks.is_empty() { + targets.extend(candidates.into_iter().map(|b| (b, Vec::new()))); + } else { + // Picks mean edges of one body (a design may stack bodies at the same place): a list + // that lands on one body's edges only is analysed, and anything else is refused. + let mut hits = Vec::new(); + let mut refused = None; + for b in candidates { + match kernel::pick_edges(&b.body, &picks) { + Ok(edges) => hits.push((b, edges)), + Err(e) => refused = refused.or(Some(e)), + } + } + if hits.is_empty() { + return Err(match (named, refused) { + (Some(_), Some(e)) => e.into(), + _ => bad(cmd, "no visible body has an edge matching `edges`"), + }); + } + if hits.len() > 1 { + return Err(bad(cmd, "`edges` matches edges of more than one body: name the `body` to analyse")); + } + targets.extend(hits); + } + let mut seams: Vec = Vec::new(); + let mut bodies = Vec::new(); + let mut summary = Summary::default(); + for (body, edges) in &targets { + let report = kernel::seam_diagnostics(&body.body, edges, samples, tolerances)?; + let mut rows = Vec::new(); + for seam in &report.seams { + summary.count(seam.verdict); + seams.push(ContinuitySeam { body: body.name.clone(), edge: seam.edge, points: seam.points.clone(), verdict: seam.verdict }); + rows.push(json!({ + "edge": seam.edge, + "length_mm": seam.length, + "samples": seam.samples, + "gap_mm": seam.gap_mm, + "normal_angle_deg": seam.normal_angle_deg, + "curvature_jump_per_mm": seam.curvature_jump_per_mm, + "verdict": seam.verdict, + })); + } + summary.skipped += report.skipped; + summary.unmeasured += report.unmeasured; + bodies.push(json!({ + "body": body.name, + "edges": rows, + "skipped": report.skipped, + "unmeasured": report.unmeasured, + })); + } + if bool_(p, "add").unwrap_or(false) { + // The seams shown stay; an edge measured again takes its new reading. + s.continuity.retain(|kept| !seams.iter().any(|new| new.body == kept.body && new.edge == kept.edge)); + s.continuity.extend(seams); + } else { + s.continuity = seams; + } + s.revision += 1; + Ok(json!({ + "analysis": "continuity", + "samples": samples, + "tolerances": { + "gap_mm": tolerances.gap_mm, + "angle_deg": tolerances.angle_deg, + "curvature_per_mm": tolerances.curvature_per_mm, + }, + "bodies": bodies, + "summary": { + "edges": summary.edges, + "smooth": summary.smooth, + "curvature_jump": summary.curvature_jump, + "sharp": summary.sharp, + "gap": summary.gap, + "skipped": summary.skipped, + "unmeasured": summary.unmeasured, + "worst": summary.worst(), + }, + })) +} + +/// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a +/// string, a bare `null`) is refused: `num` would fold it into "not given". +fn tolerance(p: &Value, key: &str, default: f64) -> Result { + match p.get(key) { + None => Ok(default), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (0.0..1e9).contains(&x) => Ok(x), + _ => Err(bad("inspect.continuity", format!("`{key}` must be a finite number, 0 or more"))), + }, + } +} + +/// The `edges` parameter: points on the edges to measure, or `{"index": i}` entries in the +/// display mesh's edge order. Absent (or an empty list) means every edge of the body. +fn edge_picks(p: &Value) -> Result> { + let Some(value) = p.get("edges") else { return Ok(Vec::new()) }; + let Some(list) = value.as_array() else { + return Err(bad("inspect.continuity", "`edges` must be a list of [x,y,z] points or {\"index\": i} entries")); + }; + if list.len() > MAX_PICKS { + return Err(bad("inspect.continuity", format!("pick at most {MAX_PICKS} edges"))); + } + let mut out = Vec::new(); + for item in list { + if let Some(index) = item.get("index").and_then(Value::as_u64) { + let index = usize::try_from(index).map_err(|_| bad("inspect.continuity", "`index` is too large"))?; + out.push(kernel::EdgePick::Index(index)); + } else if let Some(point) = vec3(item) { + out.push(kernel::EdgePick::Point(point)); + } else { + return Err(bad("inspect.continuity", "each `edges` entry is a point [x,y,z] or {\"index\": i}")); + } + } + Ok(out) +} + +/// Counts of a continuity reply, and the worst verdict over every edge measured. +#[derive(Clone, Copy, Debug, Default)] +struct Summary { + edges: usize, + smooth: usize, + curvature_jump: usize, + sharp: usize, + gap: usize, + skipped: usize, + unmeasured: usize, +} + +impl Summary { + fn count(&mut self, verdict: kernel::SeamVerdict) { + self.edges += 1; + match verdict { + kernel::SeamVerdict::Smooth => self.smooth += 1, + kernel::SeamVerdict::CurvatureJump => self.curvature_jump += 1, + kernel::SeamVerdict::Sharp => self.sharp += 1, + kernel::SeamVerdict::Gap => self.gap += 1, + } + } + + /// The worst verdict over the edges measured (`None` when there was nothing to measure). + /// [`kernel::SeamVerdict`] runs from the best continuity to the worst. + fn worst(&self) -> Option { + [ + (self.smooth, kernel::SeamVerdict::Smooth), + (self.curvature_jump, kernel::SeamVerdict::CurvatureJump), + (self.sharp, kernel::SeamVerdict::Sharp), + (self.gap, kernel::SeamVerdict::Gap), + ] + .into_iter() + .filter(|(count, _)| *count > 0) + .map(|(_, verdict)| verdict) + .max() + } +} + #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::{Session, SurfaceAnalysis}; + fn boxed(s: &mut Session, size: f64) { + s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap(); + } + + /// The edges of the first body of a continuity reply. + fn edges(v: &Value) -> &Vec { + v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}")) + } + + /// The seams that read as tangent (within 0.01° of the neighbouring face). + fn tangent(edges: &[Value]) -> Vec<&Value> { + edges.iter().filter(|e| e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN) < 0.01).collect() + } + #[test] fn surface_analyses_are_view_state() { let mut s = Session::default(); @@ -121,4 +344,147 @@ mod tests { assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err()); assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err()); } + + /// A box edge: the faces meet exactly (G0) and square to each other (G1), so the analysis + /// must read 0 gap and 90°, and must keep its seams as view state without an undo step. + #[test] + fn continuity_reports_a_box_edge_as_a_gap_free_right_angle() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + let undo = s.undo.len(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 12); + for e in edges(&v) { + let gap = e["gap_mm"]["value"].as_f64().unwrap(); + let angle = e["normal_angle_deg"]["value"].as_f64().unwrap(); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!(gap < 1e-6, "gap {gap} in {e}"); + assert!((angle - 90.0).abs() < 1e-9, "angle {angle} in {e}"); + assert!(jump < 1e-9, "curvature jump {jump} in {e}"); + assert_eq!(e["verdict"], json!("sharp"), "{e}"); + assert_eq!(e["samples"], json!(33), "{e}"); + assert!((e["length_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{e}"); + assert_eq!(e["normal_angle_deg"]["at"].as_array().map(Vec::len), Some(3), "{e}"); + let f = e["normal_angle_deg"]["edge_fraction"].as_f64().unwrap(); + assert!((0.0..=1.0).contains(&f), "{e}"); + } + assert_eq!(v["analysis"], json!("continuity")); + assert_eq!(v["samples"], json!(33)); + assert_eq!(v["tolerances"], json!({"gap_mm": 1e-3, "angle_deg": 1.0, "curvature_per_mm": 0.01})); + assert_eq!(v["summary"]["edges"], json!(12)); + assert_eq!(v["summary"]["sharp"], json!(12)); + assert_eq!(v["summary"]["worst"], json!("sharp")); + assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport"); + assert_eq!(s.undo.len(), undo, "no undo step"); + assert_eq!(s.analysis, None, "the other analyses are left alone"); + } + + /// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its + /// two seams read ~0° with the fillet's 1 / radius curvature jump against the planes. + #[test] + fn continuity_finds_the_fillet_seams_tangent_and_their_curvature_jump() { + let mut s = Session::default(); + boxed(&mut s, 20.0); + s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap(); + let v = s.execute("inspect.continuity", &json!({})).unwrap(); + assert_eq!(edges(&v).len(), 15, "the rounded edge leaves three new edges behind: {v}"); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 2, "the fillet meets its two neighbours tangentially: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.25).abs() < 1e-6, "1 / radius 4: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(2)); + assert_eq!(v["summary"]["sharp"], json!(13)); + assert_eq!(v["summary"]["smooth"], json!(0)); + } + + /// A slot's straight wall runs tangentially into its arc wall: the join is G1 but its + /// curvature jumps by the arc's 1 / radius, which is exactly what a Class-A check looks for. + #[test] + fn continuity_reports_one_over_the_radius_where_a_line_meets_an_arc() { + let mut s = Session::default(); + s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); + s.execute("sketch.slot.center_point", &json!({"center": [0, 0], "end": [20, 0], "width": 10})).unwrap(); + s.execute("solid.extrude", &json!({"distance": 5})).unwrap(); + let v = s.execute("inspect.continuity", &json!({"angle_tolerance_deg": 0.01})).unwrap(); + let seams = tangent(edges(&v)); + assert_eq!(seams.len(), 4, "a slot has four line-to-arc joins: {v}"); + for e in seams { + assert!(e["gap_mm"]["value"].as_f64().unwrap() < 1e-6, "{e}"); + let jump = e["curvature_jump_per_mm"]["value"].as_f64().unwrap(); + assert!((jump - 0.2).abs() < 1e-6, "1 / radius 5: {e}"); + assert_eq!(e["verdict"], json!("curvature_jump"), "{e}"); + } + assert_eq!(v["summary"]["curvature_jump"], json!(4)); + } + + /// `clear` takes the shading away, and every continuity call stays view state. + #[test] + fn continuity_is_view_state_and_clears() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + let undo = s.undo.len(); + s.execute("inspect.continuity", &json!({"samples": 5})).unwrap(); + assert_eq!(s.continuity.len(), 12); + assert_eq!(s.undo.len(), undo, "no undo step"); + let cleared = s.execute("inspect.continuity", &json!({"clear": true})).unwrap(); + assert_eq!(cleared["cleared"], json!(true)); + assert!(s.continuity.is_empty(), "clear leaves nothing shaded"); + assert_eq!(s.undo.len(), undo); + } + + /// `add` keeps what is shaded and adds the picked edges: an edge measured again replaces its + /// seam instead of drawing it twice, and a call without `add` starts over. + #[test] + fn continuity_add_keeps_the_seams_shown() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap(); + assert_eq!(s.continuity.len(), 1); + let v = s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap(); + assert_eq!(edges(&v).len(), 1, "the reply covers the edges this call measured: {v}"); + assert_eq!(s.continuity.len(), 2, "the first seam stays"); + s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]], "add": true, "samples": 5})).unwrap(); + assert_eq!(s.continuity.len(), 2, "measured again, not added twice"); + assert!(s.continuity.iter().any(|seam| seam.points.len() == 5), "with its new reading"); + assert!(s.continuity.iter().all(|seam| seam.body == "Body1"), "{:?}", s.continuity); + s.execute("inspect.continuity", &json!({"edges": [[10, 5, 0]]})).unwrap(); + assert_eq!(s.continuity.len(), 1, "without `add` the shading starts over"); + s.execute("inspect.continuity", &json!({"add": true})).unwrap(); + assert_eq!(s.continuity.len(), 12, "every edge once"); + } + + /// Hostile parameters are refused with an error, never a panic. + #[test] + fn hostile_continuity_params_are_refused() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + for params in [ + json!({"samples": 0}), + json!({"samples": 2}), + json!({"samples": 258}), + json!({"samples": 3.5}), + json!({"samples": "33"}), + json!({"samples": null}), + json!({"gap_tolerance_mm": -1}), + json!({"gap_tolerance_mm": 1e12}), + json!({"angle_tolerance_deg": f64::NAN}), + json!({"curvature_tolerance_per_mm": "tight"}), + json!({"edges": 3}), + json!({"edges": [[1, 2]]}), + json!({"edges": [[]]}), + json!({"edges": [{"index": 99999}]}), + json!({"edges": [[500, 500, 500]]}), + json!({"body": "Nope"}), + ] { + assert!(s.execute("inspect.continuity", ¶ms).is_err(), "accepted {params}"); + } + let many: Vec = (0..513).map(|i| json!([i as f64, 0.0, 0.0])).collect(); + assert!(s.execute("inspect.continuity", &json!({"edges": many})).is_err(), "513 picks"); + assert!(s.continuity.is_empty(), "a refused call shades nothing"); + assert_eq!(s.undo.len(), 1, "only the box is an undo step"); + } } diff --git a/crates/engine/src/cmd/surface_boundary.rs b/crates/engine/src/cmd/surface_boundary.rs index 6bbc3f1a..79532302 100644 --- a/crates/engine/src/cmd/surface_boundary.rs +++ b/crates/engine/src/cmd/surface_boundary.rs @@ -21,7 +21,7 @@ const MAX_SAMPLES: usize = 512; pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary) .at("SURFACE", "CREATE") - .icon("boundary") + .icon("surface_boundary") .params( "sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)", )]; diff --git a/crates/engine/src/cmd/sweep_rails_tests.rs b/crates/engine/src/cmd/sweep_rails_tests.rs new file mode 100644 index 00000000..badeffc6 --- /dev/null +++ b/crates/engine/src/cmd/sweep_rails_tests.rs @@ -0,0 +1,143 @@ +//! Guide rails through the commands (issue #771): solid.sweep and surface.sweep with one rail +//! or two, scaling or stretching, checked against analytic values; editing, naming, saving and +//! hostile values. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::f64::consts::PI; + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A circle `C` (radius 10) on XY; a path `P` on XZ, a line 40 up the Z axis (`up`), with a rail +/// beside it (`in`) from the circle's edge at x = 10 in to x = 5 at the top; a line `L` across +/// XY from x = −10 to 10 (`l`); and in `R` on XZ two arcs from x = ±10 at the bottom to x = ±10 +/// at the top, bulging out to x = ±20 halfway (radius 25 about (∓5, 0, 20)). +fn setup() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"})); + // The XZ sketch's y runs down world Z. + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]})); + run(&mut s, "sketch.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "L"})); + run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "R"})); + run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [10, -40], "through": [20, -20], "id": "right"})); + run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [-10, -40], "through": [-20, -20], "id": "left"})); + run(&mut s, "sketch.finish", json!({})); + s +} + +fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure { + solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap() +} + +fn sweep(s: &mut Session, body: &str, extra: Value) -> Result { + let mut p = json!({"sketch": "C", "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("solid.sweep", &p).map_err(|e| e.to_string()) +} + +fn face_names(s: &mut Session, body: &str) -> Vec { + let f = run(s, "model.faces", json!({ "body": body })); + let mut n: Vec = f["faces"] + .as_array() + .into_iter() + .flatten() + .filter_map(|x| x["name"].as_str()) + .map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string()) + .collect(); + n.sort(); + n +} + +/// A rail closing in from radius 10 to 5 sweeps the circle into a frustum (and its curve into +/// the frustum's side); the faces keep the plain sweep's names; switching the fit to stretch is +/// an edit (the circle then widens only toward the rail: an elliptic cone, sections πr²k); the +/// rails are saved with the feature. +#[test] +fn a_rail_sweeps_a_frustum_and_stretching_is_an_edit() { + let mut s = setup(); + let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0); + let lateral = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt(); + sweep(&mut s, "Plain", json!({})).unwrap(); + sweep(&mut s, "Frustum", json!({"rails": [{"curves": ["in"]}], "name": "Railed"})).unwrap(); + let m = measure(&s, "Frustum"); + assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume); + assert_eq!(face_names(&mut s, "Plain"), face_names(&mut s, "Frustum")); + run( + &mut s, + "surface.sweep", + json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "P", "curves": ["in"]}], "body_name": "Side"}), + ); + let a = measure(&s, "Side").area; + assert!((a - lateral).abs() < 2e-3 * lateral, "side {a} vs {lateral}"); + + let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let reopened = Session::new(doc); + assert!((measure(&reopened, "Frustum").volume - m.volume).abs() < 1e-9 * volume); + + // Stretched: k = 1 − z/80 across (x) only, so sections are π · 100 · k. + run(&mut s, "timeline.edit", json!({"feature": "Railed", "set": {"rail_fit": "stretch"}})); + let stretched = measure(&s, "Frustum"); + let elliptic = PI * 100.0 * 40.0 * 0.75; + assert!((stretched.volume - elliptic).abs() < 2e-3 * elliptic, "stretched {} vs {elliptic}", stretched.volume); + assert!((stretched.bbox.max.y - 10.0).abs() < 1e-3 && (stretched.bbox.min.y + 10.0).abs() < 1e-3, "{:?}", stretched.bbox); +} + +/// Two arcs bulging apart carry a line between them: the sheet fills the region between the +/// arcs in the XZ plane, area 2(a√(R² − a²) + R² asin(a/R)) − 400 with R = 25, a = 20. +#[test] +fn two_rails_carry_a_line_between_arcs() { + let mut s = setup(); + run( + &mut s, + "surface.sweep", + json!({"sketch": "L", "curves": ["l"], "path_sketch": "P", "path": ["up"], "rails": [{"sketch": "R", "curves": ["left"]}, {"sketch": "R", "curves": ["right"]}], "body_name": "Between"}), + ); + let m = measure(&s, "Between"); + let area = 2.0 * (20.0 * 15.0 + 625.0 * 0.8f64.asin()) - 400.0; + assert!((m.area - area).abs() < 1e-3 * area, "area {} vs {area}", m.area); + assert!((m.bbox.max.x - 20.0).abs() < 1e-2 && (m.bbox.min.x + 20.0).abs() < 1e-2, "{:?}", m.bbox); +} + +/// Hostile rails are refused with an error and leave the design as it was. +#[test] +fn hostile_rails_are_refused() { + let mut s = setup(); + let before = s.doc.features.len(); + let rail = json!([{"curves": ["in"]}]); + for bad in [ + json!({"rails": "in"}), + json!({"rails": []}), + json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}, {"curves": ["in"]}]}), + json!({"rails": [{"curves": []}]}), + json!({"rails": [{}]}), + json!({"rails": [{"curves": ["nope"]}]}), + json!({"rails": [{"sketch": "Nowhere", "curves": ["in"]}]}), + json!({"rails": rail, "rail_fit": "wobble"}), + json!({"rails": rail, "twist": "10 deg"}), + json!({"rails": rail, "scale": 2}), + json!({"rails": rail, "orientation": "parallel"}), + // A rail along the path itself: nothing to scale against. + json!({"rails": [{"curves": ["up"]}]}), + // Two rails starting at one point. + json!({"rails": [{"curves": ["in"]}, {"curves": ["in"]}]}), + ] { + assert!(sweep(&mut s, "Bad", bad.clone()).is_err(), "{bad}"); + let mut p = json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"]}); + p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}"); + assert_eq!(s.doc.features.len(), before, "{bad}"); + } +} diff --git a/crates/engine/src/cmd/view_dxf.rs b/crates/engine/src/cmd/view_dxf.rs index 3a18f0db..c6248841 100644 --- a/crates/engine/src/cmd/view_dxf.rs +++ b/crates/engine/src/cmd/view_dxf.rs @@ -1,6 +1,10 @@ //! A drawing of the visible bodies from a supplied viewport camera. No screen pixels, grid, //! selection highlights or tessellation diagonals are exported. +#[path = "view_dxf_meshes.rs"] +mod meshes; +#[path = "view_dxf_progress.rs"] +mod progress; #[path = "view_dxf_projection.rs"] mod projection; @@ -46,28 +50,17 @@ fn export(s: &mut Session, p: &Value) -> Result { if let Some(path) = path { solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?; } - let world = s.world_state(); - let mut meshes = Vec::new(); - let mut count = 0usize; - for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) { - let mesh = body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?; - count = count.saturating_add(mesh.triangles.len()).saturating_add(mesh.edges.iter().map(Vec::len).sum::()); - if count > 1_000_000 { - return Err(bad(CMD, "view too complex to export; hide bodies or increase tolerance")); - } - if mesh.triangles.is_empty() { - return Err(bad(CMD, format!("{} has no exportable surface", body.name))); - } - meshes.push(mesh); - } - if meshes.is_empty() { - return Err(bad(CMD, "no visible bodies to export")); - } - let lines = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?; + let started = std::time::Instant::now(); + progress::Stage::new("dxf_meshes", 1).check().map_err(|e| bad(CMD, e))?; + let meshes = meshes::prepare(s, tolerance)?; + let prepare_ms = started.elapsed().as_secs_f64() * 1000.; + let drawing = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?; + let writing = progress::Stage::new("dxf_write", 1); + writing.check().map_err(|e| bad(CMD, e))?; let mut visible = 0; let mut hidden = 0; let mut entities = Vec::new(); - for line in lines { + for line in drawing.lines { if line.hidden && !dashed { continue; } @@ -86,9 +79,14 @@ fn export(s: &mut Session, p: &Value) -> Result { layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) }); } let text = solidcraft_io::write_dxf_drawing(&layers, &entities); + writing.check().map_err(|e| bad(CMD, e))?; let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}}); + result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles, + "mesh_bytes":meshes.bytes,"worker_limit":meshes::WORKERS,"segments":drawing.segments,"occlusion_work":drawing.work, + "prepare_ms":prepare_ms,"projection_ms":drawing.projection_ms,"occlusion_ms":drawing.occlusion_ms}); if let Some(path) = path { - solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?; + solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| crate::EngineError::Other(e.to_string()))?; result["path"] = json!(path); } else { result["dxf"] = json!(text); diff --git a/crates/engine/src/cmd/view_dxf_meshes.rs b/crates/engine/src/cmd/view_dxf_meshes.rs new file mode 100644 index 00000000..ec3f0e4d --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_meshes.rs @@ -0,0 +1,207 @@ +//! Export-only mesh instances. Keep one geometry buffer per unambiguous definition, while +//! retaining the current placed body's edge choices (tangent classification can vary after +//! placement). Never modify the document, fuse parts, or share across separate exports. +use std::collections::BTreeMap; +use std::mem::size_of; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use solidcraft_doc::{IDENTITY, Mat, ModelBody, mat_inverse, mat_mul}; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use solidcraft_kernel::{KernelError, map_with_local_pools}; + +use super::{CMD, progress::Stage}; +use crate::params::bad; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +pub(super) const WORKERS: usize = 8; +#[cfg(target_arch = "wasm32")] +pub(super) const WORKERS: usize = 1; +const MESH_BYTES: usize = 512 * 1024 * 1024; +const MAX_BODIES: usize = 100_000; + +pub(super) struct Placement { + pub mesh: usize, + pub transform: Mat, + pub visibility: Vec, +} +pub(super) struct Scene { + pub meshes: Vec>, + pub placements: Vec, + pub triangles: usize, + pub bytes: usize, +} + +struct Group { + first: usize, + inverse: Mat, +} +struct Item<'a> { + body: &'a ModelBody, + group: usize, + keep: bool, + transform: Mat, +} +struct Prepared { + mesh: Option>, + visibility: Vec, + triangles: usize, +} + +pub(super) fn claim(counter: &AtomicUsize, amount: usize, limit: usize) -> bool { + let mut old = counter.load(Ordering::Relaxed); + loop { + let Some(next) = old.checked_add(amount).filter(|n| *n <= limit) else { return false }; + match counter.compare_exchange_weak(old, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return true, + Err(current) => old = current, + } + } +} + +pub(super) fn buffer_bytes(m: &Mesh) -> usize { + m.positions.capacity() * size_of::() + + m.normals.capacity() * size_of::() + + m.triangles.capacity() * size_of::<[u32; 3]>() + + m.tri_face.capacity() * size_of::() + + m.edges.capacity() * size_of::>() + + m.edges.iter().map(|e| e.capacity() * size_of::()).sum::() + + m.seams.capacity() + + m.smooth_edges.capacity() + + m.sharp_edges.capacity() + + m.edge_faces.capacity() * size_of::>() + + m.edge_faces.iter().map(|e| e.capacity() * size_of::()).sum::() +} + +fn rigid(m: &Mat) -> bool { + if m.iter().flatten().any(|v| !v.is_finite()) || m.iter().zip([0., 0., 0., 1.]).any(|(c, w)| (c[3] - w).abs() > 1e-10) { + return false; + } + let axes = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])]; + let [x, y, z] = axes; + (x.len2() - 1.).abs() < 1e-9 + && (y.len2() - 1.).abs() < 1e-9 + && (z.len2() - 1.).abs() < 1e-9 + && x.dot(y).abs() < 1e-9 + && x.dot(z).abs() < 1e-9 + && y.dot(z).abs() < 1e-9 + && (x.cross(y).dot(z) - 1.).abs() < 1e-9 +} + +pub(super) fn prepare(s: &Session, tolerance: f64) -> Result { + let world = s.world_state(); + let state = s.model.state(); + let mut definitions = BTreeMap::new(); + for body in &state.bodies { + *definitions.entry((body.component, body.definition_name(), body.feature)).or_insert(0usize) += 1; + } + let mut by_definition = BTreeMap::new(); + let mut groups: Vec = Vec::new(); + let mut items = Vec::new(); + for (index, body) in world.bodies.iter().enumerate().filter(|(_, b)| s.body_visible(&b.name)) { + if items.len() >= MAX_BODIES { + return Err(bad(CMD, "too many placed bodies to export")); + } + let key = (body.component, body.definition_name(), body.feature); + let unambiguous = definitions.get(&key) == Some(&1); + let reused = unambiguous + .then(|| by_definition.get(&key).copied()) + .flatten() + .and_then(|g: usize| groups.get(g).map(|group| (g, mat_mul(&body.source_transform, &group.inverse)))) + .filter(|(_, m)| rigid(m)); + let (group, transform) = if let Some(reused) = reused { + reused + } else { + let g = groups.len(); + groups.push(Group { first: index, inverse: mat_inverse(&body.source_transform).unwrap_or(IDENTITY) }); + if unambiguous && mat_inverse(&body.source_transform).is_some() { + by_definition.entry(key).or_insert(g); + } + (g, IDENTITY) + }; + let keep = groups.get(group).is_some_and(|g| g.first == index); + items.push(Item { body, group, keep, transform }); + } + if items.is_empty() { + return Err(bad(CMD, "no visible bodies to export")); + } + let progress = Stage::new("dxf_meshes", items.len()); + let bytes = AtomicUsize::new(0); + let results = map_with_local_pools(&items, WORKERS, |item| { + progress.check().map_err(|e| KernelError::Failed(e.into()))?; + let mut mesh = item.body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?; + if mesh.triangles.is_empty() { + return Err(KernelError::Invalid(format!("{} has no exportable surface", item.body.name))); + } + let visibility = (0..mesh.edges.len()).map(|i| mesh.shaded_edge_visible(i)).collect(); + let triangles = mesh.triangles.len(); + let mesh = if item.keep { + // These are owned export buffers, not the document's cached mesh. Picking and + // shading data are unused by vector occlusion; edge choices were captured above. + mesh.normals = Vec::new(); + mesh.tri_face = Vec::new(); + mesh.seams = Vec::new(); + mesh.smooth_edges = Vec::new(); + mesh.sharp_edges = Vec::new(); + mesh.edge_faces = Vec::new(); + if !claim(&bytes, buffer_bytes(&mesh), MESH_BYTES) { + return Err(KernelError::Invalid("view exceeds export memory budget; hide bodies or increase tolerance".into())); + } + Some(Arc::new(mesh)) + } else { + None + }; + progress.advance(1).map_err(|e| KernelError::Failed(e.into()))?; + Ok(Prepared { mesh, visibility, triangles }) + }); + let mut prepared = results.into_iter().collect::>>()?; + let mut meshes: Vec>> = vec![None; groups.len()]; + for (item, p) in items.iter().zip(&mut prepared) { + if let Some(mesh) = p.mesh.take() + && let Some(slot) = meshes.get_mut(item.group) + { + *slot = Some(mesh); + } + } + let meshes = meshes.into_iter().map(|m| m.ok_or_else(|| bad(CMD, "missing export geometry"))).collect::>>()?; + let mut placements = Vec::with_capacity(items.len()); + let mut triangles = 0usize; + for (item, p) in items.iter().zip(prepared) { + if meshes.get(item.group).is_none_or(|m| m.edges.len() != p.visibility.len()) { + return Err(bad(CMD, "placed edge identity changed")); + } + triangles = triangles.saturating_add(p.triangles); + placements.push(Placement { mesh: item.group, transform: item.transform, visibility: p.visibility }); + } + Ok(Scene { meshes, placements, triangles, bytes: bytes.load(Ordering::Relaxed) }) +} + +pub(super) fn transformed_bounds(mesh: &Mesh, m: &Mat) -> Aabb3 { + let b = mesh.bounds(); + let mut out = Aabb3::EMPTY; + for x in [b.min.x, b.max.x] { + for y in [b.min.y, b.max.y] { + for z in [b.min.z, b.max.z] { + out.add(solidcraft_doc::apply_point(m, Vec3::new(x, y, z))); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_proper_rigid_placements_share_geometry() { + assert!(rigid(&solidcraft_doc::rigid(Vec3::new(1., 2., 3.), Vec3::ZERO, Vec3::Z, 0.7))); + let mut m = IDENTITY; + m[0][0] = -1.; + assert!(!rigid(&m)); + m[0][0] = 2.; + assert!(!rigid(&m)); + m[0][0] = f64::NAN; + assert!(!rigid(&m)); + } +} diff --git a/crates/engine/src/cmd/view_dxf_progress.rs b/crates/engine/src/cmd/view_dxf_progress.rs new file mode 100644 index 00000000..4c4adca7 --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_progress.rs @@ -0,0 +1,35 @@ +//! Stage counters shared by the export worker and its bounded computation workers. +use std::sync::{Arc, Mutex}; + +pub(super) const CANCELLED: &str = "DXF export cancelled"; + +pub(super) struct Stage { + watch: Option>, + name: &'static str, + total: usize, + done: Mutex, +} + +impl Stage { + pub fn new(name: &'static str, total: usize) -> Self { + let stage = Self { watch: solidcraft_kernel::watch::current(), name, total: total.max(1), done: Mutex::new(0) }; + if let Some(watch) = &stage.watch { + watch.progress(name, 0, stage.total); + } + stage + } + pub fn check(&self) -> Result<(), &'static str> { + if self.watch.as_ref().is_some_and(|w| w.cancelled()) { Err(CANCELLED) } else { Ok(()) } + } + pub fn advance(&self, count: usize) -> Result<(), &'static str> { + self.check()?; + if let Some(watch) = &self.watch { + // Serialize reports as well as increments: eight workers must not report a + // smaller completed count after a larger one. + let mut done = self.done.lock().map_err(|_| "export progress unavailable")?; + *done = done.saturating_add(count).min(self.total); + watch.progress(self.name, *done, self.total); + } + self.check() + } +} diff --git a/crates/engine/src/cmd/view_dxf_projection.rs b/crates/engine/src/cmd/view_dxf_projection.rs index c496b2ac..bfb08025 100644 --- a/crates/engine/src/cmd/view_dxf_projection.rs +++ b/crates/engine/src/cmd/view_dxf_projection.rs @@ -1,10 +1,27 @@ //! Vector hidden-line removal: intersect each projected edge with triangle half planes and //! its depth plane, then union occluded parameter intervals. A BVH bounds the candidate work. -use solidcraft_geom::{Aabb3, Mesh, Vec2, Vec3}; +#[cfg(test)] +use solidcraft_geom::Mesh; +use solidcraft_geom::{Aabb3, Vec2, Vec3}; use solidcraft_io::{DxfEntity, DxfShape}; use solidcraft_render::Camera; use std::collections::BTreeMap; +#[path = "view_dxf_scene.rs"] +mod scene; + +pub(super) struct Drawing { + pub lines: Vec, + pub work: usize, + pub segments: usize, + pub projection_ms: f64, + pub occlusion_ms: f64, +} + +pub(super) fn drawing(meshes: &super::meshes::Scene, cam: &Camera) -> Result { + scene::drawing(meshes, cam) +} + /// Keep the hidden curves connected: restarting a dash on every short chord would make a /// small circular hole look solid. Degree-two joins continue; junctions end the polyline. pub(super) fn connect(entities: Vec) -> Vec { @@ -50,6 +67,7 @@ pub(super) fn connect(entities: Vec) -> Vec { result } +#[derive(Clone, Debug)] pub(super) struct Line { pub a: Vec2, pub b: Vec2, @@ -73,15 +91,18 @@ fn overlap(a: &Aabb3, b: &Aabb3) -> bool { a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y } +#[cfg(test)] struct Triangle { p: [Vec3; 3], bounds: Aabb3, } +#[cfg(test)] struct Tree { bounds: Aabb3, children: Option<(Box, Box)>, triangles: Vec, } +#[cfg(test)] impl Tree { fn new(mut triangles: Vec) -> Self { let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds)); @@ -213,6 +234,7 @@ fn merge_lines(lines: Vec, eps: f64) -> Vec { /// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because /// different faces can have different vertex indices along their shared boundary. +#[cfg(test)] fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> { let mut result = Vec::new(); for (_, e) in mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)) { @@ -241,7 +263,8 @@ fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> { result } -pub(super) fn drawing(meshes: &[Mesh], cam: &Camera) -> Result, &'static str> { +#[cfg(test)] +fn reference_drawing(meshes: &[Mesh], cam: &Camera) -> Result, &'static str> { let (right, up, back) = cam.basis(); let scale = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bounds())).diagonal().max(1.0); let eps = scale * 1e-8; diff --git a/crates/engine/src/cmd/view_dxf_scene.rs b/crates/engine/src/cmd/view_dxf_scene.rs new file mode 100644 index 00000000..849dcb56 --- /dev/null +++ b/crates/engine/src/cmd/view_dxf_scene.rs @@ -0,0 +1,627 @@ +//! Two levels of occlusion acceleration: an instance tree over projected bounds, and one +//! triangle hierarchy per shared mesh. Per-placement node bounds are small; projected +//! triangle coordinates are evaluated only in visited leaves. Covered line intervals prune +//! subsequent nodes and instances, including the entire query once coverage is complete. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use solidcraft_doc::apply_point; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use solidcraft_render::Camera; + +use super::super::meshes::{Placement, Scene, WORKERS, claim, transformed_bounds}; +use super::super::progress::Stage; +use super::{Drawing, Line, bounds, clip, key, merge_lines, occluded, overlap, xy}; + +const TOO_COMPLEX: &str = "view exceeds export work budget; hide bodies or increase tolerance"; +const MAX_LINES: usize = 1_000_000; +const MAX_SEGMENTS: usize = 4_000_000; +const MAX_SHARED_TRIANGLES: usize = 2_000_000; +const MAX_VIEW_NODES: usize = 256 * 1024 * 1024 / std::mem::size_of::(); +const WORK: usize = 200_000_000; + +fn parallel(items: &[T], f: impl Fn(&T) -> Result + Sync) -> Result, &'static str> { + solidcraft_kernel::map_on_threads(items, WORKERS, f).into_iter().map(|r| r.ok_or("export worker failed")?).collect() +} + +struct Node { + bounds: Aabb3, + start: usize, + end: usize, + children: Option<(usize, usize)>, +} +struct Bvh { + nodes: Vec, + ids: Vec, +} +impl Bvh { + fn new(boxes: &[Aabb3], flat: bool) -> Self { + let mut tree = Self { nodes: Vec::new(), ids: (0..boxes.len()).collect() }; + if !boxes.is_empty() { + tree.build(boxes, 0, boxes.len(), flat); + } + tree + } + fn build(&mut self, boxes: &[Aabb3], start: usize, end: usize, flat: bool) -> usize { + let b = self.ids.get(start..end).into_iter().flatten().filter_map(|i| boxes.get(*i)).fold(Aabb3::EMPTY, |b, t| b.union(t)); + let index = self.nodes.len(); + self.nodes.push(Node { bounds: b, start, end, children: None }); + if end - start > 8 { + let size = b.size(); + let axis = if !flat && size.z > size.x.max(size.y) { + 2 + } else if size.x > size.y { + 0 + } else { + 1 + }; + let value = |i: &usize| { + boxes.get(*i).map(|b| b.center()).map_or(0., |p| match axis { + 0 => p.x, + 1 => p.y, + _ => p.z, + }) + }; + let mid = start + (end - start) / 2; + if let Some(ids) = self.ids.get_mut(start..end) { + ids.select_nth_unstable_by(mid - start, |a, b| value(a).total_cmp(&value(b)).then_with(|| a.cmp(b))); + } + let left = self.build(boxes, start, mid, flat); + let right = self.build(boxes, mid, end, flat); + if let Some(node) = self.nodes.get_mut(index) { + node.children = Some((left, right)); + } + } + index + } +} + +struct Edge { + vertices: [u32; 2], + start: usize, + end: usize, +} +struct Side { + key: [[i64; 3]; 2], + vertices: [u32; 2], + triangle: usize, +} +struct Shared { + mesh: Arc, + tree: Bvh, + edges: Vec, + incident: Vec, +} +impl Shared { + fn new(mesh: &Arc, eps: f64, progress: &Stage) -> Result { + let mut boxes = Vec::with_capacity(mesh.triangles.len()); + let mut sides = Vec::new(); + for (i, indices) in mesh.triangles.iter().enumerate() { + if i.is_multiple_of(1024) { + progress.check()?; + } + let points = mesh.tri(indices).ok_or("invalid export triangle")?; + if points.iter().any(|p| !p.is_finite()) { + return Err("model contains invalid coordinates"); + } + boxes.push(bounds(points)); + for (a, b) in [(0, 1), (1, 2), (2, 0)] { + let (Some(pa), Some(pb), Some(ia), Some(ib)) = (points.get(a), points.get(b), indices.get(a), indices.get(b)) else { + return Err("invalid export edge"); + }; + let (ka, kb) = (key(*pa, eps), key(*pb, eps)); + if ka == kb { + continue; + } + let (key, vertices) = if ka < kb { ([ka, kb], [*ia, *ib]) } else { ([kb, ka], [*ib, *ia]) }; + sides.push(Side { key, vertices, triangle: i }); + } + } + let tree = Bvh::new(&boxes, false); + sides.sort_unstable_by(|a, b| a.key.cmp(&b.key).then_with(|| a.triangle.cmp(&b.triangle))); + let mut edges: Vec = Vec::new(); + let mut incident = Vec::with_capacity(sides.len()); + let mut previous = None; + for side in sides { + if previous != Some(side.key) { + edges.push(Edge { vertices: side.vertices, start: incident.len(), end: incident.len() }); + previous = Some(side.key); + } + incident.push(side.triangle); + if let Some(e) = edges.last_mut() { + e.end = incident.len(); + } + } + Ok(Self { mesh: mesh.clone(), tree, edges, incident }) + } +} + +struct View<'a> { + cam: &'a Camera, + right: Vec3, + up: Vec3, + back: Vec3, +} +impl<'a> View<'a> { + fn new(cam: &'a Camera) -> Self { + let (right, up, back) = cam.basis(); + Self { cam, right, up, back } + } + fn project(&self, p: Vec3) -> Result { + if !p.is_finite() { + return Err("model contains invalid coordinates"); + } + let d = p - self.cam.target; + let z = d.dot(self.back); + let projected = if self.cam.fov > 0. { + let w = self.cam.distance - z; + if w <= self.cam.distance * 1e-6 { + return Err("model crosses the camera eye plane; zoom out before exporting"); + } + Vec3::new(d.dot(self.right) * self.cam.distance / w, d.dot(self.up) * self.cam.distance / w, 1. / w) + } else { + Vec3::new(d.dot(self.right), d.dot(self.up), z) + }; + if !projected.is_finite() || projected.len() > 1e12 { + return Err("projected coordinates exceed the export range"); + } + Ok(projected) + } + fn triangle(&self, shared: &Shared, placed: &Placement, id: usize) -> Result<[Vec3; 3], &'static str> { + let indices = shared.mesh.triangles.get(id).ok_or("invalid export triangle")?; + let [a, b, c] = shared.mesh.tri(indices).ok_or("invalid export triangle")?; + Ok([ + self.project(apply_point(&placed.transform, a))?, + self.project(apply_point(&placed.transform, b))?, + self.project(apply_point(&placed.transform, c))?, + ]) + } +} + +struct Projected { + nodes: Vec, + segments: Vec<(Vec3, Vec3)>, +} +impl Projected { + fn new(shared: &Shared, placed: &Placement, view: &View<'_>, eps: f64, segments: &AtomicUsize, progress: &Stage) -> Result { + let mut nodes = vec![Aabb3::EMPTY; shared.tree.nodes.len()]; + // Exact projected leaf bounds avoid false near-eye errors from empty corners of a + // rotated model-space box. Interior bounds are just unions of the child bounds. + let mut front = vec![false; shared.mesh.triangles.len()]; + for (i, node) in shared.tree.nodes.iter().enumerate().rev() { + if i.is_multiple_of(1024) { + progress.check()?; + } + let mut b = Aabb3::EMPTY; + if let Some((left, right)) = node.children { + b = nodes.get(left).ok_or("invalid export tree")?.union(nodes.get(right).ok_or("invalid export tree")?); + } else { + for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid export tree")? { + let p = view.triangle(shared, placed, *id)?; + b = b.union(&bounds(p)); + if let Some(f) = front.get_mut(*id) { + // A positive projective scale preserves the projected winding. + *f = super::cross(xy(p[1] - p[0]), xy(p[2] - p[0])) > 0.; + } + } + } + if let Some(slot) = nodes.get_mut(i) { + *slot = b; + } + } + let mut result = Vec::new(); + let mut add = |a: Vec3, b: Vec3| -> Result<(), &'static str> { + let (a, b) = (view.project(apply_point(&placed.transform, a))?, view.project(apply_point(&placed.transform, b))?); + if xy(a).dist(xy(b)) > eps { + if !claim(segments, 1, MAX_SEGMENTS) { + return Err(TOO_COMPLEX); + } + result.push((a, b)); + } + Ok(()) + }; + for (i, edge) in shared.mesh.edges.iter().enumerate() { + if !placed.visibility.get(i).copied().unwrap_or(false) { + continue; + } + for w in edge.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + add(*a, *b)?; + } + } + } + for edge in &shared.edges { + let mut positive = false; + let mut negative = false; + for id in shared.incident.get(edge.start..edge.end).ok_or("invalid export adjacency")? { + if front.get(*id).copied().ok_or("invalid export adjacency")? { + positive = true; + } else { + negative = true; + } + } + if (positive && negative) || edge.end - edge.start == 1 { + let a = shared.mesh.positions.get(edge.vertices[0] as usize).ok_or("invalid silhouette")?; + let b = shared.mesh.positions.get(edge.vertices[1] as usize).ok_or("invalid silhouette")?; + add(*a, *b)?; + } + } + Ok(Self { nodes, segments: result }) + } +} + +/// A sorted union, updated as occluders are found. A node's line interval already covered by +/// nearer triangles cannot change the result and is skipped before visiting its children. +#[derive(Default)] +struct Coverage { + spans: Vec<(f64, f64)>, +} +impl Coverage { + fn contains(&self, lo: f64, hi: f64) -> bool { + self.spans.iter().any(|(a, b)| *a <= lo && *b >= hi) + } + fn add(&mut self, mut lo: f64, mut hi: f64) -> Result<(), &'static str> { + let first = self.spans.partition_point(|(_, b)| *b + 1e-9 < lo); + let mut end = first; + while let Some((a, b)) = self.spans.get(end) { + if *a > hi + 1e-9 { + break; + } + lo = lo.min(*a); + hi = hi.max(*b); + end += 1; + } + if first == end && self.spans.len() >= MAX_LINES / 2 { + return Err(TOO_COMPLEX); + } + self.spans.splice(first..end, [(lo, hi)]); + Ok(()) + } +} + +fn rectangle(a: Vec3, b: Vec3, box_: &Aabb3) -> Option<(f64, f64)> { + let (mut lo, mut hi) = (0., 1.); + for (v0, v1) in [ + (a.x - box_.min.x, b.x - box_.min.x), + (box_.max.x - a.x, box_.max.x - b.x), + (a.y - box_.min.y, b.y - box_.min.y), + (box_.max.y - a.y, box_.max.y - b.y), + ] { + if !clip(&mut lo, &mut hi, v0, v1) { + return None; + } + } + Some((lo, hi)) +} + +struct Budget<'a> { + global: &'a AtomicUsize, + progress: &'a Stage, + local: usize, + spent: usize, +} +impl<'a> Budget<'a> { + fn new(global: &'a AtomicUsize, progress: &'a Stage) -> Self { + Self { global, progress, local: 0, spent: 0 } + } + fn spend(&mut self) -> Result<(), &'static str> { + if self.local == 0 { + self.progress.check()?; + let mut old = self.global.load(Ordering::Relaxed); + loop { + let granted = old.min(4096); + if granted == 0 { + return Err(TOO_COMPLEX); + } + match self.global.compare_exchange_weak(old, old - granted, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => { + self.local = granted; + break; + } + Err(current) => old = current, + } + } + } + self.local -= 1; + self.spent += 1; + Ok(()) + } +} + +struct Query<'a, 'b> { + shared: &'a [Shared], + scene: &'a Scene, + projected: &'a [Projected], + instances: &'a Bvh, + view: &'a View<'a>, + depth_eps: f64, + a: Vec3, + b: Vec3, + bounds: Aabb3, + covered: Coverage, + budget: &'b mut Budget<'a>, +} +impl Query<'_, '_> { + fn relevant(&self, b: &Aabb3) -> bool { + if b.max.z <= self.a.z.min(self.b.z) + self.depth_eps || !overlap(b, &self.bounds) { + return false; + } + rectangle(self.a, self.b, b).is_some_and(|(lo, hi)| !self.covered.contains(lo, hi)) + } + fn instance_node(&mut self, index: usize) -> Result<(), &'static str> { + self.budget.spend()?; + let node = self.instances.nodes.get(index).ok_or("invalid instance tree")?; + if !self.relevant(&node.bounds) { + return Ok(()); + } + if let Some((left, right)) = node.children { + let depth = |i: usize| self.instances.nodes.get(i).map_or(f64::NEG_INFINITY, |n| n.bounds.max.z); + let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) }; + self.instance_node(near)?; + if !self.covered.contains(0., 1.) { + self.instance_node(far)?; + } + } else { + for id in self.instances.ids.get(node.start..node.end).ok_or("invalid instance tree")? { + self.mesh_node(*id, 0)?; + if self.covered.contains(0., 1.) { + break; + } + } + } + Ok(()) + } + fn mesh_node(&mut self, instance: usize, index: usize) -> Result<(), &'static str> { + self.budget.spend()?; + let placed = self.scene.placements.get(instance).ok_or("invalid instance")?; + let shared = self.shared.get(placed.mesh).ok_or("invalid shared geometry")?; + let projected = self.projected.get(instance).ok_or("invalid instance bounds")?; + let b = projected.nodes.get(index).ok_or("invalid mesh tree")?; + if !self.relevant(b) { + return Ok(()); + } + let node = shared.tree.nodes.get(index).ok_or("invalid mesh tree")?; + if let Some((left, right)) = node.children { + let depth = |i: usize| projected.nodes.get(i).map_or(f64::NEG_INFINITY, |b| b.max.z); + let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) }; + self.mesh_node(instance, near)?; + if !self.covered.contains(0., 1.) { + self.mesh_node(instance, far)?; + } + } else { + for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid mesh tree")? { + self.budget.spend()?; + let points = self.view.triangle(shared, placed, *id)?; + if let Some((lo, hi)) = occluded(self.a, self.b, points, self.depth_eps) { + self.covered.add(lo, hi)?; + if self.covered.contains(0., 1.) { + break; + } + } + } + } + Ok(()) + } +} + +pub(super) fn drawing(scene: &Scene, cam: &Camera) -> Result { + let start = Instant::now(); + let scale = scene + .placements + .iter() + .filter_map(|p| scene.meshes.get(p.mesh).map(|m| transformed_bounds(m, &p.transform))) + .fold(Aabb3::EMPTY, |b, p| b.union(&p)) + .diagonal() + .max(1.); + if !scale.is_finite() { + return Err("model contains invalid coordinates"); + } + let eps = scale * 1e-8; + // Indexed input can have many triangles but very few positions. Bound adjacency/sort + // scratch space as well as the retained mesh bytes before starting parallel builders. + let unique_triangles = scene.meshes.iter().try_fold(0usize, |n, m| n.checked_add(m.triangles.len())); + if unique_triangles.is_none_or(|n| n > MAX_SHARED_TRIANGLES) { + return Err("view exceeds export memory budget; hide bodies or increase tolerance"); + } + let progress = Stage::new("dxf_index", scene.meshes.len()); + let shared = parallel(&scene.meshes, |m| { + progress.check()?; + let shared = Shared::new(m, eps, &progress)?; + progress.advance(1)?; + Ok(shared) + })?; + let count = scene.placements.iter().try_fold(0usize, |n, p| n.checked_add(shared.get(p.mesh)?.tree.nodes.len())); + if count.is_none_or(|n| n > MAX_VIEW_NODES) { + return Err("view exceeds export memory budget; hide bodies or increase tolerance"); + } + let view = View::new(cam); + let count = AtomicUsize::new(0); + let progress = Stage::new("dxf_projection", scene.placements.len()); + let projected = parallel(&scene.placements, |p| { + progress.check()?; + let projected = Projected::new(shared.get(p.mesh).ok_or("missing shared geometry")?, p, &view, eps, &count, &progress)?; + progress.advance(1)?; + Ok(projected) + })?; + let boxes = projected.iter().map(|p| p.nodes.first().copied().unwrap_or(Aabb3::EMPTY)).collect::>(); + let mut instances = Bvh::new(&boxes, true); + // The view is fixed for the entire drawing. Sort the at-most-eight members of each + // instance leaf once, rather than allocating/sorting on every line query. + for node in instances.nodes.iter().filter(|n| n.children.is_none()) { + if let Some(ids) = instances.ids.get_mut(node.start..node.end) { + ids.sort_unstable_by(|a, b| { + boxes + .get(*b) + .map_or(f64::NEG_INFINITY, |b| b.max.z) + .total_cmp(&boxes.get(*a).map_or(f64::NEG_INFINITY, |b| b.max.z)) + .then_with(|| a.cmp(b)) + }); + } + } + let segment_count = count.load(Ordering::Relaxed); + let projection_ms = start.elapsed().as_secs_f64() * 1000.; + let start = Instant::now(); + let depth_eps = if cam.fov > 0. { 1e-8 / cam.distance } else { eps }; + let global = AtomicUsize::new(WORK); + let output = AtomicUsize::new(0); + let chunks = projected.iter().flat_map(|p| p.segments.chunks(128)).collect::>(); + let progress = Stage::new("dxf_occlusion", segment_count); + let results = parallel(&chunks, |chunk| { + progress.check()?; + let mut budget = Budget::new(&global, &progress); + let mut lines = Vec::new(); + for &(a, b) in *chunk { + let mut query = Query { + shared: &shared, + scene, + projected: &projected, + instances: &instances, + view: &view, + depth_eps, + a, + b, + bounds: bounds([a, b]), + covered: Coverage::default(), + budget: &mut budget, + }; + query.instance_node(0)?; + let mut cursor = 0.; + let mut emit = |lo: f64, hi: f64, hidden: bool| -> Result<(), &'static str> { + let (p, q) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi)); + if p.dist(q) > eps { + if !claim(&output, 1, MAX_LINES) { + return Err(TOO_COMPLEX); + } + lines.push(Line { a: p, b: q, hidden }); + } + Ok(()) + }; + for (lo, hi) in query.covered.spans { + emit(cursor, lo, false)?; + emit(lo, hi, true)?; + cursor = hi; + } + emit(cursor, 1., false)?; + } + progress.advance(chunk.len())?; + Ok((lines, budget.spent)) + })?; + let work = results.iter().map(|(_, n)| *n).sum(); + let lines = merge_lines(results.into_iter().flat_map(|(lines, _)| lines).collect(), eps); + progress.check()?; + Ok(Drawing { lines, work, segments: segment_count, projection_ms, occlusion_ms: start.elapsed().as_secs_f64() * 1000. }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn coverage_handles_disjoint_spans_and_early_complete_coverage() { + let mut c = Coverage::default(); + c.add(0.4, 0.6).unwrap(); + c.add(0., 0.2).unwrap(); + assert!(c.contains(0.05, 0.15)); + assert!(!c.contains(0., 1.)); + c.add(0.2, 0.9).unwrap(); + c.add(0.8, 1.).unwrap(); + assert_eq!(c.spans, vec![(0., 1.)]); + } + + fn box_mesh() -> Mesh { + let mut mesh = Mesh::default(); + mesh.positions = vec![ + Vec3::new(0., 0., 0.), + Vec3::new(8., 0., 0.), + Vec3::new(8., 6., 0.), + Vec3::new(0., 6., 0.), + Vec3::new(0., 0., 3.), + Vec3::new(8., 0., 3.), + Vec3::new(8., 6., 3.), + Vec3::new(0., 6., 3.), + ]; + mesh.triangles = + vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; + mesh.edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)] + .into_iter() + .map(|(a, b)| vec![mesh.positions[a], mesh.positions[b]]) + .collect(); + mesh + } + + fn canonical(lines: Vec) -> Vec<([i64; 2], [i64; 2], bool)> { + let k = |p: solidcraft_geom::Vec2| [(p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64]; + let mut result = lines + .into_iter() + .map(|l| { + let (a, b) = (k(l.a), k(l.b)); + if a < b { (a, b, l.hidden) } else { (b, a, l.hidden) } + }) + .collect::>(); + result.sort_unstable(); + result + } + + #[test] + fn shared_rotated_instances_match_flat_occlusion_in_orthographic_and_perspective_views() { + use std::sync::Arc; + let mesh = box_mesh(); + let mut placements = Vec::new(); + let mut flat = Vec::new(); + for (offset, angle) in [(Vec3::ZERO, 0.), (Vec3::new(3., 2., 4.), 0.3), (Vec3::new(2., 1., -4.), -0.6)] { + let transform = solidcraft_doc::rigid(offset, Vec3::ZERO, Vec3::Z, angle); + placements.push(Placement { mesh: 0, transform, visibility: vec![true; mesh.edges.len()] }); + let mut placed = mesh.clone(); + placed.positions = placed.positions.iter().map(|p| apply_point(&transform, *p)).collect(); + placed.edges = placed.edges.iter().map(|e| e.iter().map(|p| apply_point(&transform, *p)).collect()).collect(); + flat.push(placed); + } + let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 36, bytes: 0 }; + for (yaw, pitch, roll) in [(0., std::f64::consts::FRAC_PI_2, 0.), (0.7, 0.4, 0.2), (0., 0., 0.)] { + for fov in [0., 0.6] { + let cam = Camera { target: Vec3::new(4., 3., 1.), yaw, pitch, roll, fov, distance: 100., ..Default::default() }; + let expected = super::super::reference_drawing(&flat, &cam).unwrap(); + let actual = drawing(&scene, &cam).unwrap(); + assert_eq!(canonical(actual.lines), canonical(expected), "yaw={yaw}, pitch={pitch}, fov={fov}"); + } + } + } + + #[test] + fn repeated_surface_over_one_million_triangles_keeps_one_mesh_and_exports_all_placements() { + use solidcraft_render::StandardView; + use std::sync::Arc; + let mut mesh = Mesh::default(); + for y in 0..=100 { + for x in 0..=100 { + mesh.positions.push(Vec3::new(x as f64 / 10., y as f64 / 10., 0.)); + } + } + for y in 0..100u32 { + for x in 0..100u32 { + let a = y * 101 + x; + mesh.triangles.extend([[a, a + 1, a + 102], [a, a + 102, a + 101]]); + } + } + mesh.edges = vec![ + vec![Vec3::ZERO, Vec3::new(10., 0., 0.)], + vec![Vec3::new(10., 0., 0.), Vec3::new(10., 10., 0.)], + vec![Vec3::new(10., 10., 0.), Vec3::new(0., 10., 0.)], + vec![Vec3::new(0., 10., 0.), Vec3::ZERO], + ]; + let placements = (0..60) + .map(|i| Placement { + mesh: 0, + transform: solidcraft_doc::rigid(Vec3::new((i % 10) as f64 * 20., (i / 10) as f64 * 20., 0.), Vec3::ZERO, Vec3::Z, 0.), + visibility: vec![true; 4], + }) + .collect(); + let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 1_200_000, bytes: 0 }; + let mut cam = Camera { target: Vec3::new(95., 55., 0.), fov: 0., distance: 300., ..Default::default() }; + cam.set_view(StandardView::Top); + let result = drawing(&scene, &cam).unwrap(); + assert_eq!(scene.meshes.len(), 1); + assert_eq!(result.lines.len(), 240); + assert!(result.lines.iter().all(|l| !l.hidden)); + } +} diff --git a/crates/engine/src/cmd/view_dxf_tests.rs b/crates/engine/src/cmd/view_dxf_tests.rs index 3b6242a6..9b8c37e4 100644 --- a/crates/engine/src/cmd/view_dxf_tests.rs +++ b/crates/engine/src/cmd/view_dxf_tests.rs @@ -21,6 +21,49 @@ fn export(s: &mut Session, c: Camera, hidden: &str) -> Value { run(s, CMD, json!({"camera":c,"hidden":hidden})) } +#[test] +fn view_dxf_cancellation_at_each_stage_preserves_the_destination_and_document() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + struct CancelAt { + stage: &'static str, + cancelled: AtomicBool, + reached: AtomicBool, + } + impl solidcraft_kernel::watch::Watch for CancelAt { + fn cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + fn progress(&self, stage: &'static str, done: usize, _total: usize) { + if stage == self.stage && (done > 0 || stage == "dxf_write") { + self.reached.store(true, Ordering::Relaxed); + self.cancelled.store(true, Ordering::Relaxed); + } + } + } + let dir = std::env::temp_dir().join(format!("solidcraft-dxf-cancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("drawing.dxf"); + let mut s = sample(); + let doc = s.doc.clone(); + let undo = s.undo.len(); + for stage in ["dxf_meshes", "dxf_index", "dxf_projection", "dxf_occlusion", "dxf_write"] { + std::fs::write(&path, b"previous drawing").unwrap(); + let watch = Arc::new(CancelAt { stage, cancelled: AtomicBool::new(false), reached: AtomicBool::new(false) }); + let result = solidcraft_kernel::watch::watching(watch.clone(), || { + s.execute(CMD, &json!({"camera":camera(StandardView::Top),"path":path.to_string_lossy()})) + }); + assert!(result.unwrap_err().to_string().contains("cancelled"), "{stage}"); + assert!(watch.reached.load(Ordering::Relaxed), "{stage}"); + assert_eq!(std::fs::read(&path).unwrap(), b"previous drawing", "{stage}"); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.undo.len(), undo); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() { let mut s = sample(); @@ -100,3 +143,32 @@ fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() { c.fov = 4.; assert!(s.execute(CMD, &json!({"camera":c})).is_err()); } + +#[test] +fn view_dxf_keeps_every_occurrence_while_sharing_geometry() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Repeated part"}))["component"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length":8,"width":6,"height":3})); + for x in [15., 30.] { + run(&mut s, "occurrence.copy", json!({"component":component,"translate":[x,0,0]})); + } + run(&mut s, "component.activate", json!({"component":0})); + let doc = s.doc.clone(); + let result = export(&mut s, camera(StandardView::Top), "dashed"); + assert_eq!(result["stats"]["bodies"], 3); + assert_eq!(result["stats"]["shared_meshes"], 1); + assert_eq!(result["stats"]["worker_limit"], meshes::WORKERS); + assert!(result["hidden"].as_u64().unwrap() > 0); + let lines = solidcraft_io::read_dxf(result["dxf"].as_str().unwrap()).unwrap(); + let mut centers = std::collections::BTreeSet::new(); + for line in lines { + if let solidcraft_io::Geom2::Line(a, b) = line { + // Each translated copy has a visible top perimeter, including the offscreen one. + if (a.y - b.y).abs() > 5.9 { + centers.insert(((a.x + 20.) / 15.).floor() as i64); + } + } + } + assert!(centers.len() >= 3, "{centers:?}"); + assert!(std::sync::Arc::ptr_eq(&doc, &s.doc)); +} diff --git a/crates/engine/src/exact_tool_tests.rs b/crates/engine/src/exact_tool_tests.rs new file mode 100644 index 00000000..dc29b3c4 --- /dev/null +++ b/crates/engine/src/exact_tool_tests.rs @@ -0,0 +1,176 @@ +//! A cut takes exactly the prism its sketch and extent define, and a join adds exactly that, +//! whatever stands beside the tool or beyond its ends. The tool is still grown past a face it +//! lies on (to keep coincident faces out of the boolean), but only where what it gains is empty +//! (a cut) or material (a join) on the bodies' faces, not just at a few sample points. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &Session, body: &str) -> f64 { + s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(f64::NAN) +} + +fn has(s: &Session, body: &str, at: [f64; 3]) -> bool { + s.model.state().body(body).is_some_and(|b| b.body.contains(Vec3::new(at[0], at[1], at[2]))) +} + +fn bodies(s: &Session) -> Vec { + s.model.state().bodies.iter().map(|b| b.name.clone()).collect() +} + +fn same(got: f64, want: f64) -> bool { + (got - want).abs() <= 1e-9 * want.abs().max(1.0) +} + +/// A rectangle sketched on `plane`, extruded by `distance` along its normal (the other way +/// when negative). +fn rectangle(s: &mut Session, plane: Value, p0: [f64; 2], p1: [f64; 2], distance: f64, operation: &str, targets: Value) { + run(s, "sketch.create", json!({"plane": plane, "name": "Tool"})); + run(s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1})); + run(s, "sketch.finish", json!({})); + let direction = if distance < 0.0 { "negative" } else { "positive" }; + let mut p = json!({"sketch": "Tool", "distance": distance.abs(), "direction": direction, "operation": operation}); + if !targets.is_null() { + p["targets"] = targets; + } + run(s, "solid.extrude", p); +} + +/// The plane z = `z`. +fn at_height(z: f64) -> Value { + json!({"origin": [0, 0, z], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}) +} + +/// An open tray, `l` × `w` × `h` outside, with a 2 mm floor and 2 mm walls. +fn tray(l: f64, w: f64, h: f64) -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": l, "width": w, "height": h, "body_name": "Tray"})); + run(&mut s, "solid.box", json!({"corner": [2, 2, 2], "length": l - 4.0, "width": w - 4.0, "height": h, "operation": "cut", "targets": ["Tray"]})); + assert!(same(volume(&s, "Tray"), l * w * h - (l - 4.0) * (w - 4.0) * (h - 2.0))); + s +} + +/// A cut from the tray's floor up, over x −1 … X and all of y, takes the end wall x = 0 … 2 +/// and, past x = 2, the start of the two long walls. At X = 2 its side lies in the end wall's +/// inner face, at 1.9 it leaves 0.1 mm of that wall, at 2.1 it reaches 0.1 mm past it. The +/// tray then starts at x = X and has lost min(X, 2) · W · (H − 2) + max(0, X − 2) · 2 · 2 · +/// (H − 2), whatever its size. (The tool's side used to be grown by 0.5 % of the tray's size +/// where three points along it found room: 0.6 mm of the long walls went with it on a 100 mm +/// tray.) +#[test] +fn a_cut_beside_a_wall_takes_only_its_sketch() { + for (l, w, h, x) in [(100.0, 60.0, 30.0, 1.9), (100.0, 60.0, 30.0, 2.0), (100.0, 60.0, 30.0, 2.1), (1000.0, 600.0, 300.0, 2.0)] { + let mut s = tray(l, w, h); + let before = volume(&s, "Tray"); + rectangle(&mut s, at_height(2.0), [-1.0, -1.0], [x, w + 10.0], h + 10.0, "cut", json!(["Tray"])); + let removed = x.min(2.0) * w * (h - 2.0) + (x - 2.0).max(0.0) * 2.0 * 2.0 * (h - 2.0); + let took = before - volume(&s, "Tray"); + assert!(same(took, removed), "{l} × {w} × {h} cut to x = {x}: took {took} mm³, its sketch {removed} mm³"); + // The long wall starts at the cut; what is left of the end wall and the floor stay. + assert!(has(&s, "Tray", [x + 0.05, 1.0, h / 2.0]) && !has(&s, "Tray", [x - 0.05, 1.0, h / 2.0]), "{l} × {w} × {h} cut to x = {x}"); + assert_eq!(has(&s, "Tray", [(x + 2.0) / 2.0, w / 2.0, h / 2.0]), x < 2.0, "{l} × {w} × {h} cut to x = {x}"); + assert!(has(&s, "Tray", [x / 2.0, w / 2.0, 1.0]), "{l} × {w} × {h} cut to x = {x}: the floor under the cut is gone"); + } +} + +/// What stands beside a cut whose side lies in a face stays: a 0.2 mm web between a slot and +/// the plate's edge, and a 1 mm plate standing 0.8 mm from the face (which the grown tool used +/// to cut in two). +#[test] +fn a_cut_leaves_what_stands_beside_it() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 200, "width": 100, "height": 10, "body_name": "Plate"})); + rectangle(&mut s, at_height(0.0), [0.2, 20.0], [10.0, 80.0], 10.0, "cut", Value::Null); + assert!(same(volume(&s, "Plate"), 200.0 * 100.0 * 10.0 - 9.8 * 60.0 * 10.0), "{}", volume(&s, "Plate")); + assert!(has(&s, "Plate", [0.1, 50.0, 5.0]) && !has(&s, "Plate", [0.3, 50.0, 5.0])); + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 1000, "width": 600, "height": 200, "body_name": "A"})); + run(&mut s, "solid.box", json!({"corner": [1000.8, 0, 0], "length": 1, "width": 600, "height": 200, "operation": "new_body", "body_name": "B"})); + rectangle(&mut s, at_height(0.0), [900.0, 100.0], [1000.0, 500.0], 200.0, "cut", Value::Null); + assert_eq!(bodies(&s), ["A", "B"], "the plate must not be cut in two"); + assert!(same(volume(&s, "A"), 1000.0 * 600.0 * 200.0 - 100.0 * 400.0 * 200.0), "{}", volume(&s, "A")); + assert!(same(volume(&s, "B"), 600.0 * 200.0), "{}", volume(&s, "B")); +} + +/// A join adds only its sketch: a block against a wall narrower than the block adds nothing +/// beside the wall, a block 0.1 mm from a wall leaves the gap, and a pad flush with a block's +/// side does not reach a neighbour 0.8 mm from that side. +#[test] +fn a_join_adds_only_its_sketch() { + let wall = || { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 10, 0], "length": 2, "width": 40, "height": 30, "body_name": "Wall"})); + s + }; + let mut s = wall(); + rectangle(&mut s, at_height(0.0), [2.0, 0.0], [20.0, 60.0], 30.0, "join", Value::Null); + assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 18.0 * 60.0 * 30.0), "{}", volume(&s, "Wall")); + assert!(!has(&s, "Wall", [1.9, 5.0, 15.0]) && !has(&s, "Wall", [1.9, 55.0, 15.0]) && has(&s, "Wall", [1.9, 30.0, 15.0])); + + let mut s = wall(); + rectangle(&mut s, at_height(0.0), [2.1, 10.0], [20.0, 50.0], 30.0, "join", Value::Null); + assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{}", volume(&s, "Wall")); + assert!(!has(&s, "Wall", [2.05, 30.0, 15.0]), "the 0.1 mm gap is filled"); + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 300, "width": 200, "height": 100, "body_name": "A"})); + run( + &mut s, + "solid.box", + json!({"corner": [300.8, 0, 0], "length": 99.2, "width": 200, "height": 150, "operation": "new_body", "body_name": "B"}), + ); + rectangle(&mut s, at_height(100.0), [280.0, 50.0], [300.0, 150.0], 20.0, "join", Value::Null); + assert_eq!(bodies(&s), ["A", "B"], "the pad must not bridge the gap"); + assert!(same(volume(&s, "A"), 300.0 * 200.0 * 100.0 + 20.0 * 100.0 * 20.0), "{}", volume(&s, "A")); +} + +/// A window cut down through a plate ends on its underside, where a 4 mm rib hangs below +/// between the points the end used to be checked at: the rib stays whole. +#[test] +fn a_cut_ending_on_a_face_leaves_the_rib_beyond_it() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Section"})); + let tee = [[0, 20], [88, 20], [88, 0], [92, 0], [92, 20], [200, 20], [200, 30], [0, 30]]; + run(&mut s, "sketch.line", json!({"points": tee, "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Section", "distance": 200, "body_name": "Tee"})); + let before = volume(&s, "Tee"); + assert!(same(before, (200.0 * 10.0 + 4.0 * 20.0) * 200.0), "{before}"); + rectangle(&mut s, at_height(30.0), [50.0, -150.0], [150.0, -50.0], -10.0, "cut", Value::Null); + assert!(same(before - volume(&s, "Tee"), 100.0 * 100.0 * 10.0), "{}", before - volume(&s, "Tee")); + assert!(has(&s, "Tee", [90.0, -100.0, 19.5]), "the rib under the window is cut"); +} + +/// A hole cut up through a ring from its flat underside (the reference case `60-pipe-flange`). +/// The ring is revolved, so that face is a line swept round the axis, not a plane, but flat +/// all the same: the tool still starts past it and the hole opens it, leaving no film of zero +/// thickness over the hole (two discs of its area more surface). +#[test] +fn a_hole_from_a_revolved_rings_flat_side_opens_it() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Profile"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 0], "p1": [75, -16]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "angle": 360, "body_names": ["Ring"]})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [60, 0], "radius": 7})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "positive", "operation": "cut"})); + let pi = std::f64::consts::PI; + let ring = s.model.state().body("Ring").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap(); + let want = 2.0 * pi * (75.0 * 75.0 - 25.0 * 25.0 - 7.0 * 7.0) + 2.0 * pi * (75.0 + 25.0 + 7.0) * 16.0; + assert!((ring.area - want).abs() < 1e-3 * want, "area {} instead of {want}", ring.area); + let volume = pi * (75.0 * 75.0 - 25.0 * 25.0 - 7.0 * 7.0) * 16.0; + assert!((ring.volume / volume - 1.0).abs() < 1e-3, "volume {} instead of {volume}", ring.volume); + assert!(!has(&s, "Ring", [60.0, 0.0, 8.0]), "the hole is not cut"); +} diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 10fed494..122e1626 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -36,7 +36,8 @@ enum K { /// component frame, whichever component the geometry under it belongs to (a sketch in the /// root on a moved component's face finds the face where it is shown). Here, - /// A plane object for a new sketch or canvas: into the active component's frame. + /// A plane object for a new sketch or canvas (or a mirror's copies): into the active + /// component's frame. HerePlane, } @@ -59,7 +60,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] { "solid.press_pull" => &[("face", Pick), ("edges", Picks)], "solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)], "solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)], - "solid.mirror" | "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], + // A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`); + // its plane is in the active component, where the copies go. + "solid.mirror" => &[("plane", HerePlane)], + "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)], "plastic.boss" | "plastic.snap_fit" | "plastic.rest" => { &[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c75ba999..ab44c02d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod render_jobs; pub mod sample; mod slicer_icon; pub mod slicers; +pub mod timing; pub mod view; use std::sync::Arc; @@ -31,7 +32,7 @@ use serde_json::Value; use solidcraft_doc::{Document, Model, ModelState}; use solidcraft_geom::Vec3; -pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command}; +pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, extrude_reach, find_command}; pub use solidcraft_doc as doc; pub use solidcraft_doc::Visibility; pub use solidcraft_geom as geom; @@ -156,6 +157,16 @@ pub struct Snapshot { pub pending_moves: std::collections::BTreeMap, } +/// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]). +pub struct Ahead(Session); + +impl Ahead { + /// Run `commands` (their results stay in the shared evaluation cache). + pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> { + self.0.preview_in_place(commands).map(|_| ()) + } +} + /// What some commands would make, evaluated on a scratch copy of a session /// ([`Session::preview`]). The session itself is not changed. #[derive(Clone, Debug)] @@ -229,6 +240,9 @@ pub struct Session { pub section: Option<(Vec3, Vec3)>, /// Surface analysis shading the model (zebra, draft, curvature map). pub analysis: Option, + /// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not + /// part of the design and not an undo step. + pub continuity: Vec, /// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an /// undo step. A design brings its saved visibility when it opens (`Session::new`). pub visibility: Visibility, @@ -291,6 +305,20 @@ pub enum SurfaceAnalysis { Access { dir: Vec3 }, } +/// One seam measured by `inspect.continuity`, where the viewport shades it: the stations it was +/// sampled at, in world coordinates, and how it reads. View state only: the design is untouched. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct ContinuitySeam { + /// The body the seam is an edge of. + pub body: String, + /// The edge's index in the body's display mesh edge order. + pub edge: usize, + /// Sample points along the seam, in world coordinates. + pub points: Vec, + /// The seam's worst verdict along its length. + pub verdict: kernel::SeamVerdict, +} + impl SurfaceAnalysis { /// How a picture shades the faces for this analysis. pub fn mode(self) -> render::AnalysisMode { @@ -346,6 +374,7 @@ impl Session { sketch_clipboard: None, section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, saved_visibility: visibility.clone(), @@ -813,6 +842,7 @@ impl Session { sketch_clipboard: self.sketch_clipboard.clone(), section: None, analysis: None, + continuity: Vec::new(), checkpoints: Vec::new(), next_checkpoint: 1, visibility: self.visibility.clone(), @@ -887,6 +917,17 @@ impl Session { self.scratch().preview_in_place(commands).map(|(after, replies)| Preview { before: visible(self), after: visible(&after), replies }) } + /// Run `commands` on a [`Session::scratch`] copy that keeps the undo and redo stacks: what + /// they evaluate lands in the evaluation cache the copy shares with this session, so running + /// them here afterwards finds it there. For a front end that computes a command which may + /// take a while (undo, the history marker moved) off its window thread first. + pub fn ahead(&self) -> Ahead { + let mut copy = self.scratch(); + copy.undo.clone_from(&self.undo); + copy.redo.clone_from(&self.redo); + Ahead(copy) + } + fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<(Self, Vec)> { if commands.is_empty() { return Err(EngineError::Other("nothing to preview".into())); @@ -920,6 +961,18 @@ impl Session { /// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a /// `commands` array). Stops at the first error. Returns each command's result. pub fn run_script(&mut self, script: &Value) -> Result> { + Ok(self.run_script_with(script, None)?.0) + } + + /// [`Session::run_script`], timing each command (#1833): the results and + /// `{"timings": [{"index", "command", "ms", "profile"?}], "total_ms"}`, with `profile` the + /// kernel's share of each command ([`timing`]). + pub fn run_script_timed(&mut self, script: &Value, profile: bool) -> Result<(Vec, Value)> { + let (results, timings) = self.run_script_with(script, Some(profile))?; + Ok((results, timings.unwrap_or(Value::Null))) + } + + fn run_script_with(&mut self, script: &Value, profile: Option) -> Result<(Vec, Option)> { let list = match script { Value::Array(a) => a.clone(), Value::Object(o) => { @@ -931,6 +984,8 @@ impl Session { return Err(EngineError::Other("script too long".into())); } let mut out = Vec::new(); + let mut steps = Vec::new(); + let clock = timing::Clock::start(); for (i, c) in list.iter().enumerate() { let id = c .get("command") @@ -938,10 +993,18 @@ impl Session { .and_then(Value::as_str) .ok_or_else(|| EngineError::Other(format!("script step {}: missing `command`", i + 1)))?; let p = c.get("params").cloned().unwrap_or(Value::Null); - let r = self.execute(id, &p).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; - out.push(r); + let r = match profile { + Some(profile) => { + let (r, step) = timing::time(profile, || self.execute(id, &p)); + steps.push(step.to_json(i, id)); + r + } + None => self.execute(id, &p), + }; + out.push(r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?); } - Ok(out) + let timings = profile.map(|_| serde_json::json!({"timings": steps, "total_ms": timing::round_ms(clock.ms())})); + Ok((out, timings)) } } @@ -956,6 +1019,8 @@ mod derived_units_tests; #[cfg(test)] mod display_mesh_tests; #[cfg(test)] +mod exact_tool_tests; +#[cfg(test)] mod extent_tests; #[cfg(test)] mod face_name_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d0be87f3..41505eb2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -314,6 +314,7 @@ mod large_step_import; mod loft_cut; mod loft_ring; mod loft_spacing; +mod mirror_placed; mod mirror_planes; #[cfg(not(target_arch = "wasm32"))] mod native_cad_import; @@ -512,6 +513,30 @@ fn revolve_and_primitives() { assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4); } +/// #726: a revolve swept each profile edge into two or three patches and left the planar ones +/// apart, so a pick on such a face took part of it, and a sketch or an extrude there was refused +/// ("the selected planar face no longer exists") until a later feature merged them. +#[test] +fn revolved_planar_faces_come_out_whole() { + for angle in [360, 240] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ"})); + run(&mut s, "sketch.line", json!({"points": [[8, 0], [30, 0], [30, -6], [18, -6], [18, -22], [8, -22]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"axis": "Z", "angle": angle})); + // Picks at 170°, in the last patch, which 240° covers whichever way it turns. + let at = |r: f64, z: f64| json!([r * 170f64.to_radians().cos(), r * 170f64.to_radians().sin(), z]); + // The flange's top annulus (z = 6, r 18…30), well inside it. + let sk = run(&mut s, "sketch.create", json!({"plane": {"face": at(24.0, 6.0)}})); + assert_eq!(sk["attachment"], "exact_face", "{angle}°: {sk}"); + run(&mut s, "sketch.finish", json!({})); + // The bottom annulus (z = 0, r 8…30). + let v = volume(&mut s); + run(&mut s, "solid.extrude", json!({"face": at(20.0, 0.0), "distance": 2, "operation": "join"})); + assert!(volume(&mut s) > v + 1.0, "{angle}°: the extrude added nothing"); + } +} + /// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a /// sketch plane that does not pass through the origin: the body is the shell, as it is on XY. /// Before, built in place, it measured a third too large (truck tessellates the degenerate faces @@ -781,6 +806,7 @@ fn hostile_params_never_panic() { "scale", "continuity", "crown", + "faces", ]; // Commands are independent; worker threads take them one at a time (their costs vary a lot). let ids: Vec<&str> = command_specs() @@ -877,6 +903,100 @@ fn sample_design_builds() { assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore); } +fn silhouette_box(s: &mut Session, name: &str, x0: f64, x1: f64, z: f64) { + let sketch = format!("{name}Sketch"); + run(s, "sketch.create", json!({"plane": "XY", "name": sketch})); + run(s, "sketch.rectangle.two_point", json!({"p0": [x0, 0], "p1": [x1, 10]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": sketch, "distance": z, "operation": "new_body", "body_name": name})); +} + +fn silhouette_pixels(v: &Value) -> usize { + v["mask"] + .as_array() + .unwrap() + .iter() + .map(|row| row.as_array().unwrap().iter().map(|span| (span[1].as_u64().unwrap() - span[0].as_u64().unwrap()) as usize).sum::()) + .sum() +} + +#[test] +fn silhouette_box_standard_views_and_full_mask() { + let mut s = Session::default(); + silhouette_box(&mut s, "Box", 0.0, 20.0, 30.0); + let front = run(&mut s, "inspect.silhouette", json!({"rows": 60})); + assert_eq!(front["box_mm"], json!([[0.0, 0.0], [20.0, 30.0]])); + assert_eq!(front["mm_per_px"], 0.5); + assert_eq!((front["width"].as_u64(), front["height"].as_u64()), (Some(40), Some(60))); + assert_eq!(silhouette_pixels(&front), 2400); + assert_eq!(front["bodies"], 1); + let right = run(&mut s, "inspect.silhouette", json!({"view": "right", "rows": 60})); + assert_eq!(right["box_mm"], json!([[0.0, 0.0], [10.0, 30.0]])); + assert_eq!((right["width"].as_u64(), right["height"].as_u64()), (Some(20), Some(60))); + let top = run(&mut s, "inspect.silhouette", json!({"view": "top", "rows": 60})); + assert_eq!(top["box_mm"], json!([[0.0, 0.0], [20.0, 10.0]])); + assert_eq!((top["width"].as_u64(), top["height"].as_u64()), (Some(120), Some(60))); +} + +#[test] +fn silhouette_front_and_back_mirror_tall_body() { + let mut s = Session::default(); + silhouette_box(&mut s, "Tall", 0.0, 10.0, 30.0); + silhouette_box(&mut s, "Short", 30.0, 40.0, 10.0); + let front = run(&mut s, "inspect.silhouette", json!({"view": "front", "rows": 60})); + let back = run(&mut s, "inspect.silhouette", json!({"view": "back", "rows": 60})); + assert_eq!(front["mask"][0], json!([[0, 20]])); + assert_eq!(back["mask"][0], json!([[60, 80]])); + assert_eq!(front["right"], json!([1.0, 0.0, 0.0])); + assert_eq!(back["right"], json!([-1.0, 0.0, 0.0])); +} + +#[test] +fn silhouette_visibility_selection_and_bad_input() { + let mut s = Session::default(); + silhouette_box(&mut s, "Shown", 0.0, 20.0, 30.0); + silhouette_box(&mut s, "Hidden", 40.0, 50.0, 10.0); + run(&mut s, "browser.visibility", json!({"items": ["Hidden"], "visible": false})); + assert_eq!(run(&mut s, "inspect.silhouette", json!({}))["bodies"], 1); + let named = run(&mut s, "inspect.silhouette", json!({"bodies": ["Hidden"]})); + assert_eq!(named["bodies"], 1); + assert_eq!(named["box_mm"], json!([[40.0, 0.0], [50.0, 10.0]])); + assert!(s.execute("inspect.silhouette", &json!({"bodies": ["Unknown"]})).unwrap_err().to_string().contains("Unknown")); + assert!(s.execute("inspect.silhouette", &json!({"view": "front", "camera": {}})).is_err()); + assert!(s.execute("inspect.silhouette", &json!({"camera": {"target": [0,0,0], "yaw": 0, "pitch": 0, "distance": 0}})).is_err()); +} + +#[test] +fn silhouette_perspective_scales_the_near_face_and_refuses_bodies_behind_the_eye() { + let mut s = Session::default(); + silhouette_box(&mut s, "Box", 0.0, 20.0, 30.0); + // Looking along +Y at the box's centre from 100 mm: the near face (Y 0) is 95 mm from the + // eye, so it is drawn 100/95 times its size on the plane through the target. + let camera = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 100, "fov": 0.6}); + let perspective = run(&mut s, "inspect.silhouette", json!({"camera": camera, "rows": 60})); + assert_eq!(perspective["projection"], "perspective"); + assert_eq!(perspective["box_mm"], json!([[-0.5263, -0.7895], [20.5263, 30.7895]])); + let flat = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 100}); + let parallel = run(&mut s, "inspect.silhouette", json!({"camera": flat, "rows": 60})); + assert_eq!(parallel["projection"], "parallel"); + assert_eq!(parallel["box_mm"], json!([[0.0, 0.0], [20.0, 30.0]])); + let inside = json!({"target": [10, 5, 15], "yaw": 0, "pitch": 0, "distance": 1, "fov": 0.6}); + assert!(s.execute("inspect.silhouette", &json!({"camera": inside})).unwrap_err().to_string().contains("behind the camera")); + // Between two bodies, looking -X from X 28: Short (X 30..40) is behind the eye. + silhouette_box(&mut s, "Short", 30.0, 40.0, 10.0); + let between = json!({"target": [25, 5, 15], "yaw": -std::f64::consts::FRAC_PI_2, "pitch": 0, "distance": 3, "fov": 0.6}); + assert!(s.execute("inspect.silhouette", &json!({"camera": between})).unwrap_err().to_string().contains("behind the camera")); + assert_eq!(run(&mut s, "inspect.silhouette", json!({"camera": between, "bodies": ["Box"]}))["bodies"], 1); +} + +#[test] +fn silhouette_is_an_agent_command_not_a_toolbar_button() { + let specs = command_specs(); + let spec = specs.iter().find(|c| c.id == "inspect.silhouette").unwrap(); + assert!(spec.tab.is_empty(), "{} › {}", spec.tab, spec.panel); + assert!(!spec.undoable); +} + #[test] fn sample_edges_chain_and_faces() { let mut s = Session::default(); @@ -1196,6 +1316,99 @@ fn extrude_auto_operation() { assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6); } +/// #1666: a loft between two 18-point polylines has twisted (bilinear) side faces. Each was +/// meshed as two flat triangles, which cross the surface; the mesh volume was 16 800.6 mm³ +/// against the B-rep's 17 884. Divided to the chord tolerance, the mesh matches the B-rep. +#[test] +fn twisted_ruled_faces_are_meshed_to_the_chord_tolerance() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Bottom"})); + run( + &mut s, + "sketch.line", + json!({"closed": true, "infer": false, "connect": false, "points": [[-26,3],[-24.76384,3],[-16.0568,14.97217],[-8,4.90117],[0,14.90117],[8,4.90117],[16.0568,14.97217],[24.76384,3],[26,3],[26,0],[23.23616,0],[15.9432,10.02783],[8,0.09883],[0,10.09883],[-8,0.09883],[-15.9432,10.02783],[-23.23616,0],[-26,0]]}), + ); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 90.5, "name": "Top"})); + run( + &mut s, + "sketch.line", + json!({"closed": true, "infer": false, "connect": false, "points": [[-26,3],[-24,3],[-16,3],[-8,3],[0,3],[8,3],[16,3],[24,3],[26,3],[26,0],[24,0],[16,0],[8,0],[0,0],[-8,0],[-16,0],[-24,0],[-26,0]]}), + ); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Bottom"}, {"sketch": "Top"}], "operation": "new"})); + let st = s.model.state(); + let body = &st.bodies.first().unwrap().body; + let exact = solidcraft_kernel::measure(body).unwrap().volume; + let mesh = body.tessellate(0.05).unwrap(); + let volume: f64 = mesh + .triangles + .iter() + .map(|t| { + let p = |i: u32| mesh.positions[i as usize]; + p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0 + }) + .sum(); + assert!(mesh.triangles.len() > 72, "the twisted faces are divided: {} triangles", mesh.triangles.len()); + assert!(rel(volume.abs(), exact) < 2e-3, "mesh {volume} against B-rep {exact}"); +} + +/// #1633: to an object, the extrude goes to it on whichever side it is, whatever its +/// `direction`: through the body to its far face is a Cut, out to something above is a Join. +#[test] +fn extrude_auto_operation_follows_the_to_object() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "corner": [0, 0, 50], "body_name": "Above"})); + for direction in ["positive", "negative"] { + // From the top face to the bottom face: through the body. + let down = json!({"face": [20, 15, 20], "to": [10, 10, 0], "distance": 19, "direction": direction}); + assert!(extrude_reach(&s, &down).is_some_and(|h| (h + 20.0).abs() < 1e-6), "{down}"); + assert_eq!(auto_operation(&s, &down), Some("cut"), "{down}"); + // From the top face up to the box above: out of the body. + let up = json!({"face": [20, 15, 20], "to": [10, 10, 50], "distance": 19, "direction": direction}); + assert!(extrude_reach(&s, &up).is_some_and(|h| (h - 30.0).abs() < 1e-6), "{up}"); + assert_eq!(auto_operation(&s, &up), Some("join"), "{up}"); + } + // Without a To object the direction still counts. + assert_eq!(extrude_reach(&s, &json!({"face": [20, 15, 20], "distance": 5, "direction": "negative"})), Some(-5.0)); +} + +/// #1678: a hole's `position` takes length expressions, keeps them and follows the parameters: a +/// hole on a plate's middle line stays there when the plate gets wider. A position of plain +/// numbers is stored as before, with no `at`. +#[test] +fn a_hole_position_of_expressions_follows_the_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "width", "expression": "30 mm"})); + run(&mut s, "parameters.add", json!({"name": "t", "expression": "4 mm"})); + run(&mut s, "solid.box", json!({"length": 100, "width": "width", "height": "t", "body_name": "Plate"})); + run(&mut s, "solid.hole", json!({"position": ["40", "width/2", "t"], "clearance": "M5", "name": "Middle"})); + run(&mut s, "solid.hole", json!({"position": [80, 10, 4], "clearance": "M5", "name": "Fixed"})); + let centre_y = |s: &Session| { + let st = s.model.state(); + solidcraft_kernel::measure(&st.body("Plate").unwrap().body).unwrap().centroid.y + }; + let hole = |s: &Session, name: &str| -> Value { + let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap(); + saved["features"].as_array().and_then(|f| f.iter().find(|f| f["name"] == name)).cloned().unwrap() + }; + assert_eq!(hole(&s, "Middle")["at"], json!([40.0, "width/2", "t"]), "the expressions are kept"); + assert!(hole(&s, "Fixed").get("at").is_none(), "plain numbers are stored as before"); + // The fixed hole sits off the middle line, so it alone moves the centre; widen the plate. + let before = centre_y(&s); + run(&mut s, "parameters.change", json!({"name": "width", "expression": "40 mm"})); + let after = centre_y(&s); + // The middle hole is at y = 20 now (the plate's middle); the fixed one stays at y = 10. With + // the middle hole left at y = 15 the centre would sit lower still. + let plate = 100.0 * 40.0 * 4.0; + let hole_v = std::f64::consts::PI * (5.5f64 / 2.0).powi(2) * 4.0; + let want = (plate * 20.0 - hole_v * 20.0 - hole_v * 10.0) / (plate - 2.0 * hole_v); + assert!((after - want).abs() < 1e-3, "centre y {after}, want {want} (was {before})"); + // A bad expression is refused. + assert!(s.execute("solid.hole", &json!({"position": ["40", "nope/2", "t"], "diameter": 3})).is_err()); +} + /// Additive extrusions default to joining even when only a profile side touches material. #[test] fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() { diff --git a/crates/engine/src/tests/import_component.rs b/crates/engine/src/tests/import_component.rs index 061f40d9..6fb58874 100644 --- a/crates/engine/src/tests/import_component.rs +++ b/crates/engine/src/tests/import_component.rs @@ -322,3 +322,41 @@ fn a_design_with_a_loose_import_rebuilds_unchanged() { assert_eq!(shown_boxes(&s), file_boxes(&path)); let _ = std::fs::remove_dir_all(&dir); } + +/// A design whose `Outer` component holds a plate and an `Inner` component placed twice (the +/// first placement turned and moved), with `Outer` itself turned about X and lifted. +fn nested_file(dir: &std::path::Path) -> String { + let mut s = Session::default(); + let outer = run(&mut s, "component.create", json!({"name": "Outer"}))["occurrence"].clone(); + run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "body_name": "Plate"})); + let inner = run(&mut s, "component.create", json!({"name": "Inner"}))["occurrence"].clone(); + run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10, "body_name": "Block"})); + run(&mut s, "component.activate", json!({"component": "Outer"})); + run(&mut s, "occurrence.move", json!({"occurrence": inner, "axis": [0, 0, 1], "angle": "90 deg", "translate": [100, 0, 0]})); + run(&mut s, "occurrence.copy", json!({"component": "Inner", "translate": [0, 0, 30]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "occurrence.move", json!({"occurrence": outer, "space": "world", "axis": [1, 0, 0], "angle": "90 deg", "translate": [0, 0, 50]})); + let path = dir.join("nested.step").to_string_lossy().to_string(); + run(&mut s, "file.export", json!({"path": path})); + path +} + +#[test] +fn an_inserted_assembly_exports_its_nested_parts_where_they_are() { + let dir = temp_dir("reexport"); + let path = nested_file(&dir); + let placed = file_boxes(&path); + assert_eq!(placed.len(), 3, "a plate and two blocks: {placed:?}"); + let mut s = Session::default(); + run(&mut s, "file.insert_step", json!({"path": path})); + assert_eq!(shown_boxes(&s), placed); + // The whole design as an assembly: every part is placed once, by its occurrences. + let again = dir.join("again.step").to_string_lossy().to_string(); + run(&mut s, "file.export", json!({"path": again})); + assert_eq!(file_boxes(&again), placed); + // And inserted again, it shows as the first file did. + let mut back = Session::default(); + run(&mut back, "file.insert_step", json!({"path": again})); + assert_eq!(shown_boxes(&back), placed); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests/mirror_placed.rs b/crates/engine/src/tests/mirror_placed.rs new file mode 100644 index 00000000..781fca35 --- /dev/null +++ b/crates/engine/src/tests/mirror_placed.rs @@ -0,0 +1,105 @@ +//! `solid.mirror` of bodies in a placed component (a moved occurrence, a further instance, an +//! inserted design) mirrors each body where it is shown. Before, it mirrored the component's own +//! geometry and put the copy in the active component, so the copy lost the placement. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::run; +use crate::*; + +fn world_box(s: &Session, body: &str) -> (Vec3, Vec3) { + let b = s.world_state().body(body).unwrap_or_else(|| panic!("no body `{body}`")).mesh().bounds(); + (b.min, b.max) +} + +/// The box of `body` mirrored in the world YZ plane. +fn mirrored_yz(s: &Session, body: &str) -> (Vec3, Vec3) { + let (lo, hi) = world_box(s, body); + (Vec3::new(-hi.x, lo.y, lo.z), Vec3::new(-lo.x, hi.y, hi.z)) +} + +fn assert_box(got: (Vec3, Vec3), want: (Vec3, Vec3)) { + assert!(got.0.dist(want.0) < 1e-6 && got.1.dist(want.1) < 1e-6, "got {got:?}, want {want:?}"); +} + +/// A component "Arm" holding "ArmBody", its occurrence turned and moved; the root is active. +fn moved_arm(s: &mut Session) -> (u64, u64) { + let c = run(s, "component.create", json!({"name": "Arm"})); + let (component, occurrence) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); + run(s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 4, "height": 6, "body_name": "ArmBody"})); + run(s, "component.activate", json!({"component": 0})); + run(s, "occurrence.move", json!({"occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg", "translate": [30, 50, 0]})); + (component, occurrence) +} + +fn created(r: &Value) -> String { + r["created_bodies"][0].as_str().unwrap().to_string() +} + +#[test] +fn a_moved_components_body_is_mirrored_where_it_is_shown() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}))); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // The copy follows its source when the occurrence moves again. + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "space": "world", "translate": [0, 200, 0]})); + assert_box(world_box(&s, ©), mirrored_yz(&s, "ArmBody")); + // It survives a save and reopen. + let t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_box(world_box(&t, ©), mirrored_yz(&t, "ArmBody")); +} + +#[test] +fn a_further_instance_is_mirrored_at_its_own_placement() { + let mut s = Session::default(); + let (component, _) = moved_arm(&mut s); + run(&mut s, "occurrence.copy", json!({"component": component, "translate": [0, 0, 40]})); + let names: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + let instance = names.iter().find(|n| n.starts_with("ArmBody (")).cloned().unwrap(); + let r = run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody", instance], "plane": "YZ"})); + let copies: Vec = r["created_bodies"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect(); + assert_eq!(copies.len(), 2, "{r}"); + assert_box(world_box(&s, &copies[0]), mirrored_yz(&s, "ArmBody")); + assert_box(world_box(&s, &copies[1]), mirrored_yz(&s, &instance)); +} + +#[test] +fn a_plane_picked_on_a_moved_body_is_a_plane_of_the_active_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane x = 30 through the moved body: the copy is mirrored in it, as shown. + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [30, 50, 0], "normal": [1, 0, 0]}}))); + let (lo, hi) = world_box(&s, "ArmBody"); + assert_box(world_box(&s, ©), (Vec3::new(60.0 - hi.x, lo.y, lo.z), Vec3::new(60.0 - lo.x, hi.y, hi.z))); +} + +#[test] +fn a_joined_mirror_of_a_moved_body_stays_in_its_component() { + let mut s = Session::default(); + moved_arm(&mut s); + // A world plane through the body's far end: the joined body doubles along x where it is. + let (lo, hi) = world_box(&s, "ArmBody"); + run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": {"origin": [hi.x, 0, 0], "normal": [1, 0, 0]}, "combine": true})); + let len = hi.x - lo.x; + assert_box(world_box(&s, "ArmBody"), (lo, Vec3::new(hi.x + len, hi.y, hi.z))); +} + +#[test] +fn bodies_in_place_keep_no_placement() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); + run(&mut s, "solid.mirror", json!({"bodies": ["Base"], "plane": "YZ"})); + let f = s.doc.features.last().unwrap(); + assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Mirror { placements, .. } if placements.is_empty()), "{:?}", f.kind); +} + +#[test] +fn a_position_not_captured_yet_is_refused() { + let mut s = Session::default(); + let (_, occurrence) = moved_arm(&mut s); + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [5, 0, 0], "capture": false})); + let e = s.execute("solid.mirror", &json!({"bodies": ["ArmBody"], "plane": "YZ"})).unwrap_err().to_string(); + assert!(e.contains("Capture Position"), "{e}"); +} diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index 48a06a6b..229e3bee 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -558,6 +558,7 @@ fn blend_corners_where_edges_meet_on_a_curved_wall() { fn a_boundary_found_across_a_seam_is_not_used() { let r = fuzz::run(20, 8); assert_eq!(r.outcome.kind, "ok", "seed 20: {:?}\n{}", r.outcome, r.script()); + assert!(r.steps.iter().any(|s| s.kind == "circular pattern"), "the pattern must build, not be skipped as unsupported: {:?}", r.unsupported); } /// Seed 33 ends in a fillet on its second body (#854's corner blends build it differently from diff --git a/crates/engine/src/tests/robustness_goldens.rs b/crates/engine/src/tests/robustness_goldens.rs index 9cee7ef0..05922229 100644 --- a/crates/engine/src/tests/robustness_goldens.rs +++ b/crates/engine/src/tests/robustness_goldens.rs @@ -5,12 +5,14 @@ //! volume and area (within a relative 1e-6) and their face, edge and shell counts (exact). Any //! change fails, the better and the worse alike: a seed that used to be refused and now builds //! is recorded on purpose, with the reason. To record: `ROBUSTNESS_GOLDENS_BLESS="why" cargo -//! test --profile ci -p solidcraft-engine --lib robustness_goldens:: -- --include-ignored` -//! (docs/robustness.md). The reason goes into the file's `notes` with the seeds it changed. +//! test --profile ci -p solidcraft-engine --lib robustness_goldens::` (docs/robustness.md); run +//! it with `cargo test`, whose tests share one process and so one lock on the file, not with +//! nextest. The reason goes into the file's `notes` with the seeds it changed. //! -//! Pull requests that touch `crates/kernel` run a sample (`sample_0` … `sample_3`, every 8th -//! seed, split so the shards share them); pushes to main and the nightly run every seed -//! (`all_0` … `all_3`). +//! Each seed is its own test (`seed_00` … `seed_95`), so the runner spreads them over the CI +//! shards and their cores: a few seeds take minutes, and grouped four ways they made one test +//! of half an hour. Pull requests that touch `crates/kernel` run every 8th seed (ci.yml); +//! pushes to main and the nightly run every seed. use std::collections::BTreeMap; use std::path::PathBuf; @@ -52,9 +54,8 @@ fn differs(want: &Value, got: &Value) -> Option { (!out.is_empty()).then(|| out.join("; ")) } -fn check(part: u64, parts: u64, every: u64) { - let seeds: Vec = (0..SEEDS).filter(|s| s % every == 0 && (s / every) % parts == part).collect(); - let got: BTreeMap = seeds.iter().map(|s| (*s, fuzz::golden(*s, MAX_STEPS))).collect(); +fn check(seed: u64) { + let got: BTreeMap = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]); let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner()); let mut file = load(); let changed: Vec<(u64, String)> = got @@ -111,42 +112,123 @@ fn check(part: u64, parts: u64, every: u64) { ); } -#[test] -fn sample_0() { - check(0, 4, 8); +/// One test per seed, and the list of them. +macro_rules! seeds { + ($($name:ident = $seed:literal),* $(,)?) => { + $( + #[test] + fn $name() { + check($seed); + } + )* + const LISTED: &[u64] = &[$($seed),*]; + }; } -#[test] -fn sample_1() { - check(1, 4, 8); -} +seeds!( + seed_00 = 0, + seed_01 = 1, + seed_02 = 2, + seed_03 = 3, + seed_04 = 4, + seed_05 = 5, + seed_06 = 6, + seed_07 = 7, + seed_08 = 8, + seed_09 = 9, + seed_10 = 10, + seed_11 = 11, + seed_12 = 12, + seed_13 = 13, + seed_14 = 14, + seed_15 = 15, + seed_16 = 16, + seed_17 = 17, + seed_18 = 18, + seed_19 = 19, + seed_20 = 20, + seed_21 = 21, + seed_22 = 22, + seed_23 = 23, + seed_24 = 24, + seed_25 = 25, + seed_26 = 26, + seed_27 = 27, + seed_28 = 28, + seed_29 = 29, + seed_30 = 30, + seed_31 = 31, + seed_32 = 32, + seed_33 = 33, + seed_34 = 34, + seed_35 = 35, + seed_36 = 36, + seed_37 = 37, + seed_38 = 38, + seed_39 = 39, + seed_40 = 40, + seed_41 = 41, + seed_42 = 42, + seed_43 = 43, + seed_44 = 44, + seed_45 = 45, + seed_46 = 46, + seed_47 = 47, + seed_48 = 48, + seed_49 = 49, + seed_50 = 50, + seed_51 = 51, + seed_52 = 52, + seed_53 = 53, + seed_54 = 54, + seed_55 = 55, + seed_56 = 56, + seed_57 = 57, + seed_58 = 58, + seed_59 = 59, + seed_60 = 60, + seed_61 = 61, + seed_62 = 62, + seed_63 = 63, + seed_64 = 64, + seed_65 = 65, + seed_66 = 66, + seed_67 = 67, + seed_68 = 68, + seed_69 = 69, + seed_70 = 70, + seed_71 = 71, + seed_72 = 72, + seed_73 = 73, + seed_74 = 74, + seed_75 = 75, + seed_76 = 76, + seed_77 = 77, + seed_78 = 78, + seed_79 = 79, + seed_80 = 80, + seed_81 = 81, + seed_82 = 82, + seed_83 = 83, + seed_84 = 84, + seed_85 = 85, + seed_86 = 86, + seed_87 = 87, + seed_88 = 88, + seed_89 = 89, + seed_90 = 90, + seed_91 = 91, + seed_92 = 92, + seed_93 = 93, + seed_94 = 94, + seed_95 = 95, +); +/// Every seed in 0..SEEDS has its test, once, and every recorded seed is one of them. #[test] -fn sample_2() { - check(2, 4, 8); -} - -#[test] -fn sample_3() { - check(3, 4, 8); -} - -#[test] -fn all_0() { - check(0, 4, 1); -} - -#[test] -fn all_1() { - check(1, 4, 1); -} - -#[test] -fn all_2() { - check(2, 4, 1); -} - -#[test] -fn all_3() { - check(3, 4, 1); +fn every_seed_has_a_test() { + assert_eq!(LISTED, (0..SEEDS).collect::>().as_slice()); + let file = load(); + let recorded: Vec = file["seeds"].as_object().into_iter().flatten().filter_map(|(k, _)| k.parse().ok()).collect(); + assert!(recorded.iter().all(|s| *s < SEEDS), "{recorded:?}"); } diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs index 906fcb62..3b725197 100644 --- a/crates/engine/src/thread_tests.rs +++ b/crates/engine/src/thread_tests.rs @@ -22,6 +22,18 @@ fn volume(s: &mut Session, b: &str) -> f64 { const SIZES: [(f64, f64); 9] = [(3.0, 0.5), (4.0, 0.7), (5.0, 0.8), (6.0, 1.0), (8.0, 1.25), (10.0, 1.5), (12.0, 1.75), (16.0, 2.0), (20.0, 2.5)]; +#[test] +fn non_finite_thread_designations_are_refused_without_changing_the_design() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius":4,"height":10})); + let saved = s.doc.to_json(); + for designation in ["Minfx1", "M8xinf", "MNaNx1", "M8xNaN", "M1e999x1"] { + let result = s.execute("solid.thread", &json!({"face":[4,0,2],"designation":designation})); + assert!(result.is_err(), "{designation}: {result:?}"); + assert_eq!(s.doc.to_json(), saved); + } +} + /// ∫ 2π r · width(r) dr over [a, b]: the volume one turn of the groove takes out (a screw /// motion of a section in an axial plane sweeps 2π∫r dA per turn). fn per_turn(a: f64, b: f64, width: impl Fn(f64) -> f64) -> f64 { diff --git a/crates/engine/src/timing.rs b/crates/engine/src/timing.rs new file mode 100644 index 00000000..a3dede46 --- /dev/null +++ b/crates/engine/src/timing.rs @@ -0,0 +1,111 @@ +//! Per-command timings for script runs and batches (#1833): how long each command took and, +//! when asked, how much of it went to kernel booleans and tessellation. The CLI's `run +//! --timings`, MCP `batch` and the app's `engine.batch` all report steps the same way: +//! `{"index", "command", "ms", "profile"?: {"boolean_ms", "tessellate_ms", "other_ms"}}`. + +use serde_json::{Value, json}; + +use crate::kernel::profile::{self, KernelTimes}; + +/// One step's time. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Step { + /// Wall time, ms. + pub ms: f64, + /// Kernel time within it, when profiled. + pub kernel: Option, +} + +/// A clock for a whole run (`total_ms`). +pub struct Clock(web_time::Instant); + +impl Clock { + pub fn start() -> Clock { + Clock(web_time::Instant::now()) + } + pub fn ms(&self) -> f64 { + self.0.elapsed().as_secs_f64() * 1000.0 + } +} + +/// Run `f` and time it; with `profile`, its kernel time too. +pub fn time(profile: bool, f: impl FnOnce() -> T) -> (T, Step) { + let clock = Clock::start(); + if profile { + let (r, k) = profile::capture(f); + (r, Step { ms: clock.ms(), kernel: Some(k) }) + } else { + let r = f(); + (r, Step { ms: clock.ms(), kernel: None }) + } +} + +/// Milliseconds to the microsecond, so the JSON stays readable. +pub fn round_ms(ms: f64) -> f64 { + if ms.is_finite() { (ms * 1000.0).round() / 1000.0 } else { 0.0 } +} + +impl Step { + /// The step's report entry (`index` from 0, as a batch's `failed.index`). + pub fn to_json(&self, index: usize, command: &str) -> Value { + let mut v = json!({"index": index, "command": command, "ms": round_ms(self.ms)}); + if let (Some(k), Some(o)) = (self.kernel, v.as_object_mut()) { + let other = (self.ms - k.boolean_ms - k.tessellate_ms).max(0.0); + o.insert( + "profile".into(), + json!({"boolean_ms": round_ms(k.boolean_ms), "tessellate_ms": round_ms(k.tessellate_ms), "other_ms": round_ms(other)}), + ); + } + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps_report_their_time_and_profile_only_when_asked() { + let (r, step) = time(false, || 7); + assert_eq!(r, 7); + let v = step.to_json(2, "solid.box"); + assert_eq!((v["index"].as_u64(), v["command"].as_str()), (Some(2), Some("solid.box"))); + assert!(v["ms"].as_f64().is_some_and(|ms| ms >= 0.0)); + assert!(v.get("profile").is_none()); + let step = Step { ms: 10.0, kernel: Some(KernelTimes { boolean_ms: 6.0, tessellate_ms: 3.0 }) }; + assert_eq!(step.to_json(0, "x")["profile"], json!({"boolean_ms": 6.0, "tessellate_ms": 3.0, "other_ms": 1.0})); + // Rounding can make the parts add up to a hair more than the whole: never a negative rest. + let step = Step { ms: 1.0, kernel: Some(KernelTimes { boolean_ms: 1.0000004, tessellate_ms: 0.0 }) }; + assert_eq!(step.to_json(0, "x")["profile"]["other_ms"], json!(0.0)); + assert_eq!(round_ms(f64::NAN), 0.0); + } + + #[test] + fn a_timed_script_reports_every_step_and_the_kernel_share_of_a_join() { + let script = json!({"commands": [ + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}, + {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}}, + ]}); + let mut s = crate::Session::default(); + let (results, t) = s.run_script_timed(&script, true).unwrap(); + assert_eq!(results.len(), 2); + let rows = t["timings"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1]["command"], "solid.box"); + assert_eq!(rows[1]["index"], 1); + let p = &rows[1]["profile"]; + assert!(p["boolean_ms"].as_f64().unwrap() > 0.0, "{t}"); + let sum: f64 = ["boolean_ms", "tessellate_ms", "other_ms"].iter().map(|k| p[k].as_f64().unwrap()).sum(); + assert!((sum - rows[1]["ms"].as_f64().unwrap()).abs() < 0.01, "{t}"); + let total = t["total_ms"].as_f64().unwrap(); + assert!(total + 0.01 >= rows.iter().map(|r| r["ms"].as_f64().unwrap()).sum::(), "{t}"); + // Without profile: the same rows, no kernel split; the plain run is unchanged. + let (_, t) = crate::Session::default().run_script_timed(&script, false).unwrap(); + assert!(t["timings"][0].get("profile").is_none()); + assert_eq!(crate::Session::default().run_script(&script).unwrap().len(), 2); + // A failing step still fails the run, with its step number. + let bad = json!([{"command": "solid.box", "params": {"length": -1}}]); + let e = crate::Session::default().run_script_timed(&bad, true).unwrap_err().to_string(); + assert!(e.contains("step 1"), "{e}"); + } +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index e27e5027..5c1397ea 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -390,13 +390,13 @@ fn build_scene( }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); - // Grid on the ground plane (XY with Z up, XZ with Y up), or the upright origin plane facing - // a view that sees the ground edge-on ([`grid_plane`]), drawn under the model as in the viewport. + // Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the viewport. if guides.grid { let (minor, major) = grid_step(cam.half_height()); let ext = (cam.half_height() * 3.0 / major).ceil() * major; let tf = cam.to_frame(cam.target); - let (u, v) = grid_plane(cam.to_frame(cam.back())); + // Headless pictures keep the grid on the ground, the viewport's default. + let (u, v) = (Vec3::X, Vec3::Y); let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); let c = snap(u) + snap(v); let w = |p: Vec3| cam.from_frame(p); diff --git a/crates/engine/tests/mesh_export_regressions.rs b/crates/engine/tests/mesh_export_regressions.rs new file mode 100644 index 00000000..1d51267a --- /dev/null +++ b/crates/engine/tests/mesh_export_regressions.rs @@ -0,0 +1,77 @@ +//! Mesh export regressions: a closed body exports as a closed mesh. + +use std::collections::HashMap; + +use serde_json::{Value, json}; +use solidcraft_engine::Session; +use solidcraft_io::{Format, export, read_3mf, read_stl}; + +fn run(s: &mut Session, script: Value) { + for c in script.as_array().into_iter().flatten() { + let (id, p) = (c["command"].as_str().unwrap_or_default(), &c["params"]); + if let Err(e) = s.execute(id, p) { + panic!("{id} {p}: {e}"); + } + } +} + +/// The edges (by vertex index) that are not used by exactly two triangles. +fn open_edges(triangles: &[[u32; 3]]) -> Vec<((u32, u32), u32)> { + let mut uses: HashMap<(u32, u32), u32> = HashMap::new(); + for t in triangles { + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + *uses.entry((a.min(b), a.max(b))).or_default() += 1; + } + } + uses.into_iter().filter(|(_, n)| *n != 2).collect() +} + +/// A bowl section (lines and arcs) revolved about Z, its rim cut by a large cylinder across it. +/// The edge where the cylinder meets the inner cone is an intersection curve; one of its samples +/// lay a few ten-thousandths of a millimetre off both surfaces, each face put the point on its +/// own surface, and the exported mesh had a crack there (four edges used once). +#[test] +fn a_rim_cut_by_a_cylinder_exports_closed() { + let mut s = Session::default(); + run( + &mut s, + json!([ + {"command": "sketch.create", "params": {"plane": "XZ", "name": "Section"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [48.8, 0], [50.0240096077, -1.2]]}}, + {"command": "sketch.arc.three_point", "params": {"start": [50.0240096077, -1.2], "end": [58.7867965644, -21.2132034356], "through": [52.5188160894, -12.0326443841]}}, + {"command": "sketch.line", "params": {"points": [[58.7867965644, -21.2132034356], [85.5735931288, -48], [82.1794805791, -48], [57.0897402896, -22.9102597104]]}}, + {"command": "sketch.arc.three_point", "params": {"start": [57.0897402896, -22.9102597104], "end": [48.3846976331, -7.0875], "through": [51.6125175266, -15.6176451113]}}, + {"command": "sketch.arc.three_point", "params": {"start": [48.3846976331, -7.0875], "end": [42.5300120096, -2.4], "through": [46.2800120096, -3.7162515012]}}, + {"command": "sketch.line", "params": {"points": [[42.5300120096, -2.4], [0, -2.4], [0, 0]]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.revolve", "params": {"sketch": "Section", "axis": "Z"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "name": "RimCutter"}}, + {"command": "sketch.arc.three_point", "params": {"start": [-100, 11.6204810674], "end": [100, 46.4045454912], "through": [-52, 8]}}, + {"command": "sketch.line", "params": {"points": [[100, 46.4045454912], [100, 70], [-100, 70], [-100, 11.6204810674]]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "RimCutter", "distance": 200, "direction": "symmetric", "operation": "cut"}} + ]), + ); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1); + + let (objects, _) = read_3mf(&export(&st, &[], Format::ThreeMf, "cove").unwrap()).unwrap(); + assert_eq!(objects.len(), 1); + let open = open_edges(&objects[0].triangles); + assert!(open.is_empty(), "3MF: {} edges not used twice, e.g. {:?}", open.len(), open.first()); + + // STL has no indices: vertices are the same where their coordinates are. + let mut index: HashMap<[u32; 3], u32> = HashMap::new(); + let triangles: Vec<[u32; 3]> = read_stl(&export(&st, &[], Format::StlBinary, "cove").unwrap()) + .unwrap() + .iter() + .map(|t| { + t.map(|p| { + let n = index.len() as u32; + *index.entry(p.map(f32::to_bits)).or_insert(n) + }) + }) + .collect(); + let open = open_edges(&triangles); + assert!(open.is_empty(), "STL: {} edges not used twice, e.g. {:?}", open.len(), open.first()); +} diff --git a/crates/geom/src/blend.rs b/crates/geom/src/blend.rs new file mode 100644 index 00000000..6466ff98 --- /dev/null +++ b/crates/geom/src/blend.rs @@ -0,0 +1,53 @@ +//! Blend curves: one Bézier that joins two curves with a requested continuity order, by Hermite +//! interpolation of each reference curve's position and derivatives at the joints. This is the +//! geometry `sketch.blend_curve` builds; it knows nothing about sketches or the document. + +use crate::{MAX_DERIVATIVE_ORDER, Vec2, profile::binom}; + +/// Control points of the Bézier of degree `2k + 1`, `k = d0.len()`, that starts at `p0` with +/// derivatives `d0` and ends at `p1` with derivatives `d1`, all with respect to its own parameter +/// on [0, 1]. The derivative vectors are the reference curves' at the joints, measured in their +/// own parameters and oriented the way the blend runs through the joint, so the blend agrees with +/// each reference through its k-th derivative (G1, G2, G3, G4 for k = 1…4). +/// +/// The two ends fix exactly half of the controls each: a Bézier's j-th forward difference is +/// `P⁽ʲ⁾(0)·(n − j)!/n!`, so the first `k + 1` controls follow from the start data and the last +/// `k + 1` from the end data, which meet exactly at the middle control. `None` when the ends +/// disagree on the order, the order is above [`MAX_DERIVATIVE_ORDER`], or a value is not finite. +pub fn hermite_bezier(p0: Vec2, p1: Vec2, d0: &[Vec2], d1: &[Vec2]) -> Option> { + let k = d0.len(); + if k != d1.len() || k > MAX_DERIVATIVE_ORDER || !p0.is_finite() || !p1.is_finite() || d0.iter().chain(d1).any(|d| !d.is_finite()) { + return None; + } + let n = 2 * k + 1; + let mut c = vec![Vec2::ZERO; n + 1]; + c[0] = p0; + for j in 1..=k { + // `Δʲp₀ = P⁽ʲ⁾(0)·(n − j)!/n!`, and solving it for `pⱼ` puts `(−1)^(j − i + 1)` on every + // control already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d0[j - 1] * scale; + for (i, p) in c.iter().take(j).enumerate() { + value += *p * (odd_sign(j - i) * binom(j, i)); + } + c[j] = value; + } + c[n] = p1; + for j in 1..=k { + // The same at the far end, reading the controls backwards: `Σᵢ(−1)ⁱ·C(j,i)·pₙ₋ᵢ` is + // `P⁽ʲ⁾(1)·(n − j)!/n!`, and solving that for `pₙ₋ⱼ` puts `(−1)^(j + i + 1)` on every control + // already known. + let scale = 1.0 / (0..j).fold(1.0, |acc, i| acc * (n - i) as f64); + let mut value = d1[j - 1] * (scale * odd_sign(j + 1)); + for (i, p) in c[n - j + 1..].iter().rev().enumerate() { + value += *p * (odd_sign(j + i) * binom(j, i)); + } + c[n - j] = value; + } + c.iter().all(|p| p.is_finite()).then_some(c) +} + +/// `(−1)^(m + 1)`: the sign an m-th difference expansion carries in a Bézier's endpoint identity. +fn odd_sign(m: usize) -> f64 { + if m % 2 == 1 { 1.0 } else { -1.0 } +} diff --git a/crates/geom/src/bvh.rs b/crates/geom/src/bvh.rs new file mode 100644 index 00000000..816fa5ad --- /dev/null +++ b/crates/geom/src/bvh.rs @@ -0,0 +1,317 @@ +//! A bounding volume hierarchy over boxes: the nearest hit along a ray, or every item whose box +//! passes a test, without looking at each item. Built once (median split on the widest axis of +//! the box centres, a few items per leaf) and only read afterwards. [`MeshIndex`] holds one over +//! a mesh's triangles and one over its edge segments, for picking. + +use crate::{Aabb3, Mesh, Vec3}; + +/// Most items in a leaf. +const LEAF: usize = 4; + +#[derive(Clone, Copy, Debug)] +struct Node { + bounds: Aabb3, + /// A leaf: the first of its `count` items in [`Bvh::order`]. Otherwise its children are + /// `first` and `first + 1`. + first: u32, + count: u32, +} + +/// A hierarchy over boxes; items are the boxes' indices. +#[derive(Clone, Debug, Default)] +pub struct Bvh { + nodes: Vec, + order: Vec, +} + +impl Bvh { + /// The hierarchy over `boxes` (empty boxes are left out). + pub fn new(boxes: &[Aabb3]) -> Bvh { + let mut order: Vec = + (0..boxes.len().min(u32::MAX as usize)).filter(|&i| boxes.get(i).is_some_and(|b| !b.is_empty() && finite(b))).map(|i| i as u32).collect(); + if order.is_empty() { + return Bvh::default(); + } + let item = |i: u32| boxes.get(i as usize).copied().unwrap_or(Aabb3::EMPTY); + let centre = |i: u32| item(i).center(); + let mut nodes = vec![Node { bounds: Aabb3::EMPTY, first: 0, count: 0 }]; + // (node, start, end) in `order` still to be filled in. + let mut todo = vec![(0usize, 0usize, order.len())]; + while let Some((n, start, end)) = todo.pop() { + let Some(span) = order.get_mut(start..end) else { continue }; + let mut bounds = Aabb3::EMPTY; + let mut centres = Aabb3::EMPTY; + for &i in span.iter() { + bounds = bounds.union(&item(i)); + centres.add(centre(i)); + } + let size = centres.size(); + let axis = if size.x >= size.y && size.x >= size.z { + 0 + } else if size.y >= size.z { + 1 + } else { + 2 + }; + let extent = [size.x, size.y, size.z][axis]; + if span.len() <= LEAF || !(extent > 0.0) { + if let Some(node) = nodes.get_mut(n) { + *node = Node { bounds, first: start as u32, count: span.len() as u32 }; + } + continue; + } + let key = |i: &u32| { + let c = centre(*i); + [c.x, c.y, c.z][axis] + }; + let mid = span.len() / 2; + span.select_nth_unstable_by(mid, |a, b| key(a).total_cmp(&key(b))); + let left = nodes.len(); + nodes.push(Node { bounds: Aabb3::EMPTY, first: 0, count: 0 }); + nodes.push(Node { bounds: Aabb3::EMPTY, first: 0, count: 0 }); + if let Some(node) = nodes.get_mut(n) { + *node = Node { bounds, first: left as u32, count: 0 }; + } + todo.push((left, start, start + mid)); + todo.push((left + 1, start + mid, end)); + } + Bvh { nodes, order } + } + + /// The box around everything (empty for no items). + pub fn bounds(&self) -> Aabb3 { + self.nodes.first().map_or(Aabb3::EMPTY, |n| n.bounds) + } + + /// Calls `item` with every item in a leaf whose box, and every box above it, `enter` accepts. + pub fn visit(&self, mut enter: impl FnMut(&Aabb3) -> bool, mut item: impl FnMut(u32)) { + let mut stack = vec![0usize]; + while let Some(n) = stack.pop() { + let Some(node) = self.nodes.get(n) else { continue }; + if !enter(&node.bounds) { + continue; + } + if node.count > 0 { + let (a, b) = (node.first as usize, node.first as usize + node.count as usize); + for &i in self.order.get(a..b).unwrap_or(&[]) { + item(i); + } + } else { + stack.push(node.first as usize + 1); + stack.push(node.first as usize); + } + } + } + + /// The nearest item along the ray from `o` along `d`: `hit` gives an item's distance along + /// `d` (`None`: missed). Equal distances go to the lowest item, as a scan in item order + /// keeping strictly nearer hits would answer. Boxes are entered nearest first, and one that + /// starts past the best hit so far is skipped. + pub fn nearest(&self, o: Vec3, d: Vec3, mut hit: impl FnMut(u32) -> Option) -> Option<(f64, u32)> { + if ![o.x, o.y, o.z, d.x, d.y, d.z].iter().all(|v| v.is_finite()) { + return None; + } + let inv = Vec3::new(1.0 / d.x, 1.0 / d.y, 1.0 / d.z); + let mut best: Option<(f64, u32)> = None; + let root = self.nodes.first().and_then(|n| ray_box(o, d, inv, &n.bounds, 0.0))?; + let mut stack = vec![(0usize, root)]; + while let Some((n, t0)) = stack.pop() { + if best.is_some_and(|(bt, _)| t0 > bt) { + continue; + } + let Some(node) = self.nodes.get(n) else { continue }; + if node.count > 0 { + let (a, b) = (node.first as usize, node.first as usize + node.count as usize); + for &i in self.order.get(a..b).unwrap_or(&[]) { + if let Some(t) = hit(i) + && best.is_none_or(|(bt, bi)| t < bt || (t == bt && i < bi)) + { + best = Some((t, i)); + } + } + continue; + } + let (l, r) = (node.first as usize, node.first as usize + 1); + let tl = self.nodes.get(l).and_then(|c| ray_box(o, d, inv, &c.bounds, 0.0)); + let tr = self.nodes.get(r).and_then(|c| ray_box(o, d, inv, &c.bounds, 0.0)); + // The nearer child is popped first. + match (tl, tr) { + (Some(a), Some(b)) if a <= b => stack.extend([(r, b), (l, a)]), + (Some(a), Some(b)) => stack.extend([(l, a), (r, b)]), + (Some(a), None) => stack.push((l, a)), + (None, Some(b)) => stack.push((r, b)), + (None, None) => {} + } + } + best + } + + /// Calls `item(i, j)` for pairs of items, `i` from this hierarchy and `j` from `other`, in + /// leaves whose boxes, and every pair of boxes above them, `enter` accepts. `enter` is asked + /// again for a pair of boxes when it is taken up, so a bound that tightens while items are + /// visited (the nearest pair so far) prunes what is still waiting. Of two children, the pair + /// whose box centres are nearer is taken up first. + pub fn visit_pairs(&self, other: &Bvh, mut enter: impl FnMut(&Aabb3, &Aabb3) -> bool, mut item: impl FnMut(u32, u32)) { + if self.nodes.is_empty() || other.nodes.is_empty() { + return; + } + let mut stack = vec![(0usize, 0usize)]; + while let Some((i, j)) = stack.pop() { + let (Some(a), Some(b)) = (self.nodes.get(i), other.nodes.get(j)) else { continue }; + if !enter(&a.bounds, &b.bounds) { + continue; + } + match (a.count > 0, b.count > 0) { + (true, true) => { + let (ia, ib) = (a.first as usize, b.first as usize); + let left = self.order.get(ia..ia + a.count as usize).unwrap_or(&[]); + let right = other.order.get(ib..ib + b.count as usize).unwrap_or(&[]); + for &x in left { + for &y in right { + item(x, y); + } + } + } + // Split the node that is not a leaf, or the larger one. + (leaf_a, leaf_b) => { + let split_a = !leaf_a && (leaf_b || a.bounds.diagonal() >= b.bounds.diagonal()); + let pairs = + if split_a { [(a.first as usize, j), (a.first as usize + 1, j)] } else { [(i, b.first as usize), (i, b.first as usize + 1)] }; + let gap = |(x, y): (usize, usize)| match (self.nodes.get(x), other.nodes.get(y)) { + (Some(x), Some(y)) => x.bounds.center().dist(y.bounds.center()), + _ => f64::INFINITY, + }; + let [p, q] = pairs; + // The nearer pair is popped first. + if gap(p) <= gap(q) { + stack.extend([q, p]); + } else { + stack.extend([p, q]); + } + } + } + } + } +} + +fn finite(b: &Aabb3) -> bool { + [b.min.x, b.min.y, b.min.z, b.max.x, b.max.y, b.max.z].iter().all(|v| v.is_finite()) +} + +/// Where the ray from `o` along `d` (`inv` = 1 / `d` per axis) enters box `b` grown by `pad`, +/// from 0 on; `None` when it misses or the box lies behind. +fn ray_box(o: Vec3, d: Vec3, inv: Vec3, b: &Aabb3, pad: f64) -> Option { + let (mut t0, mut t1) = (0.0f64, f64::INFINITY); + for (o, d, inv, lo, hi) in [(o.x, d.x, inv.x, b.min.x, b.max.x), (o.y, d.y, inv.y, b.min.y, b.max.y), (o.z, d.z, inv.z, b.min.z, b.max.z)] { + let (lo, hi) = (lo - pad, hi + pad); + if d == 0.0 || !inv.is_finite() { + if o < lo || o > hi { + return None; + } + continue; + } + let (a, b) = ((lo - o) * inv, (hi - o) * inv); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + t0 = t0.max(a); + t1 = t1.min(b); + if t0 > t1 { + return None; + } + } + (t0 <= t1).then_some(t0) +} + +/// The farthest any corner of `b` lies along `d` from `o` (0 when the box is behind). +fn farthest_along(o: Vec3, d: Vec3, b: &Aabb3) -> f64 { + let along = |lo: f64, hi: f64, o: f64, d: f64| if d >= 0.0 { (hi - o) * d } else { (lo - o) * d }; + (along(b.min.x, b.max.x, o.x, d.x) + along(b.min.y, b.max.y, o.y, d.y) + along(b.min.z, b.max.z, o.z, d.z)).max(0.0) +} + +/// A mesh's triangles and edge segments in hierarchies, for picking: the nearest triangle along +/// a ray, and the edge segments near one. Built from one mesh and answers only for it. +#[derive(Clone, Debug, Default)] +pub struct MeshIndex { + triangles: Bvh, + triangle_count: usize, + segments: Bvh, + /// Per segment item: its edge, and its first point in that edge's polyline. + segment_of: Vec<(u32, u32)>, +} + +impl MeshIndex { + /// The index of `mesh`. + pub fn new(mesh: &Mesh) -> MeshIndex { + let pad = |mut b: Aabb3| { + // Flat boxes (a triangle square to an axis) are grown a little, so a ray through + // them is never lost to rounding in the box test. + let scale = [b.min.x, b.min.y, b.min.z, b.max.x, b.max.y, b.max.z].iter().fold(1.0f64, |m, v| m.max(v.abs())); + let e = scale * 1e-9; + b.min -= Vec3::new(e, e, e); + b.max += Vec3::new(e, e, e); + b + }; + let boxes: Vec = mesh + .triangles + .iter() + .map(|t| { + let mut b = Aabb3::EMPTY; + for p in mesh.tri(t).into_iter().flatten() { + b.add(p); + } + if b.is_empty() { b } else { pad(b) } + }) + .collect(); + let mut segment_of = Vec::new(); + let mut seg_boxes = Vec::new(); + for (ei, e) in mesh.edges.iter().enumerate() { + // A one-point edge still has its vertex: it stands as a segment from it to itself. + let n = if e.len() == 1 { 1 } else { e.len().saturating_sub(1) }; + for si in 0..n { + let (Some(a), Some(b)) = (e.get(si), e.get(si + 1).or(e.get(si))) else { continue }; + let mut bx = Aabb3::EMPTY; + bx.add(*a); + bx.add(*b); + seg_boxes.push(pad(bx)); + segment_of.push((ei as u32, si as u32)); + } + } + MeshIndex { triangles: Bvh::new(&boxes), triangle_count: mesh.triangles.len(), segments: Bvh::new(&seg_boxes), segment_of } + } + + /// [`Mesh::raycast`] through the index: the same answer, including which triangle wins a tie. + /// For a mesh other than the one indexed, the plain scan answers. + pub fn raycast(&self, mesh: &Mesh, o: Vec3, dir: Vec3) -> Option<(f64, usize)> { + if mesh.triangles.len() != self.triangle_count { + return mesh.raycast(o, dir); + } + let frame = crate::mesh::RayFrame::new(o, dir)?; + self.triangles + .nearest(o, dir, |i| mesh.triangles.get(i as usize).and_then(|t| mesh.tri(t)).and_then(|p| frame.hit(p))) + .map(|(t, i)| (t, i as usize)) + } + + /// Calls `f(edge, first point)` for every edge segment that may come within `a + b·t` of the + /// ray from `o` along unit `d`, `t` being the distance along it (each at most once). A + /// constant `a` suits an orthographic view; a perspective view widens with `b`. + pub fn segments_near(&self, o: Vec3, d: Vec3, a: f64, b: f64, mut f: impl FnMut(usize, usize)) { + if ![a, b, o.x, o.y, o.z, d.x, d.y, d.z].iter().all(|v| v.is_finite()) { + return; + } + let inv = Vec3::new(1.0 / d.x, 1.0 / d.y, 1.0 / d.z); + self.segments.visit( + |bx| { + let reach = a.max(0.0) + b.max(0.0) * farthest_along(o, d, bx); + ray_box(o, d, inv, bx, reach).is_some() + }, + |i| { + if let Some((e, s)) = self.segment_of.get(i as usize) { + f(*e as usize, *s as usize); + } + }, + ); + } +} + +#[cfg(test)] +#[path = "bvh_tests.rs"] +mod tests; diff --git a/crates/geom/src/bvh_tests.rs b/crates/geom/src/bvh_tests.rs new file mode 100644 index 00000000..f551e963 --- /dev/null +++ b/crates/geom/src/bvh_tests.rs @@ -0,0 +1,130 @@ +use super::*; + +/// A small deterministic generator (the tests need no crate for randomness). +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (hi - lo) * self.next() + } + fn vec(&mut self, r: f64) -> Vec3 { + Vec3::new(self.range(-r, r), self.range(-r, r), self.range(-r, r)) + } +} + +/// A grid of `n` × `n` squares of two triangles on z = 0, and `extra` random triangles above. +fn mesh(n: u32, extra: usize, rng: &mut Lcg) -> Mesh { + let mut positions: Vec = (0..=n).flat_map(|j| (0..=n).map(move |i| Vec3::new(f64::from(i), f64::from(j), 0.0))).collect(); + let id = |i: u32, j: u32| j * (n + 1) + i; + let mut triangles = Vec::new(); + for j in 0..n { + for i in 0..n { + triangles.push([id(i, j), id(i + 1, j), id(i + 1, j + 1)]); + triangles.push([id(i, j), id(i + 1, j + 1), id(i, j + 1)]); + } + } + for _ in 0..extra { + let c = rng.vec(f64::from(n)) + Vec3::new(0.0, 0.0, f64::from(n)); + let k = positions.len() as u32; + positions.extend([c + rng.vec(1.5), c + rng.vec(1.5), c + rng.vec(1.5)]); + triangles.push([k, k + 1, k + 2]); + } + let edges = + (0..n).map(|i| (0..=n).map(|j| Vec3::new(f64::from(i), f64::from(j), 0.0)).collect()).chain([vec![Vec3::new(0.5, 0.5, 2.0)]]).collect(); + Mesh { tri_face: vec![0; triangles.len()], positions, triangles, edges, ..Default::default() } +} + +#[test] +fn indexed_raycast_answers_as_the_scan_does() { + let mut rng = Lcg(7); + let m = mesh(12, 400, &mut rng); + let index = MeshIndex::new(&m); + let mut hits = 0; + for k in 0..4000 { + // Every few rays go straight down through grid vertices and edges, where two or more + // triangles tie. + let (o, d) = if k % 4 == 0 { + let p = Vec3::new(f64::from(k % 13), f64::from((k / 13) % 13) * 0.5, 50.0); + (p, Vec3::new(0.0, 0.0, -1.0)) + } else { + let o = rng.vec(40.0) + Vec3::new(6.0, 6.0, 0.0); + let target = Vec3::new(rng.range(-1.0, 13.0), rng.range(-1.0, 13.0), rng.range(-2.0, 14.0)); + let Some(d) = (target - o).normalized() else { continue }; + (o, d) + }; + let want = m.raycast(o, d); + assert_eq!(index.raycast(&m, o, d), want, "ray {k} from {o:?} along {d:?}"); + hits += usize::from(want.is_some()); + } + assert!(hits > 1000, "the rays should mostly hit ({hits})"); +} + +#[test] +fn indexed_raycast_of_another_mesh_falls_back_to_the_scan() { + let mut rng = Lcg(3); + let (a, b) = (mesh(2, 0, &mut rng), mesh(5, 0, &mut rng)); + let index = MeshIndex::new(&a); + let (o, d) = (Vec3::new(4.5, 4.2, 9.0), Vec3::new(0.0, 0.0, -1.0)); + assert!(b.raycast(o, d).is_some()); + assert_eq!(index.raycast(&b, o, d), b.raycast(o, d)); +} + +/// Distance from point `p` to the ray from `o` along unit `d` (from its start on), and how far along. +fn ray_point(o: Vec3, d: Vec3, p: Vec3) -> (f64, f64) { + let t = (p - o).dot(d).max(0.0); + ((o + d * t).dist(p), t) +} + +#[test] +fn segments_near_the_ray_are_all_reported() { + let mut rng = Lcg(11); + let m = mesh(16, 0, &mut rng); + let index = MeshIndex::new(&m); + for k in 0..500 { + let o = rng.vec(30.0) + Vec3::new(8.0, 8.0, 30.0); + let target = Vec3::new(rng.range(0.0, 16.0), rng.range(0.0, 16.0), 0.0); + let Some(d) = (target - o).normalized() else { continue }; + let (a, b) = if k % 2 == 0 { (0.3, 0.0) } else { (0.0, 0.01) }; + let mut got = std::collections::HashSet::new(); + index.segments_near(o, d, a, b, |e, s| { + assert!(got.insert((e, s)), "segment {e}/{s} reported twice"); + }); + for (ei, e) in m.edges.iter().enumerate() { + for (si, w) in e.windows(2).enumerate() { + // Sampled along the segment: any point within reach must have its segment reported. + for f in 0..=8 { + let p = w[0] + (w[1] - w[0]) * (f64::from(f) / 8.0); + let (dist, t) = ray_point(o, d, p); + if dist <= a + b * t { + assert!(got.contains(&(ei, si)), "ray {k}: segment {ei}/{si} is within reach but was not reported"); + } + } + } + } + // The one-point edge stands as its own segment. + let last = m.edges.len() - 1; + let (dist, t) = ray_point(o, d, Vec3::new(0.5, 0.5, 2.0)); + if dist <= a + b * t { + assert!(got.contains(&(last, 0))); + } + } +} + +#[test] +fn hostile_boxes_never_panic() { + let nan = Vec3::new(f64::NAN, 0.0, 0.0); + let boxes = [Aabb3::EMPTY, Aabb3 { min: nan, max: nan }, Aabb3 { min: Vec3::ZERO, max: Vec3::new(1.0, 1.0, 1.0) }]; + let bvh = Bvh::new(&boxes); + assert_eq!(bvh.nearest(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0), |i| Some(f64::from(i))), Some((2.0, 2))); + assert_eq!(bvh.nearest(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0), |_| Some(1.0)), None); + let empty = Bvh::new(&[]); + assert!(empty.nearest(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), |_| Some(1.0)).is_none()); + empty.visit(|_| true, |_| panic!("an empty hierarchy has no items")); + let m = Mesh { positions: vec![Vec3::ZERO], triangles: vec![[0, 5, 9]], ..Default::default() }; + assert!(MeshIndex::new(&m).raycast(&m, Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, -1.0)).is_none()); + MeshIndex::new(&m).segments_near(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), f64::NAN, 0.0, |_, _| panic!("no segments")); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 850f471f..ab75d895 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -9,13 +9,16 @@ #![forbid(unsafe_code)] mod arcfit; +pub mod blend; pub mod brep; pub mod bspline; +mod bvh; pub mod compare; pub mod env; pub mod fill; pub mod fill_general; mod mesh; +pub mod mesh_distance; mod plane; mod profile; pub mod tess; @@ -23,9 +26,11 @@ pub mod tspline; mod vec; pub use arcfit::MAX_FIT_PIECES; +pub use blend::hermite_bezier; +pub use bvh::{Bvh, MeshIndex}; pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair}; pub use plane::Plane; -pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier, regions_apart}; +pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart}; pub use vec::{Vec2, Vec3}; /// Geometric tolerance for coincidence tests (mm). diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 8a9045d4..92339a0a 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -559,13 +559,39 @@ impl Mesh { /// two triangles that share an edge get exactly opposite values there. A ray through a /// shared edge or vertex hits at least one of them; it never slips between them. pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> { - let comp = |v: Vec3, k: usize| match k { - 0 => v.x, - 1 => v.y, - _ => v.z, - }; - // The ray's dominant axis becomes z; x and y follow it cyclically, swapped to keep - // the frame right-handed when the ray runs towards −z. + let frame = RayFrame::new(o, dir)?; + let mut best: Option<(f64, usize)> = None; + for (i, t) in self.triangles.iter().enumerate() { + let Some(p) = self.tri(t) else { continue }; + if let Some(t) = frame.hit(p) + && best.is_none_or(|(bt, _)| t < bt) + { + best = Some((t, i)); + } + } + best + } +} + +/// A ray in the frame of [`Mesh::raycast`]'s watertight test: its dominant axis becomes z; x and +/// y follow it cyclically, swapped to keep the frame right-handed when the ray runs towards −z. +pub(crate) struct RayFrame { + o: Vec3, + k: [usize; 3], + s: [f64; 3], +} + +fn comp(v: Vec3, k: usize) -> f64 { + match k { + 0 => v.x, + 1 => v.y, + _ => v.z, + } +} + +impl RayFrame { + /// `None` for a direction that is zero or not finite. + pub(crate) fn new(o: Vec3, dir: Vec3) -> Option { let a = [dir.x.abs(), dir.y.abs(), dir.z.abs()]; let kz = if a[0] >= a[1] && a[0] >= a[2] { 0 @@ -582,33 +608,31 @@ impl Mesh { if dz < 0.0 { std::mem::swap(&mut kx, &mut ky); } - let (sx, sy, sz) = (comp(dir, kx) / dz, comp(dir, ky) / dz, 1.0 / dz); - // A vertex in the ray's frame: (x, y) across the ray, z scaled along it. - let frame = |p: Vec3| { - let q = p - o; - let z = comp(q, kz); - (comp(q, kx) - sx * z, comp(q, ky) - sy * z, sz * z) - }; - let mut best: Option<(f64, usize)> = None; - for (i, t) in self.triangles.iter().enumerate() { - let Some([pa, pb, pc]) = self.tri(t) else { continue }; - let ((ax, ay, az), (bx, by, bz), (cx, cy, cz)) = (frame(pa), frame(pb), frame(pc)); - let u = cx * by - cy * bx; - let v = ax * cy - ay * cx; - let w = bx * ay - by * ax; - if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) { - continue; - } - let det = u + v + w; - if det == 0.0 { - continue; - } - let t = (u * az + v * bz + w * cz) / det; - if t.is_finite() && t > 1e-9 && best.is_none_or(|(bt, _)| t < bt) { - best = Some((t, i)); - } + Some(RayFrame { o, k: [kx, ky, kz], s: [comp(dir, kx) / dz, comp(dir, ky) / dz, 1.0 / dz] }) + } + + /// A vertex in the ray's frame: (x, y) across the ray, z scaled along it. + fn frame(&self, p: Vec3) -> (f64, f64, f64) { + let q = p - self.o; + let z = comp(q, self.k[2]); + (comp(q, self.k[0]) - self.s[0] * z, comp(q, self.k[1]) - self.s[1] * z, self.s[2] * z) + } + + /// The distance along the ray to triangle `[pa, pb, pc]` (`None`: missed, or not in front). + pub(crate) fn hit(&self, [pa, pb, pc]: [Vec3; 3]) -> Option { + let ((ax, ay, az), (bx, by, bz), (cx, cy, cz)) = (self.frame(pa), self.frame(pb), self.frame(pc)); + let u = cx * by - cy * bx; + let v = ax * cy - ay * cx; + let w = bx * ay - by * ax; + if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) { + return None; } - best + let det = u + v + w; + if det == 0.0 { + return None; + } + let t = (u * az + v * bz + w * cz) / det; + (t.is_finite() && t > 1e-9).then_some(t) } } diff --git a/crates/geom/src/mesh_distance.rs b/crates/geom/src/mesh_distance.rs new file mode 100644 index 00000000..31653917 --- /dev/null +++ b/crates/geom/src/mesh_distance.rs @@ -0,0 +1,261 @@ +//! Distances between two triangle meshes: the nearest pair of points, and every pair of +//! triangles within a distance. A [`TriangleSet`] keeps a mesh's triangles under a bounding +//! volume hierarchy, so a query costs about the pairs of triangles that come near each other +//! rather than the product of the triangle counts. + +use crate::compare::closest_on_triangle; +use crate::{Aabb3, Bvh, Mesh, Vec3}; + +/// A mesh's triangles with their B-rep faces, indexed for distance queries. +#[derive(Clone, Debug, Default)] +pub struct TriangleSet { + tris: Vec<[Vec3; 3]>, + face: Vec, + bvh: Bvh, + bounds: Aabb3, +} + +/// The nearest points of two triangle sets, and the triangles they lie on. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshClosest { + pub distance: f64, + pub a: Vec3, + pub b: Vec3, + pub tri_a: u32, + pub tri_b: u32, +} + +impl TriangleSet { + /// The set of `mesh`'s triangles (ones with a vertex that is not finite are left out). + pub fn new(mesh: &Mesh) -> TriangleSet { + let mut tris = Vec::with_capacity(mesh.triangles.len()); + let mut face = Vec::with_capacity(mesh.triangles.len()); + let mut boxes = Vec::with_capacity(mesh.triangles.len()); + let mut bounds = Aabb3::EMPTY; + for (k, t) in mesh.triangles.iter().enumerate() { + let Some(p) = mesh.tri(t) else { continue }; + if !p.iter().all(|v| v.is_finite()) { + continue; + } + let mut b = Aabb3::EMPTY; + for v in p { + b.add(v); + } + bounds = bounds.union(&b); + boxes.push(b); + tris.push(p); + face.push(mesh.tri_face.get(k).copied().unwrap_or(0)); + } + TriangleSet { bvh: Bvh::new(&boxes), tris, face, bounds } + } + + /// The box around every triangle. + pub fn bounds(&self) -> Aabb3 { + self.bounds + } + + pub fn len(&self) -> usize { + self.tris.len() + } + + pub fn is_empty(&self) -> bool { + self.tris.is_empty() + } + + /// Triangle `t`'s corners. + pub fn triangle(&self, t: u32) -> Option<[Vec3; 3]> { + self.tris.get(t as usize).copied() + } + + /// The B-rep face triangle `t` came from. + pub fn face(&self, t: u32) -> Option { + self.face.get(t as usize).copied() + } + + /// The nearest points of this set and `other`, if they come within `limit` of each other + /// (`f64::INFINITY`: always). Triangles that cross count as distance 0, at a point where + /// they meet. + pub fn closest(&self, other: &TriangleSet, limit: f64) -> Option { + let mut best: Option = None; + let bound = std::cell::Cell::new(if limit.is_nan() { f64::INFINITY } else { limit }); + let mut found = |i: u32, j: u32| { + let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return }; + let (d, a, b) = triangle_distance(x, y); + if d <= bound.get() && best.is_none_or(|m| d < m.distance) { + best = Some(MeshClosest { distance: d, a, b, tri_a: i, tri_b: j }); + bound.set(d); + } + }; + self.bvh.visit_pairs(&other.bvh, |x, y| box_gap(x, y) <= bound.get(), &mut found); + best + } + + /// Calls `f` with every pair of triangles, one from this set and one from `other`, that come + /// within `d` of each other, with their nearest points. + pub fn within(&self, other: &TriangleSet, d: f64, mut f: impl FnMut(MeshClosest)) { + if !(d >= 0.0) { + return; + } + self.bvh.visit_pairs( + &other.bvh, + |x, y| box_gap(x, y) <= d, + |i, j| { + let (Some(x), Some(y)) = (self.tris.get(i as usize), other.tris.get(j as usize)) else { return }; + let (dist, a, b) = triangle_distance(x, y); + if dist <= d { + f(MeshClosest { distance: dist, a, b, tri_a: i, tri_b: j }); + } + }, + ); + } + + /// The distance from `p` to the nearest of the triangles `tris` (indices into this set). + pub fn distance_to(&self, p: Vec3, tris: &[u32]) -> f64 { + tris.iter() + .filter_map(|&t| self.tris.get(t as usize)) + .map(|[a, b, c]| closest_on_triangle(p, *a, *b, *c).dist(p)) + .fold(f64::INFINITY, f64::min) + } +} + +/// The gap between two boxes (0 when they meet). +pub fn box_gap(a: &Aabb3, b: &Aabb3) -> f64 { + if a.is_empty() || b.is_empty() { + return f64::INFINITY; + } + let axis = |lo1: f64, hi1: f64, lo2: f64, hi2: f64| (lo2 - hi1).max(lo1 - hi2).max(0.0); + let d = Vec3::new(axis(a.min.x, a.max.x, b.min.x, b.max.x), axis(a.min.y, a.max.y, b.min.y, b.max.y), axis(a.min.z, a.max.z, b.min.z, b.max.z)); + d.len() +} + +/// The nearest points of segments `p0`–`p1` and `q0`–`q1` (Ericson, Real-Time Collision +/// Detection, 5.1.9). +pub fn closest_on_segments(p0: Vec3, p1: Vec3, q0: Vec3, q1: Vec3) -> (Vec3, Vec3) { + let (d1, d2, r) = (p1 - p0, q1 - q0, p0 - q0); + let (a, e, f) = (d1.len2(), d2.len2(), d2.dot(r)); + const TINY: f64 = 1e-300; + let (s, t) = if a <= TINY && e <= TINY { + (0.0, 0.0) + } else if a <= TINY { + (0.0, (f / e).clamp(0.0, 1.0)) + } else { + let c = d1.dot(r); + if e <= TINY { + ((-c / a).clamp(0.0, 1.0), 0.0) + } else { + let b = d1.dot(d2); + let den = a * e - b * b; + let mut s = if den > TINY * (a * e).max(1.0) { ((b * f - c * e) / den).clamp(0.0, 1.0) } else { 0.0 }; + let mut t = (b * s + f) / e; + if t < 0.0 { + t = 0.0; + s = (-c / a).clamp(0.0, 1.0); + } else if t > 1.0 { + t = 1.0; + s = ((b - c) / a).clamp(0.0, 1.0); + } + (s, t) + } + }; + (p0 + d1 * s, q0 + d2 * t) +} + +/// Where segment `p`–`q` passes through triangle `t` (not when it lies in its plane). +fn segment_through(p: Vec3, q: Vec3, t: &[Vec3; 3]) -> Option { + let [a, b, c] = *t; + let n = (b - a).cross(c - a); + let (dp, dq) = (n.dot(p - a), n.dot(q - a)); + if (dp > 0.0 && dq > 0.0) || (dp < 0.0 && dq < 0.0) || dp == dq { + return None; + } + let x = p + (q - p) * (dp / (dp - dq)); + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(x - *u).dot(n) >= 0.0); + inside.then_some(x) +} + +/// The distance between two triangles and their nearest points (0 where they cross). +pub fn triangle_distance(s: &[Vec3; 3], t: &[Vec3; 3]) -> (f64, Vec3, Vec3) { + let edges = |x: &[Vec3; 3]| [(x[0], x[1]), (x[1], x[2]), (x[2], x[0])]; + for (p, q) in edges(s) { + if let Some(x) = segment_through(p, q, t) { + return (0.0, x, x); + } + } + for (p, q) in edges(t) { + if let Some(x) = segment_through(p, q, s) { + return (0.0, x, x); + } + } + let mut best = (f64::INFINITY, s[0], t[0]); + let mut keep = |a: Vec3, b: Vec3| { + let d = a.dist(b); + if d < best.0 { + best = (d, a, b); + } + }; + for p in s { + keep(*p, closest_on_triangle(*p, t[0], t[1], t[2])); + } + for q in t { + keep(closest_on_triangle(*q, s[0], s[1], s[2]), *q); + } + for (p0, p1) in edges(s) { + for (q0, q1) in edges(t) { + let (a, b) = closest_on_segments(p0, p1, q0, q1); + keep(a, b); + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tri(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> [Vec3; 3] { + [Vec3::from(a), Vec3::from(b), Vec3::from(c)] + } + + /// Distances between triangles worked out by hand: face to vertex, edge to edge, crossing. + #[test] + fn triangle_distances_by_hand() { + let floor = tri([0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]); + // A vertex 3 above the floor's inside. + let (d, a, b) = triangle_distance(&floor, &tri([1.0, 1.0, 3.0], [1.0, 1.0, 9.0], [2.0, 1.0, 9.0])); + assert!((d - 3.0).abs() < 1e-12 && a.dist(Vec3::new(1.0, 1.0, 0.0)) < 1e-12 && b.dist(Vec3::new(1.0, 1.0, 3.0)) < 1e-12); + // Two skew edges: the floor's x axis edge and a line along y at x = 5, z = 2, off the floor at y < 0. + let (d, ..) = triangle_distance(&floor, &tri([5.0, -1.0, 2.0], [5.0, -9.0, 2.0], [5.0, -5.0, 9.0])); + assert!((d - 5f64.sqrt()).abs() < 1e-12, "{d}"); + // A triangle through the floor. + let (d, ..) = triangle_distance(&floor, &tri([2.0, 2.0, -1.0], [2.0, 2.0, 1.0], [3.0, 2.0, 1.0])); + assert_eq!(d, 0.0); + // Parallel segments overlapping along their length. + let (p, q) = closest_on_segments(Vec3::ZERO, Vec3::X * 4.0, Vec3::new(2.0, 1.0, 0.0), Vec3::new(6.0, 1.0, 0.0)); + assert!((p.dist(q) - 1.0).abs() < 1e-12); + } + + fn cube(at: Vec3, size: f64) -> Mesh { + let mut m = Mesh::default(); + let c = |x: f64, y: f64, z: f64| at + Vec3::new(x, y, z) * size; + m.positions = vec![c(0., 0., 0.), c(1., 0., 0.), c(1., 1., 0.), c(0., 1., 0.), c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)]; + m.triangles = + vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; + m.tri_face = vec![0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5]; + m + } + + /// Two cubes apart along a diagonal: the nearest points are their facing corners. + #[test] + fn nearest_points_of_two_meshes() { + let a = TriangleSet::new(&cube(Vec3::ZERO, 10.0)); + let b = TriangleSet::new(&cube(Vec3::new(13.0, 14.0, 0.0), 10.0)); + let c = a.closest(&b, f64::INFINITY); + assert!(c.is_some_and(|c| (c.distance - 5.0).abs() < 1e-12), "{c:?}"); + assert!(a.closest(&b, 4.0).is_none(), "nothing within 4"); + let mut n = 0; + a.within(&b, 5.5, |_| n += 1); + assert!(n > 0); + let far = TriangleSet::new(&cube(Vec3::new(5.0, 5.0, 5.0), 10.0)); + assert_eq!(a.closest(&far, f64::INFINITY).map(|c| c.distance), Some(0.0), "overlapping cubes cross"); + } +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 4ff22666..e1117898 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -38,26 +38,35 @@ pub enum Seg2 { b: Vec2, w: f64, }, - /// Bézier of degree `n` (4…7) over `p[0..=n]` (the rest unused): pieces of higher-degree - /// splines. + /// Bézier of degree `n` (4…[`MAX_BEZIER_DEGREE`]) over `p[0..=n]` (the rest unused): pieces of + /// higher-degree splines. Bezier { n: u8, - p: [Vec2; 8], + p: [Vec2; MAX_BEZIER_CTRL], }, } -/// Highest degree of [`Seg2::Bezier`]. -pub const MAX_BEZIER_DEGREE: usize = 7; +/// Highest degree of [`Seg2::Bezier`]: a degree-9 piece is the single Bézier a G4 blend curve +/// (`sketch.blend_curve` with `continuity: "g4"`) is made of. +pub const MAX_BEZIER_DEGREE: usize = 9; -/// A Bézier segment over `pts` (2…8 points): a line, a cubic or a higher-degree piece. A -/// quadratic becomes a conic of weight 1. +/// Control points a [`Seg2::Bezier`] carries: its highest degree plus one (the unused tail is +/// zero). +pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1; + +/// Highest derivative order [`Seg2::derivatives`] hands out, and therefore the highest +/// continuity order a blend curve can be built for (4 = G4). +pub const MAX_DERIVATIVE_ORDER: usize = 4; + +/// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree +/// piece. A quadratic becomes a conic of weight 1. pub fn bezier(pts: &[Vec2]) -> Option { Some(match *pts { [a, b] => Seg2::Line { a, b }, [a, apex, b] => Seg2::Conic { a, apex, b, w: 1.0 }, [p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 }, - _ if pts.len() <= MAX_BEZIER_DEGREE + 1 => { - let mut p = [Vec2::ZERO; 8]; + _ if (2..=MAX_BEZIER_CTRL).contains(&pts.len()) => { + let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL]; for (d, s) in p.iter_mut().zip(pts) { *d = *s; } @@ -86,6 +95,38 @@ fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec, Vec) { (left.last().copied().unwrap_or(Vec2::ZERO), left, right) } +/// Binomial coefficient `C(n, k)` for the small orders the derivative rules need (0 when +/// `k > n`). +pub(crate) fn binom(n: usize, k: usize) -> f64 { + if k > n { + return 0.0; + } + (0..k.min(n - k)).fold(1.0, |v, i| v * (n - i) as f64 / (i + 1) as f64) +} + +/// Derivatives 1…`order` of the Bézier over `c` (degree `c.len() - 1`) at `t`: the j-th forward +/// difference of the control polygon, scaled by `n!/(n − j)!` and evaluated by de Casteljau. +/// Exact in exact arithmetic, zero above the degree, `None` for non-finite input. +fn bezier_derivatives(c: &[Vec2], t: f64, order: usize) -> Option> { + if !t.is_finite() || c.is_empty() || c.iter().any(|p| !p.is_finite()) { + return None; + } + let n = c.len() - 1; + let mut diffs = c.to_vec(); + let mut scale = 1.0; + let mut out = Vec::with_capacity(order); + for j in 1..=order { + if diffs.len() < 2 { + out.push(Vec2::ZERO); + continue; + } + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(casteljau(&diffs, t).0 * scale); + } + Some(out) +} + /// Gauss–Legendre nodes and weights on [0, 1] (5 points). const GL5: [(f64, f64); 5] = [ (0.046_910_077_030_668, 0.118_463_442_528_095), @@ -96,7 +137,7 @@ const GL5: [(f64, f64); 5] = [ ]; impl Seg2 { - fn bez(n: u8, p: &[Vec2; 8]) -> &[Vec2] { + fn bez(n: u8, p: &[Vec2; MAX_BEZIER_CTRL]) -> &[Vec2] { &p[..(n as usize).clamp(1, MAX_BEZIER_DEGREE) + 1] } /// Derivative with respect to the parameter at `t` (Béziers and conics; lines and arcs too). @@ -121,6 +162,86 @@ impl Seg2 { } } } + /// Derivatives 1…`order` with respect to the segment's own parameter at `t`, exactly: a line + /// and an arc from their analytic form (an arc's are the circular-motion recurrence, zero + /// curvature terms included), a cubic and a higher-degree piece from the forward differences + /// of their controls, a conic through the quotient rule of its rational form. A blend curve + /// reads a reference curve's derivatives here to build a Hermite interpolation, so this is + /// exact at both ends (`t` = 0 and 1) and `None` — never a wrong number — when `order` is + /// above [`MAX_DERIVATIVE_ORDER`], `t` is not finite or a value is not finite, as with a + /// degenerate rational piece. + pub fn derivatives(&self, t: f64, order: usize) -> Option> { + if !t.is_finite() || order > MAX_DERIVATIVE_ORDER { + return None; + } + let mut d = match *self { + Seg2::Line { a, b } => { + if !a.is_finite() || !b.is_finite() { + return None; + } + let mut d = vec![b - a]; + d.resize(MAX_DERIVATIVE_ORDER, Vec2::ZERO); + d + } + Seg2::Arc { center, radius, start, sweep } => { + if !center.is_finite() || !radius.is_finite() || !start.is_finite() || !sweep.is_finite() { + return None; + } + // Differentiating the radial direction twice returns its negative, so the + // directions of the successive derivatives are tangential, radial, tangential… + // with alternating signs, each order picking up one more factor of the sweep. + let mut v = Vec2::from_angle(start + sweep * t).perp(); + let mut d = Vec::with_capacity(MAX_DERIVATIVE_ORDER); + for j in 1..=MAX_DERIVATIVE_ORDER { + d.push(v * (radius * sweep.powi(j as i32))); + v = v.perp(); + } + d + } + Seg2::Cubic { p0, p1, p2, p3 } => bezier_derivatives(&[p0, p1, p2, p3], t, MAX_DERIVATIVE_ORDER)?, + Seg2::Bezier { n, ref p } => bezier_derivatives(Self::bez(n, p), t, MAX_DERIVATIVE_ORDER)?, + Seg2::Conic { a, apex, b, w } => { + if !a.is_finite() || !apex.is_finite() || !b.is_finite() || !w.is_finite() { + return None; + } + // The conic is `N(t) / d(t)` with `N` the quadratic through `a`, `w·apex`, `b` and + // `d` its weight polynomial; both are evaluated here in the power basis (at the + // ends `d` = 1, so a join is never singular). The quotient rule in its + // derivative-of-a-product form gives the higher derivatives: the numerator of + // `x⁽ʲ⁾` is `N⁽ʲ⁾` minus the terms that already use `d` and a lower `x`. + let (n0, n1, n2) = (a, apex * (2.0 * w) - a * 2.0, a - apex * (2.0 * w) + b); + let (e1, e2) = (2.0 * (w - 1.0), 2.0 * (1.0 - w)); + let quotient = |j: usize| match j { + 0 => n0 + n1 * t + n2 * (t * t), + 1 => n1 + n2 * (2.0 * t), + 2 => n2 * 2.0, + _ => Vec2::ZERO, + }; + let weight = |j: usize| match j { + 0 => 1.0 + e1 * t + e2 * (t * t), + 1 => e1 + e2 * (2.0 * t), + 2 => e2 * 2.0, + _ => 0.0, + }; + let den = weight(0); + if !(den.abs() > 1e-300) { + return None; + } + let mut x = vec![quotient(0) / den]; + for j in 1..=MAX_DERIVATIVE_ORDER { + let mut v = quotient(j); + for i in 1..=j { + v -= x[j - i] * (weight(i) * binom(j, i)); + } + x.push(v / den); + } + x.remove(0); + x + } + }; + d.truncate(order); + d.iter().all(|q| q.is_finite()).then_some(d) + } pub fn start(&self) -> Vec2 { match *self { Seg2::Line { a, .. } | Seg2::Conic { a, .. } => a, diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e607f32..39010d87 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -560,3 +560,161 @@ mod close_pairs { assert!(budget < 10_000_000, "work was counted"); } } + +mod blend { + use super::*; + + /// `j!` for the small orders the Hermite and quotient identities use. + fn fact(j: usize) -> f64 { + (1..=j).fold(1.0, |a, b| a * b as f64) + } + + /// The derivatives 1…`order` a Bézier's control polygon carries at its end `at_end`: the j-th + /// difference of the controls, scaled by `n!/(n − j)!`. This is the textbook endpoint identity + /// of a Bézier, written here independently of the library's. + fn control_derivatives(ctrl: &[Vec2], at_end: bool, order: usize) -> Vec { + let c: Vec = if at_end { ctrl.iter().rev().copied().collect() } else { ctrl.to_vec() }; + let n = c.len() - 1; + let (mut diffs, mut scale) = (c, 1.0); + let mut out = Vec::new(); + for j in 1..=order { + diffs = diffs.windows(2).map(|w| w[1] - w[0]).collect(); + scale *= (n + 1 - j) as f64; + out.push(diffs.first().copied().unwrap_or_default() * scale); + } + // The reversed controls describe `P(1 − s)`, so reversing again to read the original + // parameterisation negates the odd orders. + if at_end { + for (i, v) in out.iter_mut().enumerate() { + if i % 2 == 0 { + *v = -*v; + } + } + } + out + } + + #[test] + fn hermite_bezier_meets_the_derivative_data_at_both_ends() { + let (p0, p1) = (Vec2::new(1.0, 2.0), Vec2::new(-3.0, 4.5)); + let d0 = [Vec2::new(2.0, 1.0), Vec2::new(-0.5, 3.0), Vec2::new(1.5, 1.0), Vec2::new(0.25, -2.0)]; + let d1 = [Vec2::new(-1.0, 0.5), Vec2::new(2.0, -1.5), Vec2::new(-0.75, 0.5), Vec2::new(3.0, 1.25)]; + for k in 0..=MAX_DERIVATIVE_ORDER { + let cps = hermite_bezier(p0, p1, &d0[..k], &d1[..k]).unwrap(); + assert_eq!(cps.len(), 2 * k + 2, "degree 2k+1 has 2k+2 controls"); + assert_eq!(cps[0], p0); + assert_eq!(*cps.last().unwrap(), p1); + for (got, want) in control_derivatives(&cps, false, k).iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=0: {got:?} vs {want:?}"); + } + for (got, want) in control_derivatives(&cps, true, k).iter().zip(&d1[..k]) { + assert!(got.dist(*want) < 1e-11, "k={k} at t=1: {got:?} vs {want:?}"); + } + // The piece is exactly the single Bézier `bezier` hands back, and its ends are the two + // joints. + let seg = bezier(&cps).unwrap(); + assert!(seg.start().dist(p0) < 1e-12 && seg.end().dist(p1) < 1e-12); + for (got, want) in seg.derivatives(0.0, k).unwrap().iter().zip(&d0[..k]) { + assert!(got.dist(*want) < 1e-10, "k={k} at t=0: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn hermite_bezier_refuses_hostile_data() { + let (p0, p1) = (Vec2::ZERO, Vec2::X); + let d = [Vec2::Y, Vec2::X]; + assert!(hermite_bezier(p0, p1, &d, &d).is_some()); + assert!(hermite_bezier(p0, p1, &d, &d[..1]).is_none(), "the ends must agree on the order"); + assert!(hermite_bezier(p0, p1, &[Vec2::ZERO; 5], &[Vec2::ZERO; 5]).is_none(), "order above G4"); + assert!(hermite_bezier(Vec2::new(f64::NAN, 0.0), p1, &[], &[]).is_none()); + assert!(hermite_bezier(p0, p1, &[Vec2::new(f64::INFINITY, 0.0)], &[Vec2::Y]).is_none()); + assert!(hermite_bezier(p0, p1, &[], &[Vec2::Y]).is_none()); + } + + #[test] + fn seg_derivatives_are_exact_for_lines_and_polynomial_pieces() { + let line = Seg2::Line { a: Vec2::new(-1.0, 2.0), b: Vec2::new(3.0, 5.0) }; + let d = line.derivatives(0.0, MAX_DERIVATIVE_ORDER).unwrap(); + assert!(d[0].dist(Vec2::new(4.0, 3.0)) < 1e-12); + assert_eq!(d[1].len() + d[2].len() + d[3].len(), 0.0, "a line has no curvature"); + let cubic = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(3.0, 9.0), p2: Vec2::new(7.0, -4.0), p3: Vec2::new(11.0, 2.0) }; + let ctrl = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 9.0), Vec2::new(7.0, -4.0), Vec2::new(11.0, 2.0)]; + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in cubic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&ctrl, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + for t in [0.0, 0.35, 1.0] { + assert!(cubic.derivatives(t, 1).unwrap()[0].dist(cubic.derivative(t)) < 1e-9, "t={t}"); + } + // A degree-9 piece (the G4 blend's own shape) at both ends. + let pts: Vec = (0..10).map(|i| Vec2::new(i as f64 * 0.7, (i as f64 * 1.3).sin())).collect(); + let seg = bezier(&pts).unwrap(); + assert!(matches!(seg, Seg2::Bezier { n: 9, .. })); + for (t, at_end) in [(0.0, false), (1.0, true)] { + for (got, want) in seg.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap().iter().zip(control_derivatives(&pts, at_end, 4)) { + assert!(got.dist(want) < 1e-9, "t={t}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn seg_derivatives_follow_the_circular_recurrence() { + let arc = Seg2::Arc { center: Vec2::new(2.0, -1.0), radius: 5.0, start: 0.7, sweep: -1.9 }; + let Seg2::Arc { center, sweep, .. } = arc else { panic!("an arc") }; + for t in [0.0, 0.4, 1.0] { + let d = arc.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + let p = arc.point_at(t); + let sw2 = sweep * sweep; + // On a circle every derivative follows from the second: P″ = −sw²(P − c), and the rest + // differentiate that relation, so a line and an arc have zero curvature rates. + assert!(d[1].dist((p - center) * -sw2) < 1e-9, "t={t}"); + assert!(d[2].dist(d[0] * -sw2) < 1e-9, "t={t}"); + assert!(d[3].dist(d[1] * -sw2) < 1e-9, "t={t}"); + assert!(d[0].dist(arc.derivative(t)) < 1e-9, "t={t}"); + } + } + + #[test] + fn seg_derivatives_of_a_conic_satisfy_the_quotient_identity() { + let conic = Seg2::Conic { a: Vec2::new(-2.0, 0.0), apex: Vec2::new(0.0, 3.0), b: Vec2::new(4.0, 0.5), w: 0.7 }; + let Seg2::Conic { a, apex, b, w } = conic else { panic!("a conic") }; + // The rational quadratic's numerator and weight polynomials, straight from their Bernstein + // form, and their power coefficients at `t` by differences (exact for a quadratic). + let n_at = |x: f64| a * ((1.0 - x) * (1.0 - x)) + apex * (2.0 * w * x * (1.0 - x)) + b * (x * x); + let d_at = |x: f64| (1.0 - x) * (1.0 - x) + 2.0 * w * x * (1.0 - x) + x * x; + for t in [0.0, 0.25, 1.0] { + let h = 1e-3; + // Taylor coefficients at `t` (differences are exact for a quadratic): f, f′/1!, f″/2!. + let coef_v = |f: &dyn Fn(f64) -> Vec2| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let coef_s = |f: &dyn Fn(f64) -> f64| [f(t), (f(t + h) - f(t - h)) / (2.0 * h), (f(t + h) - f(t) * 2.0 + f(t - h)) / (2.0 * h * h)]; + let n = coef_v(&n_at); + let d = coef_s(&d_at); + let got = conic.derivatives(t, MAX_DERIVATIVE_ORDER).unwrap(); + // Taylor coefficients of P: P(t) and P⁽ʲ⁾(t)/j!. Leibniz on N = P·D then reads + // Σ xᵢ·dⱼ₋ᵢ = nⱼ at every order, which the quotient rule must reproduce. + let x: Vec = std::iter::once(conic.point_at(t)).chain(got.iter().enumerate().map(|(i, v)| *v / fact(i + 1))).collect(); + for j in 0..=4usize { + let lhs: Vec2 = (0..=j).fold(Vec2::ZERO, |acc, i| acc + x[i] * d.get(j - i).copied().unwrap_or(0.0)); + let rhs = n.get(j).copied().unwrap_or(Vec2::ZERO); + assert!(lhs.dist(rhs) < 1e-8 * rhs.len().max(1.0), "t={t} j={j}: {lhs:?} vs {rhs:?}"); + } + assert!(got[0].dist(conic.derivative(t)) < 1e-8, "t={t}"); + } + } + + #[test] + fn seg_derivatives_refuse_hostile_input_and_degenerate_pieces() { + let c = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::Y, p3: Vec2::X + Vec2::Y }; + assert!(c.derivatives(0.0, MAX_DERIVATIVE_ORDER + 1).is_none(), "no order above G4"); + assert!(c.derivatives(f64::NAN, 2).is_none()); + let nan = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; + assert!(nan.derivatives(0.0, 1).is_none()); + // A conic whose weight vanishes is a straight chord drawn at zero speed: its derivatives + // are finite, but the first is zero, which is what a blend refuses. + let flat = Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 1.0), b: Vec2::new(2.0, 0.0), w: 0.0 }; + let d = flat.derivatives(0.0, 1).unwrap(); + assert_eq!(d[0], Vec2::ZERO); + } +} diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 07d4e1b9..00a4c930 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -20,7 +20,6 @@ use crate::{GpuError, Result, popped}; /// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual /// (non-sRGB) target. pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; -const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8; /// Samples per pixel (MSAA), as the desktop viewport's default; 1 where the device has no 4×. pub const SAMPLES: u32 = 4; /// The largest picture side (pixels); the device's texture limit may lower it. @@ -39,6 +38,8 @@ pub struct Capturer { /// The adapter is a GPU driver running on the CPU (llvmpipe, lavapipe, WARP). software: bool, samples: u32, + /// The depth format: 32-bit float (reversed depth) where the device has it ([`crate::depth_format`]). + depth: wgpu::TextureFormat, max_side: u32, state: Mutex, } @@ -54,17 +55,25 @@ struct State { impl Capturer { /// The pipelines on an existing device (the application's). pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result { + let depth = crate::depth_format(&device, true); + Self::with_depth(adapter, device, queue, depth) + } + + /// As [`Capturer::new`], drawing with the depth format `depth` (with a stencil): the fallback + /// `Depth24PlusStencil8` on a device that has a float one too, to compare the two. + pub fn with_depth(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue, depth: wgpu::TextureFormat) -> Result { let flags = |f: wgpu::TextureFormat| adapter.get_texture_format_features(f).flags; - let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(DEPTH).sample_count_supported(SAMPLES) { SAMPLES } else { 1 }; + let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(depth).sample_count_supported(SAMPLES) { SAMPLES } else { 1 }; let scopes = error_scopes(&device); - let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(DEPTH), samples, tess: false }, None); - let background = background_pipeline(&device, samples); + let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(depth), samples, tess: false }, None); + let background = background_pipeline(&device, samples, depth); caught(scopes).map_or(Ok(()), |e| Err(GpuError::Pipeline(e)))?; let max_side = device.limits().max_texture_dimension_2d.min(MAX_SIDE); Ok(Capturer { adapter: adapter.get_info().name, software: adapter.get_info().device_type == wgpu::DeviceType::Cpu, samples, + depth, max_side, state: Mutex::new(State { renderer, background, key: 0, access_version: 0 }), device, @@ -82,6 +91,19 @@ impl Capturer { Self::new(&adapter, device, queue) } + /// As [`Capturer::headless`], drawing with the depth format `depth` ([`Capturer::with_depth`]). + #[cfg(not(target_arch = "wasm32"))] + pub fn headless_with_depth(depth: wgpu::TextureFormat) -> Result { + let (adapter, device, queue) = crate::open_headless()?; + device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("GPU: {e}"))); + Self::with_depth(&adapter, device, queue, depth) + } + + /// The depth format pictures are drawn with; float formats are drawn with reversed depth. + pub fn depth_format(&self) -> wgpu::TextureFormat { + self.depth + } + /// The adapter's name. pub fn adapter_name(&self) -> &str { &self.adapter @@ -132,7 +154,7 @@ impl Capturer { }; let resolved = texture("sc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); let multisampled = (self.samples > 1).then(|| texture("sc_capture_ms", FORMAT, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); - let depth = texture("sc_capture_depth", DEPTH, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT); + let depth = texture("sc_capture_depth", self.depth, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT); let (resolved_view, depth_view) = (resolved.create_view(&Default::default()), depth.create_view(&Default::default())); let multisampled_view = multisampled.as_ref().map(|t| t.create_view(&Default::default())); let (top, bottom) = scene.background.unwrap_or(BACKGROUND); @@ -155,7 +177,10 @@ impl Capturer { })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &depth_view, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(crate::view::far_depth(crate::view::reversed_depth(Some(self.depth)))), + store: wgpu::StoreOp::Discard, + }), stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), }), timestamp_writes: None, @@ -387,7 +412,7 @@ fn background_vertices(device: &wgpu::Device, top: Rgb, bottom: Rgb) -> wgpu::Bu }) } -fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipeline { +fn background_pipeline(device: &wgpu::Device, samples: u32, depth: wgpu::TextureFormat) -> wgpu::RenderPipeline { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_capture_background"), source: wgpu::ShaderSource::Wgsl(BACKGROUND_SHADER.into()), @@ -409,7 +434,7 @@ fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipel }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH, + format: depth, depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::Always), stencil: Default::default(), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 98f4515a..6be54f70 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -21,6 +21,7 @@ use wgpu::util::DeviceExt; pub mod capture; mod pbr; +pub mod pick; #[cfg(not(target_arch = "wasm32"))] pub mod select; pub mod view; @@ -28,6 +29,26 @@ pub mod view; /// The tessellation compute shader (WGSL). pub const SHADER: &str = include_str!("tess.wgsl"); +/// The device feature for a 32-bit float depth buffer with a stencil, which the viewport draws +/// with reversed depth ([`view::reversed_depth`]) where the device has it. A device is opened +/// with it whenever its adapter offers it ([`depth_features`]). +pub const FLOAT_DEPTH_STENCIL: wgpu::Features = wgpu::Features::DEPTH32FLOAT_STENCIL8; + +/// The depth features to open a device on `adapter` with: [`FLOAT_DEPTH_STENCIL`] if it has it. +pub fn depth_features(adapter: &wgpu::Adapter) -> wgpu::Features { + adapter.features() & FLOAT_DEPTH_STENCIL +} + +/// The depth format to draw with on `device`: 32-bit float (reversed depth) where it can, with a +/// stencil when `stencil` (the section cap needs one), else 24 bits with a stencil. +pub fn depth_format(device: &wgpu::Device, stencil: bool) -> wgpu::TextureFormat { + match (stencil, device.features().contains(FLOAT_DEPTH_STENCIL)) { + (false, _) => wgpu::TextureFormat::Depth32Float, + (true, true) => wgpu::TextureFormat::Depth32FloatStencil8, + (true, false) => wgpu::TextureFormat::Depth24PlusStencil8, + } +} + /// The error a popped error scope caught. Native devices answer at once; a browser's answer /// comes later, and is not waited for (no error is assumed). fn popped(f: impl std::future::Future>) -> Option { @@ -47,6 +68,7 @@ fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("solidcraft headless"), + required_features: depth_features(&adapter), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ..Default::default() })) diff --git a/crates/gpu/src/pick.rs b/crates/gpu/src/pick.rs new file mode 100644 index 00000000..c62d2c7c --- /dev/null +++ b/crates/gpu/src/pick.rs @@ -0,0 +1,503 @@ +//! The pick buffer: which model edges are visible at each pixel of the viewport, for picking on a +//! large model without testing every edge. Surfaces are drawn into the depth buffer only; then +//! every edge segment is drawn as a line carrying its id, depth tested against them (lifted +//! toward the eye by the picker's slack, the depth by which an edge may lie behind a face and +//! still count as visible) and written into an `R32Uint` id texture (0: no edge). +//! +//! The pass is drawn once per view (camera, viewport size, model), and only small tiles around +//! the cursor are read back, so a pointer moving over a still view reads a few kilobytes and +//! draws nothing. Read-backs arrive a frame or two later ([`PickResults`]); until a tile is in, +//! the picker answers on the CPU. The ids are hints: the picker still runs its exact tests on +//! the edges a tile names. + +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; + +use wgpu::util::DeviceExt; + +/// The side of a read-back tile, in pixels (a multiple of 64, so rows need no padding). +pub const TILE: u32 = 128; +/// Most tiles kept from one view. +const KEEP: usize = 6; +/// Most read-backs in flight. +const IN_FLIGHT: usize = 3; +/// Bytes per surface vertex (position) and per edge vertex (position, id). +const FACE_VERTEX: u64 = 12; +const EDGE_VERTEX: u64 = 16; +const UNIFORM_SIZE: u64 = 96; + +const SHADER: &str = r#" +struct U { + vp: mat4x4, + eye: vec4, // eye position, w = 1 for perspective + back: vec4, // toward the eye (orthographic), w = how far edges are lifted toward it +}; +@group(0) @binding(0) var u: U; + +@vertex +fn vs_face(@location(0) p: vec3) -> @builtin(position) vec4 { + return u.vp * vec4(p, 1.0); +} + +@fragment +fn fs_face() -> @location(0) u32 { + return 0u; +} + +struct EOut { + @builtin(position) pos: vec4, + @location(0) @interpolate(flat) id: u32, +}; + +@vertex +fn vs_edge(@location(0) p: vec3, @location(1) id: u32) -> EOut { + var toward = u.back.xyz; + if (u.eye.w > 0.5) { + let v = u.eye.xyz - p; + let l = length(v); + if (l > 0.0) { + toward = v / l; + } + } + var o: EOut; + o.pos = u.vp * vec4(p + toward * u.back.w, 1.0); + o.id = id; + return o; +} + +@fragment +fn fs_edge(i: EOut) -> @location(0) u32 { + return i.id; +} +"#; + +/// What the pass draws: surfaces (for depth) and edge segments with their ids (from 1). +#[derive(Default, Clone)] +pub struct PickScene { + faces: Vec, + edges: Vec, +} + +impl PickScene { + pub fn triangle(&mut self, t: [[f32; 3]; 3]) { + for p in t { + for c in p { + self.faces.extend_from_slice(&c.to_le_bytes()); + } + } + } + + /// Segment `a`–`b` of the edge `id` (0 is no edge: not drawn). + pub fn segment(&mut self, a: [f32; 3], b: [f32; 3], id: u32) { + if id == 0 { + return; + } + for p in [a, b] { + for c in p { + self.edges.extend_from_slice(&c.to_le_bytes()); + } + self.edges.extend_from_slice(&id.to_le_bytes()); + } + } + + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } +} + +/// The view the pass is drawn for. +#[derive(Clone, Copy, Debug)] +pub struct PickView { + /// Changes whenever anything below or the scene changes. + pub key: u64, + /// Column-major view-projection (as [`crate::view::Frame::view_proj`]). + pub view_proj: [[f32; 4]; 4], + /// Eye position, and 1 for a perspective camera (0: orthographic). + pub eye: [f32; 4], + /// Direction toward the eye (orthographic). + pub back: [f32; 3], + /// How far edges are lifted toward the eye (model units). + pub lift: f32, + /// The viewport in physical pixels. + pub size: [u32; 2], +} + +/// Edge ids of a block of pixels of one view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickTile { + pub key: u64, + pub origin: [u32; 2], + pub size: [u32; 2], + /// Row-major, `size[0] * size[1]`. + pub ids: Vec, +} + +impl PickTile { + /// Whether the tile is of view `key` and holds every pixel of `[x0, y0, x1, y1]` (inclusive) + /// that lies inside a viewport of `view` pixels. + pub fn covers(&self, key: u64, view: [u32; 2], [x0, y0, x1, y1]: [i64; 4]) -> bool { + let clamp = |v: i64, n: u32| v.clamp(0, i64::from(n.max(1)) - 1); + let (x0, x1, y0, y1) = (clamp(x0, view[0]), clamp(x1, view[0]), clamp(y0, view[1]), clamp(y1, view[1])); + let [ox, oy] = self.origin.map(i64::from); + let [w, h] = self.size.map(i64::from); + self.key == key && x0 >= ox && y0 >= oy && x1 < ox + w && y1 < oy + h + } + + /// The ids within `[x0, y0, x1, y1]` (inclusive; outside the tile: none), each once. + pub fn ids_in(&self, [x0, y0, x1, y1]: [i64; 4]) -> Vec { + let [ox, oy] = self.origin.map(i64::from); + let [w, h] = self.size.map(i64::from); + let mut out = Vec::new(); + for y in y0.max(oy)..=y1.min(oy + h - 1) { + for x in x0.max(ox)..=x1.min(ox + w - 1) { + if let Some(&id) = self.ids.get(((y - oy) * w + (x - ox)) as usize) + && id != 0 + { + out.push(id); + } + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The tiles read back so far (of the latest view only), newest last. +#[derive(Default, Debug)] +pub struct PickResults { + pub tiles: Vec, +} + +impl PickResults { + /// The newest tile of view `key` covering `window`. + pub fn tile(&self, key: u64, view: [u32; 2], window: [i64; 4]) -> Option<&PickTile> { + self.tiles.iter().rev().find(|t| t.covers(key, view, window)) + } +} + +pub type PickOut = Arc>; + +/// The tile (origin and size) around pixel `centre` of a viewport of `view` pixels. +pub fn tile_around(centre: [u32; 2], view: [u32; 2]) -> ([u32; 2], [u32; 2]) { + let size = [TILE.min(view[0]), TILE.min(view[1])]; + let origin = [0, 1].map(|k| centre[k].saturating_sub(size[k] / 2).min(view[k].saturating_sub(size[k]))); + (origin, size) +} + +const NEW: u8 = 0; +const MAPPING: u8 = 1; +const READY: u8 = 2; +const FAILED: u8 = 3; + +/// A tile being copied out of the id texture. +struct Pending { + key: u64, + origin: [u32; 2], + size: [u32; 2], + bytes_per_row: u32, + buffer: wgpu::Buffer, + /// The frame whose commands copy it (it is mapped once they are submitted). + frame: u64, + state: Arc, +} + +struct Target { + size: [u32; 2], + ids: wgpu::Texture, + ids_view: wgpu::TextureView, + depth_view: wgpu::TextureView, +} + +struct Uploaded { + key: u64, + faces: Option<(wgpu::Buffer, u32)>, + edges: Option<(wgpu::Buffer, u32)>, +} + +/// The pass's pipelines, its textures and the read-backs in flight, on one device. +pub struct PickBuffer { + face: wgpu::RenderPipeline, + edge: wgpu::RenderPipeline, + uniform: wgpu::Buffer, + bind: wgpu::BindGroup, + scene: Option, + target: Option, + /// The view drawn into the textures. + drawn: Option, + pending: Vec, + frame: u64, +} + +const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +const IDS: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint; + +impl PickBuffer { + pub fn new(device: &wgpu::Device) -> PickBuffer { + let module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_pick"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_pick"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_pick"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_pick_u"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_pick"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], + }); + const FA: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0 => Float32x3]; + const EA: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32]; + let pipeline = |label: &str, + vs: &str, + fs: &str, + stride: u64, + attributes: &'static [wgpu::VertexAttribute], + topology: wgpu::PrimitiveTopology, + write: bool, + mask: wgpu::ColorWrites| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some(vs), + buffers: &[Some(wgpu::VertexBufferLayout { array_stride: stride, step_mode: wgpu::VertexStepMode::Vertex, attributes })], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH, + depth_write_enabled: Some(write), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: IDS, blend: None, write_mask: mask })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + let face = + pipeline("sc_pick_face", "vs_face", "fs_face", FACE_VERTEX, &FA, wgpu::PrimitiveTopology::TriangleList, true, wgpu::ColorWrites::empty()); + let edge = pipeline("sc_pick_edge", "vs_edge", "fs_edge", EDGE_VERTEX, &EA, wgpu::PrimitiveTopology::LineList, false, wgpu::ColorWrites::ALL); + PickBuffer { face, edge, uniform, bind, scene: None, target: None, drawn: None, pending: Vec::new(), frame: 0 } + } + + /// Before the frame is submitted: collect the tiles read back, then draw `view` of `scene` + /// (`scene_key` names it; it is uploaded when that changes) unless it is drawn already, and + /// start reading back the tile around pixel `centre` unless it is in or on its way. The tiles + /// land in `out` a frame or two later; keep frames coming until then. + #[allow(clippy::too_many_arguments)] + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + scene_key: u64, + scene: &PickScene, + view: &PickView, + centre: Option<[u32; 2]>, + out: &PickOut, + ) { + self.frame += 1; + self.collect(device, out); + let [w, h] = view.size; + let max = device.limits().max_texture_dimension_2d; + if w == 0 || h == 0 || w > max || h > max { + return; + } + if self.scene.as_ref().is_none_or(|s| s.key != scene_key) { + let limit = device.limits().max_buffer_size; + let upload = |label: &str, bytes: &[u8], stride: u64| { + let n = bytes.len() as u64 / stride; + (n > 0 && (bytes.len() as u64) <= limit && n <= u64::from(u32::MAX)).then(|| { + ( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytes, + usage: wgpu::BufferUsages::VERTEX, + }), + n as u32, + ) + }) + }; + self.scene = Some(Uploaded { + key: scene_key, + faces: upload("sc_pick_faces", &scene.faces, FACE_VERTEX), + edges: upload("sc_pick_edges", &scene.edges, EDGE_VERTEX), + }); + self.drawn = None; + } + if self.target.as_ref().is_none_or(|t| t.size != view.size) { + let texture = |label: &str, format, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let ids = texture("sc_pick_ids", IDS, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); + let depth = texture("sc_pick_depth", DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT); + let (ids_view, depth_view) = (ids.create_view(&Default::default()), depth.create_view(&Default::default())); + self.target = Some(Target { size: view.size, ids, ids_view, depth_view }); + self.drawn = None; + } + let (Some(target), Some(scene)) = (&self.target, &self.scene) else { return }; + if self.drawn != Some(view.key) { + queue.write_buffer(&self.uniform, 0, &uniform_bytes(view)); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_pick"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target.ids_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &target.depth_view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_bind_group(0, &self.bind, &[]); + for (pipeline, part) in [(&self.face, &scene.faces), (&self.edge, &scene.edges)] { + if let Some((buffer, n)) = part { + pass.set_pipeline(pipeline); + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..*n, 0..1); + } + } + drop(pass); + self.drawn = Some(view.key); + // Tiles of other views no longer answer. + self.pending.retain(|p| p.key == view.key); + if let Ok(mut o) = out.lock() { + o.tiles.retain(|t| t.key == view.key); + } + } + let Some(centre) = centre else { return }; + let (origin, size) = tile_around(centre, view.size); + let known = out.lock().is_ok_and(|o| o.tiles.iter().any(|t| t.key == view.key && t.origin == origin && t.size == size)) + || self.pending.iter().any(|p| p.key == view.key && p.origin == origin && p.size == size); + if known || self.pending.len() >= IN_FLIGHT { + return; + } + let bytes_per_row = (size[0] * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_pick_tile"), + size: u64::from(bytes_per_row) * u64::from(size[1]), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target.ids, + mip_level: 0, + origin: wgpu::Origin3d { x: origin[0], y: origin[1], z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: Some(size[1]) }, + }, + wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + ); + self.pending.push(Pending { key: view.key, origin, size, bytes_per_row, buffer, frame: self.frame, state: Arc::new(AtomicU8::new(NEW)) }); + } + + /// Whether read-backs are still on their way (keep frames coming). + pub fn waiting(&self) -> bool { + !self.pending.is_empty() + } + + /// Maps the copies earlier frames submitted and moves the mapped ones into `out`. + fn collect(&mut self, device: &wgpu::Device, out: &PickOut) { + let frame = self.frame; + for p in &self.pending { + if p.frame < frame && p.state.load(Ordering::Acquire) == NEW { + p.state.store(MAPPING, Ordering::Release); + let state = p.state.clone(); + p.buffer.slice(..).map_async(wgpu::MapMode::Read, move |r| state.store(if r.is_ok() { READY } else { FAILED }, Ordering::Release)); + } + } + if self.pending.iter().any(|p| p.state.load(Ordering::Acquire) == MAPPING) { + let _ = device.poll(wgpu::PollType::Poll); + } + let mut done = Vec::new(); + self.pending.retain(|p| match p.state.load(Ordering::Acquire) { + READY => { + if let Ok(bytes) = p.buffer.slice(..).get_mapped_range() { + let [w, h] = p.size; + let mut ids = Vec::with_capacity(w as usize * h as usize); + for row in bytes.chunks(p.bytes_per_row as usize).take(h as usize) { + ids.extend(row.as_chunks::<4>().0.iter().take(w as usize).map(|c| u32::from_le_bytes(*c))); + } + done.push(PickTile { key: p.key, origin: p.origin, size: p.size, ids }); + } + p.buffer.unmap(); + false + } + FAILED => false, + _ => true, + }); + if !done.is_empty() + && let Ok(mut o) = out.lock() + { + for t in done { + if self.drawn == Some(t.key) && t.ids.len() == t.size[0] as usize * t.size[1] as usize { + o.tiles.retain(|x| x.key == t.key); + o.tiles.push(t); + } + } + let excess = o.tiles.len().saturating_sub(KEEP); + o.tiles.drain(..excess); + } + } +} + +fn uniform_bytes(v: &PickView) -> Vec { + let mut b = Vec::with_capacity(UNIFORM_SIZE as usize); + for col in v.view_proj { + for x in col { + b.extend_from_slice(&x.to_le_bytes()); + } + } + for x in v.eye.into_iter().chain(v.back).chain([v.lift]) { + b.extend_from_slice(&x.to_le_bytes()); + } + b +} + +#[cfg(test)] +#[path = "pick_tests.rs"] +mod tests; diff --git a/crates/gpu/src/pick_tests.rs b/crates/gpu/src/pick_tests.rs new file mode 100644 index 00000000..5ccb5e2b --- /dev/null +++ b/crates/gpu/src/pick_tests.rs @@ -0,0 +1,79 @@ +use super::*; + +/// Looking down −z at x, y in −10..10 (orthographic): depth 0 at z = 50, 1 at z = −50. +const VIEW: [[f32; 4]; 4] = [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]]; +const SIZE: u32 = 64; + +/// The pixel of model point (x, y). +fn px(x: f32, y: f32) -> [i64; 2] { + [((x / 10.0 + 1.0) * 0.5 * SIZE as f32) as i64, ((1.0 - y / 10.0) * 0.5 * SIZE as f32) as i64] +} + +/// A square face at z = 0 (x, y in −5..5), and three edges: 1 on it, 2 well behind it, 3 behind +/// it by less than the lift. +fn scene() -> PickScene { + let mut s = PickScene::default(); + let (a, b, c, d) = ([-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]); + s.triangle([a, b, c]); + s.triangle([a, c, d]); + s.segment([-8.0, 0.0, 0.0], [8.0, 0.0, 0.0], 1); + s.segment([2.0, -8.0, -3.0], [2.0, 8.0, -3.0], 2); + s.segment([-2.0, -8.0, -0.2], [-2.0, 8.0, -0.2], 3); + s +} + +#[test] +fn the_pick_buffer_names_the_edges_seen_at_each_pixel() { + let (_, device, queue) = match crate::open_headless() { + Ok(d) => d, + Err(e) => { + assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "a GPU is required here: {e}"); + eprintln!("skipping: {e}"); + return; + } + }; + let mut pb = PickBuffer::new(&device); + let out = PickOut::default(); + let view = PickView { key: 7, view_proj: VIEW, eye: [0.0, 0.0, 100.0, 0.0], back: [0.0, 0.0, 1.0], lift: 0.5, size: [SIZE, SIZE] }; + let scene = scene(); + let all = [0, 0, SIZE as i64 - 1, SIZE as i64 - 1]; + for _ in 0..8 { + let mut encoder = device.create_command_encoder(&Default::default()); + pb.prepare(&device, &queue, &mut encoder, 1, &scene, &view, Some([SIZE / 2, SIZE / 2]), &out); + queue.submit([encoder.finish()]); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + if out.lock().unwrap().tile(7, [SIZE, SIZE], all).is_some() { + break; + } + } + let results = out.lock().unwrap(); + let tile = results.tile(7, [SIZE, SIZE], all).expect("the tile is read back within a few frames"); + let at = |x: f32, y: f32| { + let [px, py] = px(x, y); + tile.ids_in([px - 1, py - 1, px + 1, py + 1]) + }; + assert_eq!(at(0.0, 0.0), vec![1], "the edge on the face"); + assert_eq!(at(2.0, 3.0), Vec::::new(), "the edge behind the face is hidden"); + assert_eq!(at(2.0, 7.5), vec![2], "and seen where the face does not cover it"); + assert_eq!(at(-2.0, 3.0), vec![3], "an edge behind the face by less than the lift counts as seen"); + assert_eq!(at(3.5, 3.5), Vec::::new(), "the face itself names no edge"); + assert!(results.tile(8, [SIZE, SIZE], all).is_none(), "another view's tile does not answer"); + drop(results); + assert!(!pb.waiting()); +} + +#[test] +fn tiles_cover_the_window_around_the_cursor_inside_the_view() { + assert_eq!(tile_around([10, 10], [1000, 800]), ([0, 0], [TILE, TILE])); + assert_eq!(tile_around([500, 400], [1000, 800]), ([500 - TILE / 2, 400 - TILE / 2], [TILE, TILE])); + assert_eq!(tile_around([999, 799], [1000, 800]), ([1000 - TILE, 800 - TILE], [TILE, TILE])); + assert_eq!(tile_around([30, 20], [50, 40]), ([0, 0], [50, 40])); + let t = PickTile { key: 1, origin: [100, 100], size: [TILE, TILE], ids: vec![0; (TILE * TILE) as usize] }; + assert!(t.covers(1, [1000, 800], [110, 110, 120, 120])); + assert!(!t.covers(1, [1000, 800], [90, 110, 120, 120])); + assert!(!t.covers(2, [1000, 800], [110, 110, 120, 120])); + // A window past the view's border needs only the pixels inside it. + let corner = PickTile { key: 1, origin: [0, 0], size: [TILE, TILE], ids: vec![5; (TILE * TILE) as usize] }; + assert!(corner.covers(1, [1000, 800], [-8, -8, 8, 8])); + assert_eq!(corner.ids_in([-8, -8, 8, 8]), vec![5]); +} diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index c29542af..3ac065fe 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -19,9 +19,9 @@ pub const TRI_SIZE: usize = 28; pub const PBR_SIZE: usize = pbr::VERTEX_SIZE; /// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). pub const LINE_SIZE: usize = 32; -/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their -/// flags (`uniform_bytes`). -const UNIFORM_SIZE: u64 = 352; +/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours, their +/// flags and the depth direction (`uniform_bytes`). +const UNIFORM_SIZE: u64 = 368; /// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face /// (the plan's face numbering; faces past the end take the last colour). @@ -599,8 +599,26 @@ pub struct Renderer { ss: Option, /// This frame was drawn supersampled (in `prepare`): `paint` only filters it down. ss_ready: bool, + /// Reversed depth drawn into the window's pass: egui clears its depth to 1, which is the near + /// plane then, so the viewport first sets its rectangle to the far plane (0). + depth_reset: Option, } +/// A triangle over the whole viewport at depth 0 (the far plane of reversed depth), writing no +/// colour: [`Renderer::depth_reset`]. +const DEPTH_RESET_SHADER: &str = r#" +@vertex +fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let x = f32((i << 1u) & 2u); + let y = f32(i & 2u); + return vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); +} +@fragment +fn fs() -> @location(0) vec4 { + return vec4(0.0); +} +"#; + /// A supersampled frame: the scene drawn at a multiple of the viewport's pixels. struct Supersampled { size: [u32; 2], @@ -735,6 +753,55 @@ pub struct GpuTarget { pub tess: bool, } +impl GpuTarget { + /// Depth runs from 1 at the near plane to 0 at the far one: see [`reversed_depth`]. + pub fn reversed_depth(&self) -> bool { + reversed_depth(self.depth) + } +} + +/// Is depth reversed (1 at the near plane, 0 at the far one) in a buffer of `format`? It is with a +/// 32-bit float buffer: a float's steps grow with its value, so with depth falling away from the +/// eye they follow the distance, about 2⁻²⁴ of it everywhere, instead of piling up at the near +/// plane (#1434: a face 0.3 mm in front of another speckled at 10 m). Other formats keep 0 near. +pub fn reversed_depth(format: Option) -> bool { + matches!(format, Some(wgpu::TextureFormat::Depth32Float | wgpu::TextureFormat::Depth32FloatStencil8)) +} + +/// The depth test `compare`, written for depth growing away from the eye, for a buffer whose depth +/// may run the other way ([`reversed_depth`]). Every pass drawn with the viewport's depth takes +/// its tests from here, so it agrees with the viewport whichever way depth runs. +pub fn depth_compare(reversed: bool, compare: wgpu::CompareFunction) -> wgpu::CompareFunction { + use wgpu::CompareFunction::{Greater, GreaterEqual, Less, LessEqual}; + match compare { + Less if reversed => Greater, + LessEqual if reversed => GreaterEqual, + Greater if reversed => Less, + GreaterEqual if reversed => LessEqual, + c => c, + } +} + +/// A depth bias written for depth growing away from the eye (negative: toward it), turned round +/// for reversed depth. +pub fn depth_bias(reversed: bool, b: wgpu::DepthBiasState) -> wgpu::DepthBiasState { + if reversed { wgpu::DepthBiasState { constant: -b.constant, slope_scale: -b.slope_scale, clamp: -b.clamp } } else { b } +} + +/// The depth of the far plane, which a depth buffer is cleared to. +pub fn far_depth(reversed: bool) -> f32 { + if reversed { 0.0 } else { 1.0 } +} + +/// The view-projection the GPU draws with: `vp` (depth 0 near, 1 far, as the CPU uses it), with +/// depth turned round (z' = w - z) for reversed depth. Columns, as `Mat4::to_f32` gives them. +pub fn gpu_view_proj(vp: [[f32; 4]; 4], reversed: bool) -> [[f32; 4]; 4] { + if !reversed { + return vp; + } + vp.map(|c| [c[0], c[1], c[3] - c[2], c[3]]) +} + impl Renderer { /// The pipelines for target `t` on `device`; `tess` evaluates planned bodies on the GPU. pub fn new(device: &wgpu::Device, t: GpuTarget, tess: Option) -> Self { @@ -766,15 +833,20 @@ impl Renderer { layout: &bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], }); + let reversed = t.reversed_depth(); let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { t.depth.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: Some(write), - depth_compare: Some(compare), + depth_compare: Some(depth_compare(reversed, compare)), stencil: Default::default(), bias: Default::default(), }) }; + // Opaque faces pushed back by their own slope (FACE_BIAS); ghosts twice as far. + let pushed = |compare: wgpu::CompareFunction, write: bool, bias: wgpu::DepthBiasState| -> Option { + depth(compare, write).map(|d| wgpu::DepthStencilState { bias: depth_bias(reversed, bias), ..d }) + }; let pipeline = |label: &str, vs: &str, fs: &str, @@ -926,9 +998,9 @@ impl Renderer { let mark = wgpu::DepthStencilState { format, depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), + depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)), stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, - bias: Default::default(), + bias: depth_bias(reversed, FACE_BIAS), }; // Then the cap's back faces again, only there, at the section plane's depth. let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; @@ -1002,7 +1074,41 @@ impl Renderer { multiview_mask: None, cache: None, }); + let depth_reset = t.depth.filter(|_| reversed).map(|format| { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("sc_depth_reset"), + source: wgpu::ShaderSource::Wgsl(DEPTH_RESET_SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_depth_reset"), + bind_group_layouts: &[], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_depth_reset"), + layout: Some(&layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::empty() })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }); Renderer { + depth_reset, target: t, max_texture: device.limits().max_texture_dimension_2d, ss_blit, @@ -1014,20 +1120,20 @@ impl Renderer { sampler, textures: Default::default(), quads: Vec::new(), - tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), false), + tri: solid("sc_tris", "fs_solid", None, pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), false), tri_cut: cut(false), - tri_planned: solid("sc_tris_planned", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), true), + tri_planned: solid("sc_tris_planned", "fs_solid", None, pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), true), tri_cut_planned: cut(true), tess, access_bgl, access_bind, access_version: 0, tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), - pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None), + pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), None), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), glass_far: glass("sc_glass_far", wgpu::Face::Front), glass_near: glass("sc_glass_near", wgpu::Face::Back), - ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + ghost: pipeline("sc_ghost", "vs_tri", "fs_tri", tri_layout(), pushed(wgpu::CompareFunction::LessEqual, false, GHOST_BIAS), alpha), xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), @@ -1053,18 +1159,40 @@ impl Renderer { /// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges. /// The bias is a few depth steps plus the face's own slope: far less than any real gap. fn highlight_depth(format: Option) -> Option { + let reversed = reversed_depth(format); format.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::LessEqual), + depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::LessEqual)), stencil: Default::default(), - bias: HIGHLIGHT_BIAS, + bias: depth_bias(reversed, HIGHLIGHT_BIAS), }) } /// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it). const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 }; +/// Opaque faces are drawn pushed back from the eye by a few depth steps and by their own depth +/// slope over [`FACE_SLOPE`] pixels, so whatever lies on a face (its edges, an origin plane, a +/// sketch, a decal) is drawn over it at any angle, and an edge behind it stays hidden however +/// large the scene (#1434, #1739). The CPU renderer has the same rule (`raster.rs`, #983). +/// Lines used to be pulled toward the eye by a share of the depth range instead, which on a large +/// design reached tens of millimetres. Positive: away from the eye. +pub const FACE_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: FACE_STEPS, slope_scale: FACE_SLOPE, clamp: 0.0 }; +/// The depth steps of [`FACE_BIAS`]: f32 rounding between a face and what lies on it (the GPU +/// evaluates planned bodies in f32; edges and overlays come from the CPU). +pub const FACE_STEPS: i32 = 8; +/// The pixels of [`FACE_BIAS`]: half a 2 px line, so edges (1.2 px) draw whole on a steep face. +/// It is also how far behind a face an edge may show, in that face's own depth per pixel: at 10 m +/// a pixel is about 9 mm, and a face turned 25° gains 4 mm of depth across it, so a larger value +/// let a washer 12 mm behind a plate show through it (#1434). A thicker line (a selected edge, +/// 3.5 px) may lose its outer edge on one side where it lies on a steep face. The section cap, +/// which writes its own depth, adds the same in the shader. +pub const FACE_SLOPE: f32 = 1.0; +/// The removed material of a preview, drawn translucent: behind the faces it shares with the new +/// shape, so twice [`FACE_BIAS`]. +const GHOST_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: 2 * FACE_STEPS, slope_scale: 2.0 * FACE_SLOPE, clamp: 0.0 }; + fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) } @@ -1162,7 +1290,7 @@ impl Renderer { if cb.product { res.product.take(device, cb.key, &cb.slot, None); res.product.sort_glass(queue, cb.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth())); return; } res.model.take(device, cb.key, &cb.slot, res.tess.as_ref().map(|t| (t, &mut *encoder))); @@ -1170,7 +1298,7 @@ impl Renderer { res.highlight.take(device, cb.hl_key, &cb.hl_slot, None); res.preview.take(device, cb.pv_key, &cb.pv_slot, None); res.model.sort_glass(queue, cb.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth())); if let Some(a) = &cb.access && a.version != res.access_version { @@ -1245,6 +1373,10 @@ impl Renderer { pass.draw(0..3, 0..1); return; } + if let Some(reset) = &self.depth_reset { + pass.set_pipeline(reset); + pass.draw(0..3, 0..1); + } self.draw(frame, pass); } @@ -1335,6 +1467,7 @@ impl Renderer { tris(pass, &res.trans, &h.trans); tris(pass, &res.guide_trans, &h.guide_trans); tris(pass, &res.xray, &pv.xray); + tris(pass, &res.xray, &h.xray); lines(pass, &res.overlay, &m.overlays); lines(pass, &res.overlay, &sk.overlays); lines(pass, &res.overlay, &h.overlays); @@ -1366,7 +1499,10 @@ impl Renderer { })], depth_stencil_attachment: ss.depth.as_ref().map(|view| wgpu::RenderPassDepthStencilAttachment { view, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(far_depth(self.target.reversed_depth())), + store: wgpu::StoreOp::Discard, + }), stencil_ops: depth_format .filter(|f| f.has_stencil_aspect()) .map(|_| wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), @@ -1419,10 +1555,10 @@ impl Renderer { } /// Uniform bytes for a frame. -pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool) -> Vec { - let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); +pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -> Vec { + let mut v: Vec = gpu_view_proj(cb.view_proj, reversed).iter().flatten().copied().collect(); v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); - v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]); + v.extend([w.max(1.0), h.max(1.0), 0.0, if cb.outline { 1.0 } else { 0.0 }]); v.extend(cb.clip); v.extend(cb.cap); v.extend(cb.analysis); @@ -1435,6 +1571,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool) -> Vec { } v.extend(cb.eye); v.extend(cb.analysis_colors); + v.extend([if reversed { 1.0 } else { 0.0 }, FACE_STEPS as f32, FACE_SLOPE, 0.0]); v.iter().flat_map(|x| x.to_le_bytes()).collect() } diff --git a/crates/gpu/tests/depth.rs b/crates/gpu/tests/depth.rs new file mode 100644 index 00000000..32e1cbd1 --- /dev/null +++ b/crates/gpu/tests/depth.rs @@ -0,0 +1,243 @@ +//! Depth in headless pictures, drawn with the viewport's pipelines (#1434, #1739): an edge behind +//! a face stays hidden however large the design and however far the camera, and whatever lies on +//! a face (its edges, a thin label) draws over it at any angle. Each check runs with the depth the +//! device draws with (a 32-bit float buffer, reversed, where it has one) and with the 24-bit +//! fallback. Skipped, with the reason printed, without a GPU adapter (`SOLIDCRAFT_REQUIRE_GPU=1` +//! makes that a failure). + +use serde_json::json; +use solidcraft_engine::Session; +use solidcraft_engine::geom::{Mesh, Vec3}; +use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine, SceneMesh, StandardView}; +use solidcraft_engine::view::{self, Guides}; +use solidcraft_gpu::GpuError; +use solidcraft_gpu::capture::{self, Capturer}; +use std::sync::{Arc, OnceLock}; + +fn or_skip(c: Result<&'static Capturer, &'static GpuError>) -> Option<&'static Capturer> { + match c { + Ok(c) => Some(c), + Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("skipping: {e}"); + None + } + Err(e) => panic!("{e}"), + } +} + +/// The device's own depth, then the 24-bit fallback (the same device kind, its own instance). +fn capturers() -> Vec<(&'static str, &'static Capturer)> { + static FALLBACK: OnceLock> = OnceLock::new(); + let Some(own) = or_skip(capture::shared()) else { return Vec::new() }; + let Some(fallback) = or_skip(FALLBACK.get_or_init(|| Capturer::headless_with_depth(wgpu::TextureFormat::Depth24PlusStencil8)).as_ref()) else { + return Vec::new(); + }; + eprintln!("GPU: {}, depth {:?} and {:?}", own.adapter_name(), own.depth_format(), fallback.depth_format()); + vec![("own", own), ("24-bit", fallback)] +} + +fn reversed(c: &Capturer) -> bool { + solidcraft_gpu::view::reversed_depth(Some(c.depth_format())) +} + +/// RGBA pixels as `[r, g, b, a]`. +fn pixels(img: &[u8]) -> impl Iterator + '_ { + img.as_chunks::<4>().0.iter().copied() +} + +fn red(p: [u8; 4]) -> bool { + p[0] > 150 && p[1] < 90 && p[2] < 90 +} + +fn session(script: &[(&str, serde_json::Value)]) -> Session { + let mut s = Session::default(); + for (c, p) in script { + s.execute(c, p).unwrap(); + } + s +} + +fn picture(c: &Capturer, s: &Session, cam: &Camera, w: u32, h: u32) -> Vec { + c.rgba(&view::scene_with_guides(s, cam, None, Guides { grid: false, axes: false }), cam, w, h).unwrap() +} + +fn differing(a: &[u8], b: &[u8]) -> usize { + pixels(a).zip(pixels(b)).filter(|(p, q)| p.iter().zip(q).any(|(x, y)| x.abs_diff(*y) > 24)).count() +} + +/// The reproduction of #1434: a 20 mm plate with a washer 22–25 mm behind its front face, and a +/// 30 m slab far away that makes the design 34 m across. The washer never shows: the picture is +/// the same without it. (Lines were pulled toward the eye by 2e-4 of the depth range, about +/// 27 mm here in orthographic.) +#[test] +fn a_washer_behind_a_plate_stays_hidden_on_a_large_design() { + let plate = [ + ("solid.box", json!({"length": 30000, "width": 8000, "height": 100, "corner": [-15000, 3000, -3000], "body_name": "slab"})), + ("solid.box", json!({"length": 200, "width": 20, "height": 200, "corner": [0, 0, 0], "body_name": "plate"})), + ("solid.cylinder", json!({"radius": 8, "height": 60, "base": [100, -20, 100], "axis": "Y", "body_name": "bolt"})), + ]; + let washer = ("solid.cylinder", json!({"radius": 15, "height": 3, "base": [100, 22, 100], "axis": "Y", "body_name": "washer"})); + let without = session(&plate); + let with = session(&[plate[0].clone(), plate[1].clone(), plate[2].clone(), washer]); + for (mode, c) in capturers() { + for fov in [0.0, 0.6] { + let cam = Camera { target: Vec3::new(100.0, 10.0, 100.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 450.0, fov, ..Default::default() }; + let n = differing(&picture(c, &with, &cam, 400, 300), &picture(c, &without, &cam, 400, 300)); + assert_eq!(n, 0, "{mode} depth, fov {fov}: the washer shows through the plate in {n} pixels"); + } + } +} + +/// #1434's second reproduction, in perspective at 10 m: a 2 m plate, a 600 mm washer 12–15 mm +/// behind its front face, and a red label 0.3 mm thick on it. The washer stays hidden in both +/// depths; with reversed float depth the label is red all over (in 24 bits one depth step there +/// is about 0.6 mm, so the label speckled). +#[test] +fn at_ten_metres_a_thin_label_is_whole_and_a_washer_behind_stays_hidden() { + let base = [ + ("solid.box", json!({"length": 2000, "width": 10, "height": 2000, "corner": [0, 0, 0], "body_name": "plate"})), + ("solid.box", json!({"length": 1000, "width": 0.3, "height": 400, "corner": [500, -0.3, 1400], "body_name": "label"})), + ("appearance.assign", json!({"bodies": ["label"], "color": [204, 34, 34]})), + ]; + let washer = ("solid.cylinder", json!({"radius": 300, "height": 3, "base": [1000, 12, 1000], "axis": "Y", "body_name": "washer"})); + let without = session(&base); + let with = session(&[base[0].clone(), base[1].clone(), base[2].clone(), washer]); + let cam = + Camera { target: Vec3::new(1000.0, 5.0, 1000.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 10_000.0, fov: 0.6, ..Default::default() }; + let (w, h) = (800, 600); + for (mode, c) in capturers() { + let img = picture(c, &with, &cam, w, h); + let n = differing(&img, &picture(c, &without, &cam, w, h)); + assert_eq!(n, 0, "{mode} depth: the washer shows through the plate in {n} pixels"); + if !reversed(c) { + continue; + } + // The label's front face (y = -0.3), sampled inside its outline. + let radius = view::scene(&with, &cam).radius; + let mut grey = Vec::new(); + for i in 1..20 { + for j in 1..20 { + let p = Vec3::new(500.0 + 1000.0 * f64::from(i) / 20.0, -0.3, 1400.0 + 400.0 * f64::from(j) / 20.0); + let Some((x, y, _)) = cam.to_screen(p, f64::from(w), f64::from(h), radius) else { continue }; + let at = (y as usize * w as usize + x as usize) * 4; + if let Some(px) = img.get(at..at + 4).and_then(|p| <[u8; 4]>::try_from(p).ok()) + && !red(px) + { + grey.push((x as u32, y as u32, px)); + } + } + } + assert!(grey.is_empty(), "{mode} depth: the label speckles at {} of 361 points: {:?}", grey.len(), &grey[..grey.len().min(5)]); + } +} + +/// A 30 × 40 mm sheet at y = z·tan(`tilt`) (facing the front camera when `tilt` is 0). +fn sheet(tilt: f64) -> Mesh { + let t = tilt.tan(); + let c = [(-15.0, -20.0), (15.0, -20.0), (15.0, 20.0), (-15.0, 20.0)].map(|(x, z)| Vec3::new(x, z * t, z)); + Mesh { + positions: c.to_vec(), + normals: vec![Vec3::new(0.0, -tilt.cos(), tilt.sin()); 4], + triangles: vec![[0, 1, 2], [0, 2, 3]], + tri_face: vec![0, 0], + ..Default::default() + } +} + +/// A front view of `mesh` with `lines`, as part of a design of `radius`, 40 mm high on 200 px. +fn front(mesh: Mesh, lines: Vec, radius: f64, fov: f64) -> (Scene, Camera) { + let scene = Scene { + meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(160, 165, 175), face_colors: Vec::new() }], + lines, + radius, + ..Default::default() + }; + let mut cam = Camera { distance: if fov > 0.0 { 25.0 / (fov * 0.5).tan() } else { 62.5 }, fov, ..Default::default() }; + cam.set_view(StandardView::Front); + (scene, cam) +} + +fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine { + SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 1.2, on_top } +} + +fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize { + pixels(&c.rgba(scene, cam, 200, 200).unwrap()).filter(|p| red(*p)).count() +} + +/// An edge lying on a face draws its whole width over it, on a face seen square and on one turned +/// to 80°, in orthographic and perspective, in a small design and a large one. +#[test] +fn an_edge_on_a_face_draws_whole_at_any_angle() { + for (mode, c) in capturers() { + for tilt in [0.0_f64, 60.0, 80.0] { + let t = tilt.to_radians().tan(); + // Across the sheet, at z = 5: y = 5·tan(tilt). + let (a, b) = (Vec3::new(-12.0, 5.0 * t, 5.0), Vec3::new(12.0, 5.0 * t, 5.0)); + for radius in [30.0, 17_000.0] { + for fov in [0.0, 0.6] { + let (on, cam) = front(sheet(tilt.to_radians()), vec![red_line(a, b, false)], radius, fov); + let (top, _) = front(sheet(tilt.to_radians()), vec![red_line(a, b, true)], radius, fov); + let (shown, all) = (red_pixels(c, &on, &cam), red_pixels(c, &top, &cam)); + assert!(all > 50, "{mode}: the line itself ({all} px)"); + assert!(shown * 10 >= all * 9, "{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}: {shown} of {all} px drawn"); + } + } + } + } +} + +/// An edge 0.5 mm behind a face seen square is hidden, however large the design: lines were pulled +/// toward the eye by a share of the depth range, about 27 mm in a design 34 m across. +#[test] +fn an_edge_just_behind_a_face_stays_hidden_on_a_large_design() { + for (mode, c) in capturers() { + for radius in [30.0, 17_000.0] { + for fov in [0.0, 0.6] { + // The sheet is at y = 0; the camera looks along +y, so behind is +y. + let (a, b) = (Vec3::new(-12.0, 0.5, 5.0), Vec3::new(12.0, 0.5, 5.0)); + let (scene, cam) = front(sheet(0.0), vec![red_line(a, b, false)], radius, fov); + let n = red_pixels(c, &scene, &cam); + assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}: the edge behind shows in {n} px"); + } + } + } +} + +/// The application's device draws with reversed float depth where it can: every desktop backend +/// this runs on (D3D12, Vulkan, Metal) has `Depth32FloatStencil8`. +#[test] +fn the_device_draws_with_float_depth_where_it_has_it() { + let Some(c) = or_skip(capture::shared()) else { return }; + eprintln!("GPU: {}, depth {:?}", c.adapter_name(), c.depth_format()); + let float = c.depth_format() == wgpu::TextureFormat::Depth32FloatStencil8; + assert_eq!(float, reversed(c)); + if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() { + assert!(float, "a desktop GPU without Depth32FloatStencil8: {:?}", c.depth_format()); + } +} + +/// What lies on a section's cut face draws over the cap, which writes its own depth (so the GPU's +/// push does not apply to it; the shader pushes it the same way). +#[test] +fn an_edge_on_the_cut_face_draws_over_the_cap() { + let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]); + // Cut away the front (y < 15), so the iso camera looks at the cut face at y = 15. + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + for (mode, c) in capturers() { + for fov in [0.0, 0.6] { + let mut cam = view::home_camera(&s); + cam.fov = fov; + let count = |on_top: bool| { + // The session's section (`scene_with_guides` takes its own). The line is 2 µm on + // the kept side: exactly on the plane, rounding cuts parts of it away with the rest. + let mut sc = view::scene(&s, &cam); + sc.lines.push(red_line(Vec3::new(5.0, 15.002, 10.0), Vec3::new(35.0, 15.002, 10.0), on_top)); + pixels(&c.rgba(&sc, &cam, 300, 220).unwrap()).filter(|p| red(*p)).count() + }; + let (shown, all) = (count(false), count(true)); + assert!(all > 50, "{mode}: the line itself ({all} px)"); + assert!(shown * 10 >= all * 9, "{mode} depth, fov {fov}: {shown} of {all} px drawn over the cap"); + } + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 7afd3eeb..de4a2ef0 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -20,6 +20,11 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +# A plain fsync where a network share refuses Apple's full flush (src/safe_write.rs); already in +# the tree through tempfile. +[target.'cfg(target_vendor = "apple")'.dependencies] +rustix = { version = "1", default-features = false, features = ["std", "fs"] } + # FreeCAD and Rhino import. Not in the browser build: it compiles the zstd C library. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] cadmpeg-core = { workspace = true } diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs index 0d321760..e3fbf151 100644 --- a/crates/io/src/design.rs +++ b/crates/io/src/design.rs @@ -294,7 +294,11 @@ impl PackageReader { if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize { return Err(invalid("too many entries or unsupported ZIP directory")); } - let mut zip = zip::ZipArchive::new(source).map_err(invalid)?; + // A design file is a ZIP with nothing in front of it. Saying so skips zip's search for a + // prefix, which reads back through the stored parts and would load a large embedded + // source before the preview can be shown. + let config = zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) }; + let mut zip = zip::ZipArchive::with_config(config, source).map_err(invalid)?; if u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != zip.len() @@ -307,7 +311,7 @@ impl PackageReader { let mut total = 0u64; for i in 0..zip.len() { let part = zip.by_index_raw(i).map_err(invalid)?; - let name = part.name(); + let name = part.name().map_err(invalid)?.into_owned(); if name.is_empty() || name.contains('\\') || name.split('/').any(|c| c.is_empty() || c == "." || c == "..") @@ -768,7 +772,7 @@ mod tests { let doc = document(); let preview = DesignPreview { meshes: vec![mesh()] }; let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap(); - let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize; + let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start().unwrap() as usize; let bytes = Rc::new(RefCell::new(bytes)); let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap(); assert!(reader.preview().is_some()); @@ -850,7 +854,7 @@ mod tests { (0..zip.len()) .map(|i| { let mut entry = zip.by_index(i).unwrap(); - let name = entry.name().to_owned(); + let name = entry.name().unwrap().into_owned(); let mut bytes = Vec::new(); entry.read_to_end(&mut bytes).unwrap(); (name, bytes) diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index bba7f847..fff56ea6 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -667,7 +667,7 @@ mod tests { assert!(degree_2(&conic(2.0), Some(vec![1.0, 2.0, 1.0]))); assert!(degree_2(&conic(1.0 - 1e-9), Some(vec![1.0, 1.0 - 1e-9, 1.0]))); // A quintic piece: six control points and twelve knots. - let mut q = [Vec2::ZERO; 8]; + let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, s) in q.iter_mut().enumerate() { *s = Vec2::new(i as f64, (i * i) as f64); } diff --git a/crates/io/src/gltf_in.rs b/crates/io/src/gltf_in.rs index bc0af454..3ea12816 100644 --- a/crates/io/src/gltf_in.rs +++ b/crates/io/src/gltf_in.rs @@ -253,6 +253,21 @@ fn parse_document(json: &[u8]) -> Result { } // The ones left only change looks (or are the quantized attributes read below). root.extensions_required.clear(); + // gltf-json's primitive check reads the POSITION accessor without a bounds check, so one + // naming an accessor the file lacks panics inside it. The guard catches that on the + // desktop, but in the browser a panic aborts the app: refuse such a file before (#469). + let accessors = root.accessors.len(); + let positions = gltf::json::validation::Checked::Valid(gltf::json::mesh::Semantic::Positions); + for (m, mesh) in root.meshes.iter().enumerate() { + for (p, primitive) in mesh.primitives.iter().enumerate() { + if let Some(a) = primitive.attributes.get(&positions).filter(|a| a.value() >= accessors) { + return Ok(Err(format!( + "invalid glTF: mesh {m}, primitive {p}: POSITION names accessor {}, but the file has {accessors}", + a.value() + ))); + } + } + } Ok(gltf::Document::from_json(root).map_err(|e| format!("invalid glTF: {e}"))) }); match parsed { @@ -868,4 +883,4 @@ pub fn glb_container(json: &[u8], bin: &[u8]) -> Vec { #[cfg(test)] #[path = "gltf_in_tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/crates/io/src/gltf_in_tests.rs b/crates/io/src/gltf_in_tests.rs index 2d6f5852..cac8fc5b 100644 --- a/crates/io/src/gltf_in_tests.rs +++ b/crates/io/src/gltf_in_tests.rs @@ -355,6 +355,23 @@ fn assert_no_panic(bytes: &[u8], what: &str) { } } +/// A primitive whose POSITION names an accessor the file lacks is refused before the glTF crate +/// sees it: the crate's own check indexes the accessors unchecked and panics, which the guard +/// catches on the desktop but which aborts the app in the browser (#469, found by mutation). +#[test] +fn a_position_naming_a_missing_accessor_is_refused_before_the_gltf_crate() { + let mut f = Fixture::default(); + let m = f.cube_mesh("Cube", 0.01); + let n = f.node(json!({"mesh": m})); + f.roots = vec![n]; + let mut j = f.json(None); + j["meshes"][0]["primitives"][0]["attributes"]["POSITION"] = json!(99); + let glb = glb_container(j.to_string().as_bytes(), &f.bin); + let e = read(&glb).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("POSITION names accessor 99, but the file has 2"), "{e}"); + assert!(!solidcraft_kernel::guarded(), "no guard left open"); +} + #[test] fn hostile_gltf_never_panics() { let mut f = Fixture::default(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 334f01f2..cf8bb2b5 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,6 +18,8 @@ pub use design::{ write_design_container_with_thumbnail, }; mod legacy_dir; +#[cfg(test)] +mod mutate; mod native_cad; pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step}; pub mod safe_write; @@ -488,7 +490,9 @@ fn mesh_object(b: &solidcraft_doc::ModelBody, tagged: bool) -> Result Result> { use solidcraft_kernel::{ExportBody, ExportProduct}; let mut index: std::collections::HashMap = std::collections::HashMap::new(); @@ -498,10 +502,18 @@ pub fn step_assembly(doc: &Document, local: &ModelState, name: &str) -> Result> = local + .bodies + .iter() + .map(|b| match doc.model_body_offset(b) { + Some(offset) if !solidcraft_doc::is_identity(offset) => solidcraft_kernel::transform_matrix(&b.body, *offset).map(Some), + _ => Ok(None), + }) + .collect::>()?; + for (b, framed) in local.bodies.iter().zip(&framed) { + let comp = doc.model_body_component(b); let Some(p) = index.get(&comp).and_then(|i| products.get_mut(*i)) else { continue }; - p.bodies.push(ExportBody { name: b.name.clone(), body: &b.body, color: b.body.color() }); + p.bodies.push(ExportBody { name: b.name.clone(), body: framed.as_ref().unwrap_or(&b.body), color: b.body.color() }); } for o in &doc.occurrences { let (Some(&parent), Some(&child)) = (index.get(&o.parent), index.get(&o.component)) else { continue }; @@ -864,6 +876,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) @@ -948,6 +961,7 @@ mod tests { body: None, style: Default::default(), fit: Default::default(), + corner: Default::default(), }, None, ) diff --git a/crates/io/src/mutate.rs b/crates/io/src/mutate.rs new file mode 100644 index 00000000..f907e56a --- /dev/null +++ b/crates/io/src/mutate.rs @@ -0,0 +1,523 @@ +//! Deterministic mutations of valid files, and the readers run over them (#469). Every reader of +//! bytes the user didn't write gets the same treatment: truncation, flipped and overwritten +//! bytes, dropped and repeated runs, appended garbage and, for text formats, dropped and +//! repeated lines and numbers that lie (`nan`, `inf`, `1e400`, …). Each mutated file must give an +//! error or a result, never a panic, within a time budget and the reader's own caps. A panic a +//! reader catches itself (`solidcraft_kernel::guard`) counts too: the browser build aborts on any +//! panic, so a caught one on the desktop is a crash there. +//! +//! The mutations come from a fixed seed (xorshift), so a failure names a case that reproduces. +//! No fuzzing dependency, nothing nightly, nothing native: it runs in the normal test suite and +//! builds for wasm. The readers' own hand-written cases stay; they cover particular lies (a +//! length in a container header) that random changes rarely hit. STEP, IGES and FreeCAD/Rhino +//! files are read by the kernel, which is moving to solidcore (#1597), and keep their own tests. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::time::{Duration, Instant}; + +/// A deterministic xorshift64 stream. +pub(crate) struct Rng(u64); + +impl Rng { + pub(crate) fn new(seed: u64) -> Rng { + Rng(seed | 1) + } + + pub(crate) fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + /// A value in `0..n` (0 when `n` is 0). + pub(crate) fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } +} + +/// Numbers a hostile file might hold in place of a real one. +const LIARS: [&str; 10] = ["nan", "inf", "-inf", "1e400", "-1e400", "-0", "4294967296", "-1", "99999999999999999999", "0x7fffffff"]; + +/// Most truncations of one file (every length up to this, evenly spaced beyond). +const MAX_TRUNCATIONS: usize = 1024; + +/// Mutations of `good`, the same for the same `good` and `seed`. `text`: also line and number +/// mutations (the file is UTF-8 text). +pub(crate) fn mutations(good: &[u8], text: bool, seed: u64) -> Vec> { + let mut out: Vec> = Vec::new(); + let n = good.len(); + let mut rng = Rng::new(seed); + // Truncations: every length of a short file; of a long one, its first and last 64 lengths + // (headers, final records) and evenly spaced ones between. + if n <= MAX_TRUNCATIONS { + out.extend((0..n).map(|k| good[..k].to_vec())); + } else { + let mut cuts: Vec = (0..64).chain(n - 64..n).collect(); + cuts.extend((0..MAX_TRUNCATIONS - 128).map(|i| 64 + i * (n - 128) / (MAX_TRUNCATIONS - 128))); + cuts.sort_unstable(); + cuts.dedup(); + out.extend(cuts.into_iter().map(|k| good[..k].to_vec())); + } + if n == 0 { + return out; + } + // Bytes flipped, zeroed and saturated at seeded places. + for i in 0..384 { + let at = rng.below(n); + let mut b = good.to_vec(); + b[at] = match i % 3 { + 0 => b[at] ^ ((rng.next() >> 32) as u8 | 1), + 1 => 0x00, + _ => 0xff, + }; + out.push(b); + } + // Runs of bytes dropped, repeated and swapped with their neighbour. + for _ in 0..96 { + let at = rng.below(n); + let len = 1 + rng.below(64.min(n - at)); + let end = at + len; + let mut dropped = good[..at].to_vec(); + dropped.extend_from_slice(&good[end..]); + out.push(dropped); + let mut repeated = good[..end].to_vec(); + repeated.extend_from_slice(&good[at..]); + out.push(repeated); + if end + len <= n { + let mut swapped = good[..at].to_vec(); + swapped.extend_from_slice(&good[end..end + len]); + swapped.extend_from_slice(&good[at..end]); + swapped.extend_from_slice(&good[end + len..]); + out.push(swapped); + } + } + // The whole file: with garbage after it, twice over, reversed. + let mut tail = good.to_vec(); + tail.extend((0..4096).map(|_| (rng.next() >> 40) as u8)); + out.push(tail); + out.push([good, good].concat()); + out.push(good.iter().rev().copied().collect()); + if text && let Ok(s) = std::str::from_utf8(good) { + out.extend(text_mutations(s, &mut rng)); + } + out +} + +/// A text file's lines dropped, repeated and swapped, and its numbers replaced by lies. +fn text_mutations(s: &str, rng: &mut Rng) -> Vec> { + let mut out = Vec::new(); + let lines: Vec<&str> = s.split_inclusive('\n').collect(); + let join = |v: &[&str]| v.concat().into_bytes(); + let picked: Vec = if lines.len() <= 200 { (0..lines.len()).collect() } else { (0..200).map(|_| rng.below(lines.len())).collect() }; + for &i in &picked { + let mut v = lines.clone(); + v.remove(i); + out.push(join(&v)); + let mut v = lines.clone(); + v.insert(i, lines[i]); + out.push(join(&v)); + if i + 1 < lines.len() { + let mut v = lines.clone(); + v.swap(i, i + 1); + out.push(join(&v)); + } + } + // A line in place of another (a block that inserts itself, a count where a coordinate was). + for _ in 0..200.min(lines.len() * lines.len()) { + let (i, j) = (rng.below(lines.len()), rng.below(lines.len())); + let mut v = lines.clone(); + v[i] = lines[j]; + out.push(join(&v)); + } + // A word in place of another of the file's own words: a renamed block, a swapped keyword, a + // block that now inserts itself. Every pair in a small file, a sample in a large one. + let words = word_spans(s); + let mut vocab: Vec<&str> = words.iter().map(|&(a, b)| &s[a..b]).collect(); + vocab.sort_unstable(); + vocab.dedup(); + let pairs = words.len().saturating_mul(vocab.len()); + let swap = |(a, b): (usize, usize), w: &str| format!("{}{w}{}", &s[..a], &s[b..]).into_bytes(); + if pairs <= 3000 { + for &span in &words { + for &w in vocab.iter().filter(|w| **w != &s[span.0..span.1]) { + out.push(swap(span, w)); + } + } + } else { + for _ in 0..400 { + out.push(swap(words[rng.below(words.len())], vocab[rng.below(vocab.len())])); + } + } + // Numbers: each of up to 200 of them, in turn, becomes one of the liars. + let numbers = number_spans(s); + let chosen: Vec<(usize, usize)> = if numbers.len() <= 200 { numbers } else { (0..200).map(|_| numbers[rng.below(numbers.len())]).collect() }; + for (k, (a, b)) in chosen.into_iter().enumerate() { + let liar = LIARS[k % LIARS.len()]; + out.push(format!("{}{liar}{}", &s[..a], &s[b..]).into_bytes()); + } + // Every number lying at once (two counts that are only harmless one at a time). + let all = number_spans(s); + for liar in LIARS { + let mut t = String::with_capacity(s.len()); + let mut at = 0; + for &(a, b) in &all { + t.push_str(&s[at..a]); + t.push_str(liar); + at = b; + } + t.push_str(&s[at..]); + out.push(t.into_bytes()); + } + out +} + +/// Byte spans of the words in `s` (runs of ASCII letters, digits and `_` that start with a +/// letter). +fn word_spans(s: &str) -> Vec<(usize, usize)> { + let b = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + if !b[i].is_ascii_alphabetic() || (i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')) { + i += 1; + continue; + } + let a = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') { + i += 1; + } + out.push((a, i)); + } + out +} + +/// Byte spans of the decimal numbers in `s` (a sign, digits, a point, an exponent). +fn number_spans(s: &str) -> Vec<(usize, usize)> { + let b = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < b.len() { + let starts = b[i].is_ascii_digit() || ((b[i] == b'-' || b[i] == b'.') && b.get(i + 1).is_some_and(u8::is_ascii_digit)); + let after_word = i > 0 && (b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_'); + if !starts || after_word { + i += 1; + continue; + } + let a = i; + i += 1; + while i < b.len() && (b[i].is_ascii_digit() || b[i] == b'.' || ((b[i] == b'e' || b[i] == b'E') && i + 1 < b.len())) { + if (b[i] == b'e' || b[i] == b'E') && (b[i + 1] == b'-' || b[i + 1] == b'+') { + i += 1; + } + i += 1; + } + out.push((a, i)); + } + out +} + +thread_local! { + /// Panics on this thread so far, caught or not ([`count_panics`]). + static PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count every panic per thread (tests run on several), then run the hook that was there. +fn count_panics() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICS.with(|p| p.set(p.get() + 1)); + previous(info); + })); + }); +} + +/// The time any one reader call may take on a mutated file (a debug build on a loaded machine: +/// generous, but far below what unbounded work on a lying count would take). +pub(crate) const BUDGET: Duration = Duration::from_secs(2); + +/// Run `read` over `inputs`: no panic, within [`BUDGET`], and `read`'s count (triangles, +/// entities, …) at most `cap`. `read` returns `None` for an error. Panics (the test fails) naming +/// the first case that breaks a rule. +pub(crate) fn survives(what: &str, inputs: &[Vec], cap: usize, mut read: impl FnMut(&[u8]) -> Option) { + count_panics(); + let mut slowest = (Duration::ZERO, 0); + let mut read_ok = 0; + for (i, input) in inputs.iter().enumerate() { + let before = PANICS.with(std::cell::Cell::get); + let t = Instant::now(); + let r = catch_unwind(AssertUnwindSafe(|| read(input))); + let took = t.elapsed(); + let head = String::from_utf8_lossy(&input[..input.len().min(120)]).into_owned(); + let Ok(r) = r else { panic!("{what}: case {i} ({} bytes) panicked; it starts {head:?}", input.len()) }; + let caught = PANICS.with(std::cell::Cell::get) - before; + assert!( + caught == 0, + "{what}: case {i} ({} bytes) panicked inside the reader (caught there, but the browser aborts); it starts {head:?}", + input.len() + ); + assert!(took <= BUDGET, "{what}: case {i} ({} bytes) took {took:?} (budget {BUDGET:?}); it starts {head:?}", input.len()); + if let Some(count) = r { + assert!(count <= cap, "{what}: case {i} gave {count} (cap {cap})"); + read_ok += 1; + } + slowest = slowest.max((took, i)); + } + eprintln!("{what}: {} mutations, {read_ok} read, slowest case {} in {:?}", inputs.len(), slowest.1, slowest.0); +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_geom::{Mesh, Vec2, Vec3}; + + fn cube() -> Mesh { + let positions = (0..8).map(|i| Vec3::new(f64::from(i & 1) * 10.0, f64::from((i >> 1) & 1) * 10.0, f64::from((i >> 2) & 1) * 10.0)).collect(); + let t = [[0, 2, 1], [1, 2, 3], [4, 5, 6], [5, 7, 6], [0, 1, 4], [1, 5, 4], [2, 6, 3], [3, 6, 7], [0, 4, 2], [2, 4, 6], [1, 3, 5], [3, 7, 5]]; + Mesh { positions, triangles: t.to_vec(), ..Default::default() } + } + + #[test] + fn mutations_are_deterministic_and_cover_the_file() { + let good = b"solid x\n facet normal 0 0 1\n vertex 1.5 -2 3e2\n endfacet\nendsolid x\n"; + let a = mutations(good, true, 7); + assert_eq!(a, mutations(good, true, 7), "the same seed gives the same cases"); + assert_ne!(a, mutations(good, true, 8)); + assert!(a.iter().any(Vec::is_empty) && a.iter().any(|m| m.len() == good.len() - 1), "truncations down to nothing"); + assert!(a.iter().any(|m| m.windows(3).any(|w| w == b"nan")), "a number replaced by a liar"); + assert_eq!(number_spans("v 1.5 -2 3e2 x1 .5e-3"), vec![(2, 5), (6, 8), (9, 12), (16, 21)]); + // A long file: bounded truncations, kept to the first and last bytes and spaced between. + let long = vec![b'a'; 100_000]; + let m = mutations(&long, false, 1); + assert!(m.len() < 3000, "{} cases", m.len()); + } + + #[test] + fn stl_survives_mutation() { + let mesh = cube(); + let binary = crate::stl_binary(std::slice::from_ref(&mesh), "fuzz"); + let ascii = crate::stl_ascii(std::slice::from_ref(&mesh), "fuzz").into_bytes(); + assert_eq!(crate::read_stl(&binary).map(|t| t.len()).ok(), Some(12)); + assert_eq!(crate::read_stl(&ascii).map(|t| t.len()).ok(), Some(12)); + let read = |b: &[u8]| crate::read_stl(b).ok().map(|t| t.len()); + survives("binary STL", &mutations(&binary, false, 0x5717), crate::MAX_STL_TRIANGLES, read); + survives("ASCII STL", &mutations(&ascii, true, 0x5718), crate::MAX_STL_TRIANGLES, read); + } + + #[test] + fn obj_survives_mutation() { + let good = crate::obj(&[("Cube".into(), cube())]).into_bytes(); + assert!(crate::read_obj(&good, "fuzz").is_ok()); + survives("OBJ", &mutations(&good, true, 0x0b1), usize::MAX, |b| { + crate::read_obj(b, "fuzz").ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum()) + }); + } + + #[test] + fn svg_survives_mutation() { + let good = br##" + + + + + + + + + +"##; + assert!(crate::read_svg(std::str::from_utf8(good).unwrap()).is_ok()); + survives("SVG", &mutations(good, true, 0x5f6), usize::MAX, |b| crate::read_svg(&String::from_utf8_lossy(b)).ok().map(|g| g.len())); + } + + #[test] + fn dxf_survives_mutation() { + let geom = [ + crate::Geom2::Line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)), + crate::Geom2::Circle(Vec2::new(5.0, 5.0), 2.5), + crate::Geom2::Arc { c: Vec2::new(0.0, 0.0), a: Vec2::new(3.0, 0.0), b: Vec2::new(0.0, 3.0) }, + crate::Geom2::Point(Vec2::new(1.0, 1.0)), + ]; + let written = crate::write_dxf(&geom).into_bytes(); + assert!(crate::read_dxf(std::str::from_utf8(&written).unwrap()).is_ok_and(|g| g.len() >= 3)); + survives("DXF (written)", &mutations(&written, true, 0xdf), usize::MAX, |b| { + crate::read_dxf(&String::from_utf8_lossy(b)).ok().map(|g| g.len()) + }); + // Blocks inside blocks, an array insert and a bulged polyline: what the block recursion, + // the array cells and the work budget guard. + let pairs: &[(&str, &str)] = &[ + ("0", "SECTION"), + ("2", "BLOCKS"), + ("0", "BLOCK"), + ("2", "inner"), + ("10", "0"), + ("20", "0"), + ("0", "LINE"), + ("10", "0"), + ("20", "0"), + ("11", "1"), + ("21", "1"), + ("0", "ENDBLK"), + ("0", "BLOCK"), + ("2", "outer"), + ("10", "0"), + ("20", "0"), + ("0", "INSERT"), + ("2", "inner"), + ("10", "2"), + ("20", "2"), + ("0", "CIRCLE"), + ("10", "0"), + ("20", "0"), + ("40", "1"), + ("0", "ENDBLK"), + ("0", "ENDSEC"), + ("0", "SECTION"), + ("2", "ENTITIES"), + ("0", "INSERT"), + ("2", "outer"), + ("10", "5"), + ("20", "5"), + ("41", "2"), + ("42", "2"), + ("50", "30"), + ("70", "3"), + ("71", "2"), + ("44", "10"), + ("45", "10"), + ("0", "LWPOLYLINE"), + ("90", "3"), + ("70", "1"), + ("10", "0"), + ("20", "0"), + ("42", "0.5"), + ("10", "5"), + ("20", "0"), + ("10", "5"), + ("20", "5"), + ("0", "ENDSEC"), + ("0", "EOF"), + ]; + let blocks: String = pairs + .iter() + .map(|(c, v)| { + format!( + "{c} +{v} +" + ) + }) + .collect(); + let n = crate::read_dxf(&blocks).map(|g| g.len()).unwrap_or(0); + assert!(n >= 12, "the array insert expands its nested blocks: {n} entities"); + survives("DXF (blocks)", &mutations(blocks.as_bytes(), true, 0xdb), usize::MAX, |b| { + crate::read_dxf(&String::from_utf8_lossy(b)).ok().map(|g| g.len()) + }); + // The two lies that need each other, written out: a block that inserts itself (the + // depth limit), and an array of 10,000 by 10,000 copies (the cell and work budgets). + let looped = blocks.replacen( + "2 +inner +10 +2", + "2 +outer +10 +2", + 1, + ); + assert_ne!(looped, blocks, "the outer block now inserts itself"); + let huge = blocks.replacen( + "70 +3 +71 +2", + "70 +10000 +71 +10000", + 1, + ); + assert_ne!(huge, blocks); + survives("DXF (self-insert, huge array)", &[looped.into_bytes(), huge.into_bytes()], usize::MAX, |b| { + crate::read_dxf(&String::from_utf8_lossy(b)).ok().map(|g| g.len()) + }); + } + + #[test] + fn threemf_survives_mutation() { + let mesh = cube(); + let object = crate::MeshObject { + name: "Cube".into(), + positions: mesh.positions, + triangles: mesh.triangles, + color: Some([0.8, 0.2, 0.1]), + ..Default::default() + }; + let good = crate::write_3mf(&[object]).unwrap(); + assert!(crate::read_3mf(&good).is_ok()); + survives("3MF", &mutations(&good, false, 0x3f), usize::MAX, |b| { + crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum()) + }); + } + + #[test] + fn gltf_survives_mutation() { + let mut f = crate::gltf_in::tests::Fixture::default(); + let m = f.cube_mesh("Cube", 0.01); + let child = f.node(serde_json::json!({"name": "C", "mesh": m, "translation": [0.0, 0.1, 0.0]})); + let n = f.node(serde_json::json!({"mesh": m, "children": [child], "rotation": [0.0, 0.0, std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2]})); + f.roots = vec![n]; + let mut no_files = |name: &str| -> std::result::Result, String> { Err(format!("no file `{name}`")) }; + let glb = f.glb(); + assert!(crate::read_gltf(&glb, "fuzz.glb", &mut no_files).is_ok()); + survives("glTF (.glb)", &mutations(&glb, false, 0x91b), usize::MAX, |b| crate::read_gltf(b, "fuzz.glb", &mut no_files).ok().map(|_| 1)); + // The JSON form, its buffer embedded as a data URI. + let uri = format!("data:application/octet-stream;base64,{}", base64(&f.bin)); + let gltf = f.json(Some(&uri)).to_string().into_bytes(); + assert!(crate::read_gltf(&gltf, "fuzz.gltf", &mut no_files).is_ok()); + survives("glTF (.gltf)", &mutations(&gltf, true, 0x917), usize::MAX, |b| crate::read_gltf(b, "fuzz.gltf", &mut no_files).ok().map(|_| 1)); + } + + #[test] + fn designs_survive_mutation() { + let mut doc = solidcraft_doc::Document::new("Fuzz"); + doc.set_param("width", "40", Some("mm"), None).unwrap(); + let mesh = solidcraft_doc::MeshData { + name: "Preview".into(), + positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + triangles: vec![0, 1, 2], + color: None, + }; + let preview = crate::DesignPreview { meshes: vec![mesh] }; + let container = crate::write_design_container_with_preview(&doc, crate::DesignCompression::Fast, Some(&preview)).unwrap(); + assert!(crate::read_design(&container).is_ok() && crate::read_design_preview(&container).is_some()); + survives("design container", &mutations(&container, false, 0xde5), usize::MAX, |b| { + let _ = crate::read_design_preview(b); + let _ = crate::read_design_thumbnail(b); + crate::read_design(b).ok().map(|d| d.features.len()) + }); + let json = crate::write_design(&doc); + assert!(crate::read_design(&json).is_ok()); + survives("design JSON", &mutations(&json, true, 0xde6), usize::MAX, |b| crate::read_design(b).ok().map(|d| d.features.len())); + } + + /// Standard base64 (RFC 4648) of `bytes`, for a data URI. + fn base64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in bytes.chunks(3) { + let v = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + for k in 0..4 { + if k <= c.len() { + s.push(char::from(A[(v >> (18 - 6 * k)) as usize & 63])); + } else { + s.push('='); + } + } + } + s + } +} diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs index e8c4ec8d..33dda467 100644 --- a/crates/io/src/safe_write.rs +++ b/crates/io/src/safe_write.rs @@ -23,7 +23,23 @@ fn temp_beside(path: &Path, tag: &str) -> PathBuf { fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; f.write_all(bytes)?; - f.sync_all() + flush(&f) +} + +/// Flush a written file to the disk. On Apple systems `sync_all` asks the drive itself to flush +/// (`F_FULLFSYNC`), which a network share refuses (an SMB share: "Operation not supported", os +/// error 45), so a save there failed before it replaced anything. The plain `fsync` the share +/// does offer is used then, as SQLite and git do. +fn flush(f: &fs::File) -> std::io::Result<()> { + flush_with(f, fs::File::sync_all) +} + +fn flush_with(f: &fs::File, full: impl FnOnce(&fs::File) -> std::io::Result<()>) -> std::io::Result<()> { + match full(f) { + #[cfg(target_vendor = "apple")] + Err(e) if e.kind() == std::io::ErrorKind::Unsupported || e.raw_os_error() == Some(45) => rustix::fs::fsync(f).map_err(std::io::Error::from), + r => r, + } } /// Flush the directory entry (the rename) to disk where the platform allows it. @@ -142,7 +158,7 @@ pub fn create_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { let tmp = temp_beside(path, "create"); // Only remove a temporary file that this invocation successfully created. let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?; - let result = file.write_all(bytes).and_then(|_| file.sync_all()); + let result = file.write_all(bytes).and_then(|_| flush(&file)); drop(file); let result = result.and_then(|_| fs::hard_link(&tmp, path)); let _ = fs::remove_file(&tmp); @@ -216,6 +232,29 @@ mod tests { d } + /// A network share (SMB on macOS) refuses the drive flush `sync_all` asks for with "Operation + /// not supported" (os error 45). The file is then flushed the plain way and the save goes on; + /// any other failure still fails the save. + #[test] + fn a_share_that_refuses_the_full_flush_gets_a_plain_one() { + let d = dir("flush"); + let p = d.join("a.solidcraft"); + fs::write(&p, b"saved").unwrap(); + let f = fs::File::open(&p).unwrap(); + let refused = |_: &fs::File| Err(std::io::Error::from_raw_os_error(45)); + #[cfg(target_vendor = "apple")] + flush_with(&f, refused).unwrap(); + #[cfg(not(target_vendor = "apple"))] + assert!(flush_with(&f, refused).is_err(), "elsewhere sync_all is the plain flush already"); + let unsupported = |_: &fs::File| Err(std::io::Error::from(std::io::ErrorKind::Unsupported)); + assert_eq!(flush_with(&f, unsupported).is_ok(), cfg!(target_vendor = "apple")); + let broken = |_: &fs::File| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + assert_eq!(flush_with(&f, broken).unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); + flush_with(&f, |_| Ok(())).unwrap(); + drop(f); + let _ = fs::remove_dir_all(&d); + } + #[test] fn replaces_and_keeps_a_backup() { let d = dir("bak"); diff --git a/crates/io/src/threemf.rs b/crates/io/src/threemf.rs index 362e03a1..351f0f83 100644 --- a/crates/io/src/threemf.rs +++ b/crates/io/src/threemf.rs @@ -352,9 +352,9 @@ fn bad(msg: impl Into) -> Result { fn read_part(zip: &mut zip::ZipArchive>, name: &str) -> Result>> { let want = name.trim_start_matches('/').to_ascii_lowercase(); - let Some(idx) = - (0..zip.len()).find(|i| zip.name_for_index(*i).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want)) - else { + let Some(idx) = (0..zip.len()).find(|i| { + zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want) + }) else { return Ok(None); }; let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?; diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs index 5749f2af..05b6ebae 100644 --- a/crates/io/src/vfs.rs +++ b/crates/io/src/vfs.rs @@ -50,6 +50,11 @@ mod imp { check(path)?; std::fs::remove_file(path) } + /// A folder and the folders above it, where missing. + pub fn create_dir_all(path: &str) -> Result<()> { + check(path)?; + std::fs::create_dir_all(path) + } } #[cfg(target_arch = "wasm32")] @@ -94,6 +99,10 @@ mod imp { pub fn remove(path: &str) -> Result<()> { FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path)) } + /// Files are keyed by their whole path here: a folder needs no making. + pub fn create_dir_all(_path: &str) -> Result<()> { + Ok(()) + } pub fn insert(path: &str, bytes: Vec) { FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes)); } diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index d174e08f..764db89f 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -20,6 +20,7 @@ truck-meshalgo = { workspace = true } truck-stepio = { workspace = true } truck-topology = { workspace = true } spade = { workspace = true } +web-time = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 324250ba..7f030879 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -1637,7 +1637,7 @@ fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool { } /// [`smooth_chain`], walking on only over edges `keep` allows. -fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { +pub(crate) fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let all = Body::unique_edges(solid); // Along the edge as it runs from its front to its back (an edge may run against its curve). @@ -1773,7 +1773,20 @@ fn loop_split(cur: &Body, pts: &[Vec3]) -> Result>, Vec = per.iter().map(|x| x.1).collect(); + // Fillets follow tangent chains in the original body. Include those edges before + // recognising a whole loop, too: two picks across a sharp arc/line joint can cover + // the entire rim once each pick's smooth chain is included. + let ids = guard("loop tangent chains", || { + let mut ids: std::collections::HashSet = per.iter().map(|x| x.1).collect(); + for (_, id) in &per { + if let Some(edge) = all.iter().find(|e| e.id() == *id) + && let Some(chain) = smooth_chain(&solid, edge) + { + ids.extend(chain); + } + } + Ok(ids) + })?; let mut covered: Vec = Vec::new(); let mut groups = Vec::new(); for f in solid.face_iter() { @@ -1859,7 +1872,7 @@ fn fillet_built(body: &Body, edges: &[Vec3], radius: f64) -> Result { // trimmed by the face. Refused, the refusal above stands. .or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)).map_err(|_| e)) // Blends meeting at corners on curved faces: all at once. - .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) + .or_else(|e| corners(body, edges, radius, Shape::Round, "fillet", false).and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2))) } /// The first refusal, unless only the second found the blend too large (a smaller one may fit). @@ -1872,8 +1885,9 @@ fn keep_size(e: KernelError, e2: KernelError) -> KernelError { } /// Blend the edges together, rebuilding the corners where they meet on faces of any surface -/// (see [`crate::curveblend::corner_blend`]). -fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result { +/// (see [`crate::curveblend::corner_blend`]); with `g2`, the corners where three blends meet are +/// G2 patches ([`crate::corner_g2`]) instead of pieces of the ball's sphere. +fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, g2: bool) -> Result { let counts = record::counts(&[body]); let cur = crate::boolean_record::healed(body)?; let size = cur.size(); @@ -1882,10 +1896,27 @@ fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Res if ids.len() != edges.len() { return Err(KernelError::Invalid("no edge near a selected point".into())); } - let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?; + let (made, rec) = guard(what, || match g2 { + // The corner blend as it always was, bit for bit. + false => crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round), + true => crate::curveblend::corner_blend_style(&solid, &ids, size, r, shape == Shape::Round, true), + })?; Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts)) } +/// [`fillet`] whose corners where three blended edges meet are G2 patches: the corner repeats each +/// blend's surface up to the second order (see [`crate::corner_g2`]) instead of the rolling ball's +/// sphere. The corner construction is the all-at-once corner blend, so a configuration it cannot +/// hold is refused rather than quietly given the ball's sphere; [`fillet`] (the default) is +/// unchanged. +pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64) -> Result { + body.require_brep("fillet")?; + if !(radius.is_finite() && radius > 0.0) { + return Err(KernelError::Invalid("the blend radius must be finite and positive".into())); + } + corners(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)) +} + /// The step of the radius search: three significant digits of the requested radius. fn radius_step(radius: f64) -> f64 { 10f64.powf(radius.log10().floor() - 3.0) @@ -2191,7 +2222,7 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result { Ok(cur) }; // Chamfers meeting at corners on curved faces: all at once. - one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2))) + one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer", false).map_err(|e2| keep_size(e, e2))) } /// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left). diff --git a/crates/kernel/src/blend_fit.rs b/crates/kernel/src/blend_fit.rs index f2e55301..e0c7a351 100644 --- a/crates/kernel/src/blend_fit.rs +++ b/crates/kernel/src/blend_fit.rs @@ -116,9 +116,52 @@ impl Frame { if !(sa.is_finite() && sb.is_finite() && sa > 0.0 && sb > 0.0) { return None; } + self.prism(self.ta * sa, self.tb * sb) + } + + /// For a round, the arc of its cross-section from the setback on face a to the one on face + /// b, in [`ARC_PIECES`] pieces, as offsets from the edge; `None` for a chamfer. + fn arc(&self, size: Size) -> Option> { + let Size::Round(r) = size else { return None }; + let (sa, sb) = self.setbacks(size)?; + let half = 0.5 * self.ta.dot(self.tb).clamp(-1.0, 1.0).acos(); + // The ball's centre, on the bisector, `r` from both faces. + let centre = (self.ta + self.tb).normalized()? * (r / half.sin()); + let (a, b) = (self.ta * sa - centre, self.tb * sb - centre); + (0..=ARC_PIECES).map(|k| Some(centre + (a + (b - a) * (k as f64 / ARC_PIECES as f64)).normalized()? * r)).collect() + } + + /// What a blend of the edge at `size` changes, within the wedge: for a round, the fan of + /// prisms from the edge to the pieces of its arc (between the edge and the arc lies all it + /// takes away or fills; the ball's side of the arc is left as it is), for a chamfer the + /// wedge itself. + fn pieces(&self, size: Size) -> Option> { + match self.arc(size) { + Some(arc) => arc.windows(2).map(|w| self.prism(*w.first()?, *w.get(1)?)).collect(), + None => Some(vec![self.wedge(size)?]), + } + } + + /// The blend's own surface at `size`, as polygons: the strips between its arc's pieces, or + /// the chamfer's face. + fn surface(&self, size: Size) -> Option>> { + match self.arc(size) { + Some(arc) => arc + .windows(2) + .map(|w| { + let (u, v) = (*w.first()?, *w.get(1)?); + Some(vec![self.p0 + u, self.p0 + v, self.p1 + v, self.p1 + u]) + }) + .collect(), + None => Some(vec![self.wedge(size)?.faces.get(2)?.clone()]), + } + } + + /// The triangle between the edge and the offsets `u` and `w` across it, swept along the edge. + fn prism(&self, u: Vec3, w: Vec3) -> Option { let d = (self.p1 - self.p0).normalized()?; - let (a0, b0) = (self.p0 + self.ta * sa, self.p0 + self.tb * sb); - let (a1, b1) = (self.p1 + self.ta * sa, self.p1 + self.tb * sb); + let (a0, b0) = (self.p0 + u, self.p0 + w); + let (a1, b1) = (self.p1 + u, self.p1 + w); // Each side's plane, facing away from the triangle's third corner. let side = |p: Vec3, q: Vec3, opposite: Vec3| -> Option<(Vec3, f64)> { let mut n = d.cross(q - p).normalized()?; @@ -130,13 +173,18 @@ impl Frame { let planes = [side(self.p0, a0, b0)?, side(self.p0, b0, a0)?, side(a0, b0, self.p0)?, (-d, -d.dot(self.p0)), (d, d.dot(self.p1))]; let faces = vec![vec![self.p0, a0, a1, self.p1], vec![self.p0, b0, b1, self.p1], vec![a0, b0, b1, a1], vec![self.p0, a0, b0], vec![self.p1, a1, b1]]; - let area = 0.5 * (self.ta * sa).cross(self.tb * sb).len(); + let area = 0.5 * u.cross(w).len(); Some(Wedge { planes, faces, volume: area * self.p0.dist(self.p1) }) } } -/// `poly` clipped to the half-spaces, each moved `eps` inwards: is anything left? -fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { +/// Pieces a round's arc is divided into for [`Frame::pieces`] and [`Frame::surface`]: each +/// chord sags under 0.5 % of the radius. +const ARC_PIECES: usize = 8; + +/// `poly` clipped to the half-spaces `n·x <= c`, each moved `eps` inwards (empty when nothing +/// is left). +fn clip(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> Vec { let mut cur: Vec = poly.to_vec(); for (n, c) in planes { let c = c - eps; @@ -152,11 +200,53 @@ fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { } } if next.is_empty() { - return false; + return next; } cur = next; } - true + cur +} + +/// `poly` clipped to the half-spaces, each moved `eps` inwards: is anything left? +fn reaches_inside(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64) -> bool { + !clip(poly, planes, eps).is_empty() +} + +/// Most polygons [`reaches_inside_beyond`] clips before it gives up and answers yes. +const MAX_CLIPS: usize = 4096; + +/// Whether the part of `poly` outside every one of the wedges `trimmed` (by more than `out`) +/// reaches inside `planes` (by `eps`). A face beside or at the end of another edge the same +/// feature blends is trimmed back to that blend, so its part inside that edge's wedge is gone +/// (#1668). +fn reaches_inside_beyond(poly: &[Vec3], planes: &[(Vec3, f64)], eps: f64, trimmed: &[&Wedge], out: f64) -> bool { + let mut parts = vec![poly.to_vec()]; + for w in trimmed { + // Outside a convex wedge is outside one of its planes (pieces may overlap: only + // whether anything is left counts). + let mut next = Vec::new(); + for part in &parts { + for (n, c) in &w.planes { + let piece = clip(part, &[(-*n, -*c)], out); + if !piece.is_empty() { + next.push(piece); + } + } + if next.len() > MAX_CLIPS { + return true; + } + } + parts = next; + } + parts.iter().any(|p| reaches_inside(p, planes, eps)) +} + +/// Whether two sets of convex prisms overlap (by more than `eps`): a face of one reaches +/// inside the other. +fn overlap(a: &[Wedge], b: &[Wedge], eps: f64) -> bool { + a.iter().any(|x| { + b.iter().any(|y| y.faces.iter().any(|p| reaches_inside(p, &x.planes, eps)) || x.faces.iter().any(|p| reaches_inside(p, &y.planes, eps))) + }) } /// The body's straight edges between planar faces nearest the points (each once; `None` for a @@ -270,16 +360,30 @@ enum Clash { Blend, } -fn first_clash(sel: &Selection, size: Size) -> Option { +/// What a blend at `size` runs into first, if anything. Curved faces are meshed to within the +/// chord: they count when their mesh reaches into a wedge by more than twice the chord, or, when +/// `strict`, when it comes within the chord of one (the surface may then be inside it). +fn first_clash(sel: &Selection, size: Size, strict: bool) -> Option { let wedges: Vec> = sel.frames.iter().map(|f| f.as_ref().and_then(|f| f.wedge(size))).collect(); // Planar faces are exact in the mesh; curved ones are within the chord. let flat = (sel.size * 1e-7).max(1e-9); - let curved = sel.chord * 2.0; - for (f, w) in sel.frames.iter().zip(&wedges) { + let curved = if strict { -sel.chord } else { sel.chord * 2.0 }; + // Planar faces beside an edge lie in its wedge's side planes: outside it means further. + let out = (sel.size * 1e-6).max(1e-8); + for (i, (f, w)) in sel.frames.iter().zip(&wedges).enumerate() { let (Some(f), Some(w)) = (f, w) else { continue }; let hit = sel.tris.iter().filter(|(_, face)| !f.allowed.contains(face)).any(|(t, face)| { let eps = if sel.planar.get(*face).copied().unwrap_or(false) { flat } else { curved }; - reaches_inside(t, &w.planes, eps) + // The other blended edges this face runs along or ends them trim it back. + let trimmed: Vec<&Wedge> = sel + .frames + .iter() + .zip(&wedges) + .enumerate() + .filter(|(j, (fj, _))| *j != i && fj.as_ref().is_some_and(|fj| fj.allowed.contains(face))) + .filter_map(|(_, (_, wj))| wj.as_ref()) + .collect(); + reaches_inside_beyond(t, &w.planes, eps, &trimmed, out) }); if hit { return Some(Clash::Face); @@ -295,7 +399,7 @@ fn first_clash(sel: &Selection, size: Size) -> Option { continue; } let meet = wj.faces.iter().any(|p| reaches_inside(p, &wi.planes, eps)) || wi.faces.iter().any(|p| reaches_inside(p, &wj.planes, eps)); - if meet { + if meet && blends_cross(fi, wi, fj, wj, size, eps) { return Some(Clash::Blend); } } @@ -303,6 +407,15 @@ fn first_clash(sel: &Selection, size: Size) -> Option { None } +/// Two blends whose wedges overlap: do they cross? Rounds on both sides of a wall (the outside +/// round's wedge reaches past the inside corner, which the inside round fills) need not: they +/// cross only where what one changes (between its edge and its arc) overlaps what the other +/// does, or where either's surface runs into the other's wedge (#1668). +fn blends_cross(fi: &Frame, wi: &Wedge, fj: &Frame, wj: &Wedge, size: Size, eps: f64) -> bool { + let (Some(pi), Some(pj), Some(si), Some(sj)) = (fi.pieces(size), fj.pieces(size), fi.surface(size), fj.surface(size)) else { return true }; + overlap(&pi, &pj, eps) || sj.iter().any(|p| reaches_inside(p, &wi.planes, eps)) || si.iter().any(|p| reaches_inside(p, &wj.planes, eps)) +} + /// The step the largest fitting size is given to: three significant digits. fn step(v: f64) -> f64 { 10f64.powf(v.log10().floor() - 3.0) @@ -323,8 +436,10 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> if sel.frames.iter().all(Option::is_none) || !size.value().is_finite() || size.value() <= 0.0 { return Ok(None); } - let Some(clash) = first_clash(sel, size) else { return Ok(None) }; - // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). + let Some(clash) = first_clash(sel, size, false) else { return Ok(None) }; + // Bisection on the size's steps: `hi` doesn't fit, `lo` does (or is 0). Strictly: a size + // offered as fitting must fit, though a curved face that only touches the wedge at it would + // not be refused (a hole whose rim the round's band just reaches, #324). let asked = size.value(); let q = step(asked); let (mut lo, mut hi) = (0u64, (asked / q).ceil() as u64); @@ -333,7 +448,7 @@ fn misfit_in(sel: &Selection, size: Size) -> Result)> break; } let mid = lo + (hi - lo) / 2; - if first_clash(sel, size.scaled(mid as f64 * q / asked)).is_none() { + if first_clash(sel, size.scaled(mid as f64 * q / asked), true).is_none() { lo = mid; } else { hi = mid; diff --git a/crates/kernel/src/blend_variable.rs b/crates/kernel/src/blend_variable.rs index e0d7a495..8aafc19d 100644 --- a/crates/kernel/src/blend_variable.rs +++ b/crates/kernel/src/blend_variable.rs @@ -205,19 +205,35 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: & } /// Fillet one straight edge with the radius `spline` along it, its positions from the end -/// nearest `start`. One end's radius may be zero. +/// nearest `start`. One end's radius may be zero. A curved edge, or one between curved faces, is +/// rolled by a ball of the law's radius instead ([`curved_variable`]). pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result { body.require_brep("fillet")?; spline.check()?; let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?; guard("variable fillet", || { - let s = EdgeSetup::new(body, &edge)?; + let Ok(s) = EdgeSetup::new(body, &edge) else { + return curved_variable(body, &edge, start, spline); + }; // Positions from the end nearest `start` (the edge keeps its own direction). let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() }; build(body, &s, &edge, &law) }) } +/// The radius law along a curved edge: a ball whose radius is the law's at each station rolls +/// along the edge's smooth chain (the same sections, contacts and trims as the straight-edge case, +/// sized by the law there). A law that has run to zero at an end closes the blend to the edge's +/// end point, as the straight-edge fillet does. +fn curved_variable(body: &Body, edge: &mt::Edge, start: Vec3, law: &RadiusSpline) -> Result { + // Positions from the end nearest `start` (the chain runs along the edge's own direction). + let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back())); + let law = if p1.dist(start) < p0.dist(start) { law.reversed() } else { law.clone() }; + let chain = crate::blend::smooth_chain(&body.solid, edge).unwrap_or_else(|| vec![edge.id()]); + let (made, rec) = guard("variable fillet", || crate::curveblend::curve_blend_law(&body.solid, &chain, body.size(), &law, true))?; + crate::record::rebuilt(made, &[&body.solid], &rec) +} + /// A straight edge between two planar faces as the variable blend sees it: the shell it is in /// and that shell's faces, the frame at the edge, and its ends (front first). struct EdgeSetup<'a> { @@ -570,8 +586,11 @@ pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Ok((cur, tapers)) } -/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend -/// is `chord` wide across; edges with the same face angle share a radius. +/// Chord-length fillet: each edge rounded so that the blend is `chord` wide across, from contact +/// to contact at every station. A straight edge between planar faces is built exactly, the radius +/// `chord / (2 cos(φ/2))` from its face angle and edges sharing that angle blended together; a +/// curved edge, or one between curved faces (or a chain of selected edges running on into one), +/// is rolled by a ball whose radius gives the same width, which follows the face angle along it. pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { body.require_brep("fillet")?; if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) { @@ -580,19 +599,44 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { if edges.is_empty() || edges.len() > 1000 { return Err(KernelError::Invalid("select 1…1000 edges".into())); } - // Radius per edge from its face angle. - let mut groups: Vec<(f64, Vec)> = Vec::new(); - for p in edges { - let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; - let shell = - body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?; - let faces: Vec<&mt::Face> = shell.face_iter().collect(); - let fr = edge_frame(&faces, &e)?; - let r = chord_radius(chord, fr.phi); - match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { - Some((_, v)) => v.push(*p), - None => groups.push((r, vec![*p])), + let found: Vec = edges + .iter() + .map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) + .collect::>()?; + // The exact radius of a straight edge between planar faces; None where the rolling ball has to + // take the edge (a curved edge, or one between curved faces). + let exact_radius = |e: &mt::Edge| -> Option { + if !matches!(e.curve(), mt::Curve::Line(_)) { + return None; } + let shell = body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id()))?; + let faces: Vec<&mt::Face> = shell.face_iter().collect(); + Some(chord_radius(chord, edge_frame(&faces, e).ok()?.phi)) + }; + // A straight edge running on smoothly into a selected curved one goes with it: the ball rolls + // along the chain, so blending the parts apart would leave a step at their joint. + let meets = |a: &mt::Edge, b: &mt::Edge| [a.front(), a.back()].into_iter().any(|v| v == b.front() || v == b.back()); + let mixed = |e: &mt::Edge| found.iter().any(|f| f.id() != e.id() && exact_radius(f).is_none() && meets(e, f)); + // Radius per edge from its face angle; the rest roll a ball along their chain. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + let mut rolled: Vec> = Vec::new(); + let mut done: Vec = Vec::new(); + for (p, e) in edges.iter().zip(&found) { + if let Some(r) = exact_radius(e).filter(|_| !mixed(e)) { + match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) { + Some((_, v)) => v.push(*p), + None => groups.push((r, vec![*p])), + } + continue; + } + if done.contains(&e.id()) { + continue; + } + // The whole smooth chain (as the constant-radius path rolls it): a ball cannot stop + // where the edge runs on, so the chain's other edges are blended with it. + let chain = crate::blend::smooth_chain(&body.solid, e).unwrap_or_else(|| vec![e.id()]); + done.extend(chain.iter().copied()); + rolled.push(edges.iter().zip(&found).filter(|(_, f)| chain.contains(&f.id())).map(|(p, _)| *p).collect()); } // Each step's result carries its history over `[body]`. let counts = crate::record::counts(&[body]); @@ -600,6 +644,17 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result { for (r, pts) in groups { cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts); } + for picks in rolled { + // The chain as the current body has it: the blends before it rebuilt the faces it runs + // through, and a rebuilt face carries its edges again. + let edges: Vec = picks.iter().filter_map(|p| crate::surfaces::edge_near(&cur, *p)).collect(); + let Some(first) = edges.first() else { continue }; + let keep = |x: &mt::Edge| edges.iter().any(|e| e.id() == x.id()); + let chain = crate::blend::smooth_chain_within(&cur.solid, first, &keep).unwrap_or_else(|| vec![first.id()]); + let (made, rec) = crate::guard("chord fillet", || crate::curveblend::curve_blend_chord(&cur.solid, &chain, cur.size(), chord, true))?; + let step = crate::record::rebuilt(made, &[&cur.solid], &rec)?; + cur = crate::record::after(step, &cur, &counts); + } Ok(cur) } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 5f77d5d7..add84c75 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -1038,6 +1038,10 @@ impl Body { /// [`Body::tessellate_with`] in which no facet turns more than `angle` radians either /// (infinite: the chord tolerance alone). fn tessellate_within(&self, tol: f64, angle: f64, refine: bool) -> Result { + crate::profile::timed(crate::profile::Kind::Tessellate, || self.tessellate_within_untimed(tol, angle, refine)) + } + + fn tessellate_within_untimed(&self, tol: f64, angle: f64, refine: bool) -> Result { let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 }; let tol = tol.max(self.size() * 1e-6); if let Some(m) = &self.mesh { @@ -1148,6 +1152,10 @@ impl Body { } fn plan_within(&self, tol: f64, angle: Option) -> Result { + crate::profile::timed(crate::profile::Kind::Tessellate, || self.plan_within_untimed(tol, angle)) + } + + fn plan_within_untimed(&self, tol: f64, angle: Option) -> Result { if self.is_mesh() { return Err(KernelError::Invalid("a mesh body has no surfaces to tessellate".into())); } @@ -1266,6 +1274,10 @@ impl Body { /// body's [`Body::display_plan`] (shared edge nodes, structured grids), or the way /// [`Body::tessellate`] meshes when no plan can be made. pub fn display_mesh(&self, tol: f64) -> Result { + crate::profile::timed(crate::profile::Kind::Tessellate, || self.display_mesh_untimed(tol)) + } + + fn display_mesh_untimed(&self, tol: f64) -> Result { if self.is_mesh() { return self.flagged(self.tessellate(tol)?); } @@ -1285,6 +1297,10 @@ impl Body { /// sagitta of a small radius on a large body (a sheet-metal bend, a small hole), which the /// tolerance alone leaves as one flat facet. pub fn display_mesh_angular(&self, tol: f64, angle: f64) -> Result { + crate::profile::timed(crate::profile::Kind::Tessellate, || self.display_mesh_angular_untimed(tol, angle)) + } + + fn display_mesh_angular_untimed(&self, tol: f64, angle: f64) -> Result { if self.is_mesh() { return self.flagged(self.tessellate(tol)?); } @@ -1300,6 +1316,10 @@ impl Body { /// The display mesh of a plan made from this body ([`Body::display_plan`], /// [`Body::display_plan_angular`]): evaluated on the CPU, with its edges flagged. pub fn display_mesh_of(&self, plan: &solidcraft_geom::tess::TessPlan) -> Result { + crate::profile::timed(crate::profile::Kind::Tessellate, || self.display_mesh_of_untimed(plan)) + } + + fn display_mesh_of_untimed(&self, plan: &solidcraft_geom::tess::TessPlan) -> Result { self.flagged(plan.evaluate()) } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index ffcfd70e..9078e4ab 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -305,6 +305,18 @@ pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2 /// Revolve each region about the sketch axis by `angle` radians. /// This legacy API preserves its historical sweep sign behavior. pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result> { + revolved(plane, regions, axis_origin, axis_dir, angle, false) +} + +/// [`revolve`] for bodies that are final (new bodies, not a boolean's tools): a partial turn's +/// planar faces come out whole too, as a full turn's always do (#726). A tool is left as swept: +/// healed, its planar faces meet a target exactly where they are tangent to it, and the +/// boolean leaves pieces there that touch. The boolean heals its own result anyway. +pub fn revolve_whole(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result> { + revolved(plane, regions, axis_origin, axis_dir, angle, true) +} + +fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64, whole: bool) -> Result> { let angle = finite(angle, "angle")?; if angle.abs() < 1e-6 { return Err(KernelError::Invalid("revolve angle is zero".into())); @@ -447,11 +459,46 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: let closed = angle.abs() >= std::f64::consts::TAU; let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?; let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; + let (solid, history) = if closed || whole { turned_whole(solid, history) } else { (solid, history) }; out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?); } Ok(out) } +/// A revolved solid with its planar faces whole (#726): the sweep turns each profile edge into +/// two or three patches, the planar ones on revolved surfaces, which stayed apart until a later +/// feature healed the body, so a pick on one took part of the face. Healed as booleans heal +/// their results, each face is still generated, from the profile edges of the patches merged +/// into it. The solid as it was when healing fails or can't be followed. +fn turned_whole(solid: Solid, faces: Option>) -> (Solid, Option>) { + let Some(given) = faces.as_ref() else { return (solid, faces) }; + let Ok(size) = Body::new(solid.clone()).map(|b| b.size()) else { return (solid, faces) }; + let healed = guard("heal revolve", || { + let (healed, map) = crate::heal::heal_traced(solid.clone(), size, &[]); + let failed = || KernelError::Failed("heal revolve".into()); + if !crate::boolean_record::edges_evaluate(&healed) { + return Err(failed()); + } + let carried = map + .ok_or_else(failed)? + .faces + .iter() + .map(|under| { + let mut origins: Vec<_> = under.iter().map(|i| given.get(*i).map(|f| f.origins.clone())).collect::>>()?.concat(); + origins.sort(); + origins.dedup(); + Some(FaceProv { origins, change: crate::history::Change::Generated }) + }) + .collect::>>() + .ok_or_else(failed)?; + Ok((healed, carried)) + }); + match healed { + Ok((healed, carried)) => (healed, Some(carried)), + Err(_) => (solid, faces), + } +} + /// Axis-aligned box between two corners. pub fn box_solid(a: Vec3, b: Vec3) -> Result { let (lo, hi) = (a.min(b), a.max(b)); @@ -1788,7 +1835,7 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 { /// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of /// their chords). -pub(crate) fn path_polyline(path: &[PathSeg]) -> Result> { +pub fn path_polyline(path: &[PathSeg]) -> Result> { let first = match path.first() { Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a, None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())), @@ -2016,6 +2063,15 @@ pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Resu /// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`. fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result { + guard("sphere", || { + let shell: mt::Shell = sphere_patch_faces(center, radius, rot)?.into(); + let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?; + record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default()) + }) +} + +/// The six outward patch faces of [`sphere_patches_rotated`]; truck calls, so callers guard them. +pub(crate) fn sphere_patch_faces(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result> { let radius = finite(radius, "radius")?; let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z; if radius <= 1e-6 || !center.is_finite() { @@ -2033,50 +2089,46 @@ fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Re (Vec3::Z, Vec3::X, [[-1.0, -1.0, 1.0], [1.0, -1.0, 1.0], [1.0, 1.0, 1.0], [-1.0, 1.0, 1.0]]), (-Vec3::Z, Vec3::X, [[-1.0, 1.0, -1.0], [1.0, 1.0, -1.0], [1.0, -1.0, -1.0], [-1.0, -1.0, -1.0]]), ]; - guard("sphere", || { - let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2); - let mut verts: std::collections::HashMap = std::collections::HashMap::new(); - let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone(); - let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new(); - let mut out: Vec = Vec::new(); - for (dir, axis, cs) in faces { - let (dir, axis) = (turn(dir), turn(axis)); - let mut wire: Vec = Vec::new(); - for i in 0..4 { - let (a, b) = (cs[i], cs[(i + 1) % 4]); - let (ka, kb) = (key(a), key(b)); - let e = match edges.get(&(ka.min(kb), ka.max(kb))) { - Some(e) => e.clone(), - None => { - let (lo, hi) = if ka < kb { (a, b) } else { (b, a) }; - let (va, vb) = (vert(lo), vert(hi)); - let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center; - let transit = center + mid.normalized().unwrap_or(dir) * radius; - let e = builder::circle_arc(&va, &vb, p3(transit)); - edges.insert((ka.min(kb), ka.max(kb)), e.clone()); - e - } - }; - wire.push(if ka < kb { e } else { e.inverse() }); - } - // The meridian runs pole to pole through the side away from the patch. - let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius); - let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); - let mut surface = - mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis)))); - // Outward normal at the patch centre. - let probe = p3(center + dir * radius); - use mt::{ParametricSurface3D, SearchParameter}; - if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) { - let n = surface.normal(u, v); - if mt::InnerSpace::dot(n, v3(dir)) < 0.0 { - mt::Invertible::invert(&mut surface); + let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2); + let mut verts: std::collections::HashMap = std::collections::HashMap::new(); + let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone(); + let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new(); + let mut out: Vec = Vec::new(); + for (dir, axis, cs) in faces { + let (dir, axis) = (turn(dir), turn(axis)); + let mut wire: Vec = Vec::new(); + for i in 0..4 { + let (a, b) = (cs[i], cs[(i + 1) % 4]); + let (ka, kb) = (key(a), key(b)); + let e = match edges.get(&(ka.min(kb), ka.max(kb))) { + Some(e) => e.clone(), + None => { + let (lo, hi) = if ka < kb { (a, b) } else { (b, a) }; + let (va, vb) = (vert(lo), vert(hi)); + let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center; + let transit = center + mid.normalized().unwrap_or(dir) * radius; + let e = builder::circle_arc(&va, &vb, p3(transit)); + edges.insert((ka.min(kb), ka.max(kb)), e.clone()); + e } - } - out.push(mt::Face::new(vec![wire.into()], surface)); + }; + wire.push(if ka < kb { e } else { e.inverse() }); } - let shell: mt::Shell = out.into(); - let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?; - record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default()) - }) + // The meridian runs pole to pole through the side away from the patch. + let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius); + let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); + let mut surface = + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis)))); + // Outward normal at the patch centre. + let probe = p3(center + dir * radius); + use mt::{ParametricSurface3D, SearchParameter}; + if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) { + let n = surface.normal(u, v); + if mt::InnerSpace::dot(n, v3(dir)) < 0.0 { + mt::Invertible::invert(&mut surface); + } + } + out.push(mt::Face::new(vec![wire.into()], surface)); + } + Ok(out) } diff --git a/crates/kernel/src/clearance.rs b/crates/kernel/src/clearance.rs index c33c9c96..3a505e3a 100644 --- a/crates/kernel/src/clearance.rs +++ b/crates/kernel/src/clearance.rs @@ -5,6 +5,10 @@ //! body (a cut), or nothing but its material (a join). A [`Clearance`] decides that on the //! other body's faces: every triangle of its mesh is clipped against that space, so a wall, a //! rib or a hole anywhere in it is found. Sample points miss what lies between them. +//! +//! A cut's or join's tool grown past the faces it lies on, to keep coincident faces out of its +//! boolean, is asked the same way (in the document's evaluation): it may gain only empty space +//! (a cut) or material (a join). use std::collections::HashMap; @@ -16,7 +20,7 @@ use crate::offset::{Surf, surf_of}; /// How a body's material sits in a region of space. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Occupancy { +pub enum Occupancy { /// The region holds none of it. Empty, /// The region lies wholly in it. @@ -104,7 +108,7 @@ enum Reach { } /// A body's faces, for telling how its material sits in the space a face moves through. -pub(crate) struct Clearance<'a> { +pub struct Clearance<'a> { body: &'a Body, mesh: Mesh, /// How far a curved face may stand off its triangles. @@ -119,7 +123,7 @@ const MAX_PAIRS: usize = 2_000_000; impl<'a> Clearance<'a> { /// `chord`: the chord error of the mesh the body is asked on. - pub(crate) fn new(body: &'a Body, chord: f64) -> Option> { + pub fn new(body: &'a Body, chord: f64) -> Option> { let mesh = body.tessellate_with(chord, true).ok()?; (!mesh.triangles.is_empty()).then(|| Clearance { body, mesh, sag: 2.0 * chord, tol: (body.size() * 1e-7).max(1e-9), exact: false }) } @@ -140,7 +144,7 @@ impl<'a> Clearance<'a> { /// planar faces square to `along`, each with how far its face reaches past it sideways /// (negative: the triangle's sides are drawn in, so that faces lying against them don't /// count). `Mixed` as soon as a face of the body reaches into that space. - pub(crate) fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy { + pub fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy { let Some((first, _)) = faces.first() else { return Occupancy::Mixed }; let base = along.dot(first[0]); // The body's triangles near that space. @@ -264,6 +268,11 @@ impl<'a> Clearance<'a> { return Ok(Reach::Never); } let round = surf_of(f, self.tol * 100.0); + // A flat face stored otherwise (a revolve's end: a line swept round its axis) is + // a plane all the same: its triangles lie in it. + if matches!(round, Some(Surf::Plane { .. })) { + return Ok(Reach::Exact); + } let straight = matches!(round, Some(Surf::Cylinder { a, .. }) if a.dot(along).abs() > 1.0 - 1e-12) || crate::prism::wall_along(&surface, along, self.tol * 10.0, &mut 262_144); Ok(if straight { diff --git a/crates/kernel/src/coaxial_overlap.rs b/crates/kernel/src/coaxial_overlap.rs new file mode 100644 index 00000000..378a4fbe --- /dev/null +++ b/crates/kernel/src/coaxial_overlap.rs @@ -0,0 +1,424 @@ +//! The shared volume of a pin in a hole of (nearly) its own size whose axis is a hair off the +//! hole's (parts placed from rounded coordinates). +//! +//! The pin's wall and the hole's wall then meet along two lines where they are all but tangent, +//! which the boolean cannot intersect, and the facets of the two walls pass through each other +//! by less than the boolean's sampled shortcuts can see. So the overlap is worked out in two +//! parts instead. The pin's wall is moved off the hole's by a clear step (`offset_body`) and the +//! bodies are intersected as they then are, which the boolean can do: narrowed, that gives the +//! overlap away from the walls (the core). In the band the move swept, what the two walls +//! share is a crescent in every section, whose area follows from the two circles alone. The +//! band is assumed to hold nothing but the two walls; that is checked by intersecting with the +//! pin widened by half the step and by the whole step, whose volumes must come out as the core +//! plus the crescents those wider circles give. Where they don't (a cross hole through the +//! band, a hole that is not round all the way, a pin end inside the hole), the overlap is not +//! answered here and the caller takes the general boolean. + +use solidcraft_geom::{Aabb3, Vec3}; + +use crate::body::Body; +use crate::{BoolOp, FaceSurface}; + +/// The overlap of two bodies with a pin of one in a hole of the other, worked out as above. +#[derive(Clone, Debug)] +pub struct CoaxialOverlap { + pub volume: f64, + /// Approximate: the core's area plus the crescents' walls and ends. + pub area: f64, + pub centroid: Vec3, + pub bbox: Aabb3, + /// The largest offset between a pin's axis and its hole's, and the length they share. + pub offset: f64, + pub length: f64, +} + +/// Largest axis offset plus radius difference handled, as a share of the radius. +const NEAR: f64 = 0.01; +/// The step the pin's wall is moved by, as a share of its radius. +const STEP: f64 = 0.1; + +/// The faces of one body on one cylinder, the side its material is on and their extent along +/// the axis. +#[derive(Clone, Debug)] +struct Wall { + faces: Vec, + origin: Vec3, + axis: Vec3, + radius: f64, + /// Material inside the cylinder (a pin), not outside it (a hole). + boss: bool, + /// Intervals along `axis` from `origin`, one per face. + spans: Vec<(f64, f64)>, +} + +/// A pin of one body (`pin_in_a`) in a hole of the other, and the length they share. +#[derive(Clone, Debug)] +struct Pair { + pin: Wall, + pin_in_a: bool, + hole: Wall, + /// The hole's axis seen from the pin's, across it, and how far. + toward: Vec3, + offset: f64, + /// Where they share length along the pin's axis. + shared: Vec<(f64, f64)>, + step: f64, +} + +/// The overlap of `a` and `b` when a pin of one sits in a hole of the other on nearly the same +/// cylinder (axes parallel, offset plus radius difference within 1 % of the radius, but not on +/// the same surface), worked out as the module says. `None` when there is no such pin, or the +/// bodies hold more than the pin and the hole where the work assumes they don't: the caller +/// then takes the general boolean. +pub fn coaxial_overlap(a: &Body, b: &Body) -> Option { + if a.is_mesh() || b.is_mesh() { + return None; + } + crate::guard("near-coaxial overlap", || Ok(overlap(a, b))).ok().flatten() +} + +fn overlap(a: &Body, b: &Body) -> Option { + let size = a.size().max(b.size()); + if !(size.is_finite() && size > 0.0) { + return None; + } + // The same surface as the kernel takes it elsewhere: walls that close are one wall. + let same = (size * 1e-7).max(1e-9) * 10.0; + // Meshed only when two cylinders are close enough to be asked about. + let (ca, cb) = (cylinders(a), cylinders(b)); + let close = |x: &Wall, y: &Wall| x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && near(x, y, same).is_some(); + if !ca.iter().any(|x| cb.iter().any(|y| close(x, y))) { + return None; + } + let (wa, wb) = (walls(a, ca, same)?, walls(b, cb, same)?); + let mut pairs = Vec::new(); + for x in &wa { + for y in &wb { + if x.axis.dot(y.axis).abs() <= 1.0 - 1e-9 { + continue; + } + let (pin, hole, pin_in_a) = match (x.boss, y.boss) { + (true, false) => (x, y, true), + (false, true) => (y, x, false), + // Two pins or two holes on nearly one cylinder: not this case. + _ if near(x, y, same).is_some() => return None, + _ => continue, + }; + let Some((toward, offset)) = near(pin, hole, same) else { continue }; + let shared = intersect(&union(&pin.spans), &union(&project(hole, pin))); + if total(&shared) <= same { + continue; + } + let step = (pin.radius * STEP).min(size * 0.02); + pairs.push(Pair { pin: pin.clone(), pin_in_a, hole: hole.clone(), toward, offset, shared, step }); + } + } + if pairs.is_empty() { + return None; + } + // A pin's faces move together: one step for each of them (the pin's walls do not repeat + // across pairs unless one pin is near two holes, which the check below then answers). + let shared_at = |k: f64| -> Option<(f64, Option)> { + let moved = |body: &Body, in_a: bool| -> Option { + let mine: Vec<&Pair> = pairs.iter().filter(|p| p.pin_in_a == in_a).collect(); + if mine.is_empty() { + return Some(body.clone()); + } + let shift = |i: usize, _| mine.iter().find(|p| p.pin.faces.contains(&i)).map(|p| p.step * k).unwrap_or(0.0); + let m = crate::offset::offset_body(body, shift).ok()?; + m.validity().is_empty().then_some(m) + }; + let (ma, mb) = (moved(a, true)?, moved(b, false)?); + let (r, report) = crate::capture_boolean_diagnostics(|| crate::ops::boolean(&ma, &mb, BoolOp::Intersect)); + if report.unverified().is_some() || report.warning().is_some() { + return None; + } + match r.ok()? { + None => Some((0.0, None)), + Some(body) => Some((crate::measure(&body).ok()?.volume, Some(body))), + } + }; + // The crescents' volume with every pin grown by `k` steps. + let crescents = + |k: f64| -> f64 { pairs.iter().map(|p| total(&p.shared) * crescent(p.pin.radius + p.step * k, p.hole.radius, p.offset).area).sum() }; + let (core, core_body) = shared_at(-1.0)?; + for k in [0.5, 1.0] { + let (v, _) = shared_at(k)?; + let gain = crescents(k) - crescents(-1.0); + if !((v - core - gain).abs() <= 0.02 * gain + 1e-4 * v.abs() + 1e-12 * size.powi(3)) { + return None; + } + } + // The band held the two walls only: the overlap is the core plus the crescents. + let mut volume = core; + let mut moment = Vec3::ZERO; + let mut area = 0.0; + let mut bbox = Aabb3::EMPTY; + if let Some(body) = &core_body + && let Ok(m) = crate::measure(body) + { + moment = m.centroid * m.volume; + area = m.area; + bbox = m.bbox; + volume = m.volume; + } + for p in &pairs { + let c = crescent(p.pin.radius, p.hole.radius, p.offset); + let length = total(&p.shared); + // Less what the narrowed pin already reached (nothing, unless the hole is the smaller). + let v = length * c.area - length * crescent(p.pin.radius - p.step, p.hole.radius, p.offset).area; + let mid = p.shared.iter().map(|(s, t)| (s + t) * 0.5 * (t - s)).sum::() / length; + let side = p.toward.cross(p.pin.axis); + moment += (p.pin.origin + p.pin.axis * mid + p.toward * c.centroid) * v; + volume += v; + area += p.shared.len() as f64 * 2.0 * c.area + length * c.perimeter; + for (s, t) in &p.shared { + for q in crescent_outline(p.pin.radius, p.hole.radius, p.offset) { + let at = p.pin.origin + p.toward * q.0 + side * q.1; + bbox.add(at + p.pin.axis * *s); + bbox.add(at + p.pin.axis * *t); + } + } + } + let centroid = if volume > 0.0 { moment / volume } else { bbox.center() }; + let offset = pairs.iter().map(|p| p.offset).fold(0.0, f64::max); + let length = pairs.iter().map(|p| total(&p.shared)).sum(); + Some(CoaxialOverlap { volume: volume.max(0.0), area, centroid, bbox, offset, length }) +} + +/// The pin's offset to the hole, when the two are nearly on one cylinder but not on the same +/// surface: the unit direction from the pin's axis to the hole's (any, for one axis) and the +/// distance. +fn near(pin: &Wall, hole: &Wall, same: f64) -> Option<(Vec3, f64)> { + let d = hole.origin - pin.origin; + let across = d - pin.axis * d.dot(pin.axis); + let offset = across.len(); + let gap = (pin.radius - hole.radius).abs(); + if offset <= same && gap <= same { + return None; + } + if !(offset + gap <= NEAR * pin.radius.min(hole.radius)) { + return None; + } + let toward = across.normalized().unwrap_or_else(|| pin.axis.any_perp()); + Some((toward, offset)) +} + +/// The body's cylindrical faces, each as a wall of its own with no side or extent yet. +fn cylinders(b: &Body) -> Vec { + (0..b.face_count()) + .filter_map(|i| match crate::face_surface(b, i)? { + FaceSurface::Cylinder { origin, axis, radius } if origin.is_finite() && radius.is_finite() && radius > 0.0 => { + Some(Wall { faces: vec![i], origin, axis: axis.normalized()?, radius, boss: false, spans: Vec::new() }) + } + _ => None, + }) + .collect() +} + +/// The cylindrical walls of a body from its `cylinders`: its faces on each cylinder, which side +/// its material is on and where they run along the axis. `None` when the body can't be meshed. +fn walls(b: &Body, cylinders: Vec, same: f64) -> Option> { + let mut out: Vec = Vec::new(); + let mesh = b.tessellate_with((b.size() * 1e-3).max(1e-4), true).ok()?; + for Wall { faces, origin, axis, radius, .. } in cylinders { + let Some(&face) = faces.first() else { continue }; + // Which way the face looks: out of the axis for a pin, toward it for a hole. + let (mut facing, mut lo, mut hi) = (0.0, f64::INFINITY, f64::NEG_INFINITY); + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + if *f as usize != face { + continue; + } + let Some([p, q, r]) = mesh.tri(t) else { continue }; + let c = (p + q + r) / 3.0; + let radial = (c - origin) - axis * (c - origin).dot(axis); + facing += (q - p).cross(r - p).dot(radial); + for x in [p, q, r] { + let s = (x - origin).dot(axis); + lo = lo.min(s); + hi = hi.max(s); + } + } + if !(lo < hi) || facing == 0.0 { + continue; + } + let boss = facing > 0.0; + let wall = Wall { faces: vec![face], origin, axis, radius, boss, spans: vec![(lo, hi)] }; + match out.iter_mut().find(|w| w.boss == boss && on_one_cylinder(w, &wall, same)) { + Some(w) => { + let s = (origin - w.origin).dot(w.axis) * if axis.dot(w.axis) < 0.0 { -1.0 } else { 1.0 }; + let (a, b) = if axis.dot(w.axis) < 0.0 { (-hi, -lo) } else { (lo, hi) }; + w.faces.push(face); + w.spans.push((a + s, b + s)); + } + None => out.push(wall), + } + } + Some(out) +} + +fn on_one_cylinder(x: &Wall, y: &Wall, same: f64) -> bool { + let d = y.origin - x.origin; + x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < same && (d - x.axis * d.dot(x.axis)).len() < same +} + +/// `wall`'s spans along `onto`'s axis. +fn project(wall: &Wall, onto: &Wall) -> Vec<(f64, f64)> { + let flip = wall.axis.dot(onto.axis) < 0.0; + let s = (wall.origin - onto.origin).dot(onto.axis); + wall.spans.iter().map(|&(lo, hi)| if flip { (s - hi, s - lo) } else { (s + lo, s + hi) }).collect() +} + +/// Sorted, disjoint intervals covering `spans`. +fn union(spans: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut v: Vec<(f64, f64)> = spans.iter().copied().filter(|(a, b)| a < b).collect(); + v.sort_by(|x, y| x.0.total_cmp(&y.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); + for (a, b) in v { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +fn intersect(x: &[(f64, f64)], y: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + for &(a, b) in x { + for &(c, d) in y { + let (lo, hi) = (a.max(c), b.min(d)); + if lo < hi { + out.push((lo, hi)); + } + } + } + out +} + +fn total(spans: &[(f64, f64)]) -> f64 { + spans.iter().map(|(a, b)| b - a).sum() +} + +/// The part of a disc of radius `pin` outside a disc of radius `hole` whose centre is `offset` +/// away along +x: its area, its centroid's x (y is 0) and its perimeter. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Crescent { + pub(crate) area: f64, + pub(crate) centroid: f64, + pub(crate) perimeter: f64, +} + +pub(crate) fn crescent(pin: f64, hole: f64, offset: f64) -> Crescent { + use std::f64::consts::PI; + let (r, q, e) = (pin.max(0.0), hole.max(0.0), offset.abs()); + let disc = PI * r * r; + if e >= r + q { + return Crescent { area: disc, centroid: 0.0, perimeter: 2.0 * PI * r }; + } + if e <= (r - q).abs() { + return if q >= r { + Crescent { area: 0.0, centroid: 0.0, perimeter: 0.0 } + } else { + let area = disc - PI * q * q; + Crescent { area, centroid: -PI * q * q * e / area, perimeter: 2.0 * PI * (r + q) } + }; + } + // Half-angles of the lens's two segments, seen from each centre. + let alpha = ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos(); + let beta = ((e * e + q * q - r * r) / (2.0 * e * q)).clamp(-1.0, 1.0).acos(); + let segment = |radius: f64, half: f64| { + let area = radius * radius * (half - half.sin() * half.cos()); + let w = 2.0 * half - (2.0 * half).sin(); + let from_centre = if w > 1e-12 { 4.0 * radius * half.sin().powi(3) / (3.0 * w) } else { radius }; + (area, from_centre) + }; + let (sa, da) = segment(r, alpha); + let (sb, db) = segment(q, beta); + let lens = sa + sb; + let area = (disc - lens).max(0.0); + let moment = -(sa * da + sb * (e - db)); + let centroid = if area > 0.0 { moment / area } else { 0.0 }; + Crescent { area, centroid, perimeter: r * (2.0 * PI - 2.0 * alpha) + q * 2.0 * beta } +} + +/// Points around the crescent's outline (x toward the hole's centre, y across), for its box. +fn crescent_outline(pin: f64, hole: f64, offset: f64) -> Vec<(f64, f64)> { + let (r, q, e) = (pin, hole, offset); + let alpha = if e > 0.0 { ((e * e + r * r - q * q) / (2.0 * e * r)).clamp(-1.0, 1.0).acos() } else { 0.0 }; + let n = 32; + (0..=n) + .map(|i| { + let t = alpha + (2.0 * std::f64::consts::PI - 2.0 * alpha) * f64::from(i) / f64::from(n); + (r * t.cos(), r * t.sin()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The crescent against the textbook lens of two equal circles, and its limits. + #[test] + fn crescent_matches_the_lens_formula() { + let (r, e) = (3.0f64, 0.01f64); + let lens = 2.0 * r * r * (e / (2.0 * r)).acos() - e / 2.0 * (4.0 * r * r - e * e).sqrt(); + let c = crescent(r, r, e); + assert!((c.area - (std::f64::consts::PI * r * r - lens)).abs() < 1e-12, "{c:?}"); + // Thin: twice the radius times the offset, its centroid π/4 of the radius away from + // the hole's centre, its outline nearly two half circles. + assert!((c.area - 2.0 * r * e).abs() < 1e-6, "{c:?}"); + assert!((c.centroid + std::f64::consts::FRAC_PI_4 * r).abs() < 1e-2, "{c:?}"); + assert!((c.perimeter - 2.0 * std::f64::consts::PI * r).abs() < 1e-3, "{c:?}"); + assert_eq!(crescent(r, r, 0.0).area, 0.0); + // A pin with clearance in its hole shares nothing; a pin wider than its hole a ring. + assert_eq!(crescent(2.99, 3.0, 0.005).area, 0.0); + let ring = crescent(3.01, 3.0, 0.0); + assert!((ring.area - std::f64::consts::PI * (3.01f64.powi(2) - 9.0)).abs() < 1e-12, "{ring:?}"); + } + + /// A 20 x 20 x 5 plate with a hole of radius 3 through it, and a pin of radius `pin` through + /// the hole, `offset` off its axis along X. + fn plate_and_pin(pin: f64, offset: f64) -> (Body, Body) { + let block = crate::box_solid(Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 5.0)).unwrap(); + let drill = crate::cylinder(Vec3::new(0.0, 0.0, -2.0), Vec3::Z, 3.0, 9.0).unwrap(); + let plate = crate::boolean(&block, &drill, BoolOp::Cut).unwrap().unwrap(); + (plate, crate::cylinder(Vec3::new(offset, 0.0, -2.0), Vec3::Z, pin, 9.0).unwrap()) + } + + /// The pin shares a crescent with the plate over the plate's thickness, in either order. + #[test] + fn a_pin_off_its_hole_shares_a_crescent() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let want = 5.0 * crescent(3.0, 3.0, 0.01).area; + for (x, y) in [(&plate, &pin), (&pin, &plate)] { + let o = coaxial_overlap(x, y).unwrap(); + assert!((o.volume - want).abs() < 1e-6 * want, "{o:?} {want}"); + assert!((o.offset - 0.01).abs() < 1e-9 && (o.length - 5.0).abs() < 1e-9, "{o:?}"); + // The crescent is on the pin's side away from the hole's axis (+X). + assert!(o.centroid.x > 2.0 && o.bbox.min.z > -1e-9 && o.bbox.max.z < 5.0 + 1e-9, "{o:?}"); + } + } + + /// A pin with clearance all round, though off the axis, shares nothing. + #[test] + fn a_pin_with_clearance_shares_nothing() { + let (plate, pin) = plate_and_pin(2.995, 0.003); + let o = coaxial_overlap(&plate, &pin).unwrap(); + assert!(o.volume < 1e-9, "{o:?}"); + } + + /// A slot through the hole's wall where the crescent would be: the band holds more than the + /// two walls, the widened pin says so, and the pair is left to the boolean. + #[test] + fn a_slot_through_the_band_is_left_to_the_boolean() { + let (plate, pin) = plate_and_pin(3.0, 0.01); + let slot = crate::box_solid(Vec3::new(2.5, -1.0, -1.0), Vec3::new(4.0, 1.0, 6.0)).unwrap(); + let slotted = crate::boolean(&plate, &slot, BoolOp::Cut).unwrap().unwrap(); + assert!(coaxial_overlap(&slotted, &pin).is_none()); + // Pins on the very axis, or far off it, aren't asked about. + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.0).1).is_none()); + assert!(coaxial_overlap(&plate, &plate_and_pin(3.0, 0.5).1).is_none()); + } +} diff --git a/crates/kernel/src/continuity.rs b/crates/kernel/src/continuity.rs new file mode 100644 index 00000000..001e3289 --- /dev/null +++ b/crates/kernel/src/continuity.rs @@ -0,0 +1,497 @@ +//! Continuity diagnostics across the seams between a body's faces: do the two faces that meet +//! at an edge share a curve (G0), a tangent plane (G1) and a curvature (G2)? +//! +//! The stations are taken on the edge itself — the curve both faces are supposed to share — so +//! the numbers describe the B-rep and not a tessellation: each station is projected onto each +//! face's own surface, and the two projections are compared. The gap is the distance between +//! them (G0); the normal angle is the angle between the faces' outward normals there (G1); the +//! curvature jump is the difference of their normal curvatures in the normal section square to +//! the edge, from the second fundamental form of each surface (G2). A rolling-ball fillet is +//! tangent to the faces it joins but is not curvature continuous: where it meets a plane its +//! seam reads 0° and 1 / radius, which no chord tolerance would reveal. +//! +//! [`crate::smooth_edges`] decides whether a join needs a *display* line; this module measures +//! the join, and backs the engine's `inspect.continuity`. + +use serde::Serialize; +use solidcraft_geom::Vec3; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3}; +use crate::{KernelError, Result}; + +/// Bodies with more edges than this are refused rather than sampled for minutes. +const MAX_EDGES: usize = 4096; +/// Most faces a body may have and still be analysed. +const MAX_FACES: usize = 50_000; +/// Stations one call may take over all its edges; edges past the budget are left out and +/// counted in [`ContinuityReport::skipped`]. +const MAX_STATIONS: usize = 200_000; +/// Most edges one pick list may name (the full list always fits the station budget). +const MAX_PICKS: usize = 512; +/// Samples used to find the edge nearest a picked point (as in `surfaces::edge_near`). +const PICK_SAMPLES: usize = 32; + +/// Least and most stations along one edge (`inspect.continuity`'s `samples`): three are the two +/// ends and the middle. +pub const MIN_SAMPLES: usize = 3; +pub const MAX_SAMPLES: usize = 257; + +/// How an edge is picked: a point on (or near) it, or its index in the display mesh's edge +/// order ([`Body::edges`]), which is what a viewport pick carries. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgePick { + Point(Vec3), + Index(usize), +} + +/// What counts as continuous across a seam. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContinuityTolerances { + /// Largest position gap that still counts as coincident (G0), mm. + pub gap_mm: f64, + /// Largest angle between the faces' normals that still counts as tangent (G1), degrees. + pub angle_deg: f64, + /// Largest curvature jump that still counts as curvature continuous (G2), 1/mm. + pub curvature_per_mm: f64, +} + +/// The worst value of one metric along an edge, with where it was found. +#[derive(Clone, Copy, Debug, Serialize)] +pub struct SeamWorst { + pub value: f64, + /// The station that gave the worst value, in the body's coordinates. + pub at: Vec3, + /// How far along the edge it is: 0 at the edge's start, 1 at its end. + pub edge_fraction: f64, +} + +/// Which test a seam fails, from the best continuity to the worst: [`SeamVerdict::Smooth`] is +/// within every tolerance (G2 or better), then a tangent seam whose curvature jumps (G1), then +/// a visible crease (G0 only), and finally faces that do not meet at all. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SeamVerdict { + Smooth, + CurvatureJump, + Sharp, + Gap, +} + +/// One edge's diagnostics: the worst of each metric along it, and where. +#[derive(Clone, Debug, Serialize)] +pub struct SeamReport { + /// Index in the display mesh's edge order ([`Body::edges`]). + pub edge: usize, + /// Length of the edge, mm. + pub length: f64, + /// Stations the values were taken at. + pub samples: usize, + pub gap_mm: SeamWorst, + pub normal_angle_deg: SeamWorst, + pub curvature_jump_per_mm: SeamWorst, + /// What the tolerances make of the seam. + pub verdict: SeamVerdict, + /// The stations in order: what a viewport shades. + pub points: Vec, +} + +/// The seam diagnostics of one body. +#[derive(Clone, Debug, Serialize)] +pub struct ContinuityReport { + pub seams: Vec, + /// Edges left out: not a seam between two faces (an open boundary, or a periodic face's own + /// seam, which is one face meeting itself), or past the station budget. + pub skipped: usize, + /// Edges that could not be sampled at all: no station of theirs projected onto both faces. + pub unmeasured: usize, +} + +/// The edge indices `picks` mean, in display-mesh order and without repeats. A point pick takes +/// the edge nearest it (within the distance the kernel re-finds an edge from); an index pick +/// must be an existing index. An empty list means every two-sided seam of the body, and a pick +/// of an edge that is not a seam between two faces is refused, not guessed at. +pub fn pick_edges(body: &Body, picks: &[EdgePick]) -> Result> { + body.require_brep("continuity")?; + if picks.len() > MAX_PICKS { + return Err(KernelError::Invalid(format!("pick at most {MAX_PICKS} edges"))); + } + crate::guard("continuity picks", || { + let (edges, adjacent) = topology(body)?; + if picks.is_empty() { + return Ok((0..edges.len()).filter(|i| two_sided(&adjacent, *i)).collect()); + } + let reach = body.size() * 0.05 + 1e-6; + let mut out: Vec = Vec::new(); + for pick in picks { + let index = match *pick { + EdgePick::Index(index) => index, + EdgePick::Point(p) => nearest_edge(&edges, p, reach)?, + }; + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + if !out.contains(&index) { + out.push(index); + } + } + out.sort_unstable(); + Ok(out) + }) +} + +/// Sample the two-sided seams `edges` of `body` (indices in display-mesh order; empty: every +/// one of them) at `samples` stations along each edge's own parameter, the ends included, with +/// the tolerances the seams are judged against. +pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + body.require_brep("continuity")?; + if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) { + return Err(KernelError::Invalid(format!("`samples` must be {MIN_SAMPLES}…{MAX_SAMPLES}"))); + } + for (name, value) in [("gap", tolerances.gap_mm), ("angle", tolerances.angle_deg), ("curvature", tolerances.curvature_per_mm)] { + // A range test also throws out NaN (both comparisons are false). + if !(0.0..1e9).contains(&value) { + return Err(KernelError::Invalid(format!("the {name} tolerance must be a finite number, 0 or more"))); + } + } + crate::guard("continuity", || measure(body, edges, samples, tolerances)) +} + +/// The body's unique edges and the faces meeting at each of them, either under the caps. +fn topology(body: &Body) -> Result<(Vec, Vec>)> { + if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_FACES} faces"))); + } + let (edges, adjacent) = crate::body::edge_order(body.solid.face_iter()); + if edges.len() > MAX_EDGES { + return Err(KernelError::TooLarge(format!("the body has more than {MAX_EDGES} edges"))); + } + Ok((edges, adjacent)) +} + +/// Is the edge at `index` shared by (exactly) two faces of the body? +fn two_sided(adjacent: &[Vec], index: usize) -> bool { + adjacent.get(index).is_some_and(|faces| faces.len() == 2) +} + +/// The index of the edge whose curve comes nearest `p`, within `reach`. +fn nearest_edge(edges: &[mt::Edge], p: Vec3, reach: f64) -> Result { + use mt::ParametricCurve; + let mut best: Option<(f64, usize)> = None; + for (index, edge) in edges.iter().enumerate() { + let curve = edge.curve(); + let Some((t0, t1)) = curve.try_range_tuple() else { continue }; + if !(t0.is_finite() && t1.is_finite() && t1 > t0) { + continue; + } + for k in 0..=PICK_SAMPLES { + let q = from_p3(curve.subs(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64)); + let d = q.dist(p); + if d.is_finite() && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, index)); + } + } + } + match best { + Some((d, index)) if d <= reach => Ok(index), + _ => Err(KernelError::Invalid(format!("no edge of the body is within {reach:.3} mm of ({}, {}, {})", p.x, p.y, p.z))), + } +} + +fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result { + let (list, adjacent) = topology(body)?; + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + let mut selected: Vec = Vec::new(); + let mut skipped = 0usize; + if edges.is_empty() { + for index in 0..list.len() { + if two_sided(&adjacent, index) { + selected.push(index); + } else { + skipped += 1; + } + } + } else { + selected.extend(edges.iter().copied()); + selected.sort_unstable(); + selected.dedup(); + for &index in &selected { + if index >= list.len() { + return Err(KernelError::Invalid(format!("the body has no edge {index}"))); + } + if !two_sided(&adjacent, index) { + return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces"))); + } + } + } + let mut seams = Vec::new(); + let mut unmeasured = 0usize; + let mut budget = MAX_STATIONS; + for index in selected { + let Some(face_pair) = adjacent.get(index).filter(|faces| faces.len() == 2) else { continue }; + let (Some(&first), Some(&second)) = (face_pair.first(), face_pair.get(1)) else { continue }; + let (Some(a), Some(b)) = (faces.get(first).copied(), faces.get(second).copied()) else { continue }; + let Some(edge) = list.get(index) else { continue }; + // Every edge costs `samples` stations; a run out of budget stops cleanly. + if budget < samples { + skipped += 1; + continue; + } + budget -= samples; + match seam(index, edge, a, b, samples, tolerances) { + Some(report) => seams.push(report), + None => unmeasured += 1, + } + } + Ok(ContinuityReport { seams, skipped, unmeasured }) +} + +/// One edge's worst gap, normal angle and curvature jump. +fn seam(index: usize, edge: &mt::Edge, face_a: &mt::Face, face_b: &mt::Face, samples: usize, tolerances: ContinuityTolerances) -> Option { + use mt::ParametricCurve; + let curve = edge.curve(); + let (t0, t1) = curve.try_range_tuple()?; + // The station fractions: 0 at the edge's start, 1 at its end. + let steps = samples.checked_sub(1)?; + let fraction = |k: usize| k as f64 / steps as f64; + let mut on_a = FaceSurface::new(face_a); + let mut on_b = FaceSurface::new(face_b); + let mut gap = None; + let mut angle = None; + let mut jump = None; + let mut points = Vec::with_capacity(samples); + for k in 0..samples { + let f = fraction(k); + let at = from_p3(curve.subs(t0 + (t1 - t0) * f)); + if !at.is_finite() { + continue; + } + // Both projections and both curvatures, or the station says nothing. + let (Some((qa, na, uv_a)), Some((qb, nb, uv_b))) = (on_a.project(at), on_b.project(at)) else { continue }; + let tangent = curve.der(t0 + (t1 - t0) * f); + let Some(tangent) = Vec3::new(tangent.x, tangent.y, tangent.z).normalized() else { continue }; + // The normal sections square to the seam: one direction per face, in its tangent plane. + let (Some(ka), Some(kb)) = (on_a.normal_curvature(uv_a, na, across(na, tangent)), on_b.normal_curvature(uv_b, nb, across(nb, tangent))) + else { + continue; + }; + // The faces' outward normals agree on a smooth seam; where they are opposed (a knife + // edge) the curvatures are compared in one frame, since bending away from one face is + // bending towards the other. + let signed = if na.dot(nb) < 0.0 { ka + kb } else { ka - kb }; + points.push(at); + keep(&mut gap, qa.dist(qb), at, f); + keep(&mut angle, na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), at, f); + keep(&mut jump, signed.abs(), at, f); + } + let (gap, angle, jump) = (gap?, angle?, jump?); + let verdict = if gap.value > tolerances.gap_mm { + SeamVerdict::Gap + } else if angle.value > tolerances.angle_deg { + SeamVerdict::Sharp + } else if jump.value > tolerances.curvature_per_mm { + SeamVerdict::CurvatureJump + } else { + SeamVerdict::Smooth + }; + let length = points.windows(2).map(|w| w.first().zip(w.get(1)).map_or(0.0, |(a, b)| a.dist(*b))).sum(); + Some(SeamReport { + edge: index, + length, + samples: points.len(), + gap_mm: gap, + normal_angle_deg: angle, + curvature_jump_per_mm: jump, + verdict, + points, + }) +} + +/// The direction in which a face's curvature is read at a seam: across the edge, square to it, +/// inside the face's tangent plane (a zero vector where the edge runs along the normal, which +/// leaves that station unmeasured). +fn across(normal: Vec3, tangent: Vec3) -> Vec3 { + normal.cross(tangent) +} + +/// Keep the largest value seen, with where it was found. +fn keep(worst: &mut Option, value: f64, at: Vec3, edge_fraction: f64) { + if value.is_finite() && worst.is_none_or(|w| value > w.value) { + *worst = Some(SeamWorst { value, at, edge_fraction }); + } +} + +/// A face's surface, kept over the stations of one edge so each search can start at the last +/// answer (stations run along the edge, so the parameters move smoothly). +struct FaceSurface { + surface: mt::Surface, + hint: Option<(f64, f64)>, +} + +impl FaceSurface { + /// The face's own outward-facing surface (`oriented_surface`: a reversed face's surface is + /// inverted, so its normal is the material's outward normal). + fn new(face: &mt::Face) -> Self { + Self { surface: face.oriented_surface(), hint: None } + } + + /// The nearest point of the surface to `p`, the outward unit normal there and the + /// parameters. `None` where the search or the normal is unusable. + fn project(&mut self, p: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> { + use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter}; + let uv = self.surface.search_nearest_parameter(p3(p), self.hint, 100).or_else(|| self.surface.search_nearest_parameter(p3(p), None, 100))?; + if !(uv.0.is_finite() && uv.1.is_finite()) { + return None; + } + let q = from_p3(self.surface.subs(uv.0, uv.1)); + let n = self.surface.normal(uv.0, uv.1); + if !q.is_finite() { + return None; + } + let normal = Vec3::new(n.x, n.y, n.z).normalized()?; + self.hint = Some(uv); + Some((q, normal, uv)) + } + + /// The normal curvature of the face at `uv` along `direction` (a unit vector of its tangent + /// plane), from the second fundamental form. A surface point `r(u, v)` with unit normal `n` + /// crossed that way has curvature `κ = n · r''` of the curve whose tangent there is + /// `direction`: a plane reads 0 and a cylinder of radius `R`, across its axis, −1 / R + /// (bending towards its axis). `None` at a degenerate parameter point (a cone's apex), where + /// the surface has no curvature. + fn normal_curvature(&self, uv: (f64, f64), normal: Vec3, direction: Vec3) -> Option { + use mt::ParametricSurface; + let ru = vec3(self.surface.uder(uv.0, uv.1)); + let rv = vec3(self.surface.vder(uv.0, uv.1)); + let (ru_len, rv_len) = (ru.len(), rv.len()); + if !(ru_len > 0.0 && rv_len > 0.0) { + return None; + } + // Solve the direction in the unit basis (r_u / |r_u|, r_v / |r_v|): the curve then runs + // its parameters at du/ds = α / |r_u| and dv/ds = β / |r_v|, and + // `r'' = r_uu (du/ds)² + 2 r_uv (du/ds)(dv/ds) + r_vv (dv/ds)²`. + let cos = (ru.dot(rv) / (ru_len * rv_len)).clamp(-1.0, 1.0); + let sin2 = 1.0 - cos * cos; + if !(sin2 > 1e-12) { + return None; + } + let (cu, cv) = (direction.dot(ru) / ru_len, direction.dot(rv) / rv_len); + let (du, dv) = ((cu - cv * cos) / sin2 / ru_len, (cv - cu * cos) / sin2 / rv_len); + if !(du.is_finite() && dv.is_finite()) { + return None; + } + // `direction` is a unit vector: divide by the speed the parameters imply (they only miss + // it by rounding), so κ does not depend on how the surface is parameterized. + let speed = (ru * du + rv * dv).len(); + if !(speed > 1e-6) { + return None; + } + let second = normal.dot( + vec3(self.surface.uuder(uv.0, uv.1)) * (du * du) + + vec3(self.surface.uvder(uv.0, uv.1)) * (2.0 * du * dv) + + vec3(self.surface.vvder(uv.0, uv.1)) * (dv * dv), + ); + (second / (speed * speed)).is_finite().then_some(second / (speed * speed)) + } +} + +/// A kernel point as a vector. +fn vec3(p: mt::Vector3) -> Vec3 { + Vec3::new(p.x, p.y, p.z) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use solidcraft_geom::Plane; + + fn tolerances() -> ContinuityTolerances { + ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 } + } + + fn box_body() -> Body { + crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap() + } + + fn report(body: &Body) -> ContinuityReport { + seam_diagnostics(body, &[], 33, tolerances()).unwrap() + } + + #[test] + fn a_box_edge_is_a_gap_free_right_angle() { + let body = box_body(); + let r = report(&body); + assert_eq!(r.seams.len(), 12, "a box has twelve edges"); + assert_eq!(r.skipped, 0); + for seam in &r.seams { + assert!(seam.gap_mm.value < 1e-6, "gap {}", seam.gap_mm.value); + assert!((seam.normal_angle_deg.value - 90.0).abs() < 1e-9, "angle {}", seam.normal_angle_deg.value); + assert!(seam.curvature_jump_per_mm.value < 1e-9, "jump {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::Sharp); + assert_eq!(seam.samples, 33); + assert!((seam.length - 10.0).abs() < 1e-9); + } + } + + #[test] + fn a_fillet_seam_is_tangent_with_the_fillets_curvature_jump() { + let body = box_body(); + let rounded = crate::fillet(&body, &[Vec3::new(10.0, 10.0, 5.0)], 4.0).unwrap(); + let r = report(&rounded); + // The rounded edge is replaced by the blend face: two tangent seams along it, the two + // arcs where the blend meets the ends of the edge, and the box's other eleven edges. + assert_eq!(r.seams.len(), 15); + let tangent: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(tangent.len(), 2, "the fillet meets its two neighbours tangentially"); + for seam in &tangent { + assert!(seam.gap_mm.value < 1e-6); + assert!(seam.curvature_jump_per_mm.value.is_finite()); + assert!((seam.curvature_jump_per_mm.value - 0.25).abs() < 1e-6, "1 / radius, got {}", seam.curvature_jump_per_mm.value); + assert_eq!(seam.verdict, SeamVerdict::CurvatureJump); + } + assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13); + } + + #[test] + fn a_line_tangent_into_an_arc_reports_one_over_its_radius() { + use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; + let v = |x: f64, y: f64| Vec2::new(x, y); + let outer = Loop2 { + segs: vec![ + Seg2::Arc { center: v(10.0, 0.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(10.0, 5.0), b: v(-10.0, 5.0) }, + Seg2::Arc { center: v(-10.0, 0.0), radius: 5.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(-10.0, -5.0), b: v(10.0, -5.0) }, + ], + }; + let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0); + let r = report(&body); + let joins: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + assert_eq!(joins.len(), 4, "a slot has four line-to-arc joins"); + for seam in joins { + assert!( + seam.curvature_jump_per_mm.value > 0.19 && seam.curvature_jump_per_mm.value < 0.21, + "1 / 5, got {}", + seam.curvature_jump_per_mm.value + ); + } + } + + #[test] + fn picks_refuse_what_is_not_there() { + let body = box_body(); + let all = pick_edges(&body, &[]).unwrap(); + assert_eq!(all.len(), 12); + // The edge up the corner at (0, 0), beside a point in the middle of a face. + let picked = pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 0.0, 5.0)), EdgePick::Index(all[0])]).unwrap(); + assert_eq!(picked.len(), 2); + assert!(pick_edges(&body, &[EdgePick::Index(999)]).is_err()); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 5.0, 5.0))]).is_err(), "a point on a face is not on an edge"); + assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(500.0, 0.0, 0.0))]).is_err()); + assert!(seam_diagnostics(&body, &[], 2, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 258, tolerances()).is_err()); + assert!(seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: f64::NAN, ..tolerances() }).is_err()); + assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err()); + } +} diff --git a/crates/kernel/src/corner_g2.rs b/crates/kernel/src/corner_g2.rs new file mode 100644 index 00000000..22e236df --- /dev/null +++ b/crates/kernel/src/corner_g2.rs @@ -0,0 +1,501 @@ +//! G2 corner patches: the patch that fills a vertex where filleted edges meet, built to repeat each +//! blend's surface up to the second order instead of the rolling ball's sphere. +//! +//! [`crate::blend_corner`] and [`crate::curvecorner`] fill the corner of a vertex with a piece of +//! the ball that rolls along the three edges (a sphere for equal radii, a rational patch for +//! unequal ones). That patch is tangent to every blend along the sections where they stop, so the +//! corner is G1 — but across a seam the ball keeps its own 1/r where a fillet is flat along its +//! axis, so the two surfaces meet with a jump in curvature. +//! +//! Here the corner is one tensor patch whose boundary is the blends' section curves themselves and +//! whose cross-sections carry each blend's own derivatives: the position, the direction the patch +//! leaves the blend's surface in, and the blend's curvature vector along that direction are the +//! first three control points of every cross-section, one control point per continuity condition, +//! exactly as [`crate::bridge`]'s sections do. The other end of every cross-section gathers at +//! `apex` — a point inside the material the corner caps — where the sections arrive in a common +//! tangent plane with a common curvature, so the patch closes there smoothly like a sphere's pole. +//! Along a seam the patch therefore repeats the blend up to the second order (G2). +//! +//! The boundary is a closed loop (the three sections joined corner to corner), so the direction +//! along it is a *closed* cubic B-spline: an interpolating curve with free ends swings wide at the +//! corner where the loop joins and folds the patch there. Where two blends meet at that corner +//! their sections leave in directions 90° apart and ask for different curvature at that one point +//! (a fillet is flat across its own seam, the other is not), which no single surface can hold; the +//! corner is a still point of the construction and [`seam_error`] is measured clear of it. A patch +//! that folds, or a corner with no room, is refused rather than replaced by the G1 ball. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{HPoint, NurbsSurface}; +use truck_modeling::{self as mt, builder}; + +use crate::blend::unsupported; +use crate::body::{from_p3, p3}; +use crate::bridge::Frame; +use crate::{KernelError, Result, guard}; + +/// A corner's name in an error. +fn fail(m: impl std::fmt::Display) -> KernelError { + KernelError::Failed(format!("G2 corner: {m}")) +} + +/// One of the surface's own derivative vectors as ours. +fn vec_of(v: mt::Vector3) -> Vec3 { + Vec3::new(v.x, v.y, v.z) +} + +/// Conditions each end of a cross-section carries: position, tangent and curvature. +const END_CONDITIONS: usize = 3; +/// Degree across the patch: three conditions at each end, less the control point they share. +const ACROSS: usize = 2 * END_CONDITIONS - 1; +/// Degree along the seam loop. +const ALONG: usize = 3; +/// Fewest stations a section curve may be sampled at (a cubic through them). +const MIN_STATIONS: usize = 4; +/// Most stations one section curve may carry. +const MAX_STATIONS: usize = 512; +/// How much of a station's `leave` has to lie across its seam for the patch to have a direction to +/// leave the blend in at all. +const ACROSS_ENOUGH: f64 = 0.2; + +/// The G2 corner patch over three blends' section curves: `sides[i]` is one blend's section curve, +/// sampled in order from one corner of the vertex to the next, each side's last station the next +/// side's first (the three close a loop). `apex` is a point inside the material the corner caps; +/// the patch gathers there. The face's boundary edges are the section curves themselves (one edge +/// per side, cut out of the patch's own boundary curve, so they lie on it exactly); the caller +/// re-wraps the wire where it needs its own orientation. +pub fn corner_g2_patch(sides: &[Vec], apex: Vec3) -> Result> { + guard("G2 corner patch", || build(sides, apex)) +} + +/// The seam's own direction at station `i` of a side, from a four-point central difference through +/// the stations (`crate::bridge::frames` samples a chain evenly, and a corner's own callers do the +/// same): a two-point chord would only be second order in the step and would tilt the corner's +/// tangent plane by more than G2 is judged at. `None` within two stations of a side's end. +fn seam_tangent(side: &[Frame], i: usize) -> Option { + let at = |j: isize| usize::try_from(j).ok().and_then(|j| side.get(j)).map(|f| f.p); + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() +} + +/// The seam's direction at every station of the closed loop (the same difference, wrapping round +/// its ends), falling back to the chord to a neighbour where the four points are not there. +fn loop_tangents(frames: &[Frame]) -> Vec> { + let m = frames.len(); + if m == 0 { + return Vec::new(); + } + let at = |j: isize| frames.get(j.rem_euclid(m as isize) as usize).map(|f| f.p); + (0..m) + .map(|i| { + let four = || { + let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?); + (b * 8.0 - a - c * 8.0 + d).normalized() + }; + four().or_else(|| { + let prev = at(i as isize - 1)?; + let next = at(i as isize + 1)?; + (next - prev).normalized() + }) + }) + .collect() +} + +/// The three section curves as one closed loop of stations, the loop index each side starts at, +/// and the corner's own size (twice the mean distance of a station from their centre). +fn corner_loop(sides: &[Vec]) -> Result<(Vec, Vec, f64)> { + if sides.len() != 3 { + return Err(unsupported("a G2 corner needs three blends meeting at one vertex (a corner of two, or one, still ends on the ball's patch)")); + } + let mut frames: Vec = Vec::new(); + let mut starts: Vec = Vec::new(); + for side in sides { + if side.len() < MIN_STATIONS || side.len() > MAX_STATIONS { + return Err(KernelError::Invalid(format!("a corner section curve needs {MIN_STATIONS} to {MAX_STATIONS} sampled stations"))); + } + for f in side { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) || !(f.leave.len() > 0.5) { + return Err(KernelError::Invalid("a corner station must carry finite data and a unit direction".into())); + } + } + starts.push(frames.len()); + // A side runs from one corner to the next; its last station is the next side's first, so the + // loop takes it once. + frames.extend(side.iter().take(side.len() - 1).copied()); + } + let centre = frames.iter().fold(Vec3::ZERO, |a, f| a + f.p) * (1.0 / frames.len().max(1) as f64); + let size = frames.iter().map(|f| f.p.dist(centre)).fold(0.0, f64::max) * 2.0; + if !(size > 0.0 && size.is_finite()) { + return Err(unsupported("a corner whose section curves have no extent")); + } + for (i, side) in sides.iter().enumerate() { + let Some(prev) = sides.get((i + sides.len() - 1) % sides.len()).and_then(|s| s.last()) else { continue }; + let Some(&here) = side.first() else { continue }; + if prev.p.dist(here.p) > size * 1e-3 { + return Err(unsupported("the section curves do not meet at a corner")); + } + } + Ok((frames, starts, size)) +} + +/// Every station's `leave` squared up with the seam it belongs to: the part of it across the seam, +/// which is the direction the cross-section has to leave the blend in. A `leave` along the seam +/// itself leaves the cross-section no room, and is refused. +fn transverse(frames: &[Frame], tangents: &[Option]) -> Result> { + let mut out = Vec::with_capacity(frames.len()); + for (f, t) in frames.iter().zip(tangents) { + let across = match t { + Some(t) => f.leave - *t * f.leave.dot(*t), + // Stations that offer no direction along the seam: the leave stands as it came. + None => f.leave, + }; + if across.len() < ACROSS_ENOUGH * f.leave.len() { + return Err(unsupported("a section curve meets the corner patch along its own tangent, so the patch has no direction to leave it in")); + } + let leave = across.normalized().ok_or_else(|| unsupported("a corner station has no direction across its seam"))?; + out.push(Frame { leave, ..*f }); + } + Ok(out) +} + +/// One station's cross-section: `ACROSS + 1` control points of a Bézier from the seam point to +/// `apex`. The first three hold the blend: its position, the direction the patch leaves it in +/// (one control point along `leave`) and its curvature vector across the seam (one more). The last +/// three hold the apex: the point, a tangent in the apex's own plane, and the curvature of a dome +/// over the corner, so the sections arrive smoothly instead of leaving a point. +fn section(f: &Frame, apex: Vec3, n_apex: Vec3, kappa: f64) -> Result<[Vec3; ACROSS + 1]> { + let k = ACROSS as f64; + let chord = f.p.dist(apex); + if !(chord.is_finite() && chord > 0.0) { + return Err(unsupported("a blend too small for the corner patch to fit")); + } + let speed = chord / 3.0; + let to_apex = (f.p - apex).normalized().ok_or_else(|| unsupported("a section curve starts on the apex"))?; + let leave_apex = + (to_apex - n_apex * to_apex.dot(n_apex)).normalized().ok_or_else(|| unsupported("a section curve reaches the apex along the apex normal"))?; + let mut c = [Vec3::ZERO; ACROSS + 1]; + c[0] = f.p; + c[1] = f.p + f.leave * (speed / k); + c[2] = f.bend * (speed * speed / (k * (k - 1.0))) + c[1] * 2.0 - c[0]; + c[ACROSS] = apex; + c[ACROSS - 1] = apex + leave_apex * (speed / k); + c[ACROSS - 2] = (-n_apex * kappa) * (speed * speed / (k * (k - 1.0))) + c[ACROSS - 1] * 2.0 - c[ACROSS]; + if c.iter().any(|p| !p.is_finite()) { + return Err(fail("a cross-section of the corner is not finite")); + } + Ok(c) +} + +/// A closed uniform cubic B-spline through `points`, once round the loop: the curve at each station +/// is `(q[i-1] + 4 q[i] + q[i+1]) / 6`, so the stations fix the net by a cyclic tridiagonal system, +/// solved here with the Sherman–Morrison form of the Thomas algorithm (a corner's loop runs to +/// hundreds of stations). The net comes back in the open form the tensor surface wants — the first +/// three control points repeated past the end — so it still closes, with one uniform span per +/// station and no free end anywhere. +fn closed_layer(points: &[Vec3]) -> Result> { + let m = points.len(); + if m < 2 * ALONG + 2 { + return Err(fail("a corner loop needs at least eight stations")); + } + // Sub-diagonal 1, diagonal 4, super-diagonal 1, and 1 in both corners. + let (sub, diag, sup, corner) = (1.0f64, 4.0f64, 1.0f64, 1.0f64); + let gamma = -diag; + let mut bb = vec![diag; m]; + if let Some(first) = bb.first_mut() { + *first = diag - gamma; + } + if let Some(last) = bb.last_mut() { + *last = diag - corner * corner / gamma; + } + // Thomas, one right-hand side at a time (three components solved together). + let thomas = |rhs: &[Vec3]| -> Result> { + let mut c = vec![0.0f64; m]; + let mut d = rhs.to_vec(); + let mut denom = bb.first().copied().unwrap_or(0.0); + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + let (Some(c0), Some(d0)) = (c.first_mut(), d.first_mut()) else { return Err(fail("stations")) }; + *c0 = sup / denom; + *d0 = *d0 * (1.0 / denom); + for i in 1..m { + let (prev_c, prev_d) = (c.get(i - 1).copied().unwrap_or(0.0), d.get(i - 1).copied().unwrap_or(Vec3::ZERO)); + let b = bb.get(i).copied().unwrap_or(diag); + denom = b - sub * prev_c; + if !(denom.abs() > 1e-300) { + return Err(fail("the corner's stations are degenerate")); + } + if let Some(slot) = c.get_mut(i) { + *slot = if i + 1 < m { sup / denom } else { 0.0 }; + } + if let Some(slot) = d.get_mut(i) { + *slot = (*slot - prev_d * sub) * (1.0 / denom); + } + } + let mut x = vec![Vec3::ZERO; m]; + let last = d.last().copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.last_mut() { + *slot = last; + } + for i in (0..m - 1).rev() { + let (Some(ci), Some(xn)) = (c.get(i).copied(), x.get(i + 1).copied()) else { continue }; + let di = d.get(i).copied().unwrap_or(Vec3::ZERO); + if let Some(slot) = x.get_mut(i) { + *slot = di - xn * ci; + } + } + Ok(x) + }; + let rhs: Vec = points.iter().map(|p| *p * 6.0).collect(); + let x = thomas(&rhs)?; + // The unit right-hand side the corner terms add: gamma at the first station, the corner at the + // last. + let mut unit = vec![Vec3::ZERO; m]; + if let Some(slot) = unit.first_mut() { + *slot = Vec3::new(gamma, gamma, gamma); + } + if let Some(slot) = unit.last_mut() { + *slot = Vec3::new(corner, corner, corner); + } + let z = thomas(&unit)?; + let (x0, xn) = (x.first().copied().unwrap_or(Vec3::ZERO), x.last().copied().unwrap_or(Vec3::ZERO)); + let (z0, zn) = (z.first().copied().unwrap_or(Vec3::ZERO), z.last().copied().unwrap_or(Vec3::ZERO)); + // z answers a scalar right-hand side, so the correction is one scalar factor per component. + let factor = |x0: f64, xn: f64, z0: f64, zn: f64| (x0 + corner * xn / gamma) / (1.0 + z0 + corner * zn / gamma); + let f = Vec3::new(factor(x0.x, xn.x, z0.x, zn.x), factor(x0.y, xn.y, z0.y, zn.y), factor(x0.z, xn.z, z0.z, zn.z)); + let mut out = Vec::with_capacity(m + ALONG); + for i in 0..m { + let (xi, zi) = (x.get(i).copied().unwrap_or(Vec3::ZERO), z.get(i).copied().unwrap_or(Vec3::ZERO)); + out.push(xi - Vec3::new(f.x * zi.x, f.y * zi.y, f.z * zi.z)); + } + if !out.iter().all(|p| p.is_finite()) { + return Err(fail("the corner's boundary is not finite")); + } + for i in 0..ALONG { + let p = out.get(i).copied().unwrap_or(Vec3::ZERO); + out.push(p); + } + Ok(out) +} + +/// The stations of the loop as one tensor control net: across (v) each station's cross-section is +/// the Bézier above, along the seam loop (u) each of its control points is a closed cubic through +/// the stations, so the face passes through every section exactly and its parameter line along the +/// loop closes without an end to swing from. The loop's station count comes back with it: station +/// `i` sits at parameter `i + ALONG`, and the loop closes at `count + ALONG`. +fn tensor(layers: &[[Vec3; ACROSS + 1]]) -> Result<(NurbsSurface, f64)> { + let m = layers.len(); + let nu = m + ALONG; + let knots_u: Vec = (0..=(nu + ALONG) as i64).map(|i| i as f64).collect(); + let mut ctrl: Vec = Vec::with_capacity(nu * (ACROSS + 1)); + for j in 0..=ACROSS { + let points: Vec = layers.iter().map(|c| c.get(j).copied().unwrap_or(Vec3::ZERO)).collect(); + let net = closed_layer(&points)?; + for p in &net { + ctrl.push(HPoint::weighted(*p, 1.0)); + } + } + let mut knots_v = vec![0.0; ACROSS + 1]; + knots_v.extend(std::iter::repeat_n(1.0, ACROSS + 1)); + let s = NurbsSurface::from_homogeneous([ALONG, ACROSS], [knots_u, knots_v], ctrl).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok((s, m as f64)) +} + +/// The surface across its own domain: the patch must keep facing one way, or a blend would be met +/// by a piece of the patch folded back on itself. +fn no_fold(surface: &mt::Surface, loop_range: (f64, f64)) -> Result<()> { + use mt::ParametricSurface; + // The u knots run past the loop at both ends (a closed curve has no clamped ends to name), so + // the loop's own range is brought in rather than read off the knot vector. + let (u0, u1) = loop_range; + let Some((v0, v1)) = surface.try_range_tuple().1 else { + return Err(fail("the corner patch has no parameter domain")); + }; + let normal = |u: f64, v: f64| -> Option { vec_of(surface.uder(u, v)).cross(vec_of(surface.vder(u, v))).normalized() }; + let mid = normal((u0 + u1) * 0.5, v0 + (v1 - v0) * 0.5).ok_or_else(|| unsupported("the corner patch has no normal at its middle"))?; + // Clear of the apex (v1), where the patch's parameter line along the seam collapses to a point + // and the normal is read from the sections approaching it. + for i in 0..=8 { + for j in 0..9 { + let u = u0 + (u1 - u0) * i as f64 / 8.0; + let v = v0 + (v1 - v0) * j as f64 / 9.0; + let n = normal(u, v).ok_or_else(|| unsupported("the corner patch folds back on itself"))?; + if n.dot(mid) <= 0.0 { + return Err(unsupported("the corner patch folds back on itself with these section curves")); + } + } + } + Ok(()) +} + +/// Build the patch: sections, tensor surface, and the wire of the section curves themselves. +fn build(sides: &[Vec], apex: Vec3) -> Result> { + if !apex.is_finite() { + return Err(KernelError::Invalid("the corner apex must be a finite point".into())); + } + let (frames, starts, size) = corner_loop(sides)?; + let frames = transverse(&frames, &loop_tangents(&frames))?; + // The apex's own plane: the mean direction from the apex to the boundary it caps. + let n_apex = frames + .iter() + .fold(Vec3::ZERO, |a, f| a + (f.p - apex)) + .normalized() + .ok_or_else(|| unsupported("the corner's apex sits on its own boundary curves"))?; + // How hard the patch bends into the apex: as much as its blends do across their seams, and + // never flatter than a dome of the corner's own size (a flat cap would end in a point). + let mean_bend = frames.iter().map(|f| f.bend.len()).sum::() / frames.len().max(1) as f64; + let reach = (frames.iter().map(|f| f.p.dist(apex)).sum::() / frames.len().max(1) as f64).max(size * 1e-3); + let kappa = mean_bend.max(1.0 / (2.0 * reach)); + if !(kappa.is_finite() && kappa > 0.0) { + return Err(unsupported("a corner the patch cannot span")); + } + let layers: Vec<[Vec3; ACROSS + 1]> = frames.iter().map(|f| section(f, apex, n_apex, kappa)).collect::>()?; + let (nurbs, m) = tensor(&layers)?; + let surface = crate::spline_conv::surface(&nurbs)?; + no_fold(&surface, (ALONG as f64, m + ALONG as f64))?; + // The boundary is the loop of section curves; each side is cut out of the patch's own boundary + // curve so that it lies on the patch exactly. + let nu = nurbs.count().first().copied().unwrap_or(0); + let row: Vec = (0..nu).map(|i| nurbs.point(i, 0)).collect(); + let Some(knots_u) = nurbs.knots().first().cloned() else { return Err(fail("knots")) }; + let loop_curve = crate::spline_conv::nurbs_curve(mt::KnotVec::from(knots_u), &row)?; + let mut cuts: Vec = starts.iter().map(|i| *i as f64 + ALONG as f64).collect(); + cuts.push(m + ALONG as f64); + let mut verts: Vec = Vec::with_capacity(sides.len()); + for side in sides { + let first = side.first().ok_or_else(|| fail("a section curve has no stations"))?; + verts.push(builder::vertex(p3(first.p))); + } + let mut edges: Vec = Vec::with_capacity(sides.len()); + for i in 0..sides.len() { + let (Some(&a), Some(&b)) = (cuts.get(i), cuts.get(i + 1)) else { return Err(fail("the corner's seams")) }; + let mut piece = loop_curve.clone(); + let _tail = mt::Cut::cut(&mut piece, b); + let piece = mt::Cut::cut(&mut piece, a); + let (Some(from), Some(to)) = (verts.get(i), verts.get((i + 1) % verts.len())) else { return Err(fail("the corner's vertices")) }; + edges.push(mt::Edge::try_new(from, to, piece).map_err(|e| fail(format!("a corner seam: {e}")))?); + } + let face = mt::Face::try_new(vec![mt::Wire::from(edges)], surface).map_err(|e| fail(format!("the corner patch: {e}")))?; + Ok(vec![face]) +} + +/// What the patch achieves along the seams of `sides`: the largest distance from a seam station to +/// the patch, the largest angle (radians) between the patch's normal there and the blend's, and the +/// largest difference between the curvature across the seam and the blend's own, relative to +/// `reference` — the curvature a rolling-ball corner shows across the same seam, which is what G2 +/// removes. Measured on the built surface alone, by the same finite differences of its own samples +/// that `crate::bridge`'s tests read a cross-section's shape with, so it is evidence about the +/// geometry and not about the construction. +pub fn seam_error(surface: &mt::Surface, sides: &[Vec], reference: f64) -> Result<(f64, f64, f64)> { + use mt::{ParametricSurface, SearchNearestParameter}; + if !(reference.is_finite() && reference > 0.0) { + return Err(KernelError::Invalid("a reference curvature must be finite and positive".into())); + } + let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { + return Err(fail("the corner patch has no parameter domain")); + }; + // Samples across the section for a five-point difference, as `bridge`'s tests use. + const INWARDS: usize = 5; + let hv = (v1 - v0).abs() * 1e-3; + let hu = (u1 - u0).abs() * 1e-3; + if !(hv > 0.0 && hu > 0.0) { + return Err(fail("the corner patch has no extent to measure")); + } + let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, 0.0f64); + for side in sides { + if side.len() < 5 { + return Err(KernelError::Invalid("a seam needs at least five stations to measure its direction".into())); + } + let mut hint: Option<(f64, f64)> = None; + for (i, f) in side.iter().enumerate() { + let Some(t) = seam_tangent(side, i) else { continue }; + let found = surface + .search_nearest_parameter(p3(f.p), hint, 100) + .or_else(|| surface.search_nearest_parameter(p3(f.p), None, 200)) + .ok_or_else(|| fail("a seam station is not on the corner patch"))?; + hint = Some(found); + let (u, v) = found; + let at = from_p3(surface.subs(u, v)); + gap = gap.max(at.dist(f.p)); + // The cross-section, sampled inwards from the seam (or from the other end of the patch, + // with the odd differences turned round). + let out = v <= (v0 + v1) * 0.5; + let step = if out { hv } else { -hv }; + let q: Vec = (0..INWARDS).map(|k| from_p3(surface.subs(u, v + step * k as f64))).collect(); + let (Some(&f0), Some(&f1), Some(&f2), Some(&f3), Some(&f4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else { + return Err(fail("the corner patch has no room for a sample")); + }; + let sign = if out { 1.0 } else { -1.0 }; + let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (sign / (12.0 * hv)); + let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * hv * hv)); + // Along the seam, to stand the tangent plane up. + let du = if u - hu >= u0 && u + hu <= u1 { + (from_p3(surface.subs(u + hu, v)) - from_p3(surface.subs(u - hu, v))) * (0.5 / hu) + } else { + let (a, b) = if u + hu <= u1 { (u, u + hu) } else { (u, u - hu) }; + (from_p3(surface.subs(b, v)) - from_p3(surface.subs(a, v))) * (1.0 / hu) + }; + let n = du.cross(d1).normalized().ok_or_else(|| fail("the corner patch has no normal at a seam"))?; + let blend_n = t.cross(f.leave).normalized().ok_or_else(|| fail("a blend has no normal at a seam"))?; + angle = angle.max(n.dot(blend_n).abs().clamp(0.0, 1.0).acos()); + // The curvature vector of the cross-seam curve: what the blend's own surface does along + // the direction the patch leaves it in. + let speed2 = d1.len2().max(1e-300); + let bend = (d2 - d1 * (d2.dot(d1) / speed2)) * (1.0 / speed2); + curvature = curvature.max((bend - f.bend).len() / reference.max(f.bend.len()).max(1e-30)); + } + } + Ok((gap, angle, curvature)) +} + +#[cfg(test)] +#[path = "corner_g2_tests.rs"] +mod tests; + +/// The G2 corner through the fillet path: a box's three edges at one corner rounded, rolling ball +/// against G2 patch, measured on the built bodies with the continuity analysis. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod fillet_corner_tests { + use super::*; + use crate::{ContinuityTolerances, SeamReport, measure, seam_diagnostics}; + + /// A 20 mm box with its three edges at one corner rounded by 4: the rolling ball fills that + /// corner with a piece of its sphere, the G2 route with a patch on the blends' own sections. + fn cornered(g2: bool) -> crate::Body { + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + if g2 { crate::fillet_g2_corners(&b, &edges, 4.0).unwrap() } else { crate::fillet(&b, &edges, 4.0).unwrap() } + } + + #[test] + fn the_ball_corner_is_measurably_g1_and_a_g2_corner_is_refused_until_it_sews() { + // The rolling ball: tangent to every blend, keeping its own 1/r across each corner seam. + let body = cornered(false); + let volume = measure(&body).unwrap().volume; + assert!(volume > 7000.0 && volume < 8000.0, "the box is still a solid: {volume}"); + let report = seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap(); + // Every seam of a filleted box is tangent; the corner's three are the short ones (the + // blend-to-face seams run the edge's own 12 mm and more). + let tangent: Vec<&SeamReport> = report.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect(); + let corner: Vec<&&SeamReport> = tangent.iter().filter(|s| s.length < 8.0).collect(); + assert_eq!( + corner.len(), + 3, + "three corner seams among {} tangent ones: {:?}", + tangent.len(), + tangent.iter().map(|s| s.length).collect::>() + ); + let worst = corner.iter().map(|s| s.curvature_jump_per_mm.value).fold(0.0f64, f64::max); + eprintln!("rolling ball: corner seams jump {worst:.6} per mm (volume {volume:.3})"); + assert!((worst - 0.25).abs() < 1e-3, "the ball keeps its own 1/r (r = 4) across the seam: {worst}"); + + // The G2 corner patch is built on the blends' own sections, but sewing it into the shell + // is not done yet: the fillet with `corner: g2` refuses with a clear message rather than + // leaving the shell open, and leaves the ball the corner. + let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap(); + let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)]; + let err = crate::fillet_g2_corners(&b, &edges, 4.0).unwrap_err().to_string(); + eprintln!("G2 corner: {err}"); + assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}"); + } +} diff --git a/crates/kernel/src/corner_g2_tests.rs b/crates/kernel/src/corner_g2_tests.rs new file mode 100644 index 00000000..0968522b --- /dev/null +++ b/crates/kernel/src/corner_g2_tests.rs @@ -0,0 +1,154 @@ +//! What the G2 corner patch has to prove on a corner whose answer is known analytically: the box. + +use super::*; +use std::f64::consts::FRAC_PI_2; + +use crate::body::v3; + +/// One seam of a box corner: a quarter circle about `centre` from direction `u` to direction `w`, +/// with `leave` the way the patch leaves the blend's surface. A fillet is flat along its own axis, +/// so its curvature across the seam is nothing. +fn seam(centre: Vec3, u: Vec3, w: Vec3, leave: Vec3, r: f64, n: usize) -> Vec { + (0..n) + .map(|i| { + let theta = FRAC_PI_2 * i as f64 / (n - 1) as f64; + Frame { p: centre + (u * theta.cos() + w * theta.sin()) * r, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// A box corner at the origin (material x, y, z ≥ 0) with three fillets of radius `r`: the three +/// section curves where the fillets stop, meeting at (0,r,r), (r,0,r) and (r,r,0), and the middle of +/// the ball's octant a rolling ball would leave there. +fn box_corner(r: f64, n: usize) -> (Vec>, Vec3, Vec3) { + let c = Vec3::new(r, r, r); + let (a, b, d) = (Vec3::new(0.0, r, r), Vec3::new(r, 0.0, r), Vec3::new(r, r, 0.0)); + let dir = |p: Vec3| (p - c).normalized().unwrap_or(Vec3::X); + let sides = vec![ + // A → B on the z edge's cylinder (flat along z), B → C on the x edge's, C → A on the y + // edge's; each patch leaves the blend towards the material the corner caps. + seam(c, dir(a), dir(b), Vec3::new(0.0, 0.0, -1.0), r, n), + seam(c, dir(b), dir(d), Vec3::new(-1.0, 0.0, 0.0), r, n), + seam(c, dir(d), dir(a), Vec3::new(0.0, -1.0, 0.0), r, n), + ]; + let apex = c - Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z) * r; + (sides, apex, c) +} + +/// The rolling-ball corner the kernel builds today: the sphere through the same three seams, made +/// the way `curvecorner` makes it (a meridian arc swept about the axis through the ball's centre). +fn rolling_ball(r: f64, c: Vec3) -> mt::Surface { + let axis = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z); + let side = axis.any_perp(); + let meridian = builder::circle_arc(&builder::vertex(p3(c + axis * r)), &builder::vertex(p3(c - axis * r)), p3(c + side * r)); + mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(c), v3(axis)))) +} + +/// The stations clear of the corners. Two blends meet at a corner with their seams 90° apart and +/// ask for different curvature at that one point, which no single patch can hold, and the corner's +/// own spline rounds the seam there; both effects fade within a few stations, so the measurement +/// starts clear of them. +fn interior(sides: &[Vec]) -> Vec> { + sides.iter().map(|s| s.iter().skip(4).take(s.len().saturating_sub(8)).copied().collect()).collect() +} + +#[test] +fn a_box_corner_is_curvature_continuous_with_every_blend() { + let r = 2.0; + let (sides, apex, centre) = box_corner(r, 25); + let faces = corner_g2_patch(&sides, apex).unwrap(); + let face = faces.first().expect("a corner patch"); + let surface = face.oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, 1.0 / r).unwrap(); + println!("G2 corner: gap {gap:e}, normal angle {angle:e} rad, curvature {curvature:e} (relative to 1/r)"); + assert!(gap <= r * 1e-5, "the patch is {gap} from the seam"); + // The stated tolerances: the normal angle within 0.01° and the curvature across the seam within + // 1% of the blend's own (here the fillet is flat across the seam, so that is 1% of 1/r). + assert!(angle < 0.01f64.to_radians(), "normal angle {angle} rad"); + assert!(curvature < 1e-2, "curvature across the seam is {curvature} of the blend's"); + + // The same measurement on the rolling-ball corner the kernel builds today: G1 (angle nought) + // but the ball's own 1/r across each seam, which is the whole of the blend's curvature. + let ball = rolling_ball(r, centre); + let (ball_gap, ball_angle, ball_curvature) = seam_error(&ball, &inner, 1.0 / r).unwrap(); + println!("rolling ball: gap {ball_gap:e}, normal angle {ball_angle:e} rad, curvature {ball_curvature:e}"); + assert!(ball_angle < 0.01f64.to_radians(), "the ball is tangent to each blend"); + assert!(ball_curvature > 0.5, "the ball keeps its own curvature across the seam: {ball_curvature}"); + assert!(curvature * 20.0 < ball_curvature, "the G2 patch has to be far closer than the ball"); +} + +#[test] +fn a_corner_meets_three_blends_of_different_shape_apart() { + // Blends that differ along their seams: a fillet of radius 2 next to rounder walls, each asking + // for its own curvature across the seam, at its own direction. + let r = 2.0; + let (mut sides, apex, _) = box_corner(r, 33); + let bends = [0.25, 0.4, 0.6]; + for (i, bend) in bends.iter().enumerate() { + let Some(side) = sides.get_mut(i) else { continue }; + for f in side.iter_mut() { + // Across the seam, perpendicular to both the leave direction and the seam. + f.bend = f.leave.any_perp() * *bend; + } + } + let faces = corner_g2_patch(&sides, apex).unwrap(); + let surface = faces.first().expect("a corner patch").oriented_surface(); + let inner = interior(&sides); + let (gap, angle, curvature) = seam_error(&surface, &inner, bends.iter().copied().fold(0.0, f64::max)).unwrap(); + println!("three different blends: gap {gap:e}, angle {angle:e} rad, curvature {curvature:e}"); + assert!(gap <= r * 1e-6, "{gap}"); + assert!(angle < 0.01f64.to_radians(), "{angle}"); + assert!(curvature < 1e-2, "each blend's own curvature across the seam: {curvature}"); +} + +#[test] +fn a_g2_corner_refuses_what_it_cannot_hold() { + let r = 2.0; + let (sides, apex, _) = box_corner(r, 9); + assert!(corner_g2_patch(&sides, apex).is_ok()); + + // Two blends (the ball's patch still fills that corner), or one. + assert!(corner_g2_patch(&sides[..2], apex).is_err()); + assert!(corner_g2_patch(&sides[..1], apex).is_err()); + assert!(corner_g2_patch(&[], apex).is_err()); + + // Too few stations to interpolate a cubic through, and too many to work with. + let thin: Vec> = sides.iter().map(|s| s[..3].to_vec()).collect(); + assert!(corner_g2_patch(&thin, apex).is_err()); + let fat: Vec> = sides.iter().map(|s| s[..1].iter().cycle().take(MAX_STATIONS + 1).copied().collect()).collect(); + assert!(corner_g2_patch(&fat, apex).is_err()); + + // A vertex the construction cannot hold: the section curves do not meet at their corners. + let mut apart = sides.clone(); + if let Some(first) = apart.first_mut().and_then(|s| s.first_mut()) { + first.p += Vec3::new(0.5, 0.0, 0.0); + } + assert!(corner_g2_patch(&apart, apex).is_err()); + + // A section curve that runs along the seam, so the patch has no direction to leave it in. + let along: Vec> = sides + .iter() + .map(|s| { + s.windows(2) + .map(|w| { + let d = w.first().zip(w.get(1)).map(|(a, b)| (b.p - a.p).normalized().unwrap_or(Vec3::X)).unwrap_or(Vec3::X); + Frame { leave: d, ..w.first().copied().unwrap_or(s[0]) } + }) + .chain(s.last().copied()) + .collect() + }) + .collect(); + assert!(corner_g2_patch(&along, apex).is_err()); + + // Hostile numbers never panic. + let mut nan = sides.clone(); + if let Some(f) = nan.first_mut().and_then(|s| s.get_mut(1)) { + f.p = Vec3::new(f64::NAN, 0.0, 0.0); + } + assert!(corner_g2_patch(&nan, apex).is_err()); + assert!(corner_g2_patch(&sides, Vec3::new(f64::INFINITY, 0.0, 0.0)).is_err()); + // A corner with no room: every station on the apex. + let on_apex: Vec> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect(); + assert!(corner_g2_patch(&on_apex, apex).is_err()); +} diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index 6de2a465..37b606a5 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1,10 +1,17 @@ //! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces -//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball of the -//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its -//! contacts the curves where the blend meets each side. The blend face is a rational B-spline -//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed -//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are -//! shortened to the contacts. +//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball rolls along +//! the edge touching both sides; its centre traces the spine and its contacts the curves where +//! the blend meets each side. The blend face is a rational B-spline through the circular +//! cross-sections (exact arcs at the samples), the side faces are trimmed back to the contact +//! curves, and seams that met the edge (lines, or arcs of an end face) are shortened to the +//! contacts. +//! +//! The ball's size along the chain is one of [`Size`]: one radius all along (the ordinary +//! fillet), a chord width across the blend (each section spans the requested width from contact +//! to contact, so its radius is `chord / (2 sin(ψ/2))` for the `ψ` between the two outward +//! normals where the ball touches), or a radius law along the chain (a [`RadiusSpline`] whose +//! positions are the fraction of the chain's arc length, zero only at an end, where the blend +//! closes to the edge's end point as the straight-edge fillet does). use std::collections::HashMap; @@ -12,6 +19,7 @@ use solidcraft_geom::Vec3; use truck_modeling::{self as mt, builder}; use crate::blend::too_large; +use crate::blend_variable::RadiusSpline; use crate::body::{Solid, from_p3, p3}; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -40,21 +48,60 @@ const TANGENT_RETRIES: usize = 8; const MAX_STEP_STRETCH: f64 = 3.0; /// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last. const KNIFE_STEPS: usize = 40; +/// Corrections of the radius a chord fillet makes to the width it measures across the blend. +const CHORD_STEPS: usize = 12; +/// A section this close to a point is closed: the blend ends at the edge's end point (a radius +/// that has run to zero there). +const CLOSED: f64 = 1e-7; +/// Parameters an edge is sampled at to measure the arc length a radius law is read off. +const LENGTH_STEPS: usize = 64; + +/// The ball's size along the edge chain. +#[derive(Clone, Copy)] +enum Size<'a> { + /// One radius all along. + Constant(f64), + /// The width across the blend, from contact to contact, at every station. + Chord(f64), + /// The radius at the station's arc-length fraction along the chain, 0 at the front and 1 at + /// the back of its edge. + Law(&'a RadiusSpline), +} struct Fit { surface: mt::Surface, contacts: [mt::Curve; 2], /// Parameter range belonging to this edge (the fit includes neighbouring sections). range: (f64, f64), + /// The part of the fit that is the chain's own run, as fractions of its parameter range. + span: (f64, f64), } struct Guide { sections: Vec
, + /// The size law's parameter (the arc-length fraction along the chain) at each section: the + /// radius between the samples is read off the law there, not off the spine's own length. + law: Vec, lengths: Vec, + /// The part of the guide that is the chain itself, as fractions of its length: the sections + /// run on past the ends of the chain (only so that the fit can be cut back there) and the fit + /// is checked for accuracy on the chain alone. + span: (f64, f64), } impl Guide { + /// A guide whose law parameter is the section's own fraction of it (the size is one radius, + /// which needs no law). fn new(sections: Vec
) -> Result { + let den = sections.len().max(2) - 1; + let law = (0..sections.len()).map(|i| i as f64 / den as f64).collect(); + Self::with_law(sections, law) + } + + fn with_law(sections: Vec
, law: Vec) -> Result { + if sections.len() != law.len() { + return Err(unsupported("degenerate rolling-ball spine")); + } let mut lengths = vec![0.0]; let mut total = 0.0; for pair in sections.windows(2) { @@ -65,10 +112,16 @@ impl Guide { if !total.is_finite() || total <= 1e-12 { return Err(unsupported("degenerate rolling-ball spine")); } - Ok(Self { sections, lengths }) + Ok(Self { sections, law, lengths, span: (0.0, 1.0) }) } + /// The centre and tangent at `along` of the spine. fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> { + self.anchor_at(along).map(|(centre, tangent, _)| (centre, tangent)) + } + + /// [`Guide::anchor`] with the size law's parameter at that station. + fn anchor_at(&self, along: f64) -> Option<(Vec3, Vec3, f64)> { if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) { return None; } @@ -81,7 +134,8 @@ impl Guide { return None; } let fraction = (distance - lo) / span; - Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?)) + let law = self.law.get(hi - 1)? * (1.0 - fraction) + self.law.get(hi)? * fraction; + Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?, law)) } } @@ -145,7 +199,7 @@ fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: boo }; let contacts = [contact(0)?, contact(2)?]; let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?; - Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range }) + Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range, span: (0.0, 1.0) }) } /// Compare the fitted strip with a solved circular section between interpolation nodes. @@ -263,7 +317,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2], // Both contacts are cut there: strictly inside the strip's own part. let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6; if !(inside(*t1) && inside(*t2)) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 309)")); } return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs))); } @@ -341,10 +395,10 @@ fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Ed }; let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o)); let v_t = rt.ok_or_else(fail)?; - let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?; + let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces (site 387)"))?; let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6; if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 390)")); } // How far the strip's point (u, v) is outside the end face (negative: inside the body). let outside = |u: f64, v: f64| -> Option { @@ -599,6 +653,93 @@ fn solve_from( None } +/// The section whose two contacts are `chord` apart: the ball's radius is `chord / (2 sin(ψ/2))` +/// for the `ψ` between the two outward normals where it touches, `|q1 − q2| = 2 r sin(ψ/2)`. The +/// normals turn as the ball grows against curved faces, so the width is measured on the solved +/// section and the radius corrected until it matches (the width is proportional to the radius, +/// so the corrections converge quickly). The normals at `p` give the first radius, exact where +/// both faces are planar; `None` where the faces meet too nearly tangentially to span a chord. +#[allow(clippy::too_many_arguments)] +fn solve_chord( + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + chord: f64, + sigma: f64, + cusp: f64, + extra: Option>, + tol: f64, +) -> Option<(Section, f64)> { + let normal = |fs: &[&mt::Face]| fs.iter().filter_map(|f| project(f, p, None)).min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|x| x.1); + let (n1, n2) = (normal(f1)?, normal(f2)?); + let half = (n1.dot(n2).clamp(-1.0, 1.0).acos() / 2.0).sin(); + if !(half > 1e-3) { + return None; + } + let mut r = chord / (2.0 * half); + for _ in 0..CHORD_STEPS { + if !(r.is_finite() && r > 0.0 && r < 1e9) { + return None; + } + let s = solve(f1, f2, p, t, r, sigma, cusp, extra, tol)?; + let width = s.q1.dist(s.q2); + if !width.is_finite() || width <= 0.0 { + return None; + } + if (width - chord).abs() <= (chord * 1e-9).max(tol * 0.5) { + return Some((s, r)); + } + r *= chord / width; + } + None +} + +/// The rolling ball at one station, sized by the blend's law. +struct Sizer<'a> { + size: Size<'a>, + /// The tolerance the section's equations are solved at. + tol: f64, +} + +impl Sizer<'_> { + /// The section at `p` (tangent `t`, the size law's parameter `s`) holding the third + /// condition `extra` when given, with the radius it was solved at. + #[allow(clippy::too_many_arguments)] + fn section( + &self, + f1: &[&mt::Face], + f2: &[&mt::Face], + p: Vec3, + t: Vec3, + s: f64, + sigma: f64, + cusp: f64, + extra: Option>, + ) -> Option<(Section, f64)> { + match self.size { + Size::Constant(r) => solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)), + Size::Chord(chord) => solve_chord(f1, f2, p, t, chord, sigma, cusp, extra, self.tol), + Size::Law(law) => { + let r = law_beyond(law, s)?; + if !(r.is_finite() && (0.0..1e6).contains(&r)) { + return None; + } + solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)) + } + } + } + + /// What a station the ball does not fit at is refused with: a chord too large is about the + /// size asked for, anything else about where the ball is. + fn refusal(&self) -> KernelError { + match self.size { + Size::Chord(_) => too_large("the blend is larger than the neighbouring faces (site 704)"), + _ => unsupported("the ball doesn't fit along the edge"), + } + } +} + /// Whether the strip's curvature jumps where two runs of sections meet (each three sections from /// the joint outward, the joint's first): for the centres or either contact, the difference of /// the circles through them would move the middle section by more than a tenth of `tolerance`. @@ -614,6 +755,12 @@ fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool { .any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1) } +/// Whether the section is a point: a blend whose radius has run to zero, closing to the edge's +/// end point there. +fn closed_section(s: &Section) -> bool { + s.c.dist(s.q1) <= CLOSED && s.c.dist(s.q2) <= CLOSED +} + /// Homogeneous control points of the circular (or straight) cross-section from q1 to q2. fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w); @@ -621,6 +768,11 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> { let m = (s.q1 + s.q2) * 0.5; return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]); } + if closed_section(s) { + // A closed section: the arc has shrunk to the edge's end point, all three controls + // there, so the fitted strips of the neighbouring faces meet at that vertex. + return Some([h(s.c, 1.0), h(s.c, 1.0), h(s.c, 1.0)]); + } let (a, b) = (s.q1 - s.c, s.q2 - s.c); let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos(); if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) { @@ -833,14 +985,14 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option .unwrap_or(end); let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?; if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 938)")); } Ok(t) }; let (ta, tb) = (at(&a, lo)?, at(&b, hi)?); let margin = (hi - lo) * 1e-9; if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 945)")); } if tb < hi { let _ = curve.cut(tb); @@ -851,16 +1003,100 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option, b: Option mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}"))) } +/// A monotone curve's parameter read off a table of its values at uniform parameters (`None` for +/// an empty table): the fraction of the way through it. +fn sampled(table: &[f64], along: f64) -> Option { + if table.len() < 2 || !along.is_finite() { + return None; + } + let x = along.clamp(0.0, 1.0) * (table.len() - 1) as f64; + let i = (x.floor() as usize).min(table.len() - 2); + let f = x - i as f64; + Some(table.get(i)? * (1.0 - f) + table.get(i + 1)? * f) +} + +/// The arc length of `edge` from its front to its back at each of `LENGTH_STEPS + 1` uniform +/// parameters, accumulated. `None` for an edge that has no length (or runs back over itself). +fn edge_lengths(edge: &mt::Edge) -> Option> { + use mt::{BoundedCurve, ParametricCurve}; + let c = edge.oriented_curve(); + let (a, b) = c.range_tuple(); + let mut last = from_p3(c.subs(a)); + let mut total = 0.0; + let mut out = Vec::with_capacity(LENGTH_STEPS + 1); + out.push(0.0); + for i in 1..=LENGTH_STEPS { + let p = from_p3(c.subs(a + (b - a) * i as f64 / LENGTH_STEPS as f64)); + total += last.dist(p); + last = p; + out.push(total); + } + (total.is_finite() && total > 1e-12).then_some(out) +} + +/// The radius a law gives at the chain fraction `s`, continued past 0 and 1 at the law's own end +/// slope. The ball runs on past a chain end (to be cut back there by its end face), and holding +/// the end radius would kink the strip exactly where the edge begins: a kink a cubic fit spreads +/// back over the whole blend. Never below a fifth of the end radius, so that a law running down +/// steeply still has a ball to run on with. +fn law_beyond(law: &RadiusSpline, s: f64) -> Option { + if (0.0..=1.0).contains(&s) { + return Some(law.eval(s)); + } + let (end, step) = if s < 0.0 { (0.0, s) } else { (1.0, s - 1.0) }; + let h = 1e-3; + let slope = (law.eval(end + h) - law.eval(end)) / h; + let r = law.eval(end); + let r = (r + slope * step).max(0.2 * r); + (r.is_finite() && r > 0.0).then_some(r) +} + /// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from /// `solid` (each blend face in place of its chain edge). pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES) } +/// [`curve_blend`] with the blend `chord` wide across, from contact to contact, at every station. +pub(crate) fn curve_blend_chord(solid: &Solid, ids: &[mt::EdgeID], size: f64, chord: f64, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Chord(chord), round, MAX_SAMPLES) +} + +/// [`curve_blend`] with the radius following `law` along the chain (its positions the fraction of +/// the chain's arc length, from the front of its first edge to the back of its last). +pub(crate) fn curve_blend_law(solid: &Solid, ids: &[mt::EdgeID], size: f64, law: &RadiusSpline, round: bool) -> Result<(Solid, Rebuild)> { + curve_blend_sized(solid, ids, size, Size::Law(law), round, MAX_SAMPLES) +} + fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { - if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { + curve_blend_sized(solid, ids, size, Size::Constant(r), round, max_samples) +} + +/// Blend the chain of edges (ids in any order) sized by `size`: one radius, a chord width, or a +/// radius law. With what it built from `solid` (each blend face in place of its chain edge). +fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Size<'_>, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> { + if !(size.is_finite() && size > 0.0) { return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); } + match size_law { + Size::Constant(r) | Size::Chord(r) => { + if !(r.is_finite() && r > 1e-6 && r < 1e6) { + return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into())); + } + } + Size::Law(law) => { + let knots = law.knots.len() == law.radii.len() + law.degree + 1; + if law.radii.len() < 2 || !knots || law.radii.iter().any(|r| !(r.is_finite() && (0.0..1e6).contains(r))) { + return Err(KernelError::Invalid("a radius law along the edge must be finite and non-negative".into())); + } + // A law that runs to zero closes the blend at the edge's end point. The fitted strip + // pinches to a point there, and the sections' own interpolation cannot follow the last + // span of it: the straight-edge fillet, which builds that closure exactly, is the way. + if law.eval(0.0) <= CLOSED || law.eval(1.0) <= CLOSED { + return Err(unsupported("a radius law that runs to zero along a curved edge")); + } + } + } if !(4..=MAX_SAMPLES).contains(&max_samples) { return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into())); } @@ -991,10 +1227,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // below the first (against its outward normal) when the cusp is material, above when it is // a gap. The cross product of the normals, which tells the other edges apart, is zero here. let knife = 1.0 + n1.dot(n2) < KNIFE; + // The one radius, where the size is one radius (a chord or a law has none). + let fixed = match size_law { + Size::Constant(r) => Some(r), + _ => None, + }; + // The blend's scale where the size itself is not what matters (the knife edge's probe): the + // radius asked for, or the law's middle value. + let scale = match size_law { + Size::Constant(r) | Size::Chord(r) => r, + Size::Law(law) => law.eval(0.5).max(1e-6), + }; let (convex, cusp) = if knife { let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?; // Second order: from the point of face 1 a little way along `w`, face 2's nearest point. - let probe = project(f1, pm + w * r, None) + let probe = project(f1, pm + w * scale, None) .and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na))) .filter(|depth| depth.abs() > size * 1e-9) .ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?; @@ -1008,10 +1255,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if n1.dot(n2) > 1.0 - 1e-6 { return Err(unsupported("blending a smooth edge")); } + // The chain's arc length, in fractions of its whole, at each station: where the size is one + // radius it is not read, but the sections take their parameter from it all the same. + let tables: Vec> = chain.iter().map(|e| edge_lengths(e).unwrap_or_else(|| vec![0.0; LENGTH_STEPS + 1])).collect(); + let total: f64 = tables.iter().filter_map(|t| t.last().copied()).sum(); + if !matches!(size_law, Size::Constant(_)) && !(total.is_finite() && total > 1e-12) { + return Err(unsupported("a zero-length edge of the chain")); + } + let total = if total > 1e-12 { total } else { 1.0 }; + let mut cum: Vec = Vec::with_capacity(n + 1); + let mut acc = 0.0; + for table in &tables { + cum.push(acc); + acc += table.last().copied().unwrap_or(0.0); + } + cum.push(acc); + // A law that runs around a closed chain meets itself: its two ends must agree there. + if closed + && let Size::Law(law) = size_law + && (law.eval(0.0) - law.eval(1.0)).abs() > 1e-9 * law.radii.iter().copied().fold(1.0_f64, f64::max) + { + return Err(KernelError::Invalid("a closed edge chain runs back into itself: give the same radius law at both ends".into())); + } + // The law's parameter at `along` of edge `k` (its front to its back). + let frac_at = |k: usize, along: f64| -> f64 { + let (Some(table), Some(lo)) = (tables.get(k), cum.get(k)) else { return if k >= n { 1.0 } else { 0.0 } }; + let s = sampled(table, along).unwrap_or(0.0); + ((lo + s) / total).clamp(0.0, 1.0) + }; + let sizer = Sizer { size: size_law, tol: tol * 100.0 }; // Arcs on a planar side that curve round the ball: its centre runs round them at their // radius less the blend's. Less than nothing is too large; nothing is a pivot. let mut pivots: Vec> = vec![None; n]; - for (k, e) in chain.iter().enumerate() { + 'pivots: for (k, e) in chain.iter().enumerate() { + // A size that varies along the edge has no one radius to compare a corner's arc with. + let Some(r) = fixed else { break 'pivots }; let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue }; for side in [1, 2] { let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue }; @@ -1032,7 +1310,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } if rho < r - tol * 1e3 { - return Err(too_large("the blend is larger than a corner radius")); + return Err(too_large("the blend is larger than a corner radius (site 1257)")); } if (rho - r).abs() > tol * 1e3 { continue; @@ -1145,6 +1423,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?)) }; let mut vsec: Vec
= Vec::with_capacity(nv); + // The radius each vertex section was solved at (the closed end of a law: zero). + let mut vradius: Vec = Vec::with_capacity(nv); for k in 0..nv { let (p, t) = if k < n { tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0) @@ -1167,8 +1447,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } let q = pv.c + (p - pv.o); vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } }); + vradius.push(fixed.unwrap_or_default()); continue; } + // The law's parameter at this vertex: the front of its edge, or the back of the last + // edge at an open chain's end. + let law_s = if k < n { frac_at(k, 0.0) } else { frac_at(n - 1, 1.0) }; if at_end { // A plane square to the edge holds the blend's end section. Any other end face: the // blend runs on past the end and is cut back where it meets that face. @@ -1181,7 +1465,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } _ => false, }; - if !square || split_ends.iter().any(|e| e.0 == k) { + // A radius that has run to zero closes the blend at the vertex itself: it does not run + // on past the end, and the end face keeps the corner it had there. + let closed_end = matches!(size_law, Size::Law(law) if law.eval(law_s) <= CLOSED); + if (!square || split_ends.iter().any(|e| e.0 == k)) && !closed_end { slanted.push((k, fi, p, t, None)); } } @@ -1212,7 +1499,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // longer is (a wall past the round at a corner, which a ball rolling round it pokes // through). let (a1, a2) = edge_sides(&[k.min(n - 1)]); - let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?; + let (s, r) = sizer.section(&a1, &a2, p, t, law_s, sigma, cusp, extra).ok_or_else(|| sizer.refusal())?; // A seam on the other side too must pass through that side's contact. if let Some((side, seam)) = here.get(1).filter(|_| !at_end) { let q = if *side == 1 { s.q1 } else { s.q2 }; @@ -1222,10 +1509,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } vsec.push(s); + vradius.push(r); } // Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near // at the chain's start, near to far at its end), far enough to cross the end face. - let mut beyond: Vec<(usize, Vec
)> = Vec::new(); + let mut beyond: Vec<(usize, Vec
, Vec)> = Vec::new(); for (k, fi, p, t, tangent) in slanted.iter_mut() { let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?; let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?; @@ -1250,12 +1538,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if cos < 0.15 && tangent.is_none() { return Err(unsupported("an end face almost along the edge")); } - let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r; + let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + vradius.get(*k).copied().unwrap_or(scale); let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos }; let out = if *k == 0 { -*t } else { *t }; + // The run on past the end only gives the fit its context (the end face cuts it back), but + // it keeps the law's end slope: the radius is read at the distance past the end. + let law_s = |d: f64| if *k == 0 { -d / total } else { 1.0 + d / total }; // Past a tangent end the straight run along the tangent leaves the curved spine: where // the ball no longer fits, the run is shortened (it only gives the fit its context). let mut list = Vec::with_capacity(EXTENSION); + let mut laws = Vec::with_capacity(EXTENSION); let mut reach = reach; // Past the end of a curved edge the planes square to its end tangent cut the ring of // centres further and further round it, until a step jumps across the ring (#1255: a @@ -1263,26 +1555,33 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, // then, as long as one of its sections still lies wholly past the end face. let ng_out = if ng.dot(out) < 0.0 { -ng } else { ng }; let past = |sec: &Section| [sec.c, sec.q1, sec.q2].iter().all(|x| (*x - *p).dot(ng_out) > tol); - let mut fallback: Option> = None; + // A run shortened when its centres jump across the ring (a knuckle's cap circle, #1255) + // stands if no full-length run works; a size law's own end slope is read at each station's + // distance past the end. + let mut fallback: Option> = None; for attempt in 0..TANGENT_RETRIES { list.clear(); + laws.clear(); let step = reach / EXTENSION as f64; let sections = (1..=EXTENSION).map(|j| { - let anchor = *p + out * (step * j as f64); - solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0) + let d = step * j as f64; + let anchor = *p + out * d; + let s = law_s(d); + sizer.section(&e1, &e2, anchor, *t, s, sigma, cusp, None).map(|(section, _)| (s, section)) }); match sections.collect::>>() { Some(all) => { let smooth = tangent.is_some() - || std::iter::once(at.c).chain(all.iter().map(|s| s.c)).collect::>().windows(2).all(|w| match w { + || std::iter::once(at.c).chain(all.iter().map(|(_, s)| s.c)).collect::>().windows(2).all(|w| match w { [a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step, _ => true, }); if smooth { // A shortened run must still cross the end face; else the first, // full-length run stands. - if fallback.is_none() || all.iter().any(past) { - list = all; + if fallback.is_none() || all.iter().any(|(_, s)| past(s)) { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); } break; } @@ -1294,12 +1593,27 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, reach *= if tangent.is_some() { 0.5 } else { 0.75 }; } if list.is_empty() { - list = fallback.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?; + match fallback { + // The shortened run stands when the ring's step jumped, whatever its size. + Some(all) => { + list = all.iter().map(|(_, s)| *s).collect(); + laws = all.iter().map(|(s, _)| *s).collect(); + } + // A size asked for beyond what the faces around the end hold is refused as such; + // one radius past the end is the rolling ball's own failure to fit. + None => { + return Err(match size_law { + Size::Constant(_) => unsupported("the ball doesn't fit past the end of the edge"), + _ => too_large("the blend is larger than the neighbouring faces (site 1472)"), + }); + } + } } if *k == 0 { list.reverse(); + laws.reverse(); } - beyond.push((*k, list)); + beyond.push((*k, list, laws)); } // Uniform refinement preserves a common spacing across neighbouring patches. Work is // capped: a difficult strip returns an error instead of accepting an inaccurate fit. @@ -1319,12 +1633,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1))) }) .collect::>()?; - let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result
{ + let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<(Section, f64)> { let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?; let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?; let anchor = p + t * (start * (1.0 - along) + end * along); let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge")) + sizer.section(&a1, &a2, anchor, t, frac_at(k, along), sigma, cusp, None).ok_or_else(|| sizer.refusal()) }; let mut samples = SAMPLES.min(max_samples); let (esec, fits) = loop { @@ -1339,18 +1653,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?]; + let mut law = vec![frac_at(k, 0.0)]; + let (mut front_beyond, mut back_beyond) = (0usize, 0usize); for j in 1..samples - 1 { - list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?); + let along = j as f64 / (samples - 1) as f64; + let (section, _) = section_at(k, e, along)?; + list.push(section); + law.push(frac_at(k, along)); } list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?); - for (kv, ext) in &beyond { + law.push(frac_at(k, 1.0)); + for (kv, ext, exts) in &beyond { if *kv == k && k == 0 { list.splice(0..0, ext.iter().copied()); + law.splice(0..0, exts.iter().copied()); + front_beyond = ext.len(); } else if *kv == k + 1 && k + 1 == nv - 1 { list.extend(ext.iter().copied()); + law.extend(exts.iter().copied()); + back_beyond = ext.len(); } } - guides.push(Some(Guide::new(list)?)); + let mut guide = Guide::with_law(list, law)?; + // The chain's own run: the spliced sections at either end are context for the fit + // only (the blend is cut back where it meets the end face there). + let last = guide.lengths.last().copied().unwrap_or(0.0); + let at = |i: usize| if last > 0.0 { guide.lengths.get(i).copied().unwrap_or(0.0) / last } else { 0.0 }; + guide.span = (at(front_beyond), at(guide.sections.len().saturating_sub(1 + back_beyond))); + guides.push(Some(guide)); } // Intersection curves inherit their leader's piecewise parameter speed. Fitting // those parameters creates oscillations even on a geometrically smooth spine. @@ -1364,9 +1694,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if along == 1.0 { return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into())); } - let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; + let (anchor, tangent, law_s) = guide.anchor_at(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?; let (a1, a2) = edge_sides(&[k]); - solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0) + sizer + .section(&a1, &a2, anchor, tangent, law_s, sigma, cusp, None) + .map(|(section, _)| section) .ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine")) }; let esec: Vec> = (0..n) @@ -1391,10 +1723,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, continue; } let h = length(k) / (samples - 1) as f64; - let spaced = |nb: usize, from_end: bool| -> Result>> { + // A neighbour shorter than two of the strip's steps lends none: its own sections are + // spaced far closer (a small round beside a long line), and borrowing them made the + // strip overshoot and fold back on itself past the joint, which its faces' meshes + // then folded with (#1667). The strip ends on the joint's section on its own. + let spaced = |nb: usize, from_end: bool| -> Result> { let l = length(nb); if !(l > 2.0 * h && h > 0.0) { - return Ok(None); + return Ok(Vec::new()); } let mut out = Vec::with_capacity(2); for j in [1.0, 2.0] { @@ -1403,7 +1739,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if from_end { out.reverse(); } - Ok(Some(out)) + Ok(out) }; let (prev, next) = ((k + n - 1) % n, (k + 1) % n); let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?; @@ -1416,22 +1752,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, _ => false, }; let before = match (closed || k > 0) && rolls(prev) { - true => spaced(prev, true)? - .map(|b| if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() }), - false => Some(Vec::new()), + true => { + let b = spaced(prev, true)?; + if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() } + } + false => Vec::new(), }; let after = match (closed || k + 1 < n) && rolls(next) { - true => spaced(next, false)?.map(|a| { + true => { + let a = spaced(next, false)?; let m = list.len(); let tail = [list.last(), m.checked_sub(2).and_then(|i| list.get(i)), m.checked_sub(3).and_then(|i| list.get(i))]; if smooth(tail, [list.last(), a.first(), a.get(1)]) { a } else { Vec::new() } - }), - false => Some(Vec::new()), - }; - let fit = match (before, after) { - (Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?, - _ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?, + } + false => Vec::new(), }; + let mut fit = crate::guard("fit curve blend", || fit_strip(list, &before, &after, round))?; + fit.span = guides.get(k).and_then(Option::as_ref).map(|g| g.span).unwrap_or((0.0, 1.0)); fits.push(Some(fit)); } let mut accurate = true; @@ -1440,6 +1777,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, for j in 0..samples - 1 { for fraction in [0.25, 0.5, 0.75] { let along = (j as f64 + fraction) / (samples - 1) as f64; + // A size that varies along the edge reaches past its ends only for the fit's + // context (the end face cuts that away), so it is not held to the tolerance + // there. One radius keeps the check it always had, the whole strip. + if !matches!(size_law, Size::Constant(_)) + && fits.get(k).and_then(Option::as_ref).is_some_and(|f| along < f.span.0 || along > f.span.1) + { + continue; + } let section = uniform_section(k, along)?; if !crate::guard("check curve blend fit", || Ok(section_fits(fit, §ion, along, round, sigma, fit_tolerance)))? { accurate = false; @@ -1515,7 +1860,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, && n == 1 && !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1668)")); } crate::guard("curve blend", || { let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}")); @@ -1530,6 +1875,18 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, *b = builder::vertex(curve.subs(hi)); } } + // A closed section (a radius that has run to zero) has one vertex for both contacts, so + // the blend face's wire and the side faces' meet there. + for (k, s) in vsec.iter().enumerate() { + if !closed_section(s) { + continue; + } + if let Some(v) = v1.get(k).cloned() + && let Some(slot) = v2.get_mut(k) + { + *slot = v; + } + } // Along a pivot the planar side's contact is one point: one vertex at both its ends. for (k, pv) in pivots.iter().enumerate() { let Some(pv) = pv else { continue }; @@ -1557,18 +1914,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } } } - let mut arcs = Vec::with_capacity(nv); + // The section's own radius, where the blend is sized by a law or a chord width (`r` is the + // one radius of a constant blend, exactly as before). + let rad = |s: &Section| match size_law { + Size::Constant(r) => r, + Size::Chord(_) | Size::Law(_) => s.c.dist(s.q1), + }; + let mut arcs: Vec> = Vec::with_capacity(nv); let mut split_arcs: HashMap> = HashMap::new(); for (k, s) in vsec.iter().enumerate() { let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); - let e = if round { - let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r; - builder::circle_arc(a, b, p3(m)) + // A closed section has no arc to run between the contacts: the blend meets itself + // there, at the edge's end point. + let e = if closed_section(s) { + None + } else if round { + let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * rad(s); + Some(builder::circle_arc(a, b, p3(m))) } else { - builder::line(a, b) + Some(builder::line(a, b)) }; let e = match caps.iter().find(|c| c.0 == k) { - Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?, + Some((_, _, _, _, curve)) => Some(mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?), None => e, }; if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) { @@ -1581,7 +1948,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result { use mt::{ParametricSurface3D, SearchNearestParameter}; let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?; - let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r; + let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * rad(s); if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) { let nn = surface.normal(u, v); let want = (s.c - mid) * sigma; @@ -1688,7 +2055,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .filter(|f| f.edge_iter().any(|e| e.id() == id)) .any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol)); if !fits || !crate::blend::on_extension(&seam, q, on_tol) { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1853)")); } } seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?); @@ -1708,7 +2075,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0)); let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0)); if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 { - return Err(too_large("the blend is larger than the neighbouring faces")); + return Err(too_large("the blend is larger than the neighbouring faces (site 1873)")); } let a = a.unwrap_or_else(|| seam.front().clone()); let b = b.unwrap_or_else(|| seam.back().clone()); @@ -1723,7 +2090,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, if corners.iter().any(|k| split_arcs.contains_key(k)) { let connectors: Vec = corners .iter() - .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect())) + .flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().flatten().into_iter().collect())) .collect(); let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?; rec.face(&rebuilt, f); @@ -1754,8 +2121,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, es.push(ne.clone()); } if let Some(k) = arrives { - let arc = arcs.get(k).ok_or_else(|| fail("arc"))?; - let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?); + // A closed section has no blend arc at the corner: the blend ends at + // the vertex itself and the end face keeps the corner it had. + let (Some(arc), Some(a1), Some(a2)) = (arcs.get(k).and_then(Option::as_ref), v1.get(k), v2.get(k)) else { + continue; + }; let to = if used_up { e.front() } else { ne.back() }; let corner = if to == a1 { arc.clone() @@ -1810,9 +2180,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, } // Blend faces: each runs its side-1 contact the other way from side 1's face. for (k, bf) in blends.iter().enumerate() { - let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % nv)) else { + let (Some(e1), Some(e2)) = (c1.get(k), c2.get(k)) else { return Err(fail("blend")); }; + let ak = arcs.get(k).and_then(Option::as_ref); + let ak1 = arcs.get((k + 1) % nv).and_then(Option::as_ref); let f1i = side1.get(k).ok_or_else(|| fail("side"))?; let f1 = faces.get(*f1i).ok_or_else(|| fail("side"))?; let chain_e = chain.get(k).ok_or_else(|| fail("edge"))?; @@ -1824,20 +2196,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, .map(|e| e.front() == chain_e.front()) .ok_or_else(|| fail("edge use"))?; // Side 1 runs the contact forward (as the chain) when it ran the edge forward. - // A pivot's planar contact is a point: three sides. - let edges = if used_fwd { - [e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())] + // A pivot's planar contact is a point: three sides. A closed section has no arc: two. + let edges: Vec = if used_fwd { + [e1.as_ref().map(|e| e.inverse()), ak.cloned(), e2.clone(), ak1.map(|e| e.inverse())] } else { - [e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())] + [e1.clone(), ak1.cloned(), e2.as_ref().map(|e| e.inverse()), ak.map(|e| e.inverse())] } .into_iter() .flatten() - .collect::>(); + .collect(); + // An arc the chamfer crossed is two lines through the crossing: the blend meets the + // shortened seam there. let wire: mt::Wire = edges .into_iter() .flat_map(|e| { let split = [(k, ak), ((k + 1) % nv, ak1)] .into_iter() + .filter_map(|(i, arc)| arc.map(|arc| (i, arc.clone()))) .find(|(_, arc)| arc.id() == e.id()) .and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts))); match split { @@ -1861,8 +2236,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, #[path = "curvecorner.rs"] mod corner; +pub(crate) use corner::corner_blend_style; +// The planar corner builder the G2 corner work left in place of `corner_blend_style`'s sphere. +#[allow(unused_imports)] pub(crate) use corner::corner_blend; #[cfg(test)] #[path = "curveblend_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "curveblend_law_tests.rs"] +mod law_tests; diff --git a/crates/kernel/src/curveblend_law_tests.rs b/crates/kernel/src/curveblend_law_tests.rs new file mode 100644 index 00000000..f9d5195d --- /dev/null +++ b/crates/kernel/src/curveblend_law_tests.rs @@ -0,0 +1,315 @@ +//! Chord-width and radius-law blends along curved edges: the width across the blend at every +//! station, and the radius along the edge, measured on the body the kernel built against values +//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis). + +use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; +use solidcraft_geom::Vec3; + +use super::*; +use crate::blend::vtx; +use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline}; +use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export}; + +/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on +/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces +/// run from 63° to 117° apart around it. +fn oblique_boss() -> (Body, Vec3, Vec3) { + let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap(); + let base = Vec3::new(0.0, 0.0, -10.0); + let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap(); + let rod = cylinder(base, axis, 8.0, 30.0).unwrap(); + (boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis) +} + +/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a +/// cylinder away from the front (the wall curves into the part), otherwise the part is the box +/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base +/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it). +fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) { + let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap(); + let (axis, op, side) = + if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) }; + let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap(); + let body = boolean(&block, &tool, op).unwrap().unwrap(); + (body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side) +} + +/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces +/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves). +fn blend_face(input: &Body, body: &Body) -> Option { + use mt::ParametricSurface; + let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6)); + body.solid + .face_iter() + .filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_))) + .find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5)))) + .cloned() +} + +/// The edges of the blend face's boundary, each once, with the point at the middle of it (the +/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face). +fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> { + let Some(f) = blend_face(input, body) else { return Vec::new() }; + let mut out: Vec<(mt::Edge, Vec3)> = Vec::new(); + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + if out.iter().any(|(x, _)| x.id() == e.id()) { + continue; + } + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5)))); + } + } + out +} + +/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out. +fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec { + blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect() +} + +/// The fraction of the way along `edge` (its absolute front to its back) of the point of it +/// nearest `q`. +fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option { + let e = edge.absolute_clone(); + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = vec![from_p3(c.subs(lo))]; + let mut run = vec![0.0]; + for i in 1..=steps { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64)); + run.push(run.last().copied()? + at.last().copied()?.dist(p)); + at.push(p); + } + let total = *run.last()?; + let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY); + let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0); + let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?; + let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64); + let (k, f) = (i.floor() as usize, i.fract()); + let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None }; + Some((a + (b - a) * f) / total) +} + +/// The azimuth of `p` about the Z axis through `axis`. +fn azimuth(axis: Vec3, p: Vec3) -> f64 { + (p.y - axis.y).atan2(p.x - axis.x) +} + +/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`. +fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option { + let off = |p: Vec3| { + let mut d = azimuth(axis, p) - phi; + while d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + while d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } + d + }; + let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY); + for e in edges { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + let steps = 512; + let mut at = lo; + let mut dist = f64::INFINITY; + for i in 0..=steps { + let t = lo + (hi - lo) * i as f64 / steps as f64; + let d = off(from_p3(c.subs(t))).abs(); + if d < dist { + (at, dist) = (t, d); + } + } + let step = (hi - lo) / steps as f64; + let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi)); + let g = 0.618_033_988_749_894_9; + for _ in 0..80 { + let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0)); + if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() { + x1 = b; + } else { + x0 = a; + } + } + let t = 0.5 * (x0 + x1); + let d = off(from_p3(c.subs(t))).abs(); + if d < best_d { + (best, best_d) = (from_p3(c.subs(t)), d); + } + } + (best_d < 1e-6).then_some(best) +} + +/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from +/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean +/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip +/// back there is only placed for one radius, so a width that varies along the edge is refused +/// rather than approximated (the fixed radius of `fillet` along the same edge rolls). +#[test] +fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() { + let (body, _, _) = oblique_boss(); + let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0); + // A fixed radius rolls along the same chain: the edge itself is supported. + assert!(fillet(&body, &[pick], 2.5).is_ok()); + let e = fillet_chord(&body, &[pick], 4.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); +} + +/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend +/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant +/// fillet of that radius) and its two contacts are that far apart at every station. +#[test] +fn chord_fillet_along_an_arc_edge() { + let (chord, r) = (3.0, 3.0 / 2f64.sqrt()); + let (body, mid, axis, _) = arc_walled_box(false); + let blended = fillet_chord(&body, &[mid], chord).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let plain = fillet(&body, &[mid], r).unwrap(); + let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume); + assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}"); + // The contact on the plane and the one on the wall at the same azimuth: the width across. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6); + assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's"); + let mid_azimuth = azimuth(axis, mid); + let mut worst: f64 = 0.0f64; + let mut stations = 0; + for i in 1..12 { + let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9; + let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue }; + stations += 1; + worst = worst.max((p.dist(w) - chord).abs()); + } + eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations"); + assert!(stations >= 9, "only {stations} stations measured"); + assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}"); +} + +/// A chord fillet on a straight edge between planar faces is still the exact local round: the +/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is +/// r = c / √2). +#[test] +fn chord_fillet_on_a_straight_edge_is_unchanged() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0); + let f = fillet_chord(&b, &[at], chord).unwrap(); + let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap(); + assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap()); + assert_eq!(f.unmeshed_faces(), 0); +} + +/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction +/// of the edge's length rolls along it, and the setback on the planar face — the plane and the +/// wall meet at a right angle, so it is the section's radius — follows the law from end to end. +#[test] +fn variable_radius_along_an_arc_edge_follows_the_law() { + let (body, mid, axis, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + // Started from the edge's back end: the law runs the other way, as the positions are from the + // end nearest `start`. + let start = vtx(edge.absolute_back()); + // A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip: + // the sections' own last spans cannot be held to the fit's accuracy there. That is refused, + // not approximated. + for wide in [4.0, 5.0] { + let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err(); + assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}"); + } + let (r0, r1) = (2.0, 3.4); + let blended = fillet_variable(&body, mid, start, r0, r1).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + assert_eq!(blended.unmeshed_faces(), 0); + let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false); + // The law runs from the end nearest `start`: the chain's front here is the far end. + let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() }; + // `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1 + // at the front. + assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12); + // The blend's contact on the bottom plane is at 40 − r from the axis. + let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6); + assert!(!plane.is_empty(), "no contact on the bottom plane"); + let mut worst: f64 = 0.0; + let mut stations = 0; + let mut shown = Vec::new(); + for e in &plane { + let c = e.oriented_curve(); + let (lo, hi) = c.range_tuple(); + for i in 0..=32 { + let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0)); + let measured = 40.0 - (p - axis).len(); + let along = along_fraction(&edge, p).unwrap(); + // Leave the two ends, where the caps cut the contact: the blend closes on the end + // faces there. + if !(0.04..=0.96).contains(&along) { + continue; + } + stations += 1; + if shown.len() < 3 || (0.45..0.55).contains(&along) { + shown.push((along, measured, want.eval(along))); + } + worst = worst.max((measured - want.eval(along)).abs()); + } + } + eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}"); + assert!(stations >= 24, "only {stations} stations measured"); + assert!(worst < 0.02, "the section radius is {worst} mm off the law"); + // Wider, still inside what the strip holds: the same body as the exact straight-edge case of + // that law is not built here, but the radius is the law's at every station measured. +} + +/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would +/// close to the edge's end point, but the fitted strip pinches to a point there and the last span +/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is +/// the way (see `variable_radius_down_to_zero_at_one_end`). +#[test] +fn a_law_running_to_zero_along_a_curved_edge_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + let edge = crate::surfaces::edge_near(&body, mid).unwrap(); + for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] { + let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err(); + assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}"); + let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] }; + assert!(fillet_variable_spline(&body, mid, mid, &law).is_err()); + // A law that stays clear of zero along it rolls: the blend is closed, a three-sided face + // where nothing closes it. + let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap(); + assert!(made.validity().is_empty(), "{:?}", made.validity()); + let mesh = made.tessellate_with(5e-4, true).unwrap(); + let about = |o: Vec3| { + mesh.triangles + .iter() + .map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0) + .sum::() + }; + assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed"); + } +} + +/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size, +/// never a silent approximation and never a panic. +#[test] +fn a_chord_or_radius_the_faces_cannot_hold_is_refused() { + let (body, mid, _, _) = arc_walled_box(false); + // A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block). + let e = fillet_chord(&body, &[mid], 30.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok()); + // A radius law too large for the wall. + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err()); + assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err()); + // Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched. + let bad = |radii: Vec| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii }; + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err()); + assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err()); + assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err()); + // An edge the body does not have is refused by name, not approximated. + assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err()); +} diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index cf88c611..12fc36b1 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -693,3 +693,39 @@ fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() { let got = volume(&body) - volume(&made); assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}"); } + +/// #1667: an R1 round around the end of a long bar whose four long edges are R5. The chain is +/// 290 mm lines and quarter turns of 7.85 mm, so the lines' sections are spaced far wider than +/// the turns'; borrowing the turns' sections for the lines' ends made their strips overshoot and +/// fold back past the joints, the strips' meshes folded with them, and the measured volume came +/// out thousands of mm³ off. The R1 pipe is exact: the corner's section, `1 - π/4`, along the +/// lines, and round each turn's axis at the section's centroid (Pappus). +#[test] +fn round_along_long_lines_and_small_turns_measures_its_volume() { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + let (w, d, h) = (300.0, 60.0, 40.0); + let bar = crate::build::box_solid(Vec3::new(-w / 2.0, 0.0, 0.0), Vec3::new(w / 2.0, d, h)).unwrap(); + let long = [Vec3::new(-w / 2.0, d / 2.0, 0.0), Vec3::new(w / 2.0, d / 2.0, 0.0), Vec3::new(-w / 2.0, d / 2.0, h), Vec3::new(w / 2.0, d / 2.0, h)]; + let rounded = fillet(&bar, &long, 5.0).unwrap(); + // The rolling ball along the end face's loop (the exact blends would take this loop; a + // loop next to free-form faces, as in the issue, comes here). + let ids: Vec = crate::body::Body::unique_edges(&rounded.solid) + .into_iter() + .filter(|e| [e.front(), e.back()].iter().all(|v| crate::blend::vtx(v).y.abs() < 1e-9)) + .map(|e| e.id()) + .collect(); + assert_eq!(ids.len(), 8); + let (solid, _) = crate::guard("test", || curve_blend(&rounded.solid, &ids, rounded.size(), 1.0, true)).unwrap(); + let body = Body::new(solid).unwrap(); + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let section = 1.0 - FRAC_PI_4; + // The section's centroid lies this far from its corner along each side. + let inset = (5.0 / 6.0 - FRAC_PI_4) / section; + let lines = 2.0 * (w - 10.0) + 2.0 * (h - 10.0); + let turns = 4.0 * FRAC_PI_2 * (5.0 - inset); + let want = w * d * h - 4.0 * 25.0 * section * d - section * (lines + turns); + let m = crate::measure(&body).unwrap(); + assert!((m.volume - want).abs() < 1e-5 * want, "{} vs {want}", m.volume); + // Centred across the bar, as the part is. + assert!(m.centroid.x.abs() < 1e-3, "centre of mass x {}", m.centroid.x); +} diff --git a/crates/kernel/src/curvecorner.rs b/crates/kernel/src/curvecorner.rs index 46b943d0..affabdaa 100644 --- a/crates/kernel/src/curvecorner.rs +++ b/crates/kernel/src/curvecorner.rs @@ -25,6 +25,7 @@ use super::{ }; use crate::blend::{too_large, vtx}; use crate::body::{Solid, from_p3, p3, v3}; +use crate::bridge::Frame; use crate::record::Rebuild; use crate::{KernelError, Result}; @@ -683,9 +684,65 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec)], Err(fail("a boundary does not close")) } +/// The strip's own station nearest the corner ball `c`: the section where the strip stops against +/// that ball, where its direction of leave and its curvature are read. +fn corner_station(strip: &Strip, c: Vec3) -> Option { + strip.sections.iter().min_by(|a, b| a.1.c.dist(c).total_cmp(&b.1.c.dist(c))).map(|x| x.0) +} + +/// One side of a G2 corner: the corner ball's own section arc of radius `r` about `c`, from its +/// contact `q_from` to its contact `q_to`, each station carrying the direction the corner patch +/// leaves the blend in (towards the corner's apex) and the blend's own curvature across it, read +/// from `strip` where the ball stops against it. A rolling ball's section is a circular arc about +/// its own centre wherever the edge runs, so this arc is the blend's section curve itself, and its +/// ends are the ball's contacts exactly — the sides close the loop whatever the fit's station +/// spacing is. +fn g2_side(strip: &Strip, c: Vec3, r: f64, q_from: Vec3, q_to: Vec3, apex: Vec3) -> Result> { + use mt::ParametricSurface; + /// Stations across one section: enough for the patch's cubic cross-sections, few enough to + /// keep the corner's own patch small. + const STATIONS: usize = 9; + let vec_of = |d: mt::Vector3| Vec3::new(d.x, d.y, d.z); + let v = corner_station(strip, c).ok_or_else(|| fail("corner section"))?; + let a = (q_from - c).normalized().ok_or_else(|| fail("corner contact"))?; + let b = (q_to - c).normalized().ok_or_else(|| fail("corner contact"))?; + let dot = a.dot(b).clamp(-1.0, 1.0); + let w = (b - a * dot).normalized().ok_or_else(|| fail("corner contact"))?; + let theta = dot.acos(); + // The blend's own direction of leave and curvature across the seam, at its corner station. + let speed = vec_of(strip.surface.vder(0.5, v)).len(); + if !(speed > 1e-30) { + return Err(unsupported("a corner section of no width")); + } + let leave = vec_of(strip.surface.vder(0.5, v)) * (1.0 / speed); + let bend = vec_of(strip.surface.vvder(0.5, v)) * (1.0 / (speed * speed)); + if !(leave.is_finite() && bend.is_finite() && theta.is_finite() && r.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + let mut out = Vec::with_capacity(STATIONS); + for k in 0..STATIONS { + let t = theta * k as f64 / (STATIONS - 1) as f64; + let p = c + (a * t.cos() + w * t.sin()) * r; + // The patch leaves the blend towards the material the corner caps, so the sign is the one + // towards the corner's apex (the blends' own v direction runs away from the vertex). + let leave = if leave.dot(apex - p) < 0.0 { -leave } else { leave }; + if !(p.is_finite() && leave.is_finite() && bend.is_finite()) { + return Err(unsupported("a corner section that is not finite")); + } + out.push(Frame { p, leave, bend, bend_change: Vec3::ZERO }); + } + Ok(out) +} + /// Blend the edges `ids` of `solid` with radius `r` (a chamfer's distance), rebuilding the /// corners at their ends; with what it built from `solid`. pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> { + corner_blend_style(solid, ids, size, r, round, false) +} + +/// [`corner_blend`]; with `g2`, the corners where three blends meet are G2 patches +/// ([`crate::corner_g2`]) instead of pieces of the rolling ball's sphere. +pub(crate) fn corner_blend_style(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, g2: bool) -> Result<(Solid, Rebuild)> { if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) { return Err(KernelError::Invalid("blend size and radius must be finite and positive".into())); } @@ -1006,6 +1063,33 @@ pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, let out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z); let side = out.any_perp(); let (np, sp, back) = (c + side * r, c - side * r, c - out * r); + if g2 { + // A G2 patch on the blends' own end sections, gathered at the ball's pole + // (the point the sphere's meridians are built through above). Each side is + // the strip's section through the ball's contact with the face it shares + // with the previous strip; the strips run on past their edges, so that + // station is found by projecting the contact point, not taken from the + // edge's own end. + let s2 = strip_of(e2)?; + let s3 = strip_of(e3)?; + // The cap's own pole: the ball's point nearest the corner (the sphere + // patch's `back` above is its opposite pole, where the meridian starts). + let apex = c + out * r; + // Side 1 runs from the ball's contact with the face it shares with e3 + // round to the one it shares with e2, and so on round the loop: each + // side's last station is the next side's first, at that contact. + let sides = [g2_side(s1, c, r, q31, q12, apex)?, g2_side(s2, c, r, q12, q23, apex)?, g2_side(s3, c, r, q23, q31, apex)?]; + let _patch = crate::corner_g2::corner_g2_patch(&sides, apex)?; + // Sewing the patch into the shell is not done yet: it brings its own + // boundary vertices where the strips' contacts end at the ball's contacts + // (`v12`/`v23`/`v31`), and a truck vertex is its allocation, not its point, + // so the rebuilt shell does not close there (rewrapping the section curves + // onto the contacts is the missing step). Refused rather than left open: + // the rolling ball fills the corner instead. + return Err(unsupported( + "a G2 corner sewn between these blends (the patch's section curves do not join the blends' contacts yet)", + )); + } let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back)); let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution( meridian.oriented_curve(), diff --git a/crates/kernel/src/curvecorner_tests.rs b/crates/kernel/src/curvecorner_tests.rs index 7773a2b9..3153d107 100644 --- a/crates/kernel/src/curvecorner_tests.rs +++ b/crates/kernel/src/curvecorner_tests.rs @@ -244,10 +244,9 @@ fn a_round_ending_in_an_earlier_round_matches_both_together() { } } -/// The back edge rounded first, then the arc's top edge: the arc's round leaves 0.05 mm³ more at -/// the knife-edge tip than both together, or the other order. +/// The back edge rounded first, then the arc's top edge: the same solid as both together, or the +/// other order (the arc's round once left 0.05 mm³ more at the knife-edge tip). #[test] -#[ignore = "not yet: the arc's round after the back edge's leaves 0.05 mm³ more at the tip"] fn the_arc_rounded_after_the_back_edge_matches_both_together() { one_then_the_other(BACK, arc_top()); } diff --git a/crates/kernel/src/distance.rs b/crates/kernel/src/distance.rs new file mode 100644 index 00000000..29cd5920 --- /dev/null +++ b/crates/kernel/src/distance.rs @@ -0,0 +1,312 @@ +//! The minimum distance between two bodies, on their exact faces and edges. +//! +//! The display meshes find where the bodies come nearest and prune pairs that are far apart: +//! the meshes lie within their chord tolerance of the faces, so a mesh distance bounds the exact +//! one from both sides. The answer itself comes from the exact geometry. Near the nearest mesh +//! points, each body offers the face there and the edges of that face that pass close by; the +//! points are moved in turn to the nearest point of one feature from the other (alternating +//! projection) until they settle, on analytic surfaces in closed form and on other surfaces and +//! curves through the kernel's point inversion. A point that settles off its face (past the +//! face's boundary) is dropped: the face's edges, offered too, then carry the minimum. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use solidcraft_geom::mesh_distance::{MeshClosest, TriangleSet}; +use solidcraft_geom::{Aabb3, Mesh, Vec3}; +use truck_modeling as mt; + +use crate::body::{Body, from_p3, p3}; +use crate::surface_kind::{FaceSurface, surface_of}; +use crate::{Result, guard}; + +/// The minimum distance between two bodies and the points where it is reached. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BodyDistance { + pub distance: f64, + /// On the first body. + pub from: Vec3, + /// On the second body. + pub to: Vec3, + /// Measured on the exact faces and edges (false: on the meshes, for a mesh body or where the + /// exact geometry gave no answer). + pub exact: bool, +} + +/// A body made ready for distance queries: its mesh's triangles under a hierarchy, and its faces +/// and edges as they are first needed. Share one between the queries that involve its body. +pub struct DistanceShape<'a> { + body: &'a Body, + mesh: &'a Mesh, + set: TriangleSet, + chord: f64, + faces: Vec, + edges: Vec, + surfaces: Vec>>, + face_tris: OnceLock>>, +} + +/// Most face pairs near the nearest mesh points whose exact distance is worked out. +const MAX_FACE_PAIRS: usize = 24; +/// Most projection rounds for one pair of features. +const MAX_ROUNDS: usize = 400; + +impl<'a> DistanceShape<'a> { + /// `mesh` is `body`'s mesh, within `chord` (mm) of its faces. + pub fn new(body: &'a Body, mesh: &'a Mesh, chord: f64) -> DistanceShape<'a> { + let exact = body.mesh.is_none(); + let faces: Vec = if exact { body.solid.face_iter().cloned().collect() } else { Vec::new() }; + let edges = if exact { Body::unique_edges(&body.solid) } else { Vec::new() }; + let surfaces = faces.iter().map(|_| OnceLock::new()).collect(); + let chord = if chord.is_finite() && chord > 0.0 { chord } else { 0.0 }; + DistanceShape { body, mesh, set: TriangleSet::new(mesh), chord, faces, edges, surfaces, face_tris: OnceLock::new() } + } + + /// The box around the body: its mesh's, grown by the chord tolerance. + pub fn bounds(&self) -> Aabb3 { + let b = self.set.bounds(); + if b.is_empty() { + return b; + } + let g = Vec3::new(1.0, 1.0, 1.0) * self.chord; + Aabb3 { min: b.min - g, max: b.max + g } + } + + /// How far the mesh may lie from the faces (mm). + pub fn chord(&self) -> f64 { + self.chord + } + + /// The nearest points of the two meshes, if they come within `limit`. + pub fn mesh_closest(&self, other: &DistanceShape, limit: f64) -> Option { + self.set.closest(&other.set, limit) + } + + fn surface(&self, face: usize) -> Option { + *self.surfaces.get(face)?.get_or_init(|| self.faces.get(face).and_then(|f| surface_of(f, self.body.size()))) + } + + fn face_triangles(&self, face: u32) -> &[u32] { + let map = self.face_tris.get_or_init(|| { + let mut m: HashMap> = HashMap::new(); + for t in 0..self.set.len() as u32 { + if let Some(f) = self.set.face(t) { + m.entry(f).or_default().push(t); + } + } + m + }); + map.get(&face).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Does `p`, a point on face `face`'s surface, lie on the face itself? Its mesh rules out + /// points farther than twice the chord. Nearer the face's boundary than that, the exact edge + /// nearest `p` decides: `p` must lie on the side of it where the face's mesh is. + fn on_face(&self, face: u32, p: Vec3) -> bool { + let tris = self.face_triangles(face); + let tiny = 1e-9 * self.body.size().max(1.0); + let reach = 2.0 * self.chord + tiny; + if self.set.distance_to(p, tris) > reach { + return false; + } + // The exact edges of the face near `p`: their nearest points and directions there. + let mut near: Vec<(f64, Vec3, Vec3)> = Vec::new(); + for e in self.mesh.face_edges(face) { + let Some(line) = self.mesh.edges.get(e) else { continue }; + let rough = match line.as_slice() { + [one] => one.dist(p), + _ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), + }; + if rough > 2.0 * reach { + continue; + } + if let Some((at, dir)) = self.edge_nearest(e, p, &mut Hint::default()) { + near.push((at.dist(p), at, dir)); + } + } + let Some(least) = near.iter().map(|n| n.0).reduce(f64::min) else { return true }; + if least <= tiny { + return true; + } + // The nearest edge (any of them, where several meet at a vertex) with the face's mesh on + // `p`'s side. An edge with the face on both sides (a seam inside it) says nothing. + let mut decided = false; + for &(_, at, dir) in near.iter().filter(|n| n.0 <= least + tiny) { + let Some(t) = dir.normalized() else { continue }; + let across = |x: Vec3| { + let v = x - at; + v - t * v.dot(t) + }; + let sides: Vec = tris + .iter() + .filter_map(|&k| self.set.triangle(k)) + .filter(|[a, b, c]| solidcraft_geom::compare::closest_on_triangle(at, *a, *b, *c).dist(at) <= reach) + .map(|[a, b, c]| across((a + b + c) / 3.0)) + .filter(|w| w.len() > tiny) + .collect(); + let Some(inward) = sides.iter().copied().max_by(|x, y| x.len().total_cmp(&y.len())) else { continue }; + if sides.iter().any(|w| w.dot(inward) < 0.0) { + continue; + } + // `p` straight along the edge (past its end, on the line through it) says nothing + // about which side it is on: another edge meeting there decides. + let off = across(p); + if off.len() <= tiny { + continue; + } + decided = true; + if off.dot(inward) >= 0.0 { + return true; + } + } + !decided + } + + /// The features near mesh point `p` on face `face`: the face, and its edges within twice the chord. + fn features(&self, face: u32, p: Vec3) -> Vec { + let mut out = vec![Feature::Face(face as usize)]; + let reach = 2.0 * self.chord + 1e-9 * self.body.size().max(1.0); + for e in self.mesh.face_edges(face) { + let Some(line) = self.mesh.edges.get(e) else { continue }; + let near = match line.as_slice() { + [one] => one.dist(p), + _ => line.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), + }; + if near <= reach && e < self.edges.len() { + out.push(Feature::Edge(e)); + } + } + out + } + + /// The point of `f` nearest `p`, with the parameter hint for the next call. + fn project(&self, f: Feature, p: Vec3, hint: &mut Hint) -> Option { + use mt::{ParametricSurface, SearchNearestParameter}; + match f { + Feature::Face(i) => { + if let Some(q) = self.surface(i).and_then(|s| s.nearest(p)) { + return Some(q); + } + let s = self.faces.get(i)?.oriented_surface(); + let uv = s.search_nearest_parameter(p3(p), hint.uv, 100).or_else(|| s.search_nearest_parameter(p3(p), None, 100))?; + hint.uv = Some(uv); + Some(from_p3(s.subs(uv.0, uv.1))).filter(|q| q.is_finite()) + } + Feature::Edge(i) => self.edge_nearest(i, p, hint).map(|(q, _)| q), + } + } + + /// The point of edge `i` nearest `p`, with the edge's direction there. + fn edge_nearest(&self, i: usize, p: Vec3, hint: &mut Hint) -> Option<(Vec3, Vec3)> { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let c = self.edges.get(i)?.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let (lo, hi) = (t0.min(t1), t0.max(t1)); + let at = |t: f64| from_p3(c.subs(t)); + let start = match hint.t { + Some(t) => t, + None => { + // A coarse look along the curve, then the inversion from the best sample. + let n = 32; + (0..=n).map(|k| t0 + (t1 - t0) * f64::from(k) / f64::from(n)).min_by(|a, b| at(*a).dist(p).total_cmp(&at(*b).dist(p)))? + } + }; + let mut best = (start.clamp(lo, hi), f64::INFINITY); + best.1 = at(best.0).dist(p); + for t in [c.search_nearest_parameter(p3(p), Some(start), 64).map(|t| t.clamp(lo, hi)), Some(t0), Some(t1)].into_iter().flatten() { + let d = at(t).dist(p); + if d < best.1 { + best = (t, d); + } + } + hint.t = Some(best.0); + let d = c.der(best.0); + let q = at(best.0); + q.is_finite().then_some((q, Vec3::new(d.x, d.y, d.z))) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Feature { + Face(usize), + Edge(usize), +} + +#[derive(Clone, Copy, Default)] +struct Hint { + uv: Option<(f64, f64)>, + t: Option, +} + +/// Settle a pair of points on features `fa` of `a` and `fb` of `b`, starting from `pb` on `b`. +fn settle(a: &DistanceShape, fa: Feature, b: &DistanceShape, fb: Feature, pb: Vec3) -> Option<(f64, Vec3, Vec3)> { + let (mut ha, mut hb) = (Hint::default(), Hint::default()); + let scale = a.body.size().max(b.body.size()).max(1.0); + let mut q = b.project(fb, pb, &mut hb)?; + let mut p = a.project(fa, q, &mut ha)?; + for _ in 0..MAX_ROUNDS { + let q2 = b.project(fb, p, &mut hb)?; + let p2 = a.project(fa, q2, &mut ha)?; + let moved = p2.dist(p).max(q2.dist(q)); + p = p2; + q = q2; + if moved <= 1e-12 * scale { + break; + } + } + let d = p.dist(q); + d.is_finite().then_some((d, p, q)) +} + +/// The minimum distance between the bodies of `a` and `b`, when it may be within `limit` (mm; +/// `f64::INFINITY` for any): `None` when the meshes show it is not. Overlapping bodies are not +/// told apart from touching ones here (both give 0 or the distance between their surfaces): +/// an intersection says whether they share volume. +pub fn body_distance(a: &DistanceShape, b: &DistanceShape, limit: f64) -> Result> { + let slack = 2.0 * (a.chord + b.chord); + let Some(m) = a.mesh_closest(b, if limit.is_finite() { limit + slack } else { f64::INFINITY }) else { return Ok(None) }; + let mesh = BodyDistance { distance: m.distance, from: m.a, to: m.b, exact: false }; + if a.faces.is_empty() || b.faces.is_empty() { + return Ok(Some(mesh)); + } + // The face pairs near the nearest mesh points, each with its nearest triangle pair. + let mut seeds: HashMap<(u32, u32), MeshClosest> = HashMap::new(); + a.set.within(&b.set, m.distance + slack, |c| { + let (Some(fa), Some(fb)) = (a.set.face(c.tri_a), b.set.face(c.tri_b)) else { return }; + let e = seeds.entry((fa, fb)).or_insert(c); + if c.distance < e.distance { + *e = c; + } + }); + let mut seeds: Vec<((u32, u32), MeshClosest)> = seeds.into_iter().collect(); + seeds.sort_by(|x, y| x.1.distance.total_cmp(&y.1.distance).then(x.0.cmp(&y.0))); + seeds.truncate(MAX_FACE_PAIRS); + let best = guard("body distance", || { + let mut best: Option = None; + let mut tried = std::collections::HashSet::new(); + for ((fa, fb), c) in &seeds { + for xa in a.features(*fa, c.a) { + for xb in b.features(*fb, c.b) { + if !tried.insert((xa, xb)) { + continue; + } + let Some((d, p, q)) = settle(a, xa, b, xb, c.b) else { continue }; + let holds = |s: &DistanceShape, f: Feature, x: Vec3| match f { + Feature::Face(i) => s.on_face(i as u32, x), + Feature::Edge(_) => true, + }; + if holds(a, xa, p) && holds(b, xb, q) && best.is_none_or(|bd| d < bd.distance) { + best = Some(BodyDistance { distance: d, from: p, to: q, exact: true }); + } + } + } + } + Ok(best) + })?; + // The exact minimum lies within the chords of the mesh's; an answer outside that went astray. + let chords = a.chord + b.chord + 1e-9 * a.body.size().max(b.body.size()).max(1.0); + Ok(Some(match best { + Some(e) if e.distance <= m.distance + chords && e.distance >= m.distance - chords => e, + _ => mesh, + })) +} diff --git a/crates/kernel/src/emboss.rs b/crates/kernel/src/emboss.rs index 92c076a0..94acc8c1 100644 --- a/crates/kernel/src/emboss.rs +++ b/crates/kernel/src/emboss.rs @@ -442,7 +442,7 @@ fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis: add(p)?; } } - Seg2::Bezier { n, p } if (1..=7).contains(&n) => { + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { for p in p.iter().take(usize::from(n) + 1) { add(*p)?; } diff --git a/crates/kernel/src/issue_1080_tests.rs b/crates/kernel/src/issue_1080_tests.rs index 914ffa3f..4d3f6648 100644 --- a/crates/kernel/src/issue_1080_tests.rs +++ b/crates/kernel/src/issue_1080_tests.rs @@ -94,3 +94,28 @@ fn a_result_that_removes_more_than_the_blend_is_refused() { let m = too_large(crate::blend_fit::check_volume(&b, worse, &e, size)); assert!(m.contains("more than the chamfer can"), "{m}"); } + +/// #1668: a box shelled outward 2 mm, open at the bottom. R5 on its top outer edge alone would +/// reach past the sharp inside corner 2 mm away; with the inside edge rounded in the same +/// fillet, that corner is filled, and the two build as one feature as they do one after the +/// other: the outer round takes (1 - π/4) · 25 mm² along 104 mm, the inner one adds it along +/// 100 mm. +#[test] +fn rounds_on_both_sides_of_a_wall_build_as_one_fillet() { + let outer = box_solid(Vec3::new(-52.0, -32.0, 0.0), Vec3::new(52.0, 32.0, 42.0)).unwrap(); + let shelled = cut(&outer, [-50.0, -30.0, -1.0], [50.0, 30.0, 40.0]); + let (out_edge, in_edge) = (Vec3::new(0.0, 32.0, 42.0), Vec3::new(0.0, 30.0, 40.0)); + let m = too_large(crate::fillet(&shelled, &[out_edge], 5.0)); + assert!(m.contains("runs past the faces beside the edge"), "{m}"); + let both = crate::fillet(&shelled, &[out_edge, in_edge], 5.0).unwrap(); + assert!(both.validity().is_empty(), "{:?}", both.validity()); + let section = (1.0 - std::f64::consts::FRAC_PI_4) * 25.0; + let want = shelled.volume_at(0.01).unwrap() - section * 104.0 + section * 100.0; + let v = crate::measure(&both).unwrap().volume; + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); + let stepped = crate::fillet(&crate::fillet(&shelled, &[in_edge], 5.0).unwrap(), &[out_edge], 5.0).unwrap(); + assert!((crate::measure(&stepped).unwrap().volume - v).abs() < 1e-4 * want); + // Past the wall's own room they still cross: R7's outer round reaches the inner one's arc. + let m = too_large(crate::fillet(&shelled, &[out_edge, in_edge], 7.0)); + assert!(m.contains("the largest radius that fits"), "{m}"); +} diff --git a/crates/kernel/src/issue_1510_tests.rs b/crates/kernel/src/issue_1510_tests.rs new file mode 100644 index 00000000..b942ebe1 --- /dev/null +++ b/crates/kernel/src/issue_1510_tests.rs @@ -0,0 +1,79 @@ +//! #1510: a body split by a plane that a face of the body lies in: the parts meet in the plane +//! and together are the body. + +use solidcraft_geom::{Plane, Vec3}; + +use crate::{Body, BoolOp, build::box_solid, build::cylinder, ops::boolean, ops::split_by_plane}; + +fn cut(a: &Body, t: crate::Result) -> Body { + boolean(a, &t.unwrap(), BoolOp::Cut).unwrap().unwrap() +} + +/// A 2.5 mm plate with a through hole of radius 2.5 and a groove 4 mm wide and 1.5 deep from +/// the bottom, running from the hole's centre to the edge: the groove's ceiling lies at z = 1.5 +/// and its boundary runs into the hole. +fn plate(width: f64) -> Body { + let p = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 2.5)).unwrap(); + let p = cut(&p, cylinder(Vec3::new(20.0, 10.0, -1.0), Vec3::Z, 2.5, 5.0)); + cut(&p, box_solid(Vec3::new(20.0, 10.0 - width / 2.0, -1.0), Vec3::new(41.0, 10.0 + width / 2.0, 1.5))) +} + +#[test] +fn a_split_in_the_plane_of_a_face_keeps_all_of_the_body() { + for width in [4.0, 5.0] { + let b = plate(width); + let v = b.volume_at(1e-3).unwrap(); + let plane = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap(); + let (parts, d) = crate::capture_boolean_diagnostics(|| split_by_plane(&b, &plane)); + let parts = parts.unwrap(); + assert_eq!(parts.len(), 2, "{width}"); + let vs: Vec = parts.iter().map(|p| p.volume_at(1e-3).unwrap()).collect(); + assert!((vs.iter().sum::() - v).abs() < 1e-6 * v, "{width}: {vs:?} against {v}; {:?}", d.summary()); + // Above: the plate from 1.5 to 2.5 less the hole; below: less the hole and the groove. + let (r, h) = (2.5f64, width / 2.0); + let hole = std::f64::consts::PI * r * r; + // The strip 20 <= x <= 40, |y - 10| <= h, less the part of it in the hole. + let groove = width * 20.0 - (h * (r * r - h * h).sqrt() + r * r * (h / r).asin()); + let want = [800.0 - hole, 1.5 * (800.0 - hole - groove)]; + for (got, want) in vs.iter().zip(want) { + assert!((got - want).abs() < 0.05, "{width}: {vs:?} against {want}; {:?}", d.summary()); + } + // Both parts end in the plane, not off it. + let z: Vec<(f64, f64)> = parts.iter().map(|p| crate::measure(p).map(|m| (m.bbox.min.z, m.bbox.max.z)).unwrap()).collect(); + assert!((z[0].0 - 1.5).abs() < 1e-6 && (z[1].1 - 1.5).abs() < 1e-6, "{width}: {z:?}; {:?}", d.summary()); + } +} + +/// A slab whose bottom rises 0.03 mm over its 700 mm length, its plane through the origin: its +/// normal is within 1e-9 of the split plane's, but it doesn't lie in the plane, so it is not +/// moved, and the part on the upper side is the whole slab. +#[test] +fn a_face_only_nearly_in_the_plane_is_not_moved() { + use truck_modeling as mt; + let v = |x: f64, y: f64, z: f64| mt::builder::vertex(crate::body::p3(Vec3::new(x, y, z))); + let p = [v(0.0, 0.0, 0.0), v(0.0, 100.0, 0.0), v(700.0, 100.0, 0.03), v(700.0, 0.0, 0.03)]; + let wire: mt::Wire = (0..4).map(|i| mt::builder::line(&p[i], &p[(i + 1) % 4])).collect(); + let surface = mt::Plane::new(crate::body::p3(Vec3::ZERO), crate::body::p3(Vec3::Y), crate::body::p3(Vec3::new(700.0, 0.0, 0.03))); + let bottom = mt::Face::new(vec![wire], mt::Surface::Plane(surface)); + let b = Body::new(mt::builder::tsweep(&bottom, mt::Vector3::new(0.0, 0.0, 10.0))).unwrap(); + let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap(); + assert!(crate::ops::off_plane(&b, &plane, true).is_none()); + let v0 = b.volume_at(1e-2).unwrap(); + let up = crate::ops::half_space(&b, &plane, true).unwrap().0.unwrap(); + let v1 = up.volume_at(1e-2).unwrap(); + assert!((v1 - v0).abs() < 1e-3 * v0, "{v1} against {v0}"); +} + +/// A box whose top face is 3e-5 above the plane, within the boolean's tolerance but off it: the +/// face bounds a real sliver, and it is not moved. +#[test] +fn a_face_close_to_the_plane_but_off_it_is_not_moved() { + let b = box_solid(Vec3::new(0.0, 0.0, -10.0), Vec3::new(700.0, 100.0, 3e-5)).unwrap(); + let plane = Plane::from_normal(Vec3::ZERO, Vec3::Z).unwrap(); + assert!(crate::ops::off_plane(&b, &plane, true).is_none()); + assert!(crate::ops::off_plane(&b, &plane, false).is_none()); + // A face in the plane is moved. + let plate = plate(4.0); + let at = Plane::from_normal(Vec3::new(0.0, 0.0, 1.5), Vec3::Z).unwrap(); + assert!(crate::ops::off_plane(&plate, &at, true).is_some() && crate::ops::off_plane(&plate, &at, false).is_some()); +} diff --git a/crates/kernel/src/issue_324_tests.rs b/crates/kernel/src/issue_324_tests.rs new file mode 100644 index 00000000..57cb86a5 --- /dev/null +++ b/crates/kernel/src/issue_324_tests.rs @@ -0,0 +1,37 @@ +//! #324: a round on a block's top edge that would overlap a through hole is refused, and the +//! largest radius the refusal offers fits. + +use solidcraft_geom::Vec3; + +use crate::{Body, BoolOp, build::box_solid, build::cylinder, ops::boolean}; + +/// A 40 x 20 x 10 block with a through hole of radius 3 at (20, `y`). +fn block_with_hole(y: f64) -> Body { + let block = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 20.0, 10.0)).unwrap(); + let hole = cylinder(Vec3::new(20.0, y, -1.0), Vec3::Z, 3.0, 12.0).unwrap(); + boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap() +} + +/// The round on the top edge at y = 20 covers the top face from y = 20 - r, and the hole's +/// wall reaches y = 17.5: a radius over 2.5 runs into the hole. The refusal of 4 mm offered +/// 2.721 mm, which was refused the same way: the curved wall's mesh, with its tolerance, was +/// first seen inside the round's region 0.22 mm past where the wall really enters it. +#[test] +fn the_radius_offered_beside_a_hole_fits() { + let b = block_with_hole(14.5); + let edge = Vec3::new(10.0, 20.0, 10.0); + let m = crate::fillet(&b, &[edge], 4.0).unwrap_err().to_string(); + let offered: f64 = m + .split("the largest radius that fits is ") + .nth(1) + .and_then(|s| s.split(' ').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("no radius offered: {m}")); + assert!(offered > 2.0 && offered <= 2.5, "{m}"); + let r = crate::fillet(&b, &[edge], offered).unwrap(); + assert!(r.validity().is_empty(), "{:?}", r.validity()); + // The block, the hole and the round's corner: the round stays clear of the hole. + let want = 8000.0 - std::f64::consts::PI * 9.0 * 10.0 - 40.0 * offered * offered * (1.0 - std::f64::consts::FRAC_PI_4); + let v = r.volume_at(0.005).unwrap(); + assert!((v - want).abs() < 0.05, "{v} against {want}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 22756f87..0f63b8de 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -28,9 +28,13 @@ mod boundary; mod bridge; mod build; mod clearance; +mod coaxial_overlap; +mod continuity; mod coplanar; +mod corner_g2; mod curveblend; mod delete_face; +mod distance; #[cfg(test)] mod edge_polyline_tests; mod emboss; @@ -53,6 +57,10 @@ mod issue_1038_tests; #[cfg(test)] mod issue_1080_tests; #[cfg(test)] +mod issue_1510_tests; +#[cfg(test)] +mod issue_324_tests; +#[cfg(test)] mod issue_616_tests; #[cfg(test)] mod issue_674_tests; @@ -67,6 +75,7 @@ mod issue_871_tests; #[cfg(test)] mod join_between_caps_tests; mod lip; +pub mod loft_blend; mod loopblend; mod matchsurf; mod measure; @@ -82,7 +91,7 @@ mod outline; #[cfg(test)] mod outline_tests; mod par; -pub use par::{for_each_on_threads, map_on_threads}; +pub use par::{for_each_on_threads, map_on_threads, map_with_local_pools}; #[cfg(test)] mod pinch_tests; mod planar_faces; @@ -95,8 +104,10 @@ mod prism; mod prism_curve; mod prism_local; mod prism_measure; +pub mod profile; pub mod provenance; mod record; +mod seam_faces; mod sew; mod smooth_edges; mod spline_conv; @@ -111,6 +122,8 @@ mod surface_ruled; mod surface_untrim; mod surfaces; mod sweep_path; +#[cfg(test)] +mod sweep_rails_tests; mod tangent_cylinders; mod tess; #[cfg(test)] @@ -118,12 +131,13 @@ mod thread_display_tests; #[cfg(test)] mod thread_tests; mod topo; +mod touching_walls; mod tspline; #[cfg(test)] mod untouched_faces_tests; pub mod watch; -pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_max, fillet_max_each}; +pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each}; pub use blend_corner::fillet_radii; pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper}; pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint}; @@ -131,10 +145,17 @@ pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrate pub use boundary::{Boundary, boundary_patch}; pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid}; pub use build::{ - PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, - sweep_with, torus, + PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, + revolve_whole, sphere, sweep, sweep_with, torus, }; +pub use clearance::{Clearance, Occupancy}; +pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap}; +pub use continuity::{ + ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics, +}; +pub use corner_g2::{corner_g2_patch, seam_error}; pub use delete_face::delete_faces; +pub use distance::{BodyDistance, DistanceShape, body_distance}; pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss}; pub use fair::{ FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve, @@ -145,13 +166,14 @@ pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh}; pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; pub use lip::raise_band; +pub use loft_blend::loft_blend; pub use matchsurf::{match_surface, seam_continuity}; pub use measure::{BodyMeasure, measure}; pub use mesh_convert::convert_prismatic_mesh; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use native::{decode_native_body, encode_native_body, native_preview_mesh}; pub use offset::with_same_surface; -pub use ops::{BoolOp, boolean, can_join_touching_faces, join_apart, split_by_plane, transform, transform_matrix, union_apart}; +pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; @@ -172,8 +194,8 @@ pub use surfaces::{ copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch, }; -pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with}; -pub use topo::{CylinderFace, cylinder_face_at}; +pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_path_with}; +pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; diff --git a/crates/kernel/src/loft_blend.rs b/crates/kernel/src/loft_blend.rs new file mode 100644 index 00000000..2364b200 --- /dev/null +++ b/crates/kernel/src/loft_blend.rs @@ -0,0 +1,550 @@ +//! Multi-section blends: one face that runs through many cross-sections and still leaves the +//! first and last of them the way a support surface wants. +//! +//! The sections run in order along the face's **u**, each sampled at the same `samples` stations +//! along the face's **v**. Section `i` sits at `u = i/(n−1)`, and the face passes through it +//! exactly: its v-curve there is that section's own cross-section curve through its stations. +//! Along v the stations are interpolated by a B-spline of degree `min(3, samples − 1)` — the same +//! interpolation [`crate::bridge`] builds its boundary curves with — so every station of every +//! section lies on the face. +//! +//! Along u the face is a B-spline whose knots are clamped at both ends and repeated at every +//! interior section, so every span is one Bézier; a span's leading control rows are the Hermite +//! rows of `bridge::section` — position, tangent (`leave`), curvature (`bend`) and change of +//! curvature (`bend_change`), scaled to that span's parameter interval. The degree is the largest +//! `c + c′ − 1` over the spans (`c` conditions at the section the span leaves, `c′` at the one it +//! arrives at), and a span whose two ends carry fewer conditions than that is elevated exactly, +//! which moves no point of the curve. A blend of two sections is therefore the [`crate::bridge`] +//! of those two boundaries, only with the two parameters the other way round. +//! +//! **The first and last section** take their tangent, curvature and change of curvature from the +//! caller's frames, exactly as a bridge takes them from a support surface's boundary, and hold +//! them to the requested [`Continuity`] (G0 up to G3). The `leave` direction of the last section's +//! frames points back into the blend, towards the interior, as a bridge's far boundary does. +//! +//! **Interior sections** take nothing from the caller's frames but their positions: the run +//! direction's tangent there is the secant of the two neighbouring sections' stations, its +//! curvature their second difference and its change of curvature their third difference, all +//! along the run. The two spans that meet at such a section are given exactly those rows, so they +//! agree in ∂/∂u, ∂²/∂u² and ∂³/∂u³ there and the joint is **C³** in the run parameter (hence G³) +//! whenever a fourth station is there to take the third difference over — that is, whenever the +//! blend runs through at least four sections. A blend of exactly three sections has only its one +//! interior section's two neighbours to read, so no third difference can be taken: the interior +//! section then keeps position, tangent and curvature only and its joint is **C²** (G²). Dropping +//! interior conditions, never the surface, is also how this blend stays within +//! [`MAX_SPLINE_DEGREE`]: a section family asks for at most `c + c′ − 1 ≤ 7` at the four conditions +//! a section can carry today, and a caller that ever asked for more would lose interior +//! conditions (change of curvature first, then curvature, then tangent) rather than fail. +//! +//! `crown` scales how fast the face leaves the first and the last section (0 the plain blend, up +//! to 3 fuller, down to −0.9 flatter), exactly as a bridge's crown does. It shapes the middle of +//! each end span and leaves every joint's conditions alone. + +use solidcraft_geom::Vec3; +use solidcraft_geom::bspline::{CurveN, HPoint, MAX_SPLINE_DEGREE, NurbsSurface}; +use truck_modeling as mt; +use truck_modeling::{ParametricSurface3D, SearchNearestParameter}; + +use crate::body::{Body, from_p3, p3}; +use crate::bridge::{Continuity, Frame}; +use crate::surfaces::surface_body; +use crate::{KernelError, Result, guard}; + +/// Most cross-sections one blend may run through. +pub const MAX_BLEND_SECTIONS: usize = 50; +/// Fewest stations one cross-section may be sampled at (a straight line across it). +pub const MIN_BLEND_STATIONS: usize = 2; +/// Most stations one cross-section may be sampled at. The control net (and the interpolation that +/// builds it) grows with the station count, and no cross-section curve needs more than this. +pub const MAX_BLEND_STATIONS: usize = 257; + +/// How many rows of the control net one interpolation solve carries at a time: a row is three +/// coordinates, so this many rows are solved together. +const ROWS_PER_SOLVE: usize = 16; + +/// A blend surface through `sections`, in order, that leaves the first and the last of them with +/// the continuity their frames ask for. +/// +/// Each section is one cross-section: `samples` stations (`samples` must match every section's +/// length, so that the stations correspond from one section to the next). Section `i` is +/// interpolated at `u = i/(n−1)`, and its stations run along `v`, so the face of the body holds +/// every station of every section. `orders[0]` and `orders[1]` are what the face holds at the +/// first and the last section, read from their frames; the interior sections join the face +/// smoothly by finite differences along the run (see the module comment for the order each joint +/// holds). `crown` scales how fast the face leaves the first (`crown[0]`) and the last +/// (`crown[1]`) section and must be within −0.9 and 3. +/// +/// Refused, without ever panicking: fewer than 2 or more than [`MAX_BLEND_SECTIONS`] sections, +/// sections of unequal station counts or a `samples` below [`MIN_BLEND_STATIONS`] or above +/// [`MAX_BLEND_STATIONS`], a frame that is not finite or a `leave` with no direction, a crown +/// outside its range, and two neighbouring sections that meet at a station (no room for a blend). +pub fn loft_blend(sections: &[Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result { + let blend = Blend::new(sections, orders, crown, samples)?; + let net = blend.net()?; + guard("loft blend surface", || crate::spline_conv::tensor_face(&net).map(|f| surface_body(vec![f]))) +} + +/// The frames of one cross-section picked as an edge chain of a body, sampled at `stations` +/// stations: the chain itself, with the neighbouring face's tangent plane, curvature vector and +/// change of curvature read along `leave` — the direction the blend runs on from this section — +/// exactly as [`crate::bridge::frames`] reads a support surface's own boundary. +/// +/// Hand the result to [`loft_blend`] as one section. The face the section repeats is the body's +/// face that holds the whole chain and faces the run best; `leave` must have a part in that face's +/// tangent plane (a direction normal to it has no tangent plane to leave in). The last section's +/// `leave` runs back into the blend, towards the section before it. +pub fn section_frames(body: &Body, edges: &[Vec3], leave: Vec3, stations: usize) -> Result> { + guard("loft blend section", || { + let chain = crate::bridge::chain(body, edges)?; + let leave = + leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?; + let first = chain.first().ok_or_else(|| KernelError::Failed("loft blend: no chain".into()))?; + let mid = (from_p3(first.front().point()) + from_p3(first.back().point())) * 0.5; + // The face the chain lies on that the blend leaves through: the one whose tangent plane + // holds as much of `leave` as possible. + let mut best: Option<(f64, mt::Face)> = None; + for face in body.solid.face_iter() { + if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) { + continue; + } + let surf = face.surface(); + let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue }; + let normal = surf.normal(u, v); + let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO); + let score = (leave - n * leave.dot(n)).len(); + if best.as_ref().is_none_or(|(b, _)| score > *b) { + best = Some((score, face.clone())); + } + } + let (score, face) = best.ok_or_else(|| KernelError::Invalid("no face of the body holds the picked edges".into()))?; + if score < 0.2 { + return Err(KernelError::Invalid("the direction towards the next section lies in no face at the picked edges".into())); + } + crate::bridge::frames(&face, &chain, leave, stations) + }) +} + +/// What the face has to do at one section: where it is, and the run direction's first, second and +/// third derivatives at each of the section's stations. The third is carried only when the +/// section holds a change-of-curvature condition. +struct Shape { + p: Vec, + d1: Vec, + d2: Vec, + d3: Option>, +} + +/// The blend's inputs, validated, with the conditions and the run direction's degree worked out. +struct Blend<'a> { + sections: &'a [Vec], + crown: [f64; 2], + samples: usize, + run: usize, + /// The run parameter interval between two neighbouring sections: `1/(run − 1)`. + step: f64, + degree: usize, + /// Conditions held at each section, one per control row of the spans that end there. + conditions: Vec, +} + +impl<'a> Blend<'a> { + fn new(sections: &'a [Vec], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result> { + if !(MIN_BLEND_STATIONS..=MAX_BLEND_STATIONS).contains(&samples) { + return Err(KernelError::Invalid(format!( + "a blend needs {MIN_BLEND_STATIONS} to {MAX_BLEND_STATIONS} stations per cross-section, not {samples}" + ))); + } + let run = sections.len(); + if !(2..=MAX_BLEND_SECTIONS).contains(&run) { + return Err(KernelError::Invalid(format!("a blend runs through 2 to {MAX_BLEND_SECTIONS} cross-sections, not {run}"))); + } + for c in crown { + if !c.is_finite() || !(-0.9..=3.0).contains(&c) { + return Err(KernelError::Invalid("a blend's crown must be between -0.9 and 3".into())); + } + } + for (i, section) in sections.iter().enumerate() { + if section.len() != samples { + return Err(KernelError::Invalid(format!( + "every cross-section of a blend must be sampled at the same {samples} stations (section {i} has {})", + section.len() + ))); + } + for (s, f) in section.iter().enumerate() { + if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) { + return Err(KernelError::Invalid(format!("the frame at section {i}, station {s} is not finite"))); + } + if f.leave.normalized().is_none() { + return Err(KernelError::Invalid(format!("the leave direction at section {i}, station {s} is zero"))); + } + } + } + // The face runs from one section to the next: where two of them meet at a station there is + // no room for a blend between them. + for i in 0..run - 1 { + for s in 0..samples { + if !(point(sections, i, s)?.dist(point(sections, i + 1, s)?) > 1e-9) { + return Err(KernelError::Invalid(format!("sections {} and {} meet at station {s}: there is no room for a blend", i + 1, i + 2))); + } + } + } + // The ends hold what their orders ask for. An interior section would like all four + // conditions (position, tangent, curvature, change of curvature); where no fourth station + // is there to take the third difference over it keeps the three its neighbours can give, + // and its joint is C² instead of C³. + let mut conditions = vec![2usize; run]; + if let Some(slot) = conditions.first_mut() { + *slot = orders[0].conditions(); + } + if let Some(slot) = conditions.last_mut() { + *slot = orders[1].conditions(); + } + for i in 1..run.saturating_sub(1) { + if let Some(slot) = conditions.get_mut(i) { + *slot = if third_difference_available(i, run) { 4 } else { 3 }; + } + } + // The run direction's degree is the largest `c + c′ − 1` of a span. Four conditions a + // section is at most what a section can carry, so this fits today; should a caller ever + // ask for more, the interior joints give up conditions rather than the blend failing. + let mut degree = span_degree(&conditions); + while degree > MAX_SPLINE_DEGREE { + let next = (1..run.saturating_sub(1)) + .filter(|&i| conditions.get(i).is_some_and(|&c| c > 1)) + .max_by_key(|&i| conditions.get(i).copied().unwrap_or(0)); + let Some(i) = next else { break }; + if let Some(c) = conditions.get_mut(i) { + *c = c.saturating_sub(1); + } + degree = span_degree(&conditions); + } + if degree == 0 || degree > MAX_SPLINE_DEGREE { + return Err(KernelError::Failed("loft blend: the requested continuity needs a degree above the kernel's".into())); + } + Ok(Blend { sections, crown, samples, run, step: 1.0 / (run - 1) as f64, degree, conditions }) + } + + /// What the face has to do at section `i`: the run direction's derivatives at every station. + fn shape(&self, i: usize) -> Result { + let samples = self.samples; + let p: Vec = (0..samples).map(|s| point(self.sections, i, s)).collect::>>()?; + let conditions = self.conditions.get(i).copied().unwrap_or(1); + let mut d1 = vec![Vec3::ZERO; samples]; + let mut d2 = vec![Vec3::ZERO; samples]; + let mut d3 = (conditions >= 4).then(|| vec![Vec3::ZERO; samples]); + if i == 0 { + for s in 0..samples { + let f = frame(self.sections, 0, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + // The speed a bridge gives a boundary: a third of the station's chord to the + // section it leaves for, the crown, over this span's parameter interval. + let speed = f.p.dist(point(self.sections, 1, s)?) / 3.0 * (1.0 + self.crown[0]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = leave * speed; + *v2 = f.bend * (speed * speed); + } + } + } else if i + 1 == self.run { + for s in 0..samples { + let f = frame(self.sections, i, s)?; + let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?; + let speed = f.p.dist(point(self.sections, i - 1, s)?) / 3.0 * (1.0 + self.crown[1]) / self.step; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + // The run leaves the last section outwards; the caller's frames there point + // back into it, so the odd derivatives turn round. + *v1 = leave * -speed; + *v2 = f.bend * (speed * speed); + } + } + } else { + let h = self.step; + for s in 0..samples { + let before = point(self.sections, i - 1, s)?; + let here = point(self.sections, i, s)?; + let after = point(self.sections, i + 1, s)?; + if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) { + *v1 = (after - before) * (1.0 / (2.0 * h)); + *v2 = (after - here * 2.0 + before) * (1.0 / (h * h)); + } + } + } + // The change of curvature: the caller's bend_change at an end, a third difference of the + // neighbouring sections at an interior one. + if let Some(third) = d3.as_mut() { + for s in 0..samples { + let (Some(&a), Some(&b)) = (d1.get(s), d2.get(s)) else { + return Err(KernelError::Failed("loft blend: a section's derivatives".into())); + }; + let value = if i == 0 { + let f = frame(self.sections, 0, s)?; + third_derivative(a, b, f.bend_change)? + } else if i + 1 == self.run { + let f = frame(self.sections, i, s)?; + -third_derivative(-a, b, f.bend_change)? + } else { + self.third_difference(i, s)? + }; + if let Some(slot) = third.get_mut(s) { + *slot = value; + } + } + } + Ok(Shape { p, d1, d2, d3 }) + } + + /// The third difference of the sections' stations at an interior section: the estimate of how + /// the curvature vector changes along the run. `i` is an interior section, and the neighbours + /// reach far enough for a stencil (see [`third_difference_available`]). + fn third_difference(&self, i: usize, s: usize) -> Result { + let h = self.step; + let cube = h * h * h; + let before = i.checked_sub(2); + let value = match (before, i + 2 < self.run) { + // Both sides: the symmetric five-point difference. + (Some(k), true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 2.0 + point(self.sections, i - 1, s)? * 2.0 + - point(self.sections, k, s)?) + * (1.0 / (2.0 * cube)) + } + // Only ahead: the forward four-point stencil at the second of them. + (None, true) => { + (point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i - 1, s)?) + * (1.0 / cube) + } + // Only behind: the same the other way round. + (Some(k), false) => { + (point(self.sections, k, s)? - point(self.sections, i - 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0 + - point(self.sections, i + 1, s)?) + * (1.0 / cube) + } + (None, false) => return Err(KernelError::Failed("loft blend: no fourth station for a change of curvature".into())), + }; + if !value.is_finite() { + return Err(KernelError::Failed("loft blend: the sections' third difference is not finite".into())); + } + Ok(value) + } + + /// The Bézier rows of the span from section `j` to section `j + 1`: one control row per + /// condition at each end, elevated to the run direction's degree so the whole face is one + /// tensor surface. Row `k` of the span is control row `j·degree + k` of the face. + fn span(&self, j: usize, left: &Shape, right: &Shape) -> Result>> { + let (Some(&kl), Some(&kr)) = (self.conditions.get(j), self.conditions.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section with no conditions".into())); + }; + let m = kl.saturating_add(kr).saturating_sub(1); + if !(1..=MAX_SPLINE_DEGREE).contains(&m) { + return Err(KernelError::Failed("loft blend: a span's degree".into())); + } + let k = m as f64; + let delta = self.step; + let one = delta / k; + let two = if m >= 2 { delta * delta / (k * (k - 1.0)) } else { 0.0 }; + let three = if m >= 3 { delta * delta * delta / (k * (k - 1.0) * (k - 2.0)) } else { 0.0 }; + let mut out = vec![vec![Vec3::ZERO; self.samples]; m + 1]; + let missing = || KernelError::Failed("loft blend: a section's derivatives".into()); + for s in 0..self.samples { + // The near end: one row per condition, position first. + let p = *left.p.get(s).ok_or_else(missing)?; + put(&mut out, 0, s, p)?; + if kl >= 2 { + let d1 = *left.d1.get(s).ok_or_else(missing)?; + let q1 = p + d1 * one; + put(&mut out, 1, s, q1)?; + if kl >= 3 { + let d2 = *left.d2.get(s).ok_or_else(missing)?; + let q2 = d2 * two + q1 * 2.0 - p; + put(&mut out, 2, s, q2)?; + if kl >= 4 { + let d3 = left.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, 3, s, d3 * three + q2 * 3.0 - q1 * 3.0 + p)?; + } + } + } + // The far end, its own conditions read backwards. + let e = *right.p.get(s).ok_or_else(missing)?; + put(&mut out, m, s, e)?; + if kr >= 2 { + let d1 = *right.d1.get(s).ok_or_else(missing)?; + let r1 = e - d1 * one; + put(&mut out, m - 1, s, r1)?; + if kr >= 3 { + let d2 = *right.d2.get(s).ok_or_else(missing)?; + let r2 = d2 * two + r1 * 2.0 - e; + put(&mut out, m - 2, s, r2)?; + if kr >= 4 { + let d3 = right.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?; + put(&mut out, m - 3, s, r2 * 3.0 - r1 * 3.0 + e - d3 * three)?; + } + } + } + } + // A span whose two ends carry fewer conditions than the run direction's degree is + // elevated: the same curve, written in more control rows. + while out.len().saturating_sub(1) < self.degree { + out = elevate(&out); + } + Ok(out) + } + + /// The run direction's knots: clamped at both ends, repeated `degree` times at every interior + /// section — so the face interpolates each section and every span in between is one Bézier. + fn run_knots(&self) -> Vec { + let degree = self.degree; + let mut knots = vec![0.0; degree + 1]; + for i in 1..self.run.saturating_sub(1) { + knots.extend(std::iter::repeat_n(i as f64 / (self.run - 1) as f64, degree)); + } + knots.extend(std::iter::repeat_n(1.0, degree + 1)); + knots + } + + /// The face's control net: u runs with the sections, v along each section's curve. + fn net(&self) -> Result { + let shapes: Vec = (0..self.run).map(|i| self.shape(i)).collect::>>()?; + // One span per pair of neighbouring sections. The first row of a span is the last row of + // the one before it — the shared section — so only the first span writes it. + let mut rows: Vec> = Vec::with_capacity((self.run - 1) * self.degree + 1); + for j in 0..self.run.saturating_sub(1) { + let (Some(left), Some(right)) = (shapes.get(j), shapes.get(j + 1)) else { + return Err(KernelError::Failed("loft blend: a section's shape".into())); + }; + for row in self.span(j, left, right)?.into_iter().skip(usize::from(j > 0)) { + rows.push(row); + } + } + let count_u = rows.len(); + // Along each section's curve the rows are the interpolation through that section's + // stations: the same parameters and degree for every row, so every row has one knot + // vector. Rows are solved in batches of `ROWS_PER_SOLVE` (three coordinates each), which + // shares one elimination between them. + let params: Vec = (0..self.samples).map(|s| s as f64 / (self.samples - 1) as f64).collect(); + let degree_v = 3.min(self.samples - 1); + let mut ctrl = vec![HPoint::ZERO; count_u * self.samples]; + let mut knots_v: Option> = None; + for (batch, chunk) in rows.chunks(ROWS_PER_SOLVE).enumerate() { + const WIDTH: usize = 3 * ROWS_PER_SOLVE; + let mut points: Vec<[f64; WIDTH]> = Vec::with_capacity(self.samples); + for s in 0..self.samples { + let mut q = [0.0f64; WIDTH]; + for (r, row) in chunk.iter().enumerate() { + if let Some(p) = row.get(s) { + for (slot, value) in q.iter_mut().skip(3 * r).zip([p.x, p.y, p.z]) { + *slot = value; + } + } + } + points.push(q); + } + let curve = CurveN::::interpolate(¶ms, &points, degree_v).map_err(|e| KernelError::Failed(format!("loft blend: {e}")))?; + if knots_v.is_none() { + knots_v = Some(curve.knots.clone()); + } + for (s, c) in curve.ctrl.iter().enumerate() { + for (r, xyz) in c.chunks(3).enumerate() { + let i = batch * ROWS_PER_SOLVE + r; + // Coordinates past the net's last row are this batch's own padding: their + // controls are not part of the surface. + if i >= count_u { + break; + } + if let [x, y, z] = *xyz { + let Some(slot) = s.checked_mul(count_u).and_then(|base| base.checked_add(i)).and_then(|k| ctrl.get_mut(k)) else { + continue; + }; + *slot = HPoint::weighted(Vec3::new(x, y, z), 1.0); + } + } + } + } + let knots_v = knots_v.ok_or_else(|| KernelError::Failed("loft blend: no row could be interpolated".into()))?; + NurbsSurface::from_homogeneous([self.degree, degree_v], [self.run_knots(), knots_v], ctrl) + .map_err(|e| KernelError::Failed(format!("loft blend: {e}"))) + } +} + +/// The frame at section `i`, station `s`. +fn frame(sections: &[Vec], i: usize, s: usize) -> Result<&Frame> { + sections.get(i).and_then(|section| section.get(s)).ok_or_else(|| KernelError::Failed("loft blend: a section is missing a station".into())) +} + +/// The point of section `i` at station `s`. +fn point(sections: &[Vec], i: usize, s: usize) -> Result { + frame(sections, i, s).map(|f| f.p) +} + +/// `rows[k][s] = v`, an error when the net has no such row or station. +fn put(rows: &mut [Vec], k: usize, s: usize, v: Vec3) -> Result<()> { + match rows.get_mut(k).and_then(|row| row.get_mut(s)) { + Some(slot) => { + *slot = v; + Ok(()) + } + None => Err(KernelError::Failed("loft blend: the control net is too small".into())), + } +} + +/// One exact degree elevation of a Bézier control net of rows: the same curve, one control row +/// more. +fn elevate(rows: &[Vec]) -> Vec> { + let degree = rows.len().saturating_sub(1); + let up = degree as f64 + 1.0; + let mut out: Vec> = Vec::with_capacity(rows.len() + 1); + if let Some(first) = rows.first() { + out.push(first.clone()); + } + for i in 1..=degree { + if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) { + let a = i as f64 / up; + out.push(before.iter().zip(here).map(|(x, y)| *x * a + *y * (1.0 - a)).collect()); + } + } + if let Some(last) = rows.last() { + out.push(last.clone()); + } + out +} + +/// Whether a third difference for the interior section `i` can be taken from the sections around +/// it: a stencil needs a station on one side of it and two on the other, and with three sections +/// there is none. +fn third_difference_available(i: usize, run: usize) -> bool { + i >= 2 || i + 2 < run +} + +/// The degree a family of sections asks for: the largest `c + c′ − 1` over its spans. +fn span_degree(conditions: &[usize]) -> usize { + let mut degree = 0; + for w in conditions.windows(2) { + if let [a, b] = w { + degree = degree.max(a.saturating_add(*b).saturating_sub(1)); + } + } + degree +} + +/// The third derivative a curve needs at a boundary so that the arclength derivative of its +/// curvature vector is `target`, from its first two derivatives there. Only the normal-plane part +/// of the third derivative can hold that condition; the rest is a reparameterisation of the +/// cross-section. This is `bridge`'s own construction, kept here so the bridge core stays +/// untouched. +fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result { + let sigma = d1.len(); + if !(sigma > 1e-12) || !sigma.is_finite() { + return Err(KernelError::Failed("loft blend: no room for the curvature condition".into())); + } + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let r = tp * (-d2.dot(t)) - t * d2.dot(tp); + let x = (target * (sigma.powi(5)) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2))) * (1.0 / (sigma * sigma)); + if !x.is_finite() { + return Err(KernelError::Failed("loft blend: the change of curvature is not finite".into())); + } + Ok(x - t * x.dot(t)) +} + +#[cfg(test)] +#[path = "loft_blend_tests.rs"] +mod tests; diff --git a/crates/kernel/src/loft_blend_tests.rs b/crates/kernel/src/loft_blend_tests.rs new file mode 100644 index 00000000..df60b675 --- /dev/null +++ b/crates/kernel/src/loft_blend_tests.rs @@ -0,0 +1,460 @@ +//! Tests of the multi-section blend: analytic cross-sections given as frames, and the surface +//! measured back off the face it built (a grid of points, five-point differences along the run). + +use super::*; +use crate::body::from_p3; +use crate::bridge::{bridge, sample_grid}; +use truck_modeling as mt; +use truck_modeling::ParametricSurface; + +/// The blend's surface: it is one face. +fn surface_of(body: &Body) -> mt::Surface { + body.solid.face_iter().next().unwrap().surface() +} + +/// Position and the run direction's first three derivatives at `(t, v)` of the face, read with the +/// five-point differences `bridge_tests` reads a cross-section with. The samples run inwards from +/// the section (so nothing is asked of the face outside its own domain); `backwards` reads with +/// the parameter falling and turns the odd derivatives round, so every value is a derivative in +/// the run direction `u`. +fn run_derivs(body: &Body, t: f64, v: f64, backwards: bool, h: f64) -> (Vec3, Vec3, Vec3, Vec3) { + let face = body.solid.face_iter().next().unwrap(); + let surf = face.surface(); + let dir = if backwards { -1.0 } else { 1.0 }; + let q: Vec = (0..5).map(|k| from_p3(surf.subs(t + dir * k as f64 * h, v))).collect(); + let d1 = (q[0] * -25.0 + q[1] * 48.0 + q[2] * -36.0 + q[3] * 16.0 + q[4] * -3.0) * (dir / (12.0 * h)); + let d2 = (q[0] * 35.0 + q[1] * -104.0 + q[2] * 114.0 + q[3] * -56.0 + q[4] * 11.0) * (1.0 / (12.0 * h * h)); + let d3 = (q[0] * -5.0 + q[1] * 18.0 + q[2] * -24.0 + q[3] * 14.0 + q[4] * -3.0) * (dir / (2.0 * h * h * h)); + (q[0], d1, d2, d3) +} + +/// The geometric shape of a curve from its first three derivatives: the unit tangent, the +/// curvature vector and the arclength derivative of that curvature vector (`bridge`'s own +/// reading, kept local to the test). +fn curve_shape(d1: Vec3, d2: Vec3, d3: Vec3) -> (Vec3, Vec3, Vec3) { + let sigma = d1.len(); + let t = d1 * (1.0 / sigma); + let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma)); + let nn = d2 - t * d2.dot(t); + let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp); + let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2)); + (t, nn * (1.0 / (sigma * sigma)), kappa * (1.0 / sigma.powi(5))) +} + +/// The angle between two vectors, in degrees. +fn angle_deg(a: Vec3, b: Vec3) -> f64 { + (a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// A circle of radius `r` at height `z` about the run (the z axis), sampled at `n` stations. Each +/// frame leaves along `leave`; its `bend` is given per station as a function of the station's +/// outward radial direction (a support surface curving in the leave direction), and its change of +/// curvature is `change`. +fn circle_section(r: f64, z: f64, n: usize, leave: Vec3, bend: impl Fn(Vec3) -> Vec3, change: Vec3) -> Vec { + (0..n) + .map(|s| { + let a = std::f64::consts::TAU * s as f64 / n as f64; + let p = Vec3::new(r * a.cos(), r * a.sin(), z); + Frame { p, leave, bend: bend(Vec3::new(p.x / r, p.y / r, 0.0)), bend_change: change } + }) + .collect() +} + +/// `count` straight cross-sections along x, `10` apart in z and rising in order: the run is +z. +/// Every frame leaves along +z except the last one, which leaves back into the blend along −z as +/// a bridge's far boundary does. `bend` and `change` are the same at every station, and `bend` is +/// perpendicular to the run (the only shape a support surface can have along a direction it does +/// not bend around). +fn line_sections(count: usize, n: usize, bend: Vec3, change: Vec3) -> Vec> { + (0..count) + .map(|i| { + let leave = if i + 1 == count { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + (0..n).map(|s| Frame { p: Vec3::new(10.0 * s as f64 / (n - 1) as f64, 0.0, 10.0 * i as f64), leave, bend, bend_change: change }).collect() + }) + .collect() +} + +/// A point at arclength `s` along the perimeter of the rounded rectangle of half-width `a`, +/// half-height `b` and corner radius `r` (in the z = 0 plane, counter-clockwise from the right +/// edge's lower end). +fn rounded_rect_at(a: f64, b: f64, r: f64, s: f64) -> Vec3 { + let (sa, sb) = ((a - r).max(0.0), (b - r).max(0.0)); + let quarter = std::f64::consts::FRAC_PI_2; + let segs = [2.0 * sb, quarter * r, 2.0 * sa, quarter * r, 2.0 * sb, quarter * r, 2.0 * sa, quarter * r]; + let mut left = s; + let mut k = 0; + while k + 1 < segs.len() && left > segs[k] { + left -= segs[k]; + k += 1; + } + let f = if segs[k] > 0.0 { left / segs[k] } else { 0.0 }; + let arc = |centre: Vec3, from: f64| { + let ang = from + quarter * f; + Vec3::new(centre.x + r * ang.cos(), centre.y + r * ang.sin(), 0.0) + }; + match k { + 0 => Vec3::new(a, -sb + 2.0 * sb * f, 0.0), + 1 => arc(Vec3::new(sa, sb, 0.0), 0.0), + 2 => Vec3::new(sa - 2.0 * sa * f, b, 0.0), + 3 => arc(Vec3::new(-sa, sb, 0.0), quarter), + 4 => Vec3::new(-a, sb - 2.0 * sb * f, 0.0), + 5 => arc(Vec3::new(-sa, -sb, 0.0), std::f64::consts::PI), + 6 => Vec3::new(-sa + 2.0 * sa * f, -b, 0.0), + _ => arc(Vec3::new(sa, -sb, 0.0), 3.0 * quarter), + } +} + +/// A rounded rectangle at height `z`, sampled at `n` even arclength steps around it: a section +/// that is not a scaled copy of the circles it can sit between, with a plan support (no bend). +fn rounded_rect_section(a: f64, b: f64, r: f64, z: f64, n: usize) -> Vec { + let total = 4.0 * ((a - r).max(0.0) + (b - r).max(0.0)) + 2.0 * std::f64::consts::PI * r; + (0..n) + .map(|s| { + let p = rounded_rect_at(a, b, r, total * s as f64 / n as f64); + Frame { p: Vec3::new(p.x, p.y, z), leave: Vec3::new(0.0, 0.0, 1.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO } + }) + .collect() +} + +/// Two parallel circular cylinders of radius `r` 20 apart in y, leaving towards each other along +/// ∓y, each bending up towards its own axis and with a curvature that does not change as it goes +/// round (`bridge_tests`'s own case, for the two-section comparison). +fn valley_between_cylinders(count: usize, r: f64) -> (Vec, Vec) { + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..count { + let y = 10.0 * i as f64 / (count - 1) as f64; + a.push(Frame { + p: Vec3::new(0.0, y, 0.0), + leave: Vec3::new(0.0, 1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0), + }); + b.push(Frame { + p: Vec3::new(0.0, y + 20.0, 0.0), + leave: Vec3::new(0.0, -1.0, 0.0), + bend: Vec3::new(0.0, 0.0, 1.0 / r), + bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0), + }); + } + (a, b) +} + +/// Three circles of different sizes at three heights, the middle one smaller: a shape that +/// changes as the face runs up it. +fn three_circles(n: usize) -> Vec> { + vec![ + // The first section is the equator of a sphere of its own radius: the support bends + // inwards by 1/r along the run, and its curvature does not change as it goes round. + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |radial| radial * -0.1, Vec3::new(0.0, 0.0, -0.01)), + // The middle section's own shape data are ignored: the blend reads it from its neighbours. + circle_section(8.0, 10.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + // The last section's frames leave back into the blend, along −z. + circle_section(12.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ] +} + +/// Every station of every section is on the face, within the tolerance a blend promises. +fn assert_runs_through(body: &Body, sections: &[Vec], n: usize) { + let grid = sample_grid(body, sections.len(), n).unwrap(); + for (k, section) in sections.iter().enumerate() { + for (s, f) in section.iter().enumerate() { + assert!(grid[k][s].dist(f.p) < 1e-9, "section {k} station {s}: {:?} vs {:?}", grid[k][s], f.p); + } + } +} + +#[test] +fn three_sections_run_through_every_station() { + let n = 9; + let sections = three_circles(n); + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The ends hold what their frames ask for: the leave direction is the tangent, and the + // support's own curvature (1/10 inwards on the sphere, nothing on the plane) is the face's. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + // The stations run round the circle at even angles. + let angle = std::f64::consts::TAU * s as f64 / n as f64; + let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); + let (_, d1, d2, d3) = run_derivs(&body, 0.0, v, false, 1e-3); + let (t, bend, _) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "the face leaves along +z: {t:?}"); + assert!(bend.dist(radial * -0.1) < 1e-4, "the face leaves the sphere with its curvature: {bend:?}"); + // The last section's frames leave back into the blend, so read the shape that way round. + let (_, d1, d2, d3) = run_derivs(&body, 1.0, v, true, 1e-3); + let (t, bend, _) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "and arrives along the far leave: {t:?}"); + assert!(bend.len() < 1e-3, "the last section's support is flat: {bend:?}"); + } + + // Three sections: the one interior joint has no fourth station to take a third difference + // over, so it holds position, tangent and curvature — C² — and both sides agree. + for s in [0usize, 4, 8] { + let v = s as f64 / (n - 1) as f64; + let (from_left, left1, left2, left3) = run_derivs(&body, 0.5, v, false, 1e-4); + let (from_right, right1, right2, right3) = run_derivs(&body, 0.5, v, true, 1e-4); + assert!(from_left.dist(from_right) < 1e-9, "the joint is on the middle section"); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "the tangent is continuous: {left1:?} vs {right1:?}"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "the curvature is continuous: {left2:?} vs {right2:?}"); + // But the change of curvature is not: a three-point stencil carries no third difference, + // so the joint is C² and not C³, and the difference stays whatever the step size is. + assert!((left3 - right3).len() > 0.05 * (1.0 + left3.len()), "a C² joint is not C³: {left3:?} vs {right3:?}"); + // What it holds is the sections' own finite differences along the run: the secant of the + // two neighbours for the tangent, their second difference for the curvature. + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-7 * (1.0 + secant.len()), "the tangent is the neighbours' secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-4 * (1.0 + second.len()), "the curvature is the neighbours' second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn the_ends_hold_the_shape_their_frames_ask_for() { + let stations = 7; + let bend = Vec3::new(0.0, 0.2, 0.0); + let change = Vec3::new(0.0, 0.5, 0.0); + let sections = line_sections(3, stations, bend, change); + let z = |s: f64| s * 10.0; + + // G1 holds the tangent and nothing more. + let g1 = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g1, 0.0, v, false, 1e-3); + let (t, _, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G1 blend leaves along leave: {t:?}"); + } + + // G2 holds the tangent and the support's curvature, at both ends. + let g2 = loft_blend(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (p, d1, d2, d3) = run_derivs(&g2, 0.0, v, false, 1e-3); + let (t, k, _) = curve_shape(d1, d2, d3); + assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G2 blend leaves along leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G2 blend holds the support's curvature: {k:?} vs {bend:?}"); + let (p, d1, d2, d3) = run_derivs(&g2, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9, "the face ends on the last section: {p:?}"); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "reading back, it arrives along the far leave: {t:?}"); + assert!(k.dist(bend) / bend.len() < 1e-3, "the far end holds the curvature too: {k:?}"); + assert!(c.len() < 1e3); + } + // A G2 blend leaves the change of curvature free: moving the frames' own value does not move + // the face at the first section, while a G3 blend follows it. + let (_, d1, d2, d3) = run_derivs(&g2, 0.0, 0.5, false, 1e-3); + let free = curve_shape(d1, d2, d3).2; + let moved = line_sections(3, stations, bend, change + Vec3::new(0.0, 1.0, 0.0)); + let g2_moved = loft_blend(&moved, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g2_moved, 0.0, 0.5, false, 1e-3); + assert!(curve_shape(d1, d2, d3).2.dist(free) < 1e-9, "a G2 blend does not read the far frame's change"); + + // G3 holds the change of curvature as well: its normal part is what a surface match can hold, + // and here the frame's own change is normal (along its curvature vector). + let g3 = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] { + let (_, d1, d2, d3) = run_derivs(&g3, 0.0, v, false, 1e-3); + let (t, k, c) = curve_shape(d1, d2, d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3, "a G3 blend still holds the curvature: {k:?}"); + assert!((c.y - change.y).abs() < 1e-3, "a G3 blend holds the change of curvature: {c:?} vs {change:?}"); + let (p, d1, d2, d3) = run_derivs(&g3, 1.0, v, true, 1e-3); + let (t, k, c) = curve_shape(-d1, d2, -d3); + assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01); + assert!(k.dist(bend) / bend.len() < 1e-3); + assert!((c.y - change.y).abs() < 1e-3, "and the far end holds it too: {c:?} vs {change:?}"); + assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9); + } + // With G3, moving the frames' own value does move the face there. + let g3_moved = loft_blend(&moved, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap(); + let (_, d1, d2, d3) = run_derivs(&g3_moved, 0.0, 0.5, false, 1e-3); + assert!((curve_shape(d1, d2, d3).2.y - free.y).abs() > 0.5, "a G3 blend follows the frame's change"); +} + +#[test] +fn four_sections_join_at_c3() { + let n = 9; + let sections: Vec> = [(10.0, 0.0), (9.0, 10.0), (8.0, 20.0), (11.0, 30.0)] + .iter() + .enumerate() + .map(|(i, &(r, z))| { + let leave = if i == 3 { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) }; + circle_section(r, z, n, leave, |_| Vec3::ZERO, Vec3::ZERO) + }) + .collect(); + let body = loft_blend(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // Every joint now has a fourth station for the third difference, so the two sides agree in + // the tangent, the curvature and its change: the joints are C³. + let h = 1.0 / 3.0; + for (i, s) in [(1usize, 0usize), (1, 4), (1, 8), (2, 0), (2, 4), (2, 8)] { + let t = i as f64 / 3.0; + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, left3) = run_derivs(&body, t, v, true, 1e-4); + let (_, right1, right2, right3) = run_derivs(&body, t, v, false, 1e-4); + assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "joint {i} station {s}: the tangent agrees"); + assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "joint {i} station {s}: the curvature agrees"); + // The change of curvature agrees too, as far as finite differences can tell: its + // mismatch falls away with the step size, so the joint is C³, not just C². + assert!( + (left3 - right3).len() < 2e-2 * (1.0 + left3.len()), + "joint {i} station {s}: the change of curvature agrees: {left3:?} vs {right3:?}" + ); + // What they hold are the sections' own differences along the run. + let p = |k: usize| sections[k][s].p; + let d1 = (p(i + 1) - p(i - 1)) * (1.0 / (2.0 * h)); + let d2 = (p(i + 1) - p(i) * 2.0 + p(i - 1)) * (1.0 / (h * h)); + let d3 = if i == 1 { + (p(0) * -1.0 + p(1) * 3.0 - p(2) * 3.0 + p(3)) * (1.0 / (h * h * h)) + } else { + (p(0) - p(1) * 3.0 + p(2) * 3.0 - p(3)) * (1.0 / (h * h * h)) + }; + assert!((left1 - d1).len() < 1e-7 * (1.0 + d1.len()), "the tangent is the secant: {left1:?} vs {d1:?}"); + assert!((left2 - d2).len() < 1e-4 * (1.0 + d2.len()), "the curvature is the second difference: {left2:?} vs {d2:?}"); + assert!((left3 - d3).len() < 2e-2 * (1.0 + d3.len()), "the change of curvature is the third difference: {left3:?} vs {d3:?}"); + } +} + +#[test] +fn a_shape_that_is_not_a_scaled_copy_still_runs_through() { + let n = 17; + let sections = vec![ + circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO), + rounded_rect_section(9.0, 7.0, 2.5, 10.0, n), + circle_section(8.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO), + ]; + let body = loft_blend(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, §ions, n); + + // The interior joint is still smooth to C² and holds the neighbours' own estimates, whatever + // the sections' shapes are. + for s in [0usize, 4, 8, 12, 16] { + let v = s as f64 / (n - 1) as f64; + let (_, left1, left2, _) = run_derivs(&body, 0.5, v, false, 2e-4); + let (_, right1, right2, _) = run_derivs(&body, 0.5, v, true, 2e-4); + assert!((left1 - right1).len() < 1e-6 * (1.0 + left1.len()), "station {s}: the tangent is continuous"); + assert!((left2 - right2).len() < 1e-6 * (1.0 + left2.len()), "station {s}: the curvature is continuous"); + let p = |k: usize| sections[k][s].p; + let secant = p(2) - p(0); + let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0; + assert!((left1 - secant).len() < 1e-6 * (1.0 + secant.len()), "station {s}: the tangent is the secant: {left1:?} vs {secant:?}"); + assert!((left2 - second).len() < 1e-6 * (1.0 + second.len()), "station {s}: the curvature is the second difference: {left2:?} vs {second:?}"); + } +} + +#[test] +fn a_two_section_blend_is_the_bridge() { + let (a, b) = valley_between_cylinders(9, 10.0); + let made = loft_blend(&[a.clone(), b.clone()], [Continuity::G2, Continuity::G2], [0.0, 0.0], 9).unwrap(); + let other = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap(); + // The blend runs with the sections, the bridge crosses from one boundary to the other: the + // same face, its two parameters the other way round. + for (run, station) in [(0.0, 0.0), (0.25, 0.3), (0.5, 0.7), (0.75, 0.1), (1.0, 1.0)] { + let x = from_p3(surface_of(&made).subs(run, station)); + let y = from_p3(surface_of(&other).subs(station, run)); + assert!(x.dist(y) < 1e-9, "run {run} station {station}: {x:?} vs {y:?}"); + } + let grid = sample_grid(&other, 9, 7).unwrap(); + for (row, frame) in grid.iter().zip(&a) { + assert!(row[0].dist(frame.p) < 1e-9); + } +} + +#[test] +fn crown_shapes_the_middle_without_moving_the_joints() { + let n = 9; + let sections = three_circles(n); + let mut heights = Vec::new(); + let mut joints = Vec::new(); + for crown in [-0.5, 0.0, 1.0, 2.0] { + let body = loft_blend(§ions, [Continuity::G2, Continuity::G2], [crown, crown], n).unwrap(); + // The interior joint holds the sections' own estimates whatever the crown does: those do + // not depend on it. + let (p, d1, d2, _) = run_derivs(&body, 0.5, 0.5, false, 2e-4); + assert!(p.dist(sections[1][4].p) < 1e-9, "the crown left the joint where the sections put it"); + joints.push((d1, d2)); + // Halfway up the first span is where a fuller crown reaches further. + heights.push(from_p3(surface_of(&body).subs(0.25, 0.5)).z); + assert_runs_through(&body, §ions, n); + } + for (d1, d2) in &joints { + let (base1, base2) = joints[0]; + assert!((*d1 - base1).len() < 1e-4 * (1.0 + base1.len()), "the crown left the joint's tangent alone: {d1:?}"); + assert!((*d2 - base2).len() < 1e-4 * (1.0 + base2.len()), "the crown left the joint's curvature alone: {d2:?}"); + } + let steps: Vec = heights.windows(2).map(|w| w[1] - w[0]).collect(); + assert!(steps.iter().all(|d| d.abs() > 1e-6 && d.signum() == steps[0].signum()), "a fuller crown moves the middle one way: {heights:?}"); +} + +#[test] +fn the_blend_refuses_what_it_cannot_hold() { + let n = 5; + let orders = [Continuity::G1, Continuity::G1]; + let good = three_circles(n); + assert!(loft_blend(&good, orders, [0.0, 0.0], n).is_ok()); + + // Fewer than two sections, and more than the cap. + assert!(loft_blend(&good[..1], orders, [0.0, 0.0], n).is_err(), "one section is no blend"); + let many: Vec> = + (0..=MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 3, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + assert!(loft_blend(&many, orders, [0.0, 0.0], 3).is_err(), "more than {MAX_BLEND_SECTIONS} sections"); + + // Station counts that do not match each other, or the count asked for. + let mut unequal = good.clone(); + unequal[1] = unequal[1][..n - 1].to_vec(); + assert!(loft_blend(&unequal, orders, [0.0, 0.0], n).is_err(), "unequal station counts"); + assert!(loft_blend(&good, orders, [0.0, 0.0], n + 1).is_err(), "the samples must match the sections"); + for samples in [0usize, 1, MAX_BLEND_STATIONS + 1] { + assert!(loft_blend(&good, orders, [0.0, 0.0], samples).is_err(), "samples {samples}"); + } + + // A crown outside its range, and one that is not a number. + for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] { + assert!(loft_blend(&good, orders, [crown, crown], n).is_err(), "crown {crown}"); + } + assert!(loft_blend(&good, orders, [0.0, f64::NAN], n).is_err()); + + // A frame that is not a number, wherever it is not, and a leave direction with no direction. + for pick in 0..4 { + let mut bad = good.clone(); + let frame = bad[1][2]; + bad[1][2] = match pick { + 0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frame }, + 1 => Frame { leave: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frame }, + 2 => Frame { bend: Vec3::new(0.0, f64::NAN, 0.0), ..frame }, + _ => Frame { bend_change: Vec3::new(0.0, 0.0, f64::NAN), ..frame }, + }; + assert!(loft_blend(&bad, orders, [0.0, 0.0], n).is_err(), "a non-finite frame {pick}"); + } + let mut no_leave = good.clone(); + no_leave[1][2].leave = Vec3::ZERO; + assert!(loft_blend(&no_leave, orders, [0.0, 0.0], n).is_err(), "a leave direction with no direction"); + + // Two sections that meet at a station: no room for a blend. + let mut meeting = good.clone(); + meeting[1] = meeting[0].clone(); + assert!(loft_blend(&meeting, orders, [0.0, 0.0], n).is_err(), "sections that meet"); + let mut touching = good.clone(); + touching[1][2].p = touching[0][2].p; + assert!(loft_blend(&touching, orders, [0.0, 0.0], n).is_err(), "a station where the sections meet"); + + // Every order from G0 to G3 builds, alone and together, and so does the largest blend. + for (near, far) in [ + (Continuity::G0, Continuity::G0), + (Continuity::G1, Continuity::G0), + (Continuity::G0, Continuity::G3), + (Continuity::G3, Continuity::G3), + (Continuity::G2, Continuity::G3), + ] { + let body = loft_blend(&good, [near, far], [0.0, 0.0], n).unwrap(); + assert_runs_through(&body, &good, n); + } + let full: Vec> = + (0..MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 5, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect(); + let body = loft_blend(&full, [Continuity::G3, Continuity::G3], [0.0, 0.0], 5).unwrap(); + assert_runs_through(&body, &full, 5); +} diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index ab50132a..d9f97c04 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -1,9 +1,10 @@ //! Blending a whole boundary loop of a planar face whose neighbours are walls standing //! perpendicular to it (planes, and cylinders with axes along the face normal): a pocket floor, -//! the top outline of an extruded plate. The loop must be smooth (lines and arcs meeting -//! tangentially). The face shrinks by the radius, the walls are trimmed by it, and each loop edge +//! the top outline of an extruded plate. Lines and arcs can meet tangentially or at mitred sharp +//! corners. The face shrinks by the radius, the walls are trimmed by it, and each loop edge //! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along -//! an arc, all meeting along the profile arcs at the joints. The result is exact. +//! an arc, all meeting along the profile curves at the joints. Sharp joints at perpendicular +//! curved walls follow the intersections of the exact offsets, fitted within tolerance. use std::collections::HashMap; @@ -56,6 +57,74 @@ impl Seg { } } +/// Intersection of two inward offsets nearest their shared corner, in the face plane. +fn offset_joint(prev: &Seg, cur: &Seg, n: Vec3, d: f64) -> Option { + let p = cur.start(); + let line_circle = |line: &Seg, arc: &Seg| { + let Seg::Arc { a, c, .. } = *arc else { return None }; + let tangent = line.tangent(p)?; + let origin = p + n.cross(tangent) * d; + let radius = a.dist(c) + n.cross(arc.tangent(p)?).dot((p - c).normalized()?) * d; + let foot = origin + tangent * (c - origin).dot(tangent); + let height2 = radius * radius - foot.dist(c).powi(2); + if !(radius > 0.0 && height2 >= 0.0) { + return None; + } + let (a, b) = (foot + tangent * height2.sqrt(), foot - tangent * height2.sqrt()); + Some(if a.dist(p) < b.dist(p) { a } else { b }) + }; + match (prev, cur) { + (Seg::Line { .. }, Seg::Arc { .. }) => line_circle(prev, cur), + (Seg::Arc { .. }, Seg::Line { .. }) => line_circle(cur, prev), + (Seg::Arc { a: a0, c: c0, .. }, Seg::Arc { a: a1, c: c1, .. }) => { + let delta = *c1 - *c0; + let distance = delta.len(); + let axis = delta.normalized()?; + let r0 = a0.dist(*c0) + n.cross(prev.tangent(p)?).dot((p - *c0).normalized()?) * d; + let r1 = a1.dist(*c1) + n.cross(cur.tangent(p)?).dot((p - *c1).normalized()?) * d; + let along = (r0 * r0 - r1 * r1 + distance * distance) / (2.0 * distance); + let height2 = r0 * r0 - along * along; + if !(r0 > 0.0 && r1 > 0.0 && height2 >= 0.0) { + return None; + } + let foot = *c0 + axis * along; + let across = n.cross(axis) * height2.sqrt(); + let (a, b) = (foot + across, foot - across); + Some(if a.dist(p) < b.dist(p) { a } else { b }) + } + _ => None, + } +} + +/// A curved-wall joint is generally not planar. Fit its exact offset slices rather than +/// projecting a circular section onto a tangent mitre and revolving that distorted section. +fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, r: f64, round: bool, tol: f64) -> Result { + use mt::ParametricCurve; + let point = |u: f64| { + let (d, h) = if round { + let (sin, cos) = (u * std::f64::consts::FRAC_PI_2).sin_cos(); + (r * (1.0 - sin), r * (1.0 - cos)) + } else { + (r * (1.0 - u), r * u) + }; + offset_joint(prev, cur, n, d).map(|p| p + n * (side * h)) + }; + for count in [8, 16, 32, 64, 128] { + let pts = (0..=count) + .map(|i| point(f64::from(i) / f64::from(count))) + .collect::>>() + .ok_or_else(|| too_large("the blend's offset walls do not meet"))?; + let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| unsupported("curved corner profile"))?; + if (0..count).all(|i| { + let u = (f64::from(i) + 0.5) / f64::from(count); + point(u).is_some_and(|p| p.dist(from_p3(curve.subs(u))) <= tol) + }) { + return Ok(mt::Curve::BSplineCurve(curve)); + } + } + Err(unsupported("could not fit the curved corner within tolerance")) +} + /// The exact shape of an oriented loop edge (a line, or an arc found from samples). fn seg_of(e: &mt::Edge, tol: f64) -> Option { use mt::{BoundedCurve, ParametricCurve}; @@ -158,10 +227,19 @@ fn face_normal(f: &mt::Face, p: Vec3) -> Option { Vec3::new(n.x, n.y, n.z).normalized() } -/// Blend the loop of `edges` (by id) if it is a whole smooth boundary loop of a planar face with +/// Blend `edges` (by id) if they form a boundary loop or neighbouring run of a planar face with /// perpendicular walls, with what it built from `solid` (each blend face in place of its loop /// edge). `None` when the edges are not such a loop. pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option> { + match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, r, round))) { + Ok(result) => result, + Err(error) => Some(Err(error)), + } +} + +fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option> { + #[cfg(test)] + assert!(crate::guarded(), "loop selection must run inside the kernel guard"); let tol = (size * 1e-6).max(1e-7); for (si, shell) in solid.boundaries().iter().enumerate() { let faces: Vec<&mt::Face> = shell.face_iter().collect(); @@ -204,6 +282,8 @@ fn build( round: bool, run: Option<(usize, usize)>, ) -> Result<(Solid, Rebuild)> { + #[cfg(test)] + assert!(crate::guarded(), "loop construction must run inside the kernel guard"); let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?; let all_edges: Vec = wire.edge_iter().cloned().collect(); @@ -226,7 +306,8 @@ fn build( // Joints: k round a whole loop; k + 1 along a run (its two ends included). let nj = if open { k + 1 } else { k }; let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k }; - // Joints: smooth, or sharp between two lines (the blends then meet in a mitre). + // Joints: smooth, or sharp where the walls meet in one straight seam (the blends + // then meet in a mitre). A loop can therefore mix lines and arcs at its corners. let mut mitre = vec![false; nj]; for i in 0..k { if open && i == 0 { @@ -236,7 +317,7 @@ fn build( let p = cur.start(); let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) }; if ta.dot(tb) < 1.0 - 1e-6 { - if !(matches!(prev, Seg::Line { .. }) && matches!(cur, Seg::Line { .. })) || ta.dot(tb) < -1.0 + 1e-3 { + if ta.dot(tb) < -1.0 + 1e-3 { return Err(unsupported("blending a loop with sharp corners next to arcs")); } if let Some(x) = mitre.get_mut(i) { @@ -340,6 +421,7 @@ fn build( let mut pa = Vec::with_capacity(nj); let mut pb = Vec::with_capacity(nj); let mut centres = Vec::with_capacity(nj); + let mut curved_joint = vec![false; nj]; // Joint i: the start of piece i (the end of the last piece for a run's far end). let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) }; for i in 0..nj { @@ -360,7 +442,18 @@ fn build( }; let x = d0.cross(d1); let lam = ((m * sb - m0 * sb0).cross(d1)).dot(x) / x.len2(); - pa.push(p + m0 * sb0 + d0 * lam); + let exact = (matches!(segs.get(ip), Some(Seg::Arc { .. })) || matches!(segs.get(i), Some(Seg::Arc { .. }))) + && t.dot(n * side) > 1.0 - 1e-9 + && t0.dot(n * side) > 1.0 - 1e-9; + if exact { + let (Some(prev), Some(cur)) = (segs.get(ip), segs.get(i)) else { return Err(unsupported("corner segments")) }; + pa.push(offset_joint(prev, cur, n, r).ok_or_else(|| too_large("the blend's offset walls do not meet"))?); + if let Some(slot) = curved_joint.get_mut(i) { + *slot = true; + } + } else { + pa.push(p + m0 * sb0 + d0 * lam); + } let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?; let (h0, h1) = (e.dot(t0), e.dot(t)); if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 { @@ -453,6 +546,12 @@ fn build( let mut profiles: Vec = Vec::with_capacity(nj); for i in 0..nj { let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) }; + if curved_joint.get(i).copied().unwrap_or(false) { + let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { return Err(unsupported("corner segments")) }; + let curve = offset_profile(prev, cur, n, side, r, round, (r * 1e-7).max(1e-9))?; + profiles.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("curved corner: {e}")))?); + continue; + } if !round { profiles.push(builder::line(a, b)); continue; @@ -471,6 +570,8 @@ fn build( let cc = centre(i) + d1 * lam; let q = p + t * sb + d1 * lam; let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r; + // q projects onto the wall rail at pb_i; fitting through pb_i before projection + // would distort the circular cross-section at mixed line/arc corners. let arc = builder::circle_arc(&builder::vertex(p3(pa_i)), &builder::vertex(p3(q)), p3(mid)); let nb = (pa_i - p).cross(pb_i - p).normalized().ok_or_else(|| unsupported("mitre plane"))?; let den = d1.dot(nb); @@ -692,7 +793,19 @@ fn build( let meridian = builder::circle_arc(&top, &bottom, eq); builder::rsweep(&meridian, p3(c), v3(w), mt::Rad(std::f64::consts::TAU)) } - Seg::Arc { c, axis, angle, .. } => builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)), + Seg::Arc { c, axis, angle, .. } + if !curved_joint.get(i).copied().unwrap_or(false) && !curved_joint.get(nxt(i)).copied().unwrap_or(false) => + { + builder::rsweep(pr, p3(c), v3(axis), mt::Rad(angle)) + } + Seg::Arc { a, c, axis, angle, .. } => { + // Sweep a radial section, not the shared corner curve: the latter can be + // nonplanar and would change the round's radius all along this edge. + let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?; + let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sb))); + let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i))) } else { builder::line(&fa, &wa) }; + builder::rsweep(&base, p3(c), v3(axis), mt::Rad(angle)) + } }; let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?; // Outward: toward the profile centre on a concave loop, away from it on a convex one. diff --git a/crates/kernel/src/native/metadata.rs b/crates/kernel/src/native/metadata.rs index 1dccec7f..f87bd4ea 100644 --- a/crates/kernel/src/native/metadata.rs +++ b/crates/kernel/src/native/metadata.rs @@ -32,7 +32,8 @@ fn segment(s: &Seg2) -> Result { Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w }, Seg2::Bezier { n, p } => { let count = usize::from(n) + 1; - if !(5..=8).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) + { return Err(invalid("unsupported certificate Bezier layout")); } w::Segment::Bezier { points: p.get(..count).ok_or_else(|| invalid("Bezier count"))?.iter().copied().map(point2).collect() } @@ -46,10 +47,10 @@ fn load_segment(s: w::Segment) -> Result { w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) }, w::Segment::Conic { points: [a, apex, b], middle_weight } => Seg2::Conic { a: p2(a), apex: p2(apex), b: p2(b), w: middle_weight }, w::Segment::Bezier { points } => { - if !(5..=8).contains(&points.len()) { + if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&points.len()) { return Err(invalid("certificate Bezier degree")); } - let mut p = [Vec2::ZERO; 8]; + let mut p = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; let n = (points.len() - 1) as u8; for (slot, value) in p.iter_mut().zip(points) { *slot = p2(value); @@ -324,7 +325,7 @@ mod tests { for value in sources { assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap()); } - assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; 9] }).is_err()); + assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; solidcraft_geom::MAX_BEZIER_CTRL + 1] }).is_err()); } #[test] diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 5cfc713c..f084660c 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -300,6 +300,18 @@ thread_local! { /// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`, /// whichever way it was made, or none where a step it took does not report one. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { + crate::profile::timed(crate::profile::Kind::Boolean, || boolean_untimed(a, b, op)) +} + +fn boolean_untimed(a: &Body, b: &Body, op: BoolOp) -> Result> { + // Earlier coarse bores can leave a junction a later pattern copy cannot intersect. + // Resolve conical-cut junctions from the first copy, while the operands are unchanged. + if op == BoolOp::Cut && !APART.with(|c| c.get()) && conical_cut_junction(a, b) { + let refined = |a: &Body, b: &Body, op| boolean_whole_with_refinement(a, b, op, true); + if let Ok(result) = boolean_via(a, b, op, &refined) { + return Ok(result); + } + } let r = boolean_via(a, b, op, &boolean_whole); if r.is_ok() || !crate::tangent_cylinders::needed(a, b) { return r; @@ -312,6 +324,19 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { boolean_via(a, b, op, &split).or(r) } +/// A conical cutter meeting a body with an earlier conical cut needs finer tracing at +/// their junctions. Guard the analytic recognition as well as the Boolean itself. +fn conical_cut_junction(a: &Body, b: &Body) -> bool { + guard("conical cut junction", || { + let conical = |body: &Body| { + let tol = (body.size() * 1e-5).max(1e-7); + body.solid.face_iter().any(|face| matches!(crate::offset::surf_of(face, tol), Some(crate::offset::Surf::Cone { .. }))) + }; + Ok(conical(b) && conical(a)) + }) + .unwrap_or(false) +} + /// [`boolean`], the boolean of the whole bodies taken by `whole`. fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Result> { let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| whole(a, b, op)); @@ -363,7 +388,9 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool match op { BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()), BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), - BoolOp::Intersect => {} + // Every face of an intersection lies on one operand's face, as a union's does: the same + // roles carry over (#565). + BoolOp::Intersect => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), } // A retry's result is held to what the operation can do at all; one that fails is refused // rather than returned wrong (issue #616). @@ -489,6 +516,15 @@ fn meeting_size(a: &Body, b: &Body) -> f64 { if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY } } +/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't). +/// Unlike [`meeting_size`], this follows curved faces: a cylinder's vertices are only the points +/// on its seam, so its vertex box says nothing about where its side reaches. +fn mesh_meeting_size(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> f64 { + let (ba, bb) = (a.bounds(), b.bounds()); + let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size(); + if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY } +} + /// Whether a result volume is plausible for `op`: within the bounds the operands' volumes /// leave, plus the slack the meshed volumes are measured to. fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool { @@ -546,6 +582,10 @@ fn swept_prism(a: &Body, b: &Body, op: BoolOp) -> Option { } fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { + boolean_whole_with_refinement(a, b, op, false) +} + +fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -> Result> { if let Some(body) = swept_prism(a, b, op) { return Ok(Some(body)); } @@ -559,7 +599,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // refined: a round side trimmed to a thin band (a short boss crossing a gently sloping // floor) is otherwise spanned by triangles cutting through it, which reads as missing // material. - let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); + let mut tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); // A prism pair (a plate and the tool through it) is decided exactly in 2D, with no mesh of // either operand. The sampled shortcuts below need a refined mesh of both, and that mesh is // most of what a boolean costs: a pattern or a multi-profile sketch rebuilds the whole @@ -583,7 +623,30 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { return Ok(Some(body.clone())); } } - let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); + let mut meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); + // The vertex boxes above miss a curved operand's reach (a cylinder has only seam vertices), + // so a thin meeting region of curved bodies kept the coarse step: a sliver 0.5 mm thick was + // faceted at 0.017 mm and a correct result failed the membership test. The meshes just made + // do span the bodies, so the step is set again from where they meet and the meshes are + // remade once, when that is a much finer step. + let refined = match &meshes { + // A meeting box only a few steps thick is the tessellation error of bodies that merely + // touch or miss each other: a finer mesh there cannot help and only slows the operation. + (Some(ma), Some(mb)) => { + let meet = mesh_meeting_size(ma, mb); + (meet > tol_m * 8.0).then(|| (size * 5e-4).min(meet * 2e-3).max(size * 1e-5)) + } + _ => None, + }; + if let Some(refined) = refined + && refined < tol_m * 0.5 + { + let again = (a.tessellate_with(refined, true).ok(), b.tessellate_with(refined, true).ok()); + if again.0.is_some() && again.1.is_some() { + tol_m = refined; + meshes = again; + } + } let (va, vb) = match &meshes { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume), _ => (volume(a), volume(b)), @@ -620,6 +683,25 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { }; (consistent, v) }; + if refine { + let keep: Vec> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); + // Bounded to two finer meshes; no tool displacement or relaxed acceptance tests. + for k in [1e-5, 1e-6] { + let tol = (size * k).max(1e-6); + if let Ok((solid, history)) = truck_boolean(a, b, op, None, tol) + && !solid.boundaries().is_empty() + && let Ok(body) = guard("refined boolean result", || recorded::truck_result(solid, history, size, &keep, &counts)) + && check(&body).0 + && crate::boolean_guard::check_retry(a, b, op, Some(&body)).is_ok() + { + let mut event = BooleanFallback::new(BooleanStrategy::RefinedTolerance); + event.tolerance_mm = tol; + record(event); + return Ok(Some(body)); + } + } + return Err(KernelError::Failed("no reliable result at refined tolerances".into())); + } // One body clear of or inside the other: no intersection needed. // Kept for the end when the facetings disagree with it: bodies that touch along curved // faces can pass through each other slightly, and often can't be intersected. @@ -817,6 +899,18 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { return Ok(Some(body)); } } + // A prism tool whose walls only touch the target (a corner of its outline on a ring's bore + // or in its end face, the wall beside it outside the ring): with those walls pushed + // outward, clear of the target, it takes the same cut exactly, without the contact the + // tries below fail on (and the nudge after them got through only seconds later, leaving a + // face of no width at the corner). + if attempt == 1 + && op == BoolOp::Cut + && let (Some(ma), Some(_)) = &meshes + && let Some(body) = pushed_walls_cut(a, b, ma, 2.0 * tol_m, size, &keep, &counts, &check) + { + return Ok(Some(body)); + } // A prism tool whose target is a prism along its direction only round it (a pocket made // deeper in a plate with a chamfered rim, a ring cut round a counterbored hole in a // chamfered disc): the exact 2D route in a column cut round the tool. Its walls lying on @@ -830,6 +924,18 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { return Ok(Some(body)); } } + // A whole turn of a cylinder or cone bounded by one wire along its seam (as STEP files + // write them) can't be divided by a loop lying inside it, and the tries below end in a + // nudged tool, or fail: the same boolean with such faces in two halves, which is exact. + if attempt == 1 && crate::seam_faces::needed(a, b) { + let (halves, diagnostics) = capture_boolean_diagnostics(|| crate::seam_faces::boolean(a, b, op, &boolean)); + if let Some(Ok(r)) = halves + && r.as_ref().is_none_or(|body| check(body).0) + { + merge(diagnostics); + return Ok(r); + } + } let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); for k in [2e-3, 5e-4] { let tol = (size * k).max(1e-5); @@ -1038,6 +1144,39 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { } } +/// `a` cut by `b` with the walls of `b` that only touch `a` pushed outward (see +/// [`crate::touching_walls`]), at each of the general boolean's tolerances by a little more than +/// it (at least `by`, past the sag of `target`, the mesh of `a`): the first result that checks +/// and whose faces lie on none of the pushed walls. The pushed tool keeps `b`'s order, so the +/// history holds for `b`. +#[allow(clippy::too_many_arguments)] +fn pushed_walls_cut( + a: &Body, + b: &Body, + target: &solidcraft_geom::Mesh, + by: f64, + size: f64, + keep: &[Vec], + counts: &[(usize, usize)], + check: &dyn Fn(&Body) -> (bool, f64), +) -> Option { + let inside = target.inside_index(); + for k in [2e-3, 5e-4] { + // Pushed past what the boolean takes for touching, and past the mesh's sag. + let tol = (size * k).max(1e-5); + let Some(pushed) = crate::touching_walls::pushed_tool(b, &inside, (2.5 * tol).max(by)) else { continue }; + let Ok((s, h)) = truck_boolean(a, &pushed.tool, BoolOp::Cut, None, tol) else { continue }; + if s.boundaries().is_empty() || s.face_iter().next().is_none() { + continue; + } + let Ok(body) = guard("boolean result", || recorded::truck_result(s, h, size, keep, counts)) else { continue }; + if pushed.untouched(&body) && check(&body).0 { + return Some(body); + } + } + None +} + /// The screw motions a tool is nudged by as a last resort: (direction of the move, axis of the /// turn), each tried both ways. The first is the one every nudge used before #1490; the others /// are asked only when it fails both ways. @@ -1183,6 +1322,10 @@ pub(crate) fn by_lumps(a: &Body, b: &Body, la: Vec, lb: Vec, op: Boo /// intersection work (many identical pattern tools, #261). `None` when any two may meet, or a /// body is a mesh: the caller unions those the usual way. pub fn union_apart(bodies: &[Body]) -> Result> { + crate::profile::timed(crate::profile::Kind::Boolean, || union_apart_untimed(bodies)) +} + +fn union_apart_untimed(bodies: &[Body]) -> Result> { if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) { return Ok(None); } @@ -1221,6 +1364,10 @@ pub fn union_apart(bodies: &[Body]) -> Result> { /// shows they are apart (profiles apart in their sketch, extruded alike). `None` for fewer than /// two bodies or a mesh. pub fn join_apart(bodies: &[Body]) -> Result> { + crate::profile::timed(crate::profile::Kind::Boolean, || join_apart_untimed(bodies)) +} + +fn join_apart_untimed(bodies: &[Body]) -> Result> { if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) { return Ok(None); } @@ -1426,15 +1573,99 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: pub fn split_by_plane(body: &Body, plane: &solidcraft_geom::Plane) -> Result> { let mut out = Vec::new(); for positive in [true, false] { - out.extend(half_space(body, plane, positive)?); + out.extend(half_space(body, plane, positive)?.0); } Ok(out) } -/// The part of a body on one side of a plane (`None` when nothing is there): its intersection -/// with [`half_box`] (whose history it carries, over the body and the box). -pub(crate) fn half_space(body: &Body, plane: &solidcraft_geom::Plane, positive: bool) -> Result> { - boolean(body, &half_box(body, plane, positive)?, BoolOp::Intersect) +/// The part of a body on one side of a plane (`None` when nothing is there), and the tool it is +/// cut with: the part is the body's intersection with [`half_box`] (the tool), whose history it +/// carries, over the body and the box. The box's face in the plane, the part's cut face, is its +/// start cap on the positive side and its end cap on the negative side. +pub fn half_space(body: &Body, plane: &solidcraft_geom::Plane, positive: bool) -> Result<(Option, Body)> { + // The box's own construction roles describe no face of the design: the cut face has none. + let tool = half_box(body, plane, positive)?.with_face_sources(None); + let (part, report) = capture_boolean_diagnostics(|| boolean(body, &tool, BoolOp::Intersect)); + // Faces of the body lying in the plane coincide with the box's cap, which the boolean may + // only take by nudging the box, by a share of its own size: the cut face then moves off the + // plane, a different way for each part, and the parts lose the slab between (#1510). Moved + // off the plane to the side the box leaves out, those faces change nothing of the part, and + // the cap meets no face. Asked only when the boolean failed or nudged the box: a part made + // without, its faces' names included, stays as it is. + let nudged = report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge); + if (part.is_err() || nudged) + && let Some(moved) = off_plane(body, plane, positive) + { + let (exact, moved_report) = capture_boolean_diagnostics(|| boolean(&moved, &tool, BoolOp::Intersect)); + if let Ok(exact) = exact { + merge(moved_report); + // The part's history over [body, box], through the moved body (its faces are the body's). + let hb = recorded::placed(recorded::through(&moved, 0, recorded::kept(body, 0, 1).as_ref()), 0, 2); + let ht = recorded::placed(recorded::kept(&tool, 0, 1), 1, 2); + let counts = record::counts(&[body, &tool]); + let exact = exact.map(|r| { + let h = recorded::of_pair(&r, &hb, &ht); + r.with_checked_history(h, &counts) + }); + return Ok((exact, tool)); + } + } + merge(report); + Ok((part?, tool)) +} + +/// `body` with its planar faces lying in `plane` moved off it, a thousandth of the body's size, +/// to the side a split's part on the `positive` side leaves out. `None` when no face lies there +/// or moving them doesn't give a sound body. +pub(crate) fn off_plane(body: &Body, plane: &solidcraft_geom::Plane, positive: bool) -> Option { + let m = plane.normal().normalized()?; + let keep = if positive { m } else { -m }; + let size = body.size(); + let tol = (size * 1e-7).max(1e-9); + // Lying in the plane means in it, not within the boolean's tolerance of it: a face that close + // but off it bounds a real sliver, which moving the face would take or add. + let exact = (size * 1e-9).max(1e-12); + let delta = size * 1e-3; + let mut out: Option = None; + for n in [keep, -keep] { + let d = n.dot(plane.origin); + // The faces `push_faces` takes for this plane (by their surface), which must all lie in + // it: one only nearly parallel, its origin in the plane, would be moved where it isn't. + let taken: Vec<&mt::Face> = body + .solid + .face_iter() + .filter(|f| match f.oriented_surface() { + mt::Surface::Plane(p) => { + let q = p.normal(); + Vec3::new(q.x, q.y, q.z).normalized().is_some_and(|q| q.dot(n) > 1.0 - 1e-9) + && (n.dot(from_p3(p.origin())) - d).abs() < tol * 10.0 + } + _ => false, + }) + .collect(); + if taken.is_empty() { + continue; + } + // Its vertices and points along its edges (a disc has one vertex). + let lies = |f: &mt::Face| { + use mt::{BoundedCurve, ParametricCurve}; + let on = |p: mt::Point3| (n.dot(from_p3(p)) - d).abs() < exact; + f.vertex_iter().all(|v| on(v.point())) + && f.edge_iter().all(|e| { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + [0.25, 0.5, 0.75].iter().all(|k| on(c.subs(t0 + (t1 - t0) * k))) + }) + }; + if !taken.iter().all(|f| lies(f)) { + return None; + } + // Along -keep: inwards for a face facing the kept side, outwards for one facing away. + let shift = if n.dot(keep) > 0.0 { -delta } else { delta }; + let cur = out.as_ref().unwrap_or(body); + out = Some(crate::coplanar::push_faces(cur, n, d, shift).ok().filter(|b| b.validity().is_empty())?); + } + out } /// A box covering everything of `body` on one side of a plane, its start cap in the plane. diff --git a/crates/kernel/src/par.rs b/crates/kernel/src/par.rs index 5e08be76..b18ea88b 100644 --- a/crates/kernel/src/par.rs +++ b/crates/kernel/src/par.rs @@ -33,6 +33,37 @@ pub fn for_each_on_threads(items: &[T], max_threads: usize, f: impl Fn( let _ = map_on_threads(items, max_threads, f); } +/// Independent kernel jobs, with one computation lane per native dispatcher. Each dispatcher +/// owns a single-thread Rayon pool: nested triangulation neither queues behind a common +/// one-thread pool nor multiplies the requested concurrency. No second body can be stolen by +/// a worker while it fills a shared lazy mesh cell. The browser runs serially. +pub fn map_with_local_pools(items: &[T], max_threads: usize, f: impl Fn(&T) -> Result + Sync) -> Vec> { + #[cfg(not(target_arch = "wasm32"))] + let one = |item: &T| { + use std::cell::OnceCell; + thread_local! { + static POOL: OnceCell = const { OnceCell::new() }; + } + POOL.with(|cell| { + if cell.get().is_none() { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .map_err(|e| crate::KernelError::Failed(format!("cannot start mesh worker: {e}")))?; + let _ = cell.set(pool); + } + let pool = cell.get().ok_or_else(|| crate::KernelError::Failed("missing mesh worker".into()))?; + pool.install(|| guard("mesh worker", || f(item))) + }) + }; + #[cfg(target_arch = "wasm32")] + let one = |item: &T| guard("mesh worker", || f(item)); + map_on_threads(items, max_threads, one) + .into_iter() + .map(|r| r.unwrap_or_else(|| Err(crate::KernelError::Internal("mesh worker failed".into())))) + .collect() +} + /// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an /// item panicked). pub fn map_on_threads(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec> { @@ -82,6 +113,19 @@ mod tests { use super::*; use crate::KernelError; + #[test] + fn local_pools_keep_order_and_errors_and_bound_nested_parallelism() { + let items: Vec = (0..32).collect(); + let result = map_with_local_pools(&items, 8, |i| { + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(rayon::current_num_threads(), 1); + if *i == 7 { Err(KernelError::Invalid("seven".into())) } else { Ok(*i * 2) } + }); + for (i, r) in result.iter().enumerate() { + assert_eq!(r.as_ref().ok().copied(), (i != 7).then_some(i * 2)); + } + } + #[test] fn each_item_once_on_threads() { let items: Vec = (0..1000).collect(); diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index 7d03a82d..951c3216 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -227,13 +227,36 @@ impl NotPlanar { } } +/// The kind of curved surface `face` lies on, or None when it lies on a plane. `flat`: whether +/// its mesh is flat. The meshes here are as coarse as a thousandth of the body, which spans a +/// round much smaller than that (a 2 mm corner of a 1.2 m channel) with one flat strip of +/// facets: the surface decides, and the mesh only for a surface none of these kinds fits. +fn curved_kind(face: &mt::Face, flat: bool, tol: f64) -> Option<&'static str> { + use crate::offset::{Surf, surf_of}; + if matches!(face.surface(), mt::Surface::Plane(_)) { + return None; + } + match surf_of(face, tol) { + Some(Surf::Plane { .. }) => None, + Some(Surf::Cylinder { .. }) => Some("cylinder"), + Some(Surf::Cone { .. }) => Some("cone"), + Some(Surf::Sphere { .. }) => Some("sphere"), + Some(Surf::Torus { .. }) => Some("torus"), + None => (!flat).then_some("freeform"), + } +} + +/// The tolerance [`curved_kind`] tells surfaces apart by: face_surface's, with the body's +/// bounds computed once. Walking to each indexed face separately made curved-body diagnostics +/// quadratic. +fn kind_tol(b: &Body) -> f64 { + (b.size() * 1e-7).max(1e-9) * 100.0 +} + /// The plane of each face (from its mesh, which must be flat), or what stands in the way: the /// curved faces by the kind of surface they lie on. fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result, NotPlanar> { - use crate::offset::{Surf, surf_of}; - // Match face_surface's classification tolerance, but compute the body bounds only - // once. Walking to each indexed face separately made curved-body diagnostics quadratic. - let surface_tol = (b.size() * 1e-7).max(1e-9) * 100.0; + let surface_tol = kind_tol(b); let mut surfaces = b.solid.face_iter(); let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new(); let mut out = Vec::with_capacity(faces.len()); @@ -247,19 +270,10 @@ fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result out.push(PlaneEq { n, w: n.dot(f.centroid) }), - None => { - let kind = match surf_of(surface, surface_tol) { - Some(Surf::Cylinder { .. }) => "cylinder", - Some(Surf::Cone { .. }) => "cone", - Some(Surf::Sphere { .. }) => "sphere", - Some(Surf::Torus { .. }) => "torus", - Some(Surf::Plane { .. }) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), - None => "freeform", - }; - *curved.entry(kind).or_insert(0) += 1; - } + match (f.plane_normal, curved_kind(surface, f.plane_normal.is_some(), surface_tol)) { + (Some(n), None) => out.push(PlaneEq { n, w: n.dot(f.centroid) }), + (None, None) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))), + (_, Some(kind)) => *curved.entry(kind).or_insert(0) += 1, } } if surfaces.next().is_some() { @@ -437,9 +451,14 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option> = + a.solid.face_iter().zip(&infos).map(|(f, info)| info.plane_normal.filter(|_| curved_kind(f, true, surface_tol).is_none())).collect(); let mut curved: BTreeMap = BTreeMap::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { - if infos.get(*fi as usize)?.plane_normal.is_some() { + if planes.get(*fi as usize)?.is_some() { continue; } let bx = curved.entry(*fi).or_insert(solidcraft_geom::Aabb3::EMPTY); @@ -458,7 +477,7 @@ pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect(); + let flat: Vec<(Vec3, Vec3)> = planes.iter().zip(&infos).filter_map(|(n, f)| Some(((*n)?, f.centroid))).collect(); let gap = size * 1e-4; let place = |k: usize, near: f64, far: f64| -> Option { let axis = *axes.get(k)?; @@ -1223,3 +1242,59 @@ mod diagnostic_tests { assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into())); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod small_round_tests { + use std::f64::consts::FRAC_PI_2; + + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + + use super::*; + + /// An `l` x `w` x 1 plate on XY with its four corners rounded (radius `r`), and a hole of + /// diameter 4 through it 100 from one end when `hole`. + fn rounded_plate(l: f64, w: f64, r: f64, hole: bool) -> Body { + let (x, y) = (l / 2.0, w / 2.0); + let corners = [(x - r, y - r), (r - x, y - r), (r - x, r - y), (x - r, r - y)]; + let mut segs = Vec::new(); + for (k, (cx, cy)) in corners.iter().enumerate() { + let start = k as f64 * FRAC_PI_2; + let at = |t: f64| Vec2::new(cx + r * t.cos(), cy + r * t.sin()); + segs.push(Seg2::Arc { center: Vec2::new(*cx, *cy), radius: r, start, sweep: FRAC_PI_2 }); + let (nx, ny) = corners[(k + 1) % 4]; + let next = start + FRAC_PI_2; + segs.push(Seg2::Line { a: at(next), b: Vec2::new(nx + r * next.cos(), ny + r * next.sin()) }); + } + let holes = if hole { vec![Loop2::circle(Vec2::new(x - 100.0, 0.0), 2.0).reversed()] } else { Vec::new() }; + crate::extrude_planar_regions(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes }], 0.0, 1.0).unwrap() + } + + /// Faces of `b` on cylinders of radius `r`. + fn cylinders(b: &Body, r: f64) -> usize { + (0..b.face_count()) + .filter(|&i| matches!(crate::face_surface(b, i), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-9)) + .count() + } + + /// A pocket cut into the middle of a long plate by the exact fallback leaves the plate's + /// small corner rounds round. Meshed at a thousandth of the 1200 mm plate (1.2 mm), each + /// 2 mm quarter round (its chord 0.59 mm inside the arc) is one flat strip of facets, which + /// was taken for a plane: the whole plate went to the polygon boolean (and, beside a round + /// hole, the rounds went into the planar slab around the pocket), and the rounds came back + /// flat chamfers, each taking r^2 (pi/4 - 1/2) = 1.14 mm^3 more than its round. + #[test] + fn a_long_plates_small_rounds_stay_round_beside_a_pocket() { + for hole in [false, true] { + let plate = rounded_plate(1200.0, 40.0, 2.0, hole); + assert_eq!(cylinders(&plate, 2.0), 4 + 2 * usize::from(hole)); + // 10 x 5, 0.1 deep from the top. + let tool = crate::box_solid(Vec3::new(-5.0, -2.5, 0.9), Vec3::new(5.0, 2.5, 1.0)).unwrap(); + let r = planar_boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap(); + assert_eq!(cylinders(&r, 2.0), 4 + 2 * usize::from(hole), "hole {hole}"); + // The pocket's 10 x 5 x 0.1 and nothing else (a flattened round takes 1.14 more). + let removed = crate::measure(&plate).unwrap().volume - crate::measure(&r).unwrap().volume; + assert!((removed - 5.0).abs() < 1e-3, "hole {hole}: removed {removed}"); + } + } +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 58b9cd80..41f989b7 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1252,6 +1252,15 @@ fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section { let mut loop_tags = Vec::new(); for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near); + // A sliver the arrangement found where a curve's arcs nearly touch another part of + // the outline (at the waist of a 3) closes up to nothing on the exact curves: it + // encloses nothing and would make edges with no length, so it is left out. + if segs.iter().map(Seg2::length).sum::() <= near { + if l == 0 { + break; + } + continue; + } loops.push(Loop2 { segs }); loop_tags.push(ts); } @@ -1300,10 +1309,15 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec near { + for m in k..=j { + let q = (first + m) % n; + pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q))); + } } } else { let t = Tag { end: tag((first + j) % n).end, ..tag(i) }; @@ -1313,6 +1327,9 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec 1e-9 && t < 1.0 - 1e-9; for i in 0..m { @@ -1690,6 +1707,11 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { { return None; } + // A point within `tol` of an end is that end (the sewing welds them into one): a cut + // there would leave a piece with no length. + if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol { + return None; + } let t = seg.closest_param(*p); (t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t) }) @@ -1951,13 +1973,38 @@ fn sew_connected_sections( }; let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z); + // One edge between two ends, whichever way a wall or a cap reads the curve there: a piece of + // a font's curve so short that one section reads it as an arc within the tolerance, where + // another has the curve itself (the same letters cut again), is one edge. The first reading + // stands for all of them. + let readings: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + let same_edge = |spec: EdgeSpec, a: Vec3, b: Vec3, mid: Vec3| -> EdgeSpec { + let mut known = readings.borrow_mut(); + for (ka, kb, km, k) in known.iter() { + if km.dist(mid) <= tol * 10.0 { + if *ka == a && *kb == b { + return k.clone(); + } + if *ka == b && *kb == a { + return k.reversed(); + } + } + } + known.push((a, b, mid, spec.clone())); + spec + }; let edge = |sg: &Seg2, z: f64| -> Option { Some(match sg { Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) }, - Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) }, + Seg2::Arc { .. } => { + let (a, b, mid) = (welded(sg.start(), z), welded(sg.end(), z), at(sg.point_at(0.5), z)); + same_edge(EdgeSpec::Arc { a, b, mid }, a, b, mid) + } _ => { let [p0, p1, p2, p3] = cubic_of(sg)?; - EdgeSpec::Chain { ctrl: same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]).to_vec().into() } + let c = same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]); + let mid = (c[0] + (c[1] + c[2]) * 3.0 + c[3]) * 0.125; + same_edge(EdgeSpec::Chain { degree: 3, ctrl: c.to_vec().into() }, c[0], c[3], mid) } }) }; diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index 27ec3b76..e4e03dd2 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -5,6 +5,9 @@ use solidcraft_geom::{Plane, Seg2, Vec2}; use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve}; const MAX_DEGREE: usize = 32; +/// How far an arc read off a curve may end from the curve's end (mm): half the gap a profile +/// loop may have between segments and still close (`crate::build`). +const ARC_END_TOL: f64 = 5e-7; const MAX_CONTROLS: usize = 4096; type H = [f64; 3]; @@ -353,7 +356,10 @@ fn arc(c: &mt::Curve, spans: &[Vec], plane: &Plane, a: Vec2, b: Vec2, tol: f6 } let start = (a - center).angle(); let end = center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius; - if end.dist(b) + error > tol { + // The arc ends where the curve does, as closely as a profile loop must close: within `tol` + // only (which grows with the model), a curve that is merely near a circle (a font's) would + // come back as an arc ending off the next segment's start, and the loop would not close. + if end.dist(b) + error > tol.min(ARC_END_TOL) { return None; } let closed = a.dist(b) < tol; @@ -471,6 +477,29 @@ mod tests { assert!(segment(&arc, &Plane::XY, 1e-5, &mut 0).is_none()); } + /// A curve read as an arc ends where the curve does, however large the tolerance (it grows + /// with the part): a font's quadratic that is within a micron of a circle, not on one, is + /// kept as the curve it is. Read as an arc whose end is a micron off, the outline of a letter + /// cut into a 2 m plate did not close. + #[test] + fn a_curve_near_a_circle_is_not_an_arc_ending_off_it() { + // A parabola within a micron of the circle through its ends and middle. + let e = edge(&[0., 2., 0.], &[0., 0., 0.2], &[1.], 2); + let (a, b) = (Vec2::new(0., 0.), Vec2::new(2., 0.2)); + assert!(from_p3(e.back().point()).dist(solidcraft_geom::Vec3::new(2., 0.2, 0.)) < 1e-12); + for tol in [1e-5, 2e-3] { + let segs = segment(&e, &Plane::XY, tol, &mut 2_000_000).unwrap(); + let (first, last) = (segs.first().unwrap(), segs.last().unwrap()); + assert!(first.start().dist(a) < 1e-9 && last.end().dist(b) < 1e-9, "{tol}: {segs:?}"); + } + // A true arc is still one at either tolerance. + let (p, q) = (mt::Vertex::new(mt::Point3::new(10., 0., 0.)), mt::Vertex::new(mt::Point3::new(0., 10., 0.))); + let arc = builder::circle_arc(&p, &q, mt::Point3::new(10. / 2f64.sqrt(), 10. / 2f64.sqrt(), 0.)); + for tol in [1e-5, 2e-3] { + assert!(matches!(segment(&arc, &Plane::XY, tol, &mut 2_000_000).unwrap()[..], [Seg2::Arc { .. }])); + } + } + #[test] fn discontinuous_and_multiple_winding_curves_decline() { let edge_from = |knots: Vec, points: Vec| { diff --git a/crates/kernel/src/prism_local.rs b/crates/kernel/src/prism_local.rs index 4bbb4d44..8456d453 100644 --- a/crates/kernel/src/prism_local.rs +++ b/crates/kernel/src/prism_local.rs @@ -239,7 +239,8 @@ fn survey(body: &Body, d: Vec3, tol: f64, mesh_tol: f64, axes: [Vec3; 3]) -> Opt let o = p.origin(); flat.push((n, n.dot(Vec3::new(o.x, o.y, o.z)))); let along = n.dot(d).abs(); - !(along > 1.0 - 1e-9 || along < 1e-9) + // NaN counts as inside, as the comparisons it replaces did. + along.is_nan() || (1e-9..=1.0 - 1e-9).contains(&along) } _ => !crate::prism::wall_along(&surface, d, tol, &mut work), } diff --git a/crates/kernel/src/profile.rs b/crates/kernel/src/profile.rs new file mode 100644 index 00000000..56dfbcf4 --- /dev/null +++ b/crates/kernel/src/profile.rs @@ -0,0 +1,136 @@ +//! Where a command's time goes inside the kernel (#1833): wall time in booleans and in +//! tessellation, measured on this thread while [`capture`] runs and not at all otherwise (one +//! thread-local check per timed call). +//! +//! Only the outermost timed call counts: a tessellation a boolean does to check its result is +//! the boolean's time, so the two never add up to more than the command took. Work a call hands +//! to other threads is inside that call's wall time. + +use std::cell::Cell; + +/// Kernel time spent while a [`capture`] ran, in milliseconds. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct KernelTimes { + pub boolean_ms: f64, + pub tessellate_ms: f64, +} + +/// What a timed call is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + Boolean, + Tessellate, +} + +#[derive(Clone, Copy, Default)] +struct State { + /// A capture is running on this thread. + on: bool, + /// A timed call is running (nested ones aren't timed). + busy: bool, + times: KernelTimes, +} + +thread_local! { + static STATE: Cell = const { Cell::new(State { on: false, busy: false, times: KernelTimes { boolean_ms: 0.0, tessellate_ms: 0.0 } }) }; +} + +/// Run `f`, and the kernel time it spent in booleans and tessellation on this thread. Captures +/// nest: an inner capture's time is the outer one's too. +pub fn capture(f: impl FnOnce() -> T) -> (T, KernelTimes) { + struct Restore(State); + impl Drop for Restore { + fn drop(&mut self) { + let inner = STATE.with(Cell::get).times; + let mut outer = self.0; + if outer.on { + outer.times.boolean_ms += inner.boolean_ms; + outer.times.tessellate_ms += inner.tessellate_ms; + } + STATE.with(|s| s.set(outer)); + } + } + let outer = STATE.with(Cell::get); + STATE.with(|s| s.set(State { on: true, busy: outer.busy, times: KernelTimes::default() })); + let restore = Restore(outer); + let r = f(); + let times = STATE.with(Cell::get).times; + drop(restore); + (r, times) +} + +/// Time `f` as `kind` when a capture is running and no timed call is. +pub(crate) fn timed(kind: Kind, f: impl FnOnce() -> T) -> T { + let st = STATE.with(Cell::get); + if !st.on || st.busy { + return f(); + } + struct Busy { + kind: Kind, + started: web_time::Instant, + } + impl Drop for Busy { + fn drop(&mut self) { + let ms = self.started.elapsed().as_secs_f64() * 1000.0; + STATE.with(|s| { + let mut st = s.get(); + st.busy = false; + match self.kind { + Kind::Boolean => st.times.boolean_ms += ms, + Kind::Tessellate => st.times.tessellate_ms += ms, + } + s.set(st); + }); + } + } + STATE.with(|s| s.set(State { busy: true, ..st })); + let busy = Busy { kind, started: web_time::Instant::now() }; + let r = f(); + drop(busy); + r +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spin(ms: u64) { + let t = web_time::Instant::now(); + while t.elapsed().as_millis() < u128::from(ms) {} + } + + #[test] + fn nothing_is_timed_outside_a_capture() { + timed(Kind::Boolean, || spin(2)); + let ((), t) = capture(|| ()); + assert_eq!(t, KernelTimes::default()); + } + + #[test] + fn only_the_outermost_call_counts() { + let started = web_time::Instant::now(); + let ((), t) = capture(|| { + timed(Kind::Boolean, || { + spin(3); + timed(Kind::Tessellate, || spin(3)); + }); + timed(Kind::Tessellate, || spin(2)); + }); + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + assert!(t.boolean_ms >= 6.0 && t.tessellate_ms >= 2.0, "{t:?}"); + // The tessellation inside the boolean is not counted twice. + assert!(t.boolean_ms + t.tessellate_ms <= elapsed + 1e-6, "{t:?} in {elapsed} ms"); + } + + #[test] + fn inner_captures_add_to_the_outer_one_and_a_panic_restores_it() { + let ((), outer) = capture(|| { + let ((), inner) = capture(|| timed(Kind::Boolean, || spin(2))); + assert!(inner.boolean_ms >= 2.0); + let _ = std::panic::catch_unwind(|| capture(|| timed(Kind::Tessellate, || panic!("kernel")))); + }); + assert!(outer.boolean_ms >= 2.0, "{outer:?}"); + let st = STATE.with(Cell::get); + assert!(!st.on && !st.busy); + } +} diff --git a/crates/kernel/src/record_tests.rs b/crates/kernel/src/record_tests.rs index 35fd1b5d..e6cd574a 100644 --- a/crates/kernel/src/record_tests.rs +++ b/crates/kernel/src/record_tests.rs @@ -208,15 +208,23 @@ fn expected_revolution(b: &Body, r: &Region2) -> Vec> { #[test] fn revolve_partial_and_full() { let r = Region2 { outer: rect(10.0, 0.0, 20.0, 5.0), holes: vec![rect(12.0, 1.0, 14.0, 2.0).reversed()] }; - // Under half a turn truck sweeps in two parts, beyond it in three; a full turn has no caps. - for (angle, parts, caps) in [(FRAC_PI_2, 2, 2), (1.5 * PI, 3, 2), (TAU, 3, 0)] { - let b = crate::revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap(); - let got = origins(&b); - assert_eq!(got, expected_revolution(&b, &r), "angle {angle}"); - for k in 0..8 { - assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), parts, "segment {k} at angle {angle}"); + // Under half a turn truck sweeps in two parts, beyond it in three; a full turn has no caps, + // and its planar faces (from the segments square to the axis) are whole (#726), as a partial + // turn's are when it is final (`revolve_whole`). + let segs: Vec = r.outer.segs.iter().chain(r.holes.iter().flat_map(|h| &h.segs)).cloned().collect(); + for whole in [false, true] { + for (angle, parts, caps) in [(FRAC_PI_2, 2, 2), (1.5 * PI, 3, 2), (TAU, 3, 0)] { + let revolve = if whole { crate::revolve_whole } else { crate::revolve }; + let b = revolve(&SIDE, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, angle).unwrap().pop().unwrap(); + let got = origins(&b); + assert_eq!(got, expected_revolution(&b, &r), "angle {angle}, whole {whole}"); + for (k, s) in segs.iter().enumerate() { + let square = matches!(s, Seg2::Line { a, b } if (a.y - b.y).abs() < 1e-12); + let want = if (angle == TAU || whole) && square { 1 } else { parts }; + assert_eq!(got.iter().filter(|o| **o == [P(k)]).count(), want, "segment {k} at angle {angle}, whole {whole}"); + } + assert_eq!(got.iter().filter(|o| **o == [START] || **o == [END]).count(), caps); } - assert_eq!(got.iter().filter(|o| **o == [START] || **o == [END]).count(), caps); } } diff --git a/crates/kernel/src/seam_faces.rs b/crates/kernel/src/seam_faces.rs new file mode 100644 index 00000000..d2ec9044 --- /dev/null +++ b/crates/kernel/src/seam_faces.rs @@ -0,0 +1,92 @@ +//! Faces that run along a seam edge twice: a whole turn of a cylinder or a cone bounded by one +//! wire, as STEP files write them (the wire goes round one end, along the seam, round the other +//! end and back along the seam). SolidCraft builds its own round faces in pieces with no such +//! seam, and a boolean that has to divide one of these faces can fail where the same bodies with +//! the face in two halves work: an intersection loop lying wholly inside the face is a hole in a +//! piece whose outer wire visits the seam's vertices twice, which the boolean refuses. So when the +//! first tries at the boolean of the bodies as they are fail, each such face is split in two along +//! a plane through its axis, a quarter turn either side of the seam (the solids are unchanged), and +//! the boolean is asked again; the result's history is composed back over the operands as they +//! were given. + +use solidcraft_geom::{Plane, Vec3}; + +use crate::body::{Body, from_p3}; +use crate::boolean_record as recorded; +use crate::history::History; +use crate::splitface::{SplitTool, split_any}; +use crate::{BoolOp, FaceSurface, Result, face_surface}; + +/// Most faces split before the boolean. +const MAX_SPLITS: usize = 64; + +thread_local! { + /// Set while the boolean of the split bodies runs (no second round of splitting). + static SPLITTING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The first face of `body` whose wire runs along a seam edge twice, on a cylinder or a cone, +/// with the plane that splits it in two: through its axis, square to the seam. +fn next_split(body: &Body) -> Option<(usize, Plane)> { + body.solid.face_iter().enumerate().find_map(|(i, face)| { + let mut seen = std::collections::HashSet::new(); + let seam = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).find(|e| !seen.insert(e.id()))?; + let (origin, axis) = match face_surface(body, i)? { + FaceSurface::Cylinder { origin, axis, .. } => (origin, axis), + FaceSurface::Cone { apex, axis, .. } => (apex, axis), + _ => return None, + }; + let axis = axis.normalized()?; + let p = from_p3(seam.front().point()); + let off = p - origin; + let radial: Vec3 = (off - axis * off.dot(axis)).normalized()?; + Some((i, Plane::from_normal(origin, radial)?)) + }) +} + +/// Whether either body has a face for [`boolean`] to split. +pub(crate) fn needed(a: &Body, b: &Body) -> bool { + !a.is_mesh() && !b.is_mesh() && !SPLITTING.with(|c| c.get()) && (next_split(a).is_some() || next_split(b).is_some()) +} + +/// `body` with every face that runs along a seam edge twice split in two, and its history over +/// `body`. `None` when a split fails. +fn halved(body: &Body) -> Option<(Body, Option)> { + let (mut x, mut h) = (body.clone(), Some(History::identity(body))); + for _ in 0..MAX_SPLITS { + let Some((face, plane)) = next_split(&x) else { return Some((x, h)) }; + let (mut done, _) = split_any(&x, &[face], &SplitTool::Plane(plane)).ok()?; + // The split is no authored boundary: a later feature may still merge what it separates. + done.regularization_protected = x.regularization_protected; + let through = recorded::through(&done, 0, h.as_ref()); + (x, h) = (done, through); + } + next_split(&x).is_none().then_some((x, h)) +} + +/// The boolean `op` of `a` and `b` with their faces that run along a seam edge twice split in +/// two, `run` doing the boolean of the split bodies. `None` when neither body has such a face, a +/// split fails, or the boolean of the split bodies gave no (checked) result: the caller then +/// keeps what it had. +pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp, run: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Option>> { + if !needed(a, b) { + return None; + } + let ((x, hx), (y, hy)) = (halved(a)?, halved(b)?); + SPLITTING.with(|c| c.set(true)); + let result = crate::guard("boolean of faces split off their seams", || run(&x, &y, op)); + SPLITTING.with(|c| c.set(false)); + let Ok(result) = result else { return None }; + // Held to what a boolean can do, as a retry's result is (see `boolean_guard`). + crate::boolean_guard::check_retry(a, b, op, result.as_ref()).ok()?; + let counts = crate::record::counts(&[a, b]); + Some(Ok(result.map(|body| { + // Each step's history over the operands `[a, b]`. + let history = recorded::of_pair(&body, &recorded::placed(hx.clone(), 0, 2), &recorded::placed(hy.clone(), 1, 2)); + body.with_checked_history(history, &counts) + }))) +} + +#[cfg(test)] +#[path = "seam_faces_tests.rs"] +mod tests; diff --git a/crates/kernel/src/seam_faces_tests.rs b/crates/kernel/src/seam_faces_tests.rs new file mode 100644 index 00000000..9bf32aee --- /dev/null +++ b/crates/kernel/src/seam_faces_tests.rs @@ -0,0 +1,94 @@ +//! Round faces read from STEP: a whole turn bounded by one wire that runs along its seam twice. +//! A boolean whose intersection loop lies wholly inside such a face takes it in two halves +//! (exactly), where it used to end in a nudged tool or fail. +use crate::*; +use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3}; +use std::f64::consts::PI; + +const R: f64 = 4.5; + +fn volume(b: &Body) -> f64 { + measure(b).unwrap().volume +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// `body` written to STEP and read back: its round faces come back whole, as other systems +/// write them. +fn through_step(body: &Body) -> Body { + step_import(&step_export(&[body], "t").unwrap()).unwrap().bodies.into_iter().next().unwrap().body +} + +/// A pin of radius `R` along X, 40 long, centred on the origin. +fn pin() -> Body { + cylinder(Vec3::new(-20.0, 0.0, 0.0), Vec3::X, R, 40.0).unwrap() +} + +/// A block pressed into the top of the pin's side: 10 long, 6 wide, from 2 above the axis up. +fn block() -> Body { + box_solid(Vec3::new(-5.0, -3.0, 2.0), Vec3::new(5.0, 3.0, 9.0)).unwrap() +} + +/// The part of the pin inside [`block`]: the circle's segment above 2 and within 3 of the +/// middle, 10 long. +fn pressed_volume() -> f64 { + let segment = 3.0 * (R * R - 9.0).sqrt() + R * R * (3.0 / R).asin() - 6.0 * 2.0; + 10.0 * segment +} + +/// The boolean, which must be exact: no retry that moves the tool or ignores part of where the +/// bodies meet. +fn exact(a: &Body, b: &Body, op: BoolOp) -> Option { + let (r, report) = capture_boolean_diagnostics(|| boolean(a, b, op)); + assert!(report.warning().is_none(), "{op:?}: {:?}", report.warning()); + r.unwrap() +} + +#[test] +fn a_pin_read_from_step_has_one_face_along_its_seam() { + let read = through_step(&pin()); + assert_eq!(read.face_count(), 3); + assert!(super::needed(&read, &block())); + assert!(!super::needed(&pin(), &block())); + // Split in two halves, with the same solid. + let (halved, history) = super::halved(&read).unwrap(); + assert!(super::next_split(&halved).is_none() && halved.face_count() > 3); + assert!(history.is_some()); + assert!(rel(volume(&halved), PI * R * R * 40.0) < 1e-3); +} + +#[test] +fn a_block_pressed_into_a_read_pins_side_is_intersected_and_cut_exactly() { + let (read, block) = (through_step(&pin()), block()); + let want = pressed_volume(); + let shared = exact(&read, &block, BoolOp::Intersect).unwrap(); + assert!(rel(volume(&shared), want) < 1e-3, "{} {want}", volume(&shared)); + let dented = exact(&read, &block, BoolOp::Cut).unwrap(); + assert!(rel(volume(&dented), PI * R * R * 40.0 - want) < 1e-3, "{}", volume(&dented)); + // The operands' faces carry over: the dented pin's round face is still the pin's. + assert!(dented.history().is_some()); +} + +#[test] +fn a_ball_pressed_into_a_read_pins_side_matches_the_built_pin() { + let ball = sphere(Vec3::new(3.0, 1.0, 5.5), 3.0).unwrap(); + let built = volume(&boolean(&pin(), &ball, BoolOp::Intersect).unwrap().unwrap()); + let read = volume(&exact(&through_step(&pin()), &ball, BoolOp::Intersect).unwrap()); + assert!(rel(read, built) < 1e-3, "{read} {built}"); +} + +/// A cone's side read from STEP is one face along its seam as well. +#[test] +fn a_block_pressed_into_a_read_cones_side_matches_the_built_cone() { + let outline = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(8.0, 0.0), Vec2::new(5.0, 20.0), Vec2::new(0.0, 20.0)]), holes: vec![] }; + let cone = revolve(&Plane::YZ, &[outline], Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap(); + let read = through_step(&cone); + assert!(super::needed(&read, &cone)); + let block = box_solid(Vec3::new(-2.0, 4.0, 6.0), Vec3::new(2.0, 12.0, 12.0)).unwrap(); + let built = volume(&boolean(&cone, &block, BoolOp::Intersect).unwrap().unwrap()); + let read = volume(&exact(&read, &block, BoolOp::Intersect).unwrap()); + assert!(rel(read, built) < 1e-3, "{read} {built}"); +} diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index 0c5c8b4a..c8c26b8b 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -1,5 +1,5 @@ //! Sewing a solid from face descriptions: each face is its boundary loops (lines, circular -//! arcs and cubic Bézier chains, counter-clockwise about the outward normal; holes clockwise) +//! arcs and Bézier chains, counter-clockwise about the outward normal; holes clockwise) //! and its surface (planar, revolved from a line, extruded from an arc, or ruled between two //! Bézier chains). Vertices and edges at the same places are shared, so the faces close into //! one shell without booleans — for solids whose faces are known exactly, such as folded @@ -17,6 +17,8 @@ use crate::{KernelError, Result, guard}; /// Most control points of one Bézier chain edge. pub const MAX_CHAIN_POINTS: usize = 3 * 1024 + 1; +/// Highest degree of a Bézier chain's pieces. +pub const MAX_CHAIN_DEGREE: usize = 9; /// A boundary edge. #[derive(Clone, Debug, PartialEq)] @@ -31,10 +33,12 @@ pub enum EdgeSpec { b: Vec3, mid: Vec3, }, - /// A chain of cubic Bézier pieces: 3n + 1 control points (n ≥ 1); the curve passes - /// through `ctrl[0]`, `ctrl[3]`, … `ctrl[3n]`. For curves with no exact line, arc or conic - /// form, such as a cut-out edge traced across a sheet metal bend. + /// A chain of Bézier pieces of degree `degree` (1…[`MAX_CHAIN_DEGREE`]): `degree`·n + 1 + /// control points (n ≥ 1); the curve passes through `ctrl[0]`, `ctrl[degree]`, … + /// `ctrl[degree·n]`. For curves with no exact line, arc or conic form, such as a font's + /// outline (cubic) or a cut-out edge traced across a sheet metal bend. Chain { + degree: usize, ctrl: Arc<[Vec3]>, }, } @@ -43,7 +47,7 @@ impl EdgeSpec { fn ends(&self) -> (Vec3, Vec3) { match self { EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (*a, *b), - EdgeSpec::Chain { ctrl } => (ctrl.first().copied().unwrap_or(Vec3::ZERO), ctrl.last().copied().unwrap_or(Vec3::ZERO)), + EdgeSpec::Chain { ctrl, .. } => (ctrl.first().copied().unwrap_or(Vec3::ZERO), ctrl.last().copied().unwrap_or(Vec3::ZERO)), } } @@ -57,7 +61,7 @@ impl EdgeSpec { let (m, e) = (*mid - *a, *b - *a); m * (1.0 / m.len2()) - e * (1.0 / e.len2()) } - EdgeSpec::Chain { ctrl } => { + EdgeSpec::Chain { ctrl, .. } => { let first = *ctrl.first()?; ctrl.iter().skip(1).map(|p| *p - first).find(|d| d.len() > 1e-12)? } @@ -70,7 +74,7 @@ impl EdgeSpec { match self { EdgeSpec::Line { a, b } => EdgeSpec::Line { a: *b, b: *a }, EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: *b, b: *a, mid: *mid }, - EdgeSpec::Chain { ctrl } => EdgeSpec::Chain { ctrl: ctrl.iter().rev().copied().collect() }, + EdgeSpec::Chain { degree, ctrl } => EdgeSpec::Chain { degree: *degree, ctrl: ctrl.iter().rev().copied().collect() }, } } @@ -79,7 +83,7 @@ impl EdgeSpec { match self { EdgeSpec::Line { .. } => None, EdgeSpec::Arc { mid, .. } => Some(key(*mid)), - EdgeSpec::Chain { ctrl } => { + EdgeSpec::Chain { ctrl, .. } => { // Summed in pairs from both ends, so that the chain run either way gives the very // same point: a font's control points are simple fractions, whose mean can fall // exactly halfway between two steps of the key, where summing in another order @@ -99,8 +103,12 @@ impl EdgeSpec { match self { EdgeSpec::Line { a, b } => a.is_finite() && b.is_finite(), EdgeSpec::Arc { a, b, mid } => a.is_finite() && b.is_finite() && mid.is_finite(), - EdgeSpec::Chain { ctrl } => { - ctrl.len() >= 4 && ctrl.len() <= MAX_CHAIN_POINTS && (ctrl.len() - 1) % 3 == 0 && ctrl.iter().all(|p| p.is_finite()) + EdgeSpec::Chain { degree, ctrl } => { + (1..=MAX_CHAIN_DEGREE).contains(degree) + && ctrl.len() > *degree + && ctrl.len() <= MAX_CHAIN_POINTS + && (ctrl.len() - 1) % degree == 0 + && ctrl.iter().all(|p| p.is_finite()) } } } @@ -115,8 +123,9 @@ pub enum SurfSpec { Revolved { a: Vec3, b: Vec3, axis_origin: Vec3, axis: Vec3, angle: f64 }, /// The surface an arc sweeps moving along `dir`. Extruded { a: Vec3, b: Vec3, mid: Vec3, dir: Vec3 }, - /// The straight lines joining equal parameters of two Bézier chains with the same number - /// of pieces, `a` and `b` running the same way; both chains lie on it as boundary edges. + /// The straight lines joining equal parameters of two Bézier chains of one degree with the + /// same number of pieces, `a` and `b` running the same way; both chains lie on it as + /// boundary edges. Ruled { a: EdgeSpec, b: EdgeSpec }, } @@ -184,7 +193,7 @@ fn welded(faces: &[FaceSpec]) -> Option> { let edge = |e: &EdgeSpec| match e { EdgeSpec::Line { a, b } => EdgeSpec::Line { a: weld(*a), b: weld(*b) }, EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: weld(*a), b: weld(*b), mid: *mid }, - EdgeSpec::Chain { ctrl } => { + EdgeSpec::Chain { degree, ctrl } => { let mut ctrl = ctrl.to_vec(); let n = ctrl.len(); for i in [0, n.saturating_sub(1)] { @@ -192,7 +201,7 @@ fn welded(faces: &[FaceSpec]) -> Option> { *p = weld(*p); } } - EdgeSpec::Chain { ctrl: ctrl.into() } + EdgeSpec::Chain { degree: *degree, ctrl: ctrl.into() } } }; Some( @@ -245,18 +254,20 @@ impl Cache { Ok(made) } - /// A Bézier chain as a cubic B-spline (triple inner knots) ending on the shared vertices. + /// A Bézier chain as a B-spline of its degree (inner knots as many times as the degree) + /// ending on the shared vertices. fn chain(&mut self, e: &EdgeSpec) -> Result> { - let EdgeSpec::Chain { ctrl } = e else { return Err(KernelError::Invalid("sew: not a Bézier chain".into())) }; + let EdgeSpec::Chain { degree, ctrl } = e else { return Err(KernelError::Invalid("sew: not a Bézier chain".into())) }; if !e.valid() { - return Err(KernelError::Invalid("sew: a Bézier chain needs 3n + 1 finite control points".into())); + return Err(KernelError::Invalid("sew: a Bézier chain needs degree·n + 1 finite control points".into())); } - let n = (ctrl.len() - 1) / 3; - let mut knots = vec![0.0; 4]; + let d = *degree; + let n = (ctrl.len() - 1) / d; + let mut knots = vec![0.0; d + 1]; for i in 1..n { - knots.extend([i as f64; 3]); + knots.extend(std::iter::repeat_n(i as f64, d)); } - knots.extend([n as f64; 4]); + knots.extend(std::iter::repeat_n(n as f64, d + 1)); let mut pts: Vec = ctrl.iter().map(|p| p3(*p)).collect(); let (a, b) = e.ends(); if let Some(first) = pts.first_mut() { @@ -491,7 +502,7 @@ fn plane_normal(face: &FaceSpec) -> Option { let n = face.loops.first()?.iter().fold(Vec3::ZERO, |n, e| match e { EdgeSpec::Line { a, b } => n + a.cross(*b), EdgeSpec::Arc { a, b, mid } => n + a.cross(*mid) + mid.cross(*b), - EdgeSpec::Chain { ctrl } => ctrl.iter().zip(ctrl.iter().skip(1)).fold(n, |n, (p, q)| n + p.cross(*q)), + EdgeSpec::Chain { ctrl, .. } => ctrl.iter().zip(ctrl.iter().skip(1)).fold(n, |n, (p, q)| n + p.cross(*q)), }); n.normalized() } @@ -830,7 +841,7 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option { let (ca, cb) = (cache.chain(a)?, cache.chain(b)?); - if ca.control_points().len() != cb.control_points().len() { + if ca.degree() != cb.degree() || ca.control_points().len() != cb.control_points().len() { return Err(KernelError::Invalid("sew: ruled chains differ in pieces".into())); } let surface = mt::Surface::BSplineSurface(mt::BSplineSurface::homotopy(ca, cb)); @@ -974,7 +985,7 @@ mod tests { p(35.32833796296296, 11.885361111111111), p(35.53081944444444, 11.885361111111111), ]; - let forward = EdgeSpec::Chain { ctrl: ctrl.to_vec().into() }; + let forward = EdgeSpec::Chain { degree: 3, ctrl: ctrl.to_vec().into() }; assert_eq!(forward.mid_key(), forward.reversed().mid_key()); } @@ -1130,7 +1141,22 @@ mod tests { /// The bulge's cubic split at its middle (de Casteljau), at height `z`. fn bulge(z: f64) -> EdgeSpec { let ctrl = [(4.0, 0.0), (4.5, 0.0), (4.75, 0.75), (4.75, 1.5), (4.75, 2.25), (4.5, 3.0), (4.0, 3.0)]; - EdgeSpec::Chain { ctrl: ctrl.iter().map(|(x, y)| Vec3::new(*x, *y, z)).collect() } + EdgeSpec::Chain { degree: 3, ctrl: ctrl.iter().map(|(x, y)| Vec3::new(*x, *y, z)).collect() } + } + + /// The bulge's cubic raised to degree 5, (4,0) (4.6,0) (4.9,0.9) (4.9,2.1) (4.6,3) (4,3), and + /// split at its middle (de Casteljau), at height `z`: the same curve as a quintic chain. + fn quintic_bulge(z: f64) -> EdgeSpec { + let raised = [(4.0, 0.0), (4.6, 0.0), (4.9, 0.9), (4.9, 2.1), (4.6, 3.0), (4.0, 3.0)].map(|(x, y)| Vec3::new(x, y, z)); + let (mut left, mut right, mut w) = (Vec::new(), Vec::new(), raised.to_vec()); + while let (Some(first), Some(last)) = (w.first().copied(), w.last().copied()) { + left.push(first); + right.push(last); + w = w.windows(2).map(|p| (p[0] + p[1]) * 0.5).collect(); + } + right.reverse(); + left.extend(right.into_iter().skip(1)); + EdgeSpec::Chain { degree: 5, ctrl: left.into() } } #[test] @@ -1145,10 +1171,22 @@ mod tests { assert!((m.bbox.max.x - 4.75).abs() < 1e-3, "{:?}", m.bbox); } + #[test] + fn a_ruled_wall_between_quintic_chains_closes_the_same_solid() { + let EdgeSpec::Chain { ctrl, .. } = quintic_bulge(0.0) else { panic!() }; + assert_eq!(ctrl.len(), 11); + let body = sew(&bulged_box(quintic_bulge(0.0), quintic_bulge(2.0))).unwrap(); + assert_eq!(body.face_count(), 6); + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let m = crate::measure(&body).unwrap(); + assert!((m.volume - 27.6).abs() < 2e-3, "{}", m.volume); + assert!((m.bbox.max.x - 4.75).abs() < 1e-3, "{:?}", m.bbox); + } + #[test] fn hostile_bezier_chains_are_refused() { - let chain = |ctrl: Vec| EdgeSpec::Chain { ctrl: ctrl.into() }; - let EdgeSpec::Chain { ctrl } = bulge(2.0) else { panic!() }; + let chain = |ctrl: Vec| EdgeSpec::Chain { degree: 3, ctrl: ctrl.into() }; + let EdgeSpec::Chain { ctrl, .. } = bulge(2.0) else { panic!() }; // Not 3n + 1 control points; a control point that is not a number; too many pieces. let mut nan = ctrl.to_vec(); nan[2].x = f64::NAN; @@ -1160,6 +1198,22 @@ mod tests { let whole = chain(vec![Vec3::new(4.0, 0.0, 2.0), Vec3::new(5.0, 0.0, 2.0), Vec3::new(5.0, 3.0, 2.0), Vec3::new(4.0, 3.0, 2.0)]); let e = sew(&bulged_box(bulge(0.0), whole)).unwrap_err().to_string(); assert!(e.contains("ruled chains differ"), "{e}"); + // Degree 0, or above the highest, or a count of control points that doesn't fit the degree + // (11 control points are no chain of quartics). + let EdgeSpec::Chain { ctrl: fifth, .. } = quintic_bulge(2.0) else { panic!() }; + let degree = |degree: usize, ctrl: &[Vec3]| EdgeSpec::Chain { degree, ctrl: ctrl.to_vec().into() }; + // `n` control points bulging out from (4, 0) to (4, 3) at height `z`. + let arch = |n: usize, z: f64| -> Vec { + let at = |i: usize| i as f64 / (n - 1) as f64; + (0..n).map(|i| Vec3::new(4.0 + (std::f64::consts::PI * at(i)).sin(), 3.0 * at(i), z)).collect() + }; + for top in [degree(0, &ctrl), degree(MAX_CHAIN_DEGREE + 1, &arch(MAX_CHAIN_DEGREE + 2, 2.0)), degree(4, &fifth)] { + assert!(sew(&bulged_box(bulge(0.0), top)).is_err()); + } + // A ruled wall needs chains of one degree: 16 control points are five cubic pieces or + // three quintic ones. + let e = sew(&bulged_box(degree(3, &arch(16, 0.0)), degree(5, &arch(16, 2.0)))).unwrap_err().to_string(); + assert!(e.contains("ruled chains differ"), "{e}"); } #[test] diff --git a/crates/kernel/src/step_in/brep.rs b/crates/kernel/src/step_in/brep.rs index cd40bb3b..2ae1dbb1 100644 --- a/crates/kernel/src/step_in/brep.rs +++ b/crates/kernel/src/step_in/brep.rs @@ -339,11 +339,77 @@ impl Builder { return Err("no face could be imported".into()); } let read = out.len(); + let ball = match (out.as_slice(), origin.as_slice()) { + ([face], [file_face]) => whole_sphere(cx, *file_face, face).map(|patches| (*file_face, patches)), + _ => None, + }; + if let Some((file_face, patches)) = ball { + origin = vec![file_face; patches.len()]; + out = patches; + } let out = super::split::split_bands(out, &mut origin, failed); Ok((out.into(), closed, read, origin)) } } +/// A ball written as one face on a sphere bounded only by its seam, run out and back from pole +/// to pole. Truck meshes a face on a revolved surface in parameter space, where that loop +/// encloses nothing, so the ball is built as the kernel's own sphere instead: six patches with +/// no pole or seam inside them (`build::sphere_patch_faces`), turned inside out for a void. +/// `None` for any other face. +fn whole_sphere(cx: &Ctx, file_face: u64, face: &mt::Face) -> Option> { + use mt::{ParametricSurface3D, SearchParameter}; + let surface = entity(cx, file_face).ok()?.params().get(2).and_then(Param::as_ref_id)?; + if entity(cx, surface).ok()?.name() != "SPHERICAL_SURFACE" { + return None; + } + let wires = face.boundaries(); + let edges: Vec<&mt::Edge> = wires.iter().flat_map(|w| w.edge_iter()).collect(); + let mut uses: HashMap = HashMap::new(); + let mut poles: Vec = Vec::new(); + for e in &edges { + *uses.entry(e.id()).or_default() += 1; + for v in [e.front(), e.back()] { + if !poles.iter().any(|p| p.id() == v.id()) { + poles.push(v.clone()); + } + } + } + // Every edge a seam (used once each way), and the two vertices it joins are the poles. + if uses.is_empty() || uses.values().any(|n| *n != 2) { + return None; + } + let [a, b] = poles.as_slice() else { return None }; + let (a, b) = (a.point(), b.point()); + let center = a + (b - a) * 0.5; + let radius = a.distance(b) * 0.5; + let tol = cx.tol.max(radius * 1e-6); + // The seam is a meridian: all of it on the sphere whose diameter joins the poles. + let samples = |e: &&mt::Edge| { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + (0..=8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0)) + }; + if !(radius.is_finite() && radius > tol) || !edges.iter().flat_map(samples).all(|p| (p.distance(center) - radius).abs() <= tol) { + return None; + } + // Ball or void: the face's normal at the middle of the seam, against the way out of the centre. + let probe = edges.first().and_then(|e| samples(e).nth(4))?; + let oriented = face.oriented_surface(); + let (u, v) = oriented.search_parameter(probe, mt::SPHint2D::None, 100)?; + let outward = oriented.normal(u, v).dot(probe - center) > 0.0; + let z = (crate::body::from_p3(a) - crate::body::from_p3(b)).normalized()?; + let x = z.any_perp(); + let c = crate::body::from_p3(center); + let mut patches = guard("step sphere", || crate::build::sphere_patch_faces(c, radius, [x, z.cross(x), z])).ok()?; + if !outward { + for f in &mut patches { + f.invert(); + } + } + Some(patches) +} + /// Read a solid-like representation item: MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP, /// or a SHELL_BASED_SURFACE_MODEL (closed shells give solids, open ones open bodies). pub(crate) fn read_item(cx: &Ctx, id: u64) -> R { diff --git a/crates/kernel/src/step_in/conic_tests.rs b/crates/kernel/src/step_in/conic_tests.rs new file mode 100644 index 00000000..f5942541 --- /dev/null +++ b/crates/kernel/src/step_in/conic_tests.rs @@ -0,0 +1,197 @@ +//! Hyperbola and parabola edges (ISO 10303-42 HYPERBOLA, PARABOLA): a cone cut by a plane +//! parallel to its axis meets it in a hyperbola, by a plane parallel to a generator in a +//! parabola. Hand-written files, volumes against closed forms. + +use std::f64::consts::{FRAC_PI_4, PI}; + +use mt::{BoundedCurve, InnerSpace, ParametricCurve}; +use truck_modeling as mt; + +use super::geom::{Frame, hyperbola_arc, parabola_arc}; +use super::*; +use crate::{measure, step_export}; + +const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n"; +const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n"; +const CONTEXT: &str = "#90=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#81),#91);\n#91=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#92))GLOBAL_UNIT_ASSIGNED_CONTEXT((#93,#94))REPRESENTATION_CONTEXT('',''));\n#92=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#93,'','');\n#93=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#94=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n"; + +/// Height of the cone pieces: the 45° cone `x² + y² = z²` up to `z = H`. +const H: f64 = 10.0; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) +} + +/// A solid bounded by the 45° cone (apex at the origin, axis Z), its top plane `z = H` and a +/// cutting plane. `curve` defines `#30` (the conic edge from `v1` to `v2`, rising parameter) and +/// `#41`, the cutting plane's placement (normal pointing out of the solid). The top circle runs +/// `#51` from `circle_from` to the other vertex counter-clockwise seen from above; the loops say +/// which way each face uses each edge. +fn cone_piece(curve: &str, v1: [f64; 3], v2: [f64; 3], circle_from_v1: bool, loops: [&str; 3]) -> String { + let mut t = String::from(HEAD); + t += "#1=CARTESIAN_POINT('',(0.,0.,10.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n"; + t += &format!("#5=CONICAL_SURFACE('',#4,10.,{FRAC_PI_4:.15});\n#6=PLANE('',#4);\n"); + t += &format!("#10=CARTESIAN_POINT('',({:.15},{:.15},{:.15}));\n#11=VERTEX_POINT('',#10);\n", v1[0], v1[1], v1[2]); + t += &format!("#12=CARTESIAN_POINT('',({:.15},{:.15},{:.15}));\n#13=VERTEX_POINT('',#12);\n", v2[0], v2[1], v2[2]); + t += curve; + t += "#31=EDGE_CURVE('',#11,#13,#30,.T.);\n#42=PLANE('',#41);\n"; + t += "#50=CIRCLE('',#4,10.);\n"; + t += if circle_from_v1 { "#51=EDGE_CURVE('',#11,#13,#50,.T.);\n" } else { "#51=EDGE_CURVE('',#13,#11,#50,.T.);\n" }; + t += "#52=VECTOR('',#3,1.);\n#53=LINE('',#10,#52);\n#54=EDGE_CURVE('',#11,#13,#53,.T.);\n"; + // Faces: the cone, the cutting plane, the top. + for (k, (lp, surface)) in loops.iter().zip(["#5", "#42", "#6"]).enumerate() { + let b = 60 + 6 * k; + let edges: Vec<&str> = lp.split(' ').collect(); + let mut oes = Vec::new(); + for (j, e) in edges.iter().enumerate() { + let (id, fwd) = e.split_at(e.len() - 1); + t += &format!("#{}=ORIENTED_EDGE('',*,*,{id},{});\n", b + j, if fwd == "+" { ".T." } else { ".F." }); + oes.push(format!("#{}", b + j)); + } + t += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", b + 3, oes.join(","), b + 4, b + 3); + t += &format!("#{}=ADVANCED_FACE('',(#{}),{surface},.T.);\n", b + 5, b + 4); + } + t += "#80=CLOSED_SHELL('',(#65,#71,#77));\n#81=MANIFOLD_SOLID_BREP('Piece',#80);\n"; + t += CONTEXT; + t += TAIL; + t +} + +/// The cone inside `z ≤ H` cut by the plane `y = D` (parallel to the axis), keeping `y ≥ D`: +/// the cut meets the cone in the hyperbola `z² − x² = D²`, centre `(0, D, 0)`, both semi axes D. +fn hyperbola_piece() -> String { + const D: f64 = 4.0; + let w = (H * H - D * D).sqrt(); + // Real axis up the plane (X = +z), normal +y, so the imaginary axis is +x: + // (x, y, z) = (D·sinh t, D, D·cosh t). + let curve = "#20=CARTESIAN_POINT('',(0.,4.,0.));\n#21=DIRECTION('',(0.,1.,0.));\n#22=AXIS2_PLACEMENT_3D('',#20,#21,#2);\n#30=HYPERBOLA('',#22,4.,4.);\n#40=DIRECTION('',(0.,-1.,0.));\n#41=AXIS2_PLACEMENT_3D('',#20,#40,#3);\n"; + // Cone: up the hyperbola from +x to −x, back along the arc; the plane (seen from −y): + // the hyperbola from −x to +x, back along the line; the top: the arc, then the line. + cone_piece(curve, [-w, D, H], [w, D, H], false, ["#31- #51-", "#31+ #54-", "#51+ #54+"]) +} + +/// The cone inside `z ≤ H` cut by the plane `z − y = C` (parallel to the generator in the +/// x = 0, y > 0 half plane), keeping `z − y ≥ C`: the cut meets the cone in a parabola with its +/// vertex at `(0, −C/2, C/2)`, axis along (0, 1, 1) and focal distance `C·√2 / 4`. +fn parabola_piece() -> String { + const C: f64 = 8.0; + let w = (H * H - (H - C) * (H - C)).sqrt(); + // Normal (0, 1, −1) out of the solid, axis X = (0, 1, 1), so Y = Z × X = +x: + // (x, y, z) = (4√2·t, −4 + 2t², 4 + 2t²). + let curve = format!( + "#20=CARTESIAN_POINT('',(0.,-4.,4.));\n#21=DIRECTION('',(0.,1.,-1.));\n#23=DIRECTION('',(0.,1.,1.));\n#22=AXIS2_PLACEMENT_3D('',#20,#21,#23);\n#30=PARABOLA('',#22,{:.15});\n#41=AXIS2_PLACEMENT_3D('',#20,#21,#23);\n", + C * 2f64.sqrt() / 4.0 + ); + // Cone (seen from −y): the parabola from −x to +x, back along the arc; the plane (seen from + // outside, +x on the left): the parabola from +x to −x, back along the line; the top: the + // arc round through −y, then the line back. + cone_piece(&curve, [-w, H - C, H], [w, H - C, H], true, ["#31+ #51-", "#31- #54+", "#51+ #54-"]) +} + +/// ∫ from D to H of the circular segment `y ≥ D` of the disc of radius z. +fn hyperbola_piece_volume() -> f64 { + let d = 4.0; + let s = (H * H - d * d).sqrt(); + H.powi(3) / 3.0 * (d / H).acos() - 2.0 / 3.0 * d * H * s + d.powi(3) / 3.0 * ((H + s) / d).ln() +} + +/// ∫ from C/2 to H of the part `y ≤ z − C` of the disc of radius z. +fn parabola_piece_volume() -> f64 { + let c = 8.0; + let q: f64 = 2.0 * c * H - c * c; + H.powi(3) / 3.0 * (PI - (1.0 - c / H).acos()) + - (0.4 * q.powf(2.5) + 4.0 / 3.0 * c * c * q.powf(1.5) + 2.0 * c.powi(4) * q.sqrt()) / (24.0 * c * c) + + (0.4 * q.powf(2.5) - 2.0 / 3.0 * c * c * q.powf(1.5)) / (4.0 * c * c) +} + +fn import_piece(text: &str, want: f64) -> StepImport { + assert_eq!(step_orientation_errors(text).unwrap(), Vec::::new()); + let imp = step_import(text).unwrap(); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert_eq!(imp.bodies.len(), 1); + assert!(imp.bodies[0].closed); + let m = measure(&imp.bodies[0].body).unwrap(); + assert_eq!(m.faces, 3); + assert!(rel(m.volume, want) < 1e-4, "volume {} vs {want}", m.volume); + imp +} + +/// Export the imported piece and read it back: the conic edges are written as exact rational +/// B-splines and come back as the same solid. +fn round_trip(imp: &StepImport, want: f64) { + let text = step_export(&[&imp.bodies[0].body], "conic").unwrap(); + step_validate(&text).unwrap(); + let back = step_import(&text).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert_eq!(back.bodies.len(), 1); + assert!(back.bodies[0].closed); + let v = measure(&back.bodies[0].body).unwrap().volume; + assert!(rel(v, want) < 1e-4, "round trip volume {v} vs {want}"); +} + +#[test] +fn hyperbola_bounded_faces_import() { + let want = hyperbola_piece_volume(); + assert!(rel(want, 175.447_506_9) < 1e-9, "{want}"); + let imp = import_piece(&hyperbola_piece(), want); + round_trip(&imp, want); +} + +#[test] +fn parabola_bounded_faces_import() { + let want = parabola_piece_volume(); + assert!(rel(want, 447.014_684_29) < 1e-9, "{want}"); + let imp = import_piece(¶bola_piece(), want); + round_trip(&imp, want); +} + +/// The kernel curves lie exactly on the conics, end to end, whichever way they run. +#[test] +fn conic_arcs_are_exact() { + let f = Frame { o: mt::Point3::new(1.0, 2.0, 3.0), x: mt::Vector3::unit_y(), y: mt::Vector3::unit_z(), z: mt::Vector3::unit_x() }; + let local = |p: mt::Point3| ((p - f.o).dot(f.x), (p - f.o).dot(f.y), (p - f.o).dot(f.z)); + for (t0, t1) in [(-1.5, 2.0), (3.0, -0.25), (0.0, 7.5)] { + let c = hyperbola_arc(&f, 3.0, 2.0, t0, t1); + let (s0, s1) = c.range_tuple(); + for i in 0..=200 { + let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0)); + assert!((x * x / 9.0 - y * y / 4.0 - 1.0).abs() < 1e-9 && x > 0.0 && z.abs() < 1e-12, "{x} {y}"); + } + let (x, y, _) = local(c.subs(s0)); + assert!((x - 3.0 * f64::cosh(t0)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t0)).abs() < 1e-9); + let (x, y, _) = local(c.subs(s1)); + assert!((x - 3.0 * f64::cosh(t1)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t1)).abs() < 1e-9); + + let c = parabola_arc(&f, 1.5, t0, t1); + let (s0, s1) = c.range_tuple(); + for i in 0..=200 { + let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0)); + assert!((y * y - 4.0 * 1.5 * x).abs() < 1e-9 * (1.0 + x.abs()) && z.abs() < 1e-12, "{x} {y}"); + } + let (x, y, _) = local(c.subs(s1)); + assert!((x - 1.5 * t1 * t1).abs() < 1e-9 && (y - 3.0 * t1).abs() < 1e-9); + } +} + +/// Hostile conics are errors (the face is reported and left out), never panics. +#[test] +fn hostile_conics_never_panic() { + let good = hyperbola_piece(); + for bad in [ + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,0.,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,4.,-1.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,1.E300,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,1.E-300,1.E-300)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "HYPERBOLA('',#22,5.,4.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "PARABOLA('',#22,0.)"), + good.replace("HYPERBOLA('',#22,4.,4.)", "PARABOLA('',#22,1.)"), + good.replace("#31=EDGE_CURVE('',#11,#13,#30,.T.)", "#31=EDGE_CURVE('',#11,#11,#30,.T.)"), + ] { + let r = std::panic::catch_unwind(|| step_import(&bad)); + let Ok(r) = r else { panic!("STEP import panicked") }; + if let Ok(imp) = r { + assert!(!imp.warnings.is_empty(), "a broken conic imported silently"); + } + } +} diff --git a/crates/kernel/src/step_in/geom.rs b/crates/kernel/src/step_in/geom.rs index 1925ddd2..91dcb1a1 100644 --- a/crates/kernel/src/step_in/geom.rs +++ b/crates/kernel/src/step_in/geom.rs @@ -1,6 +1,6 @@ //! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces. //! -//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and +//! Points, placements, lines, conics (ellipses, hyperbolas and parabolas), B-splines (plain and rational), elementary surfaces and //! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori. //! Free-form offsets and offsets through collapsed radii remain explicit import warnings. //! Edge curves are trimmed to their vertices here, so every kernel edge runs @@ -54,6 +54,17 @@ pub(crate) enum CurveGeo { a: f64, b: f64, }, + /// Hyperbola branch `o + a·cosh t·x + b·sinh t·y` in the frame's XY plane. + Hyperbola { + f: Frame, + a: f64, + b: f64, + }, + /// Parabola `o + focal·(t²·x + 2t·y)` in the frame's XY plane. + Parabola { + f: Frame, + focal: f64, + }, BSpline(mt::BSplineCurve), Nurbs(mt::NurbsCurve), } @@ -205,6 +216,57 @@ fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R { Ok(mt::KnotVec::from(v)) } +/// The ends of the part of a knot vector where a B-spline of `degree` is defined, when its end +/// knots repeat fewer than `degree + 1` times (an unclamped or periodic knot vector, as +/// OpenCascade writes closed B-splines and `UNIFORM_*` curves and surfaces have): only between +/// knot `degree` and knot `len - degree - 1` do the basis functions sum to one. `None` for a +/// clamped vector, whose whole range is the domain, and for one whose domain is a point. +fn unclamped_ends(kv: &mt::KnotVec, degree: usize) -> Option<(Option, Option)> { + let n = kv.len(); + if n < 2 * (degree + 1) || !(kv[n - 1 - degree] - kv[degree] > mt::TOLERANCE) { + return None; + } + let lo = (kv.multiplicity(0) <= degree).then(|| kv[degree]); + let hi = (kv.multiplicity(n - 1) <= degree).then(|| kv[n - 1 - degree]); + (lo.is_some() || hi.is_some()).then_some((lo, hi)) +} + +/// `c` restricted to its domain, with clamped ends (exact: knots are inserted at the domain's +/// ends and the control points beyond them dropped). The kernel takes a B-spline's whole knot +/// range as its parameter range, and an unclamped curve evaluated outside its domain leaves +/// its control polygon (a rational one divides by zero at the outermost knots). +fn clamped_curve + mt::Tolerance>(mut c: mt::BSplineCurve

) -> mt::BSplineCurve

{ + let Some((lo, hi)) = unclamped_ends(c.knot_vec(), c.degree()) else { return c }; + if let Some(lo) = lo { + c = c.cut(lo); + } + if let Some(hi) = hi { + let _ = c.cut(hi); + } + c +} + +/// [`clamped_curve`] for a surface, in each direction whose knot vector is unclamped. +fn clamped_surface + mt::Tolerance>(mut s: mt::BSplineSurface

) -> mt::BSplineSurface

{ + if let Some((lo, hi)) = unclamped_ends(s.uknot_vec(), s.udegree()) { + if let Some(lo) = lo { + s = s.ucut(lo); + } + if let Some(hi) = hi { + let _ = s.ucut(hi); + } + } + if let Some((lo, hi)) = unclamped_ends(s.vknot_vec(), s.vdegree()) { + if let Some(lo) = lo { + s = s.vcut(lo); + } + if let Some(hi) = hi { + let _ = s.vcut(hi); + } + } + s +} + fn weights_list(p: Option<&Param>) -> R> { p.and_then(Param::as_list) .ok_or("rational B-spline without weights")? @@ -256,9 +318,9 @@ fn bspline_curve(cx: &Ctx, e: &Entity) -> R { Some(r) => { let w = weights_list(r.first())?; let n = mt::NurbsCurve::::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational B-spline: {e}"))?; - Ok(CurveGeo::Nurbs(n)) + Ok(CurveGeo::Nurbs(mt::NurbsCurve::new(clamped_curve(n.into_non_rationalized())))) } - None => Ok(CurveGeo::BSpline(bsp)), + None => Ok(CurveGeo::BSpline(clamped_curve(bsp))), } } @@ -295,6 +357,22 @@ pub(crate) fn curve(cx: &Ctx, id: u64, depth: usize) -> R { } Ok(CurveGeo::Conic { f, a, b }) } + "HYPERBOLA" => { + let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("HYPERBOLA without placement")?)?; + let (a, b) = (length(cx, p.get(2), "semi axis")?, length(cx, p.get(3), "semi imaginary axis")?); + if a <= 0.0 || b <= 0.0 { + return Err("hyperbola axes must be positive".into()); + } + Ok(CurveGeo::Hyperbola { f, a, b }) + } + "PARABOLA" => { + let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("PARABOLA without placement")?)?; + let focal = length(cx, p.get(2), "focal distance")?; + if focal == 0.0 { + return Err("parabola focal distance must not be zero".into()); + } + Ok(CurveGeo::Parabola { f, focal }) + } "B_SPLINE_CURVE_WITH_KNOTS" | "BEZIER_CURVE" | "UNIFORM_CURVE" | "QUASI_UNIFORM_CURVE" => bspline_curve(cx, e), "POLYLINE" => { let pts: Vec = p @@ -346,6 +424,76 @@ pub(crate) fn conic_arc(f: &Frame, a: f64, b: f64, t0: f64, sweep: f64) -> mt::N mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), ctrl)) } +/// Largest hyperbola parameter step one rational quadratic piece spans (keeps its weight +/// `cosh(step / 2)` near 1). +const HYPERBOLA_STEP: f64 = 1.0; + +/// Exact rational quadratic NURBS of the hyperbola branch from parameter `t0` to `t1` (either +/// order): pieces whose middle control point is the meeting point of the end tangents, weighted +/// `cosh` of the half step (the hyperbolic counterpart of [`conic_arc`]). +pub(crate) fn hyperbola_arc(f: &Frame, a: f64, b: f64, t0: f64, t1: f64) -> mt::NurbsCurve { + let n = (((t1 - t0).abs() / HYPERBOLA_STEP).ceil() as usize).clamp(1, 64); + let dt = (t1 - t0) / n as f64; + let w = (dt * 0.5).cosh(); + let at = |c: f64, s: f64| f.at(a * c, b * s, 0.0); + let mut ctrl = Vec::with_capacity(2 * n + 1); + ctrl.push(at(t0.cosh(), t0.sinh()).to_homogeneous()); + let mut knots = vec![0.0, 0.0, 0.0]; + for i in 0..n { + let ta = t0 + dt * i as f64; + let tm = ta + dt * 0.5; + let tb = ta + dt; + let m = at(tm.cosh() / w, tm.sinh() / w); + ctrl.push(mt::Vector4::new(m.x * w, m.y * w, m.z * w, w)); + ctrl.push(at(tb.cosh(), tb.sinh()).to_homogeneous()); + let k = (i + 1) as f64 / n as f64; + knots.push(k); + knots.push(k); + } + knots.push(1.0); + mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), ctrl)) +} + +/// The parabola from parameter `t0` to `t1` (either order): one quadratic Bézier, exact. +pub(crate) fn parabola_arc(f: &Frame, focal: f64, t0: f64, t1: f64) -> mt::BSplineCurve { + let at = |t: f64| f.at(focal * t * t, 2.0 * focal * t, 0.0); + // The end tangents meet above the parameter midpoint: o + focal·(t0·t1, t0 + t1). + let mid = f.at(focal * t0 * t1, focal * (t0 + t1), 0.0); + mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![at(t0), mid, at(t1)]) +} + +/// Parameter of the open conic at `p`, a point on (or within `tol` of) the curve. +fn open_conic_param(g: &CurveGeo, p: mt::Point3, tol: f64) -> R { + let (t, q) = match g { + CurveGeo::Hyperbola { f, a, b } => { + let t = ((p - f.o).dot(f.y) / b).asinh(); + (t, f.at(a * t.cosh(), b * t.sinh(), 0.0)) + } + CurveGeo::Parabola { f, focal } => { + let t = (p - f.o).dot(f.y) / (2.0 * focal); + (t, f.at(focal * t * t, 2.0 * focal * t, 0.0)) + } + _ => return Err("not an open conic".into()), + }; + let d = q.distance(p); + if !t.is_finite() || !(d <= tol) { + return Err(format!( + "edge vertex is {d:.3e} mm off its {} curve", + if matches!(g, CurveGeo::Parabola { .. }) { "parabola" } else { "hyperbola" } + )); + } + Ok(t) +} + +/// The open conic between parameters `t0` and `t1` as a kernel curve. +fn open_conic_curve(g: &CurveGeo, t0: f64, t1: f64) -> R { + match g { + CurveGeo::Hyperbola { f, a, b } => Ok(mt::Curve::NurbsCurve(hyperbola_arc(f, *a, *b, t0, t1))), + CurveGeo::Parabola { f, focal } => Ok(mt::Curve::BSplineCurve(parabola_arc(f, *focal, t0, t1))), + _ => Err("not an open conic".into()), + } +} + fn conic_angle(f: &Frame, a: f64, b: f64, p: mt::Point3) -> f64 { let d = p - f.o; (d.dot(f.y) / b).atan2(d.dot(f.x) / a) @@ -473,6 +621,19 @@ pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: b }) .collect()) } + CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. } => { + if closed { + return Err("closed edge on an open conic".into()); + } + // The curve is open, so the edge is the parameters between its vertices: rising + // for the same sense, falling for the opposite one. + let (ts, te) = (open_conic_param(g, ps, tol)?, open_conic_param(g, pe, tol)?); + let scale = 1.0 + ts.abs().max(te.abs()); + if (te - ts).abs() <= 1e-9 * scale { + return Err("degenerate conic edge".into()); + } + Ok(vec![open_conic_curve(g, ts, te)?]) + } CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()), CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()), } @@ -486,6 +647,21 @@ fn bounded_curve(g: &CurveGeo, extent: &[mt::Point3]) -> R { mt::Curve::Line(mt::Line(*p + *d * lo, *p + *d * hi)) } CurveGeo::Conic { f, a, b } => mt::Curve::NurbsCurve(conic_arc(f, *a, *b, 0.0, TAU)), + CurveGeo::Hyperbola { f, .. } | CurveGeo::Parabola { f, .. } => { + // Bounded by the face's points (their parameters along the curve), with a margin. + let along = |q: &mt::Point3| match g { + CurveGeo::Hyperbola { b, .. } => ((q - f.o).dot(f.y) / b).asinh(), + CurveGeo::Parabola { focal, .. } => (q - f.o).dot(f.y) / (2.0 * focal), + _ => 0.0, + }; + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for t in extent.iter().map(along).filter(|t| t.is_finite()) { + lo = lo.min(t); + hi = hi.max(t); + } + let (lo, hi) = if lo < hi { (lo - (hi - lo) * 0.25 - 1e-3, hi + (hi - lo) * 0.25 + 1e-3) } else { (-1.0, 1.0) }; + open_conic_curve(g, lo, hi)? + } CurveGeo::BSpline(c) => mt::Curve::BSplineCurve(c.clone()), CurveGeo::Nurbs(c) => mt::Curve::NurbsCurve(c.clone()), }) @@ -764,9 +940,9 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R { return Err("weights do not match the control net".into()); } let n = mt::NurbsSurface::::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational surface: {e}"))?; - Ok(mt::Surface::NurbsSurface(n)) + Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(clamped_surface(n.into_non_rationalized())))) } - None => Ok(mt::Surface::BSplineSurface(bsp)), + None => Ok(mt::Surface::BSplineSurface(clamped_surface(bsp))), } } diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 6bf8a91d..0e57150d 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -1268,5 +1268,7 @@ impl StepReading { } } +#[cfg(test)] +mod conic_tests; #[cfg(test)] mod tests; diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 6dd7f4ea..41d841ba 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -7,6 +7,117 @@ fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-9) } +/// A single spherical face bounded by one meridian edge used in both directions. +/// `end_angle` is measured from the north pole toward the seam; PI is the south pole. +fn seam_sphere_step(center: Vec3, radius: f64, axis: Vec3, reference: Vec3, azimuth: f64, reverse_edge: bool, end_angle: f64) -> String { + let seam = reference * azimuth.cos() + axis.cross(reference) * azimuth.sin(); + let end = center + (axis * end_angle.cos() + seam * end_angle.sin()) * radius; + let north = center + axis * radius; + let circle_axis = axis.cross(seam); + let point = |p: Vec3| format!("({:.16},{:.16},{:.16})", p.x, p.y, p.z); + let (start, finish, sense, first, second) = if reverse_edge { (9, 7, "F", 15, 14) } else { (7, 9, "T", 14, 15) }; + format!( + "{HEAD}#1=CARTESIAN_POINT('',{});\n#2=DIRECTION('',{});\n#3=DIRECTION('',{});\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n#5=SPHERICAL_SURFACE('',#4,{radius:.16});\n#6=CARTESIAN_POINT('',{});\n#7=VERTEX_POINT('',#6);\n#8=CARTESIAN_POINT('',{});\n#9=VERTEX_POINT('',#8);\n#10=DIRECTION('',{});\n#11=AXIS2_PLACEMENT_3D('',#1,#10,#12);\n#12=DIRECTION('',{});\n#13=CIRCLE('',#11,{radius:.16});\n#16=EDGE_CURVE('',#{start},#{finish},#13,.{sense}.);\n#14=ORIENTED_EDGE('',*,*,#16,.T.);\n#15=ORIENTED_EDGE('',*,*,#16,.F.);\n#17=EDGE_LOOP('',(#{first},#{second}));\n#18=FACE_OUTER_BOUND('',#17,.T.);\n#19=ADVANCED_FACE('',(#18),#5,.T.);\n#20=CLOSED_SHELL('',(#19));\n#21=MANIFOLD_SOLID_BREP('Ball',#20);\n#22=SHAPE_REPRESENTATION('',(#21),$);\n{TAIL}", + point(center), + point(axis), + point(reference), + point(north), + point(end), + point(circle_axis), + point(seam), + ) +} + +fn assert_seam_sphere(text: &str, radius: f64) { + let imp = step_import(text).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + let body = &imp.bodies[0]; + assert!(body.closed); + let m = measure(&body.body).unwrap(); + let want_volume = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3); + let want_area = 4.0 * std::f64::consts::PI * radius.powi(2); + assert!(rel(m.volume, want_volume) < 1e-3, "volume {} vs {want_volume}", m.volume); + assert!(rel(m.area, want_area) < 1e-3, "area {} vs {want_area}", m.area); + let mesh = body.body.display_mesh(radius * 1e-3).unwrap(); + assert!(!mesh.positions.is_empty() && !mesh.triangles.is_empty()); +} + +#[test] +fn seam_only_sphere_imports_as_a_whole_ball() { + let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI); + assert_seam_sphere(&text, 2.0); +} + +#[test] +fn tilted_seam_only_sphere_imports_as_a_whole_ball() { + let axis = Vec3::new(0.0, std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2); + let text = seam_sphere_step(Vec3::new(30.0, -12.0, 7.0), 1.9845, axis, Vec3::X, 0.7, false, std::f64::consts::PI); + assert_seam_sphere(&text, 1.9845); +} + +#[test] +fn reversed_seam_only_sphere_imports_as_a_whole_ball() { + let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, true, std::f64::consts::PI); + assert_seam_sphere(&text, 2.0); +} + +#[test] +fn seam_only_sphere_whose_placement_axis_misses_its_poles_imports_as_a_whole_ball() { + // The seam runs between poles on the X axis, while the surface's placement points along Z. + let text = seam_sphere_step(Vec3::new(5.0, 6.0, 7.0), 3.0, Vec3::X, Vec3::Y, 0.4, false, std::f64::consts::PI); + let placement = |axis: &str, reference: &str| format!("#2=DIRECTION('',{axis});\n#3=DIRECTION('',{reference});\n"); + let (one, zero) = ("1.0000000000000000", "0.0000000000000000"); + let (x, y, z) = (format!("({one},{zero},{zero})"), format!("({zero},{one},{zero})"), format!("({zero},{zero},{one})")); + let text = text.replacen(&placement(&x, &y), &placement(&z, &x), 1); + assert!(text.contains(&placement(&z, &x)), "the placement was not replaced"); + assert_seam_sphere(&text, 3.0); +} + +#[test] +fn seam_only_sphere_void_is_turned_inside_out() { + // A 5 mm ball with a 2 mm ball-shaped void, written as voids are: the void's closed shell + // (faces facing out of it) used through an ORIENTED_CLOSED_SHELL with orientation .F. + let renumber = |text: &str, by: u64| { + let mut out = String::new(); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + out.push(c); + if c == '#' { + let mut n = String::new(); + while let Some(d) = chars.next_if(char::is_ascii_digit) { + n.push(d); + } + out += &(n.parse::().unwrap() + by).to_string(); + } + } + out + }; + let inner = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI); + let inner = &inner[inner.find("#1=").unwrap()..inner.find("#21=").unwrap()]; + let outer = seam_sphere_step(Vec3::ZERO, 5.0, Vec3::Z, Vec3::X, 1.0, false, std::f64::consts::PI); + let brep = "#21=MANIFOLD_SOLID_BREP('Ball',#20);\n"; + assert!(outer.contains(brep)); + let void = format!("{}#200=ORIENTED_CLOSED_SHELL('',*,#120,.F.);\n#21=BREP_WITH_VOIDS('Hollow',#20,(#200));\n", renumber(inner, 100)); + let text = outer.replacen(brep, &void, 1); + let imp = step_import(&text).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); + assert!(imp.bodies[0].closed); + let m = measure(&imp.bodies[0].body).unwrap(); + let want = 4.0 / 3.0 * std::f64::consts::PI * (125.0 - 8.0); + assert!(rel(m.volume, want) < 1e-3, "volume {} vs {want}", m.volume); + let area = 4.0 * std::f64::consts::PI * (25.0 + 4.0); + assert!(rel(m.area, area) < 1e-3, "area {} vs {area}", m.area); +} + +#[test] +fn non_antipodal_seam_sphere_does_not_panic() { + let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, 2.5); + let result = std::panic::catch_unwind(|| step_import(&text)); + assert!(result.is_ok()); +} + #[test] #[ignore = "manual STEP import timing; run without concurrent builds"] fn benchmark_independent_step_solids() { @@ -1255,3 +1366,120 @@ fn revolved_faces_skewed_never_panic_on_export() { } } } + +/// Weights of a rational quadratic full circle through nine control points. +const CIRCLE_WEIGHTS: &str = "1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1."; +/// Angle knots as OpenCascade writes a closed (periodic) quadratic circle: the end knots +/// appear once, so the B-spline is defined from knot 2 to knot 9 only, -π to π. +const PERIODIC_MULTS: &str = "1,2,2,2,2,2,1"; +const PERIODIC_KNOTS: &str = "-4.712388980385,-3.14159265359,-1.570796326795,0.,1.570796326795,3.14159265359,4.712388980385"; + +/// Nine control points of a quadratic circle of radius `r` about (`x`, 0, `z`), from angle 0. +fn circle_net(first: u64, x: f64, r: f64, z: f64) -> (String, Vec) { + let (mut s, mut ids) = (String::new(), Vec::new()); + for i in 0..9u64 { + let a = i as f64 * std::f64::consts::FRAC_PI_4; + let q = if i % 2 == 1 { r * std::f64::consts::SQRT_2 } else { r }; + s += &format!("#{}=CARTESIAN_POINT('',({:?},{:?},{z:?}));\n", first + i, x + q * a.cos(), q * a.sin()); + ids.push(format!("#{}", first + i)); + } + (s, ids) +} + +/// A tube face of radius `r` and length `h` about the Z axis through (`x`, 0, 0), written the +/// way OpenCascade writes one: a rational degree (1, 2) B-spline surface with the periodic +/// angle knots above, bounded by its two rims and a seam. Entities from `first` on; returns +/// them and the face's id. +fn periodic_knot_tube(first: u64, x: f64, r: f64, h: f64) -> (String, u64) { + let id = |k: u64| first + k; + let (net0, row0) = circle_net(id(40), x, r, 0.0); + let (net1, row1) = circle_net(id(50), x, r, h); + let (p0, p1, v0, v1) = (id(0), id(1), id(2), id(3)); + let (c0, c1, up, down, ref_dir) = (id(4), id(5), id(6), id(7), id(8)); + let (a0, a1, rim0, rim1) = (id(9), id(10), id(11), id(12)); + let (e0, e1, along, seam_line, seam) = (id(13), id(14), id(15), id(16), id(17)); + let (o0, o1, o2, o3, lp, bound, surface, face) = (id(18), id(19), id(20), id(21), id(22), id(23), id(24), id(25)); + let mut s = net0 + &net1; + let xr = x + r; + s += &format!("#{p0}=CARTESIAN_POINT('',({xr:?},0.,0.));\n#{p1}=CARTESIAN_POINT('',({xr:?},0.,{h:?}));\n"); + s += &format!("#{v0}=VERTEX_POINT('',#{p0});\n#{v1}=VERTEX_POINT('',#{p1});\n"); + s += &format!("#{c0}=CARTESIAN_POINT('',({x:?},0.,0.));\n#{c1}=CARTESIAN_POINT('',({x:?},0.,{h:?}));\n"); + s += &format!("#{up}=DIRECTION('',(0.,0.,1.));\n#{down}=DIRECTION('',(0.,0.,-1.));\n#{ref_dir}=DIRECTION('',(1.,0.,0.));\n"); + s += &format!("#{a0}=AXIS2_PLACEMENT_3D('',#{c0},#{up},#{ref_dir});\n#{a1}=AXIS2_PLACEMENT_3D('',#{c1},#{down},#{ref_dir});\n"); + s += &format!("#{rim0}=CIRCLE('',#{a0},{r:?});\n#{rim1}=CIRCLE('',#{a1},{r:?});\n"); + s += &format!("#{e0}=EDGE_CURVE('',#{v0},#{v0},#{rim0},.T.);\n#{e1}=EDGE_CURVE('',#{v1},#{v1},#{rim1},.T.);\n"); + s += &format!("#{along}=VECTOR('',#{up},1.);\n#{seam_line}=LINE('',#{p0},#{along});\n#{seam}=EDGE_CURVE('',#{v0},#{v1},#{seam_line},.T.);\n"); + s += &format!( + "#{o0}=ORIENTED_EDGE('',*,*,#{e0},.F.);\n#{o1}=ORIENTED_EDGE('',*,*,#{seam},.T.);\n#{o2}=ORIENTED_EDGE('',*,*,#{e1},.F.);\n#{o3}=ORIENTED_EDGE('',*,*,#{seam},.F.);\n" + ); + s += &format!("#{lp}=EDGE_LOOP('',(#{o0},#{o1},#{o2},#{o3}));\n#{bound}=FACE_BOUND('',#{lp},.T.);\n"); + s += &format!( + "#{surface}=(BOUNDED_SURFACE()B_SPLINE_SURFACE(1,2,(({}),({})),.UNSPECIFIED.,.F.,.T.,.F.)B_SPLINE_SURFACE_WITH_KNOTS((2,2),({PERIODIC_MULTS}),(0.,{:?}),({PERIODIC_KNOTS}),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE((({CIRCLE_WEIGHTS}),({CIRCLE_WEIGHTS})))REPRESENTATION_ITEM('')SURFACE());\n", + row0.join(","), + row1.join(","), + h / r + ); + s += &format!("#{face}=ADVANCED_FACE('',(#{bound}),#{surface},.T.);\n"); + (s, face) +} + +/// A B-spline whose end knots repeat fewer than degree + 1 times (an unclamped knot vector, +/// as OpenCascade writes closed curves and surfaces) is defined only between knot `degree` +/// and knot `len - degree - 1`. Read over the whole knot range, the circle and the tube went +/// on past their domain, and the rational ones divided by zero at the outermost knots. +#[test] +fn periodic_knot_bsplines_are_read_on_their_domain() { + use mt::{BoundedCurve, ParametricCurve, ParametricSurface}; + let (r, h) = (1.7, 1.5); + let (tube, face) = periodic_knot_tube(100, 0.0, r, h); + let (circle, row) = circle_net(10, 0.0, r, 0.0); + let text = format!( + "{HEAD}{tube}{circle}#30=(BOUNDED_CURVE()B_SPLINE_CURVE(2,({}),.CIRCULAR_ARC.,.T.,.F.)B_SPLINE_CURVE_WITH_KNOTS(({PERIODIC_MULTS}),({PERIODIC_KNOTS}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({CIRCLE_WEIGHTS}))REPRESENTATION_ITEM(''));\n{TAIL}", + row.join(",") + ); + let ex = p21::parse(&text).unwrap(); + let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 }; + let pi = std::f64::consts::PI; + let on_tube = |p: mt::Point3| (p.x.hypot(p.y) - r).abs() < 1e-9 && p.z > -1e-9 && p.z < h + 1e-9; + + let surface_id = ex.get(face).and_then(|e| e.params().get(2)).and_then(Param::as_ref_id).unwrap(); + let surface = geom::bspline_surface(&cx, ex.get(surface_id).unwrap()).unwrap(); + let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { panic!("unbounded surface") }; + assert!(u0.abs() < 1e-12 && (u1 - h / r).abs() < 1e-12, "u range {u0} .. {u1}"); + assert!((v0 + pi).abs() < 1e-9 && (v1 - pi).abs() < 1e-9, "v range {v0} .. {v1}"); + for i in 0..=8 { + for j in 0..=16 { + let (u, v) = (u0 + (u1 - u0) * i as f64 / 8.0, v0 + (v1 - v0) * j as f64 / 16.0); + assert!(on_tube(surface.subs(u, v)), "S({u}, {v}) = {:?}", surface.subs(u, v)); + } + } + + let geom::CurveGeo::Nurbs(curve) = geom::curve(&cx, 30, 0).unwrap() else { panic!("not a rational B-spline") }; + let (t0, t1) = curve.range_tuple(); + assert!((t0 + pi).abs() < 1e-9 && (t1 - pi).abs() < 1e-9, "curve range {t0} .. {t1}"); + for j in 0..=16 { + let t = t0 + (t1 - t0) * j as f64 / 16.0; + assert!(on_tube(curve.subs(t)), "C({t}) = {:?}", curve.subs(t)); + } +} + +/// Two such tubes far apart in one body: the body's size sets a chord tolerance coarse beside +/// the tubes, and meshing a face at it divided the surface's whole parameter box, whose +/// corners lay outside the domain. The division never converged there (#1374's recursion), +/// and the import grew by gigabytes without end. +#[test] +fn periodic_knot_tube_faces_import_and_mesh() { + let t0 = std::time::Instant::now(); + let (r, h) = (1.7, 1.5); + let (a, fa) = periodic_knot_tube(100, 0.0, r, h); + let (b, fb) = periodic_knot_tube(200, 700.0, r, h); + let text = format!( + "{HEAD}{a}{b}#1=OPEN_SHELL('',(#{fa},#{fb}));\n#2=SHELL_BASED_SURFACE_MODEL('Tubes',(#1));\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}" + ); + let imp = step_import(&text).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + let m = measure(&imp.bodies[0].body).unwrap(); + let area = 2.0 * std::f64::consts::TAU * r * h; + assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area); + assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed()); +} diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index 3cb1c59b..b7c9d378 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -532,7 +532,7 @@ fn analytic_revolution(ex: &p21::Exchange, e: &p21::Entity) -> Option [q.x, q.y, q.z] }) } - CurveGeo::Conic { .. } => return None, + CurveGeo::Conic { .. } | CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. } => return None, }; // A sample point off the axis, its revolution normal (∂S/∂angle × ∂S/∂profile). let sample = @@ -798,7 +798,9 @@ fn edge_points(ex: &p21::Exchange, edge: u64, n: usize) -> Option> { crate::step_in::geom::CurveGeo::Line { .. } => Some(ends.to_vec()), crate::step_in::geom::CurveGeo::BSpline(c) => Some(sample(&|t| c.subs(t), c.range_tuple())), crate::step_in::geom::CurveGeo::Nurbs(c) => Some(sample(&|t| c.subs(t), c.range_tuple())), - crate::step_in::geom::CurveGeo::Conic { .. } => None, + crate::step_in::geom::CurveGeo::Conic { .. } + | crate::step_in::geom::CurveGeo::Hyperbola { .. } + | crate::step_in::geom::CurveGeo::Parabola { .. } => None, } } @@ -862,7 +864,9 @@ fn collapse_short_edges(ex: &mut p21::Exchange) { crate::step_in::geom::CurveGeo::Line { .. } => return Some(true), crate::step_in::geom::CurveGeo::BSpline(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0), crate::step_in::geom::CurveGeo::Nurbs(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0), - crate::step_in::geom::CurveGeo::Conic { .. } => return Some(false), + crate::step_in::geom::CurveGeo::Conic { .. } + | crate::step_in::geom::CurveGeo::Hyperbola { .. } + | crate::step_in::geom::CurveGeo::Parabola { .. } => return Some(false), }; Some(norm(sub([mid.x, mid.y, mid.z], pa)) <= 1e-6 * (1.0 + norm(pa))) }); diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs index 2982fb59..5f5cb47f 100644 --- a/crates/kernel/src/surface_kind.rs +++ b/crates/kernel/src/surface_kind.rs @@ -43,8 +43,12 @@ pub enum FaceSurface { /// The surface of face `face` (in face order), if it is one of the analytic kinds. pub fn face_surface(b: &Body, face: usize) -> Option { - let f = b.solid.face_iter().nth(face)?; - let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + surface_of(b.solid.face_iter().nth(face)?, b.size()) +} + +/// The analytic surface of face `f` of a body whose box diagonal is `size`. +pub(crate) fn surface_of(f: &mt::Face, size: f64) -> Option { + let tol = (size * 1e-7).max(1e-9) * 100.0; Some(match surf_of(f, tol)? { Surf::Plane { n, d } => FaceSurface::Plane { normal: n, point: n * d }, Surf::Cylinder { o, a, r, .. } => FaceSurface::Cylinder { origin: o, axis: a, radius: r }, diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 2ba84275..1a7e8707 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -1,8 +1,9 @@ //! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves). //! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline //! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls -//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see -//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described. +//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale, guide +//! rails; see [`SweepControl`]) reshape the stations; their default leaves them exactly as +//! described. use mt::builder; use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; @@ -48,21 +49,44 @@ pub enum SweepOrientation { Direction(Vec3), } +/// How guide rails reshape the profile at each station. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RailFit { + /// The profile grows or shrinks evenly with its distance across to the rail (between the + /// two rails). + #[default] + Scale, + /// The profile stretches only across to the rail (between the rails), keeping its size the + /// other way. + Stretch, +} + /// Sweep options beyond following the path. `twist` turns the profile about the path by that /// many radians over the path's length (right hand about the direction of travel, in proportion /// to the length run); `scale` is the profile's size at the end, reached linearly along the -/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1) -/// is a plain sweep. -#[derive(Clone, Copy, Debug, PartialEq)] +/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1, +/// no rails) is a plain sweep. +/// +/// `rails` are guide rails, as polylines from the end nearest the profile. Each station of the +/// path is square to it; a rail meets the station's plane at one point. With one rail, the +/// profile turns about the path so that its point where the rail starts stays on the rail, and +/// is scaled (or stretched, see [`RailFit`]) by how far the rail is from the path compared with +/// the start. With two rails, the profile's points where the rails start stay on both: it moves +/// with the first rail, turns with the line between them and scales (stretches) with their +/// distance apart. Rails decide the profile's turn and size, so they take the default +/// orientation, no twist and scale 1, and a path without sharp corners. +#[derive(Clone, Debug, PartialEq)] pub struct SweepControl { pub orientation: SweepOrientation, pub twist: f64, pub scale: f64, + pub rails: Vec>, + pub rail_fit: RailFit, } impl Default for SweepControl { fn default() -> Self { - SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 } + SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0, rails: Vec::new(), rail_fit: RailFit::Scale } } } @@ -79,13 +103,15 @@ impl SweepControl { *self == SweepControl::default() } - /// Whether the profile changes along the path (it twists or scales). + /// Whether the profile changes along the path (it twists, scales or follows rails). fn varies(&self) -> bool { - self.twist != 0.0 || self.scale != 1.0 + self.twist != 0.0 || self.scale != 1.0 || !self.rails.is_empty() } /// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside - /// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length. + /// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length, + /// more than two rails, a rail that is not 2…100000 finite points, and rails with any other + /// orientation, a twist or an end scale. pub fn check(&self) -> Result<()> { if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) { return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into())); @@ -98,6 +124,17 @@ impl SweepControl { { return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into())); } + if self.rails.len() > 2 { + return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())); + } + for rail in &self.rails { + clean(rail).map_err(|_| KernelError::Invalid("a guide rail needs 2…100000 finite points along its length".into()))?; + } + if !self.rails.is_empty() && (self.orientation != SweepOrientation::Perpendicular || self.twist != 0.0 || self.scale != 1.0) { + return Err(KernelError::Invalid( + "guide rails set the profile's turn and size along the path: leave the orientation, twist and end scale at their defaults".into(), + )); + } Ok(()) } } @@ -228,6 +265,9 @@ fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result> { /// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and /// scale grow with the length run along `pts`. fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result> { + if !ctl.rails.is_empty() { + return railed_stations(pts, tangents, mitre, ctl); + } let bad = || KernelError::Invalid("sweep path".into()); let t0 = *tangents.first().ok_or_else(bad)?; let f: Vec<[Vec3; 3]> = match ctl.orientation { @@ -284,6 +324,113 @@ fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[ Ok(out) } +/// Where a rail polyline crosses the plane through `p` square to `t`: the first crossing at or +/// after `from` (segment index and fraction along it), with the position it was found at, so a +/// walk along the path finds each rail point after the last. Within `tol` of the plane a rail's +/// first and last points count as on it (a rail drawn to end where the path ends). +fn rail_crossing(rail: &[Vec3], p: Vec3, t: Vec3, from: (usize, f64), tol: f64) -> Option<(Vec3, (usize, f64))> { + let f = |q: Vec3| (q - p).dot(t); + let (k0, u0) = from; + if k0 == 0 && u0 == 0.0 && rail.first().is_some_and(|q| f(*q).abs() <= tol) { + return rail.first().map(|q| (*q, (0, 0.0))); + } + for k in k0..rail.len().saturating_sub(1) { + let (a, b) = (*rail.get(k)?, *rail.get(k + 1)?); + let a = if k == k0 { a.lerp(b, u0) } else { a }; + let (fa, fb) = (f(a), f(b)); + if fa == 0.0 && (k != k0 || u0 == 0.0) { + return Some((a, (k, if k == k0 { u0 } else { 0.0 }))); + } + if (fa < 0.0) != (fb < 0.0) || fb == 0.0 { + let s = fa / (fa - fb); + let q = a.lerp(b, s); + // The fraction along the whole segment, for the next walk. + let base = if k == k0 { u0 } else { 0.0 }; + return Some((q, (k, base + (1.0 - base) * s))); + } + } + let last = rail.last()?; + (f(*last).abs() <= tol).then(|| (*last, (rail.len().saturating_sub(2), 1.0))) +} + +/// Stations shaped by guide rails (see [`SweepControl`]): each is square to the path, turned +/// about it to follow the rail (the line between the rails) and scaled or stretched by the +/// rail's distance across (the rails' distance apart) against the start's. A rail has to meet +/// the plane of every station, in order along its length. +fn railed_stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result> { + // A polyline path's points bend it a little (its arcs' chords); a corner is a bend past + // [`CORNER`], where the stations would have to be mitred. + let corner = 1.0 / (CORNER / 2.0).cos(); + if mitre.iter().flatten().any(|(_, s)| *s > corner + 1e-12) { + return Err(KernelError::Invalid("not supported yet: guide rails along a path with sharp corners".into())); + } + let rails: Vec> = ctl.rails.iter().map(|r| clean(r)).collect::>()?; + let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); + let tol = 1e-3f64.max(1e-6 * len); + let mut cursors = vec![(0usize, 0.0f64); rails.len()]; + // At each station: the anchor the profile moves with, and the vector across that turns and + // sizes it (to the rail, or from the first rail to the second). + let mut across = Vec::with_capacity(pts.len()); + for (i, (p, t)) in pts.iter().zip(tangents).enumerate() { + let mut hits = Vec::with_capacity(rails.len()); + for (rail, cursor) in rails.iter().zip(cursors.iter_mut()) { + let Some((q, at)) = rail_crossing(rail, *p, *t, *cursor, tol) else { + let done = 100.0 * i as f64 / pts.len().saturating_sub(1).max(1) as f64; + return Err(KernelError::Invalid(format!( + "a guide rail does not reach the path's plane {done:.0}% of the way along it: the rail has to run the whole length of the path" + ))); + }; + *cursor = at; + hits.push(q); + } + let (anchor, v) = match hits[..] { + [r] => (*p, r - *p), + [a, b] => (a, b - a), + _ => return Err(KernelError::Invalid("a sweep takes one or two guide rails".into())), + }; + across.push((anchor, v - *t * v.dot(*t))); + } + let (Some(&(a0, d0)), Some(&t0)) = (across.first(), tangents.first()) else { return Err(KernelError::Invalid("sweep path".into())) }; + let s0 = *pts.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?; + let near = |d: Vec3| d.len() <= 1e-6 * (1.0 + len); + if near(d0) { + return Err(KernelError::Invalid(if rails.len() == 1 { + "the guide rail starts on the path: it has to start away from the path, at the point of the profile it guides".into() + } else { + "the two guide rails start at the same point".into() + })); + } + let (u0, w0) = { + let u = d0.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?; + (u, t0.cross(u)) + }; + let mut out = Vec::with_capacity(pts.len()); + for ((anchor, d), t) in across.iter().zip(tangents) { + if near(*d) { + return Err(KernelError::Invalid(if rails.len() == 1 { + format!("the guide rail meets the path near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z) + } else { + format!("the guide rails meet near ({:.3}, {:.3}, {:.3}): the profile would shrink to nothing", anchor.x, anchor.y, anchor.z) + })); + } + let k = d.len() / d0.len(); + let k_other = match ctl.rail_fit { + RailFit::Scale => k, + RailFit::Stretch => 1.0, + }; + let u = d.normalized().ok_or_else(|| KernelError::Invalid("guide rail".into()))?; + let w = t.cross(u); + // Along the path stays along it; across to the rail turns onto the rail and sizes with + // it; the third way follows, scaled evenly or kept. + let map = |v: Vec3| *t * v.dot(t0) + u * (k * v.dot(u0)) + w * (k_other * v.dot(w0)); + let m = [map(Vec3::X), map(Vec3::Y), map(Vec3::Z)]; + let st = Station { at: Vec3::default(), m }; + // The anchor's start position lands on the anchor. + out.push(Station { at: *anchor - st.apply(a0 - s0), m }); + } + Ok(out) +} + /// The volume a profile of `area` on `plane` sweeps through the stations: each station's /// section area times how squarely the path crosses it, summed along the path (trapezoids). fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 { diff --git a/crates/kernel/src/sweep_rails_tests.rs b/crates/kernel/src/sweep_rails_tests.rs new file mode 100644 index 00000000..4bc438e3 --- /dev/null +++ b/crates/kernel/src/sweep_rails_tests.rs @@ -0,0 +1,166 @@ +//! Guide rails on path sweeps (issue #771), checked against analytic solids: a rail that closes +//! in on the path scales a circle into a cone frustum, a stretching rail widens a square one way +//! only, a helical rail twists a square, two rails carry a line between them; and rails the +//! sweep cannot follow are refused. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::f64::consts::PI; + +use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; + +use crate::{RailFit, SweepControl, measure, sweep_path_with}; + +fn circle(r: f64) -> Region2 { + Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() } +} + +fn square(h: f64) -> Region2 { + Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() } +} + +/// The Z axis from 0 to 40. +fn up() -> Vec { + vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 40.0)] +} + +fn railed(rails: Vec>, fit: RailFit) -> SweepControl { + SweepControl { rails, rail_fit: fit, ..SweepControl::default() } +} + +fn close(a: f64, b: f64, rel: f64) -> bool { + (a - b).abs() <= rel * b.abs() +} + +/// A rail from the circle's edge at radius 10 in to radius 5 at the top scales the circle into +/// a frustum: volume πh(R² + Rr + r²)/3, lateral area π(R + r) times the slant height. +#[test] +fn a_rail_closing_in_scales_a_circle_into_a_frustum() { + let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &circle(10.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = PI * 40.0 / 3.0 * (100.0 + 50.0 + 25.0); + let area = PI * 15.0 * (40.0f64 * 40.0 + 25.0).sqrt() + PI * 100.0 + PI * 25.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); + assert!(close(m.area, area, 2e-3), "area {} vs {area}", m.area); + assert!(close(m.bbox.max.z, 40.0, 1e-9), "{:?}", m.bbox); +} + +/// A stretching rail from x = 5 out to x = 10 doubles the square's width along X by the top and +/// leaves its depth along Y: sections 10 × 10k with k = 1 + z/40, volume 100 · 40 · 1.5. +#[test] +fn a_stretching_rail_widens_a_square_one_way() { + let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Stretch)).unwrap(); + let m = measure(&b).unwrap(); + assert!(close(m.volume, 6000.0, 1e-3), "volume {}", m.volume); + assert!((m.bbox.max.x - 10.0).abs() < 1e-3 && (m.bbox.min.x + 10.0).abs() < 1e-3, "{:?}", m.bbox); + assert!((m.bbox.max.y - 5.0).abs() < 1e-3 && (m.bbox.min.y + 5.0).abs() < 1e-3, "{:?}", m.bbox); +} + +/// The same rail scaling evenly: the square grows both ways, sections 100k², volume +/// 100 ∫ (1 + z/40)² dz = 100 · 40 · 7/3. +#[test] +fn a_scaling_rail_grows_a_square_both_ways() { + let rail = vec![Vec3::new(5.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 40.0)]; + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = 100.0 * 40.0 * 7.0 / 3.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); + assert!((m.bbox.max.y - 10.0).abs() < 1e-3, "{:?}", m.bbox); +} + +/// A helical rail from the square's corner, a quarter turn about the path at the corner's +/// radius, twists the square a quarter turn: the volume stays 4000, halfway up the corners stand +/// on the axes (the box reaches 5√2), and at the top the corner is at (−5, 5). +#[test] +fn a_helical_rail_twists_a_square() { + let r = 50f64.sqrt(); + let rail: Vec = (0..=200) + .map(|i| { + let u = i as f64 / 200.0; + let a = PI / 4.0 + u * PI / 2.0; + Vec3::new(r * a.cos(), r * a.sin(), 40.0 * u) + }) + .collect(); + let b = sweep_path_with(&Plane::XY, &square(5.0), &up(), &railed(vec![rail], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + assert!(close(m.volume, 4000.0, 2e-3), "volume {}", m.volume); + assert!((m.bbox.max.x - r).abs() < 0.05 && (m.bbox.max.y - r).abs() < 0.05, "{:?}", m.bbox); + // The edge running up from the corner (5, 5) ends a quarter turn on, at (−5, 5). + let edges = b.edges(0.01).unwrap(); + let ends = |e: &crate::EdgeInfo| (e.points.first().copied().unwrap(), e.points.last().copied().unwrap()); + let joins = |p: Vec3, q: Vec3| { + edges.iter().any(|e| { + let (a, b) = ends(e); + (a.dist(p) < 1e-3 && b.dist(q) < 1e-3) || (a.dist(q) < 1e-3 && b.dist(p) < 1e-3) + }) + }; + assert!(joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(-5.0, 5.0, 40.0)), "{:?}", edges.iter().map(ends).collect::>()); + assert!(!joins(Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 40.0))); +} + +/// Along a quarter circle of radius 30, a rail keeping 5 mm out from the path (the circle's +/// outer point, on a quarter circle of radius 35) leaves the circle as it is: a quarter torus, +/// volume πr² · R · π/2 (Pappus). +#[test] +fn a_parallel_rail_along_a_curved_path_keeps_the_section() { + let arc = |radius: f64| -> Vec { + (0..=180) + .map(|i| { + let a = PI / 2.0 * i as f64 / 180.0; + Vec3::new(30.0 - radius * a.cos(), 0.0, radius * a.sin()) + }) + .collect() + }; + let b = sweep_path_with(&Plane::XY, &circle(5.0), &arc(30.0), &railed(vec![arc(35.0)], RailFit::Scale)).unwrap(); + let m = measure(&b).unwrap(); + let volume = PI * 25.0 * 30.0 * PI / 2.0; + assert!(close(m.volume, volume, 2e-3), "volume {} vs {volume}", m.volume); +} + +/// Two rails bulging apart along arcs (radius 25 about (∓5, 0, 20), through x = ±10 at z = 0 +/// and z = 40) carry a line between them: the sheet lies in the XZ plane between the arcs, area +/// 2(a√(R² − a²) + R² asin(a/R)) − 400 with a = 20. +#[test] +fn two_rails_carry_a_line_between_arcs() { + let arc = |side: f64| -> Vec { + (0..=400) + .map(|i| { + let z = 40.0 * i as f64 / 400.0; + let x = -5.0 + (625.0 - (z - 20.0) * (z - 20.0)).sqrt(); + Vec3::new(side * x, 0.0, z) + }) + .collect() + }; + let line = vec![vec![Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }]]; + for fit in [RailFit::Scale, RailFit::Stretch] { + let ctl = railed(vec![arc(-1.0), arc(1.0)], fit); + let b = crate::sweep_sheet_points_with(&Plane::XY, &line, &up(), &ctl).unwrap(); + let m = measure(&b).unwrap(); + let area = 2.0 * (20.0 * 15.0 + 625.0 * (0.8f64).asin()) - 400.0; + assert!(close(m.area, area, 1e-3), "{fit:?}: area {} vs {area}", m.area); + assert!(m.bbox.min.y.abs() < 1e-6 && m.bbox.max.y.abs() < 1e-6, "{fit:?}: {:?}", m.bbox); + } +} + +/// Rails the sweep cannot follow are refused with a reason, never a panic: three rails, a rail +/// with a bad point, a rail with a twist or scale besides, a rail starting on the path, a rail +/// that stops short, rails along a path with a corner. +#[test] +fn rails_the_sweep_cannot_follow_are_refused() { + let rail = vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 40.0)]; + let refuse = |ctl: SweepControl, path: &[Vec3], why: &str| { + let e = sweep_path_with(&Plane::XY, &circle(10.0), path, &ctl).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains(why), "expected `{why}` in `{e}`"); + }; + refuse(railed(vec![rail.clone(); 3], RailFit::Scale), &up(), "one or two guide rails"); + refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)]], RailFit::Scale), &up(), "finite points"); + refuse(SweepControl { twist: 1.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation"); + refuse(SweepControl { scale: 2.0, ..railed(vec![rail.clone()], RailFit::Scale) }, &up(), "leave the orientation"); + refuse(railed(vec![vec![Vec3::ZERO, Vec3::new(5.0, 0.0, 40.0)]], RailFit::Scale), &up(), "starts on the path"); + refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(8.0, 0.0, 20.0)]], RailFit::Scale), &up(), "whole length"); + refuse(railed(vec![rail.clone(), rail.clone()], RailFit::Scale), &up(), "start at the same point"); + let bent = vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::new(20.0, 0.0, 20.0)]; + refuse(railed(vec![vec![Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 30.0)]], RailFit::Scale), &bent, "sharp corners"); +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index ce8a67ed..d6df9e50 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1939,6 +1939,328 @@ fn fillet_an_open_curved_edge() { assert!(rel(removed, want) < 1e-3, "{removed} vs {want}"); } +fn mixed_arc_and_line_prism() -> Body { + use solidcraft_geom::Seg2; + let d = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: 0.0, sweep: PI }, + Seg2::Line { a: Vec2::new(-10.0, 0.0), b: Vec2::new(10.0, 0.0) }, + ], + }; + extrude(&Plane::XY, &[Region2 { outer: d, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap() +} + +#[test] +fn fillet_smooth_arc_and_line_loop_keeps_exact_removal() { + use solidcraft_geom::Seg2; + // A stadium: four tangent joints, none of which should use a sharp-corner mitre. + let outline = Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(-10.0, -10.0), b: Vec2::new(10.0, -10.0) }, + Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: -PI / 2.0, sweep: PI }, + Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(-10.0, 10.0) }, + Seg2::Arc { center: Vec2::new(-10.0, 0.0), radius: 10.0, start: PI / 2.0, sweep: PI }, + ], + }; + let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap(); + let blended = + fillet(&body, &[Vec3::new(0.0, -10.0, 15.0), Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 10.0, 15.0), Vec3::new(-20.0, 0.0, 15.0)], 0.5) + .unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + let r = 0.5; + let area = r * r * (1.0 - PI / 4.0); + let centroid = r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)); + let want = area * (40.0 + 2.0 * PI * (10.0 - centroid)); + let fine = |b: &Body| b.tessellate_with(1e-4, true).unwrap().measure().volume; + let removed = fine(&body) - fine(&blended); + assert!(rel(removed, want) < 1e-3, "{removed} vs {want}"); +} + +#[test] +fn fillet_sharp_arc_and_arc_loop() { + use solidcraft_geom::Seg2; + // The intersection of two radius-10 disks whose centres are 10 mm apart. + let outline = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::new(-5.0, 0.0), radius: 10.0, start: -PI / 3.0, sweep: 2.0 * PI / 3.0 }, + Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 10.0, start: 2.0 * PI / 3.0, sweep: 2.0 * PI / 3.0 }, + ], + }; + let body = extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap(); + let blended = fillet(&body, &[Vec3::new(5.0, 0.0, 15.0), Vec3::new(-5.0, 0.0, 15.0)], 0.5).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + let mesh = blended.tessellate_with(1e-4, true).unwrap(); + for p in &mesh.positions { + assert!((p.x - 5.0).hypot(p.y) <= 10.0 + 1e-3 && (p.x + 5.0).hypot(p.y) <= 10.0 + 1e-3, "outside the lens: {p:?}"); + } + let area = |rho: f64| 2.0 * rho * rho * (5.0 / rho).acos() - 10.0 * (rho * rho - 25.0).sqrt(); + let dt = (PI / 2.0) / 8192.0; + let want: f64 = (0..8192) + .map(|i| { + let theta = (f64::from(i) + 0.5) * dt; + (area(10.0) - area(10.0 - 0.5 * (1.0 - theta.cos()))) * 0.5 * theta.cos() * dt + }) + .sum(); + let removed = body.tessellate_with(1e-4, true).unwrap().measure().volume - mesh.measure().volume; + assert!(rel(removed, want) < 1e-3, "{removed} vs {want}"); +} + +#[test] +fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() { + use solidcraft_geom::Seg2; + // Reconstruct the outer outline from its seven public pick coordinates: + // R25 plate, x=+/-4 tab sides, R2 corners and a 4 mm straight top. + // The hole's dimensions come from the public reproduction, not an original design file. + let theta = (4.0_f64 / 25.0).acos(); + let y = (625.0_f64 - 16.0).sqrt(); + let corner_y = 280.0 / 9.0; + let hole_y = 262.0 / 9.0; + let outline = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::ZERO, radius: 25.0, start: PI - theta, sweep: PI + 2.0 * theta }, + Seg2::Line { a: Vec2::new(4.0, y), b: Vec2::new(4.0, corner_y) }, + Seg2::Arc { center: Vec2::new(2.0, corner_y), radius: 2.0, start: 0.0, sweep: PI / 2.0 }, + Seg2::Line { a: Vec2::new(2.0, corner_y + 2.0), b: Vec2::new(-2.0, corner_y + 2.0) }, + Seg2::Arc { center: Vec2::new(-2.0, corner_y), radius: 2.0, start: PI / 2.0, sweep: PI / 2.0 }, + Seg2::Line { a: Vec2::new(-4.0, corner_y), b: Vec2::new(-4.0, y) }, + ], + }; + let body = + extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap(); + let inner = fillet(&body, &[Vec3::new(1.5, hole_y, 2.0)], 0.5).unwrap(); + let edges = [ + Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0), + Vec3::new(24.919351255429795, -2.006472777913911, 2.0), + Vec3::new(3.999999997492012, 27.894518234675537, 2.0), + Vec3::new(3.4142135598750105, 32.525324672801695, 2.0), + Vec3::new(-2.480541994032137e-9, 33.11111111046627, 2.0), + Vec3::new(-3.4142135648720653, 32.52532467287703, 2.0), + Vec3::new(-4.0000000025087745, 27.89451823429679, 2.0), + ]; + // All 127 nonempty subsets: the whole rim, each individual edge, and every + // contiguous or disjoint partial selection of the issue's seven picks. + for mask in 1_u32..(1 << edges.len()) { + let picks: Vec<_> = edges.iter().enumerate().filter(|(i, _)| mask & (1 << i) != 0).map(|(_, p)| *p).collect(); + let blended = fillet(&inner, &picks, 0.5).unwrap_or_else(|e| panic!("{picks:?}: {e}")); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + let mesh = blended.display_mesh(0.01).unwrap(); + for p in &mesh.positions { + let inside = p.x.hypot(p.y) <= 25.001 + || (p.x.abs() <= 4.001 && p.y >= y - 0.001 && p.y <= corner_y) + || (p.x.abs() <= 2.001 && p.y >= corner_y && p.y <= corner_y + 2.001) + || (p.x - 2.0).hypot(p.y - corner_y) <= 2.001 + || (p.x + 2.0).hypot(p.y - corner_y) <= 2.001; + assert!(inside && p.z >= -0.001 && p.z <= 2.001, "outside the plate: {p:?}"); + } + } +} + +fn partial_mixed_run_prism() -> Body { + use solidcraft_geom::Seg2; + let outline = Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(40.0, 0.0) }, + Seg2::Line { a: Vec2::new(40.0, 0.0), b: Vec2::new(40.0, 20.0) }, + Seg2::Line { a: Vec2::new(40.0, 20.0), b: Vec2::new(30.0, 20.0) }, + Seg2::Arc { center: Vec2::new(20.0, 20.0), radius: 10.0, start: 0.0, sweep: PI }, + Seg2::Line { a: Vec2::new(10.0, 20.0), b: Vec2::new(0.0, 20.0) }, + Seg2::Line { a: Vec2::new(0.0, 20.0), b: Vec2::new(0.0, 0.0) }, + ], + }; + extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap() +} + +#[test] +fn mixed_loop_blend_guards_selection_and_construction() { + let body = mixed_arc_and_line_prism(); + let solid = body.deep_copy(); + let top = solid + .boundaries() + .iter() + .flat_map(|shell| shell.face_iter()) + .find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9)) + .unwrap(); + let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect(); + // The internal test-only assertions check the active guard during both selection and build. + assert!(!crate::guarded()); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, true))); + assert!(matches!(result, Ok(Some(Ok(_)))), "{result:?}"); + assert!(!crate::guarded()); + // A degenerate profile still returns a clean error and restores the guard depth. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.0, true))); + assert!(matches!(result, Ok(Some(Err(_)))), "{result:?}"); + assert!(!crate::guarded()); +} + +#[test] +fn fillet_mixed_arc_and_line_perimeter_edges() { + let body = mixed_arc_and_line_prism(); + let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], 0.5).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + let display_tol = (blended.size() * 1e-3).max(1e-3); + for mesh in [ + blended.tessellate_with(1e-4, true).unwrap(), + blended.display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(), + blended.reference_display_mesh_angular(display_tol, crate::DISPLAY_ANGLE).unwrap(), + ] { + let bounds = mesh.bounds(); + let tol = 1e-3; + assert!( + bounds.min.x >= -10.0 - tol + && bounds.max.x <= 10.0 + tol + && bounds.min.y >= -tol + && bounds.max.y <= 10.0 + tol + && bounds.min.z >= -tol + && bounds.max.z <= 15.0 + tol, + "{bounds:?}" + ); + for p in &mesh.positions { + assert!(p.x.hypot(p.y) <= 10.0 + tol, "outside the source semicircle: {p:?}"); + } + } +} + +/// Integrate exact inward-offset D slices, including the arc/line corner overlaps. +/// At height H-r+r*sin(theta), the inset is d=r*(1-cos(theta)); the remaining +/// slice is the circular segment of radius R-d above y=d. No kernel geometry is used. +fn d_fillet_removed_reference(r: f64, steps: u32) -> f64 { + let dt = (PI / 2.0) / f64::from(steps); + (0..steps) + .map(|i| { + let theta = (f64::from(i) + 0.5) * dt; + let d = r * (1.0 - theta.cos()); + let rho = 10.0 - d; + let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt(); + (PI * 50.0 - remaining) * r * theta.cos() * dt + }) + .sum() +} + +#[test] +fn fillet_mixed_arc_and_line_removed_volume_converges() { + let body = mixed_arc_and_line_prism(); + let source_exact = PI * 100.0 * 15.0 / 2.0; + let tolerances = [1e-4, 2.5e-5]; + let source_volumes = tolerances.map(|tol| body.tessellate_with(tol, true).unwrap().measure().volume); + for r in [0.5, 0.25] { + let want = d_fillet_removed_reference(r, 8192); + assert!((want - d_fillet_removed_reference(r, 4096)).abs() < 1e-7, "reference quadrature must converge"); + if r == 0.5 { + assert!((want - 2.715578447526581).abs() < 1e-7, "{want}"); + } + let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap(); + let mut previous = None; + for (tol, source) in tolerances.into_iter().zip(source_volumes) { + let volume = blended.tessellate_with(tol, true).unwrap().measure().volume; + // Equal chord tolerances reduce (not necessarily cancel) the error from the + // unchanged walls. The convergence and exact-source checks below guard this. + let removed = source - volume; + assert!(rel(removed, want) < 1e-3, "r={r}, tol={tol}: removed {removed}, expected {want}"); + if let Some(coarser) = previous { + assert!(rel(removed, coarser) < 1e-3, "r={r}: removal has not converged: {coarser} -> {removed}"); + let exact_removed = source_exact - volume; + // The source's inscribed semicylinder loses at most pi*R*H*tol. + // Do not charge its chord error to the much smaller fillet volume. + let source_error = (source_exact - source).abs(); + assert!(source_error < PI * 10.0 * 15.0 * tol, "source chord error {source_error}"); + assert!((exact_removed - want).abs() < source_error + want * 1e-3, "r={r}: exact-source removal {exact_removed}, expected {want}"); + } + previous = Some(removed); + } + } +} + +#[test] +fn loop_chamfer_mixed_arc_and_line_uses_offset_intersections() { + let body = mixed_arc_and_line_prism(); + // Exercise the shared loop builder; public chamfer prefers boolean cutters first. + let solid = body.deep_copy(); + let top = solid + .face_iter() + .find(|face| face.edge_iter().all(|edge| (edge.front().point().z - 15.0).abs() < 1e-9 && (edge.back().point().z - 15.0).abs() < 1e-9)) + .unwrap(); + let edges: Vec<_> = top.edge_iter().map(|edge| edge.id()).collect(); + let (result, history) = crate::loopblend::loop_blend(&solid, &edges, body.size(), 0.5, false).unwrap().unwrap(); + let blended = crate::record::rebuilt(result, &[&solid], &history).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + // A chamfer has a linear inset through its height instead of a circular section. + let step = 0.5 / 8192.0; + let want: f64 = (0..8192) + .map(|i| { + let d = (f64::from(i) + 0.5) * step; + let rho = 10.0 - d; + let remaining = rho * rho * (d / rho).acos() - d * (rho * rho - d * d).sqrt(); + (PI * 50.0 - remaining) * step + }) + .sum(); + // The cone's triangulation needs this chord tolerance for the small volume difference. + let fine = |b: &Body| b.tessellate_with(2.5e-5, true).unwrap().measure().volume; + let removed = fine(&body) - fine(&blended); + assert!(rel(removed, want) < 1e-3, "{removed} vs {want}"); +} + +#[test] +fn fillet_mixed_arc_and_line_joint_preserves_both_section_radii() { + use truck_modeling::{BoundedCurve, ParametricCurve}; + + let r = 0.5; + let body = mixed_arc_and_line_prism(); + let blended = fillet(&body, &[Vec3::new(0.0, 10.0, 15.0), Vec3::new(5.0, 0.0, 15.0)], r).unwrap(); + crate::guard("inspect mixed fillet mitres", || { + let solid = blended.deep_copy(); + let mut mitres = std::collections::HashSet::new(); + for edge in solid.boundaries().iter().flat_map(|shell| shell.edge_iter()) { + let a = crate::body::from_p3(edge.front().point()); + let b = crate::body::from_p3(edge.back().point()); + // Only the two corner profiles join the top contact to the wall contact. + if (a.z - b.z).abs() < r - 1e-8 || a.z.min(b.z) < 15.0 - r - 1e-8 || a.x * b.x <= 0.0 { + continue; + } + if !mitres.insert(edge.id()) { + continue; + } + let curve = edge.oriented_curve(); + let (lo, hi) = curve.range_tuple(); + for i in 0..=257 { + let p = crate::body::from_p3(curve.subs(lo + (hi - lo) * f64::from(i) / 257.0)); + // The joint lies on both the cylindrical line blend and the toroidal + // arc blend, not their tangent-plane approximation. + let radius2 = (p.y - r).powi(2) + (p.z - (15.0 - r)).powi(2); + assert!((radius2 - r * r).abs() < 1e-7, "distorted line section at {p:?}: radius squared {radius2}"); + let radius2 = (p.x.hypot(p.y) - (10.0 - r)).powi(2) + (p.z - (15.0 - r)).powi(2); + assert!((radius2 - r * r).abs() < 1e-7, "distorted arc section at {p:?}: radius squared {radius2}"); + } + } + assert_eq!(mitres.len(), 2, "both arc/line corners must be checked"); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn fillet_partial_mixed_arc_and_line_perimeter_run() { + let body = partial_mixed_run_prism(); + let blended = fillet(&body, &[Vec3::new(35.0, 20.0, 15.0), Vec3::new(20.0, 30.0, 15.0), Vec3::new(5.0, 20.0, 15.0)], 0.5).unwrap(); + assert!(blended.validity().is_empty(), "{:?}", blended.validity()); + let mesh = blended.tessellate_with(1e-4, true).unwrap(); + let bounds = mesh.bounds(); + let tol = 1e-3; + assert!( + bounds.min.x >= -tol + && bounds.max.x <= 40.0 + tol + && bounds.min.y >= -tol + && bounds.max.y <= 30.0 + tol + && bounds.min.z >= -tol + && bounds.max.z <= 15.0 + tol, + "{bounds:?}" + ); + for p in &mesh.positions { + if p.y > 20.0 + tol { + assert!((p.x - 20.0).hypot(p.y - 20.0) <= 10.0 + tol, "outside the source semicircle: {p:?}"); + } + } +} + /// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails. #[test] #[ignore] diff --git a/crates/kernel/src/thread_display_tests.rs b/crates/kernel/src/thread_display_tests.rs index 2b696b2f..2e74a35e 100644 --- a/crates/kernel/src/thread_display_tests.rs +++ b/crates/kernel/src/thread_display_tests.rs @@ -22,12 +22,12 @@ fn helix(r: f64, z0: f64, pitch: f64, a0: f64, a1: f64) -> EdgeSpec { let (s, e) = (a0 + h * i as f64, a0 + h * (i + 1) as f64); ctrl.extend([at(s) + turn(s) + rise, at(e) - turn(e) - rise, at(e)]); } - EdgeSpec::Chain { ctrl: ctrl.into() } + EdgeSpec::Chain { degree: 3, ctrl: ctrl.into() } } fn rev(e: &EdgeSpec) -> EdgeSpec { match e { - EdgeSpec::Chain { ctrl } => EdgeSpec::Chain { ctrl: ctrl.iter().rev().copied().collect::>() }, + EdgeSpec::Chain { degree, ctrl } => EdgeSpec::Chain { degree: *degree, ctrl: ctrl.iter().rev().copied().collect::>() }, EdgeSpec::Line { a, b } => EdgeSpec::Line { a: *b, b: *a }, EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: *b, b: *a, mid: *mid }, } @@ -39,7 +39,7 @@ fn line(a: Vec3, b: Vec3) -> EdgeSpec { fn ends(e: &EdgeSpec) -> (Vec3, Vec3) { match e { - EdgeSpec::Chain { ctrl } => (ctrl[0], ctrl[ctrl.len() - 1]), + EdgeSpec::Chain { ctrl, .. } => (ctrl[0], ctrl[ctrl.len() - 1]), EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (*a, *b), } } diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 32c5c6aa..60be2b42 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -663,6 +663,32 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { let tol = (size * 1e-4).max(1e-6); let surfs = classify_faces(b, &mesh, nf, tol); let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None }; + Some(cylinder_extent(&mesh, face, axis, ap, r)) +} + +/// The cylindrical face one of whose end circles is centred at `p` (a circle given by its +/// centre, where no face lies), the nearest when several are. +pub fn cylinder_end_at(b: &Body, p: Vec3) -> Option { + let size = b.size(); + let tol = (size * 1e-3).max(1e-3); + let mesh = b.tessellate(tol).ok()?; + let surfs = classify_faces(b, &mesh, b.face_count(), (size * 1e-4).max(1e-6)); + let mut best: Option<(f64, CylinderFace)> = None; + for (face, s) in surfs.iter().enumerate() { + let Surf::Cylinder { axis, p: ap, r } = *s else { continue }; + let c = cylinder_extent(&mesh, u32::try_from(face).ok()?, axis, ap, r); + let v = p - c.axis_point; + let along = v.dot(c.axis); + let off = (v - c.axis * along).len() + (along - c.start).abs().min((c.end - along).abs()); + if off <= tol && best.is_none_or(|(bo, _)| off < bo) { + best = Some((off, c)); + } + } + best.map(|(_, c)| c) +} + +/// The extent along its axis, and the side, of a cylindrical face of a tessellated body. +fn cylinder_extent(mesh: &Mesh, face: u32, axis: Vec3, ap: Vec3, r: f64) -> CylinderFace { let mut lo = f64::INFINITY; let mut hi = f64::NEG_INFINITY; let mut inward = 0.0; @@ -679,5 +705,5 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { inward += n.dot(radial); } } - Some(CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 }) + CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 } } diff --git a/crates/kernel/src/touching_walls.rs b/crates/kernel/src/touching_walls.rs new file mode 100644 index 00000000..224f4f15 --- /dev/null +++ b/crates/kernel/src/touching_walls.rs @@ -0,0 +1,181 @@ +//! Cuts by a prism whose walls only touch the target. A corner of the tool's outline lies exactly +//! on the target's face (on a ring's bore, in its end face) with one wall going into the target +//! and the other staying outside it: both walls meet the target's face along the edge between +//! them, the loops the B-rep boolean finds there have no area, and it gives up on every try. Only +//! the last-resort nudge of the tool got through, after seconds, and left a face of no width at +//! the corner. +//! +//! A wall that lies outside the target takes nothing from it wherever it stands. Such walls are +//! pushed outward, past what the boolean takes for touching, their neighbours running on to meet +//! them, and the cut is taken with that tool: the same result, exactly, with the contact gone. A wall is pushed only where the +//! samples across its pushed place all lie outside the target's mesh, and the result is refused +//! if any of its faces lies on a pushed wall (that wall took material after all). + +use solidcraft_geom::{InsideIndex, Vec3}; +use truck_modeling as mt; + +use crate::body::{Body, from_p3}; + +/// Most walls a tool may have for this route. +const MAX_WALLS: usize = 256; + +/// Samples along and up each wall. +const SAMPLES: usize = 7; + +/// Where across the slab a wall sweeps it is sampled, as fractions of the push: the pushed +/// wall itself, and two layers between. +const LAYERS: [f64; 3] = [0.125, 0.5, 1.0]; + +/// How far a vertex may move, in pushes: a corner sharper than about six degrees moves farther, +/// and is left as it is. +const MAX_MOVE: f64 = 20.0; + +/// A tool with some of its walls pushed outward, and the planes they were pushed to (outward +/// unit normal and offset). +pub(crate) struct Pushed { + pub(crate) tool: Body, + planes: Vec<(Vec3, f64)>, +} + +impl Pushed { + /// Whether `result` has no face on a pushed wall's plane: the walls took nothing. + pub(crate) fn untouched(&self, result: &Body) -> bool { + let tol = (result.size() * 1e-9).max(1e-9); + result.solid.face_iter().all(|f| { + let mt::Surface::Plane(p) = f.oriented_surface() else { return true }; + let Some(n) = Vec3::new(p.normal().x, p.normal().y, p.normal().z).normalized() else { return true }; + !self.planes.iter().any(|(m, d)| n.dot(*m).abs() > 1.0 - 1e-9 && f.vertex_iter().all(|v| (m.dot(from_p3(v.point())) - d).abs() <= tol)) + }) + } +} + +/// `b` with each wall pushed outward by `by` whose pushed place lies outside the target +/// (`target`, an index of its mesh, meshed finer than `by`): `b` must be a prism with planar caps +/// and planar walls, every edge a line, every vertex on two walls. The faces, edges and vertices +/// keep their order, so a boolean's history over the pushed tool holds for `b`. `None` when `b` +/// is no such prism, no wall can be pushed, or a corner would move too far. +pub(crate) fn pushed_tool(b: &Body, target: &InsideIndex, by: f64) -> Option { + if !(by.is_finite() && by > 0.0) || b.solid.boundaries().len() != 1 { + return None; + } + let faces: Vec<&mt::Face> = b.solid.face_iter().collect(); + if faces.len() < 5 || faces.len() > MAX_WALLS + 2 { + return None; + } + let planes: Vec<(Vec3, f64)> = faces + .iter() + .map(|f| { + let mt::Surface::Plane(p) = f.oriented_surface() else { return None }; + let n = Vec3::new(p.normal().x, p.normal().y, p.normal().z).normalized()?; + Some((n, n.dot(from_p3(p.origin())))) + }) + .collect::>()?; + // The caps: a face whose normal every other face is square to, but for one facing away. + let square = |n: Vec3, m: Vec3| n.dot(m).abs() < 1e-9; + let caps = |i: usize| -> Option { + let n = planes.get(i)?.0; + let mut opposite = None; + for (j, (m, _)) in planes.iter().enumerate() { + if j == i || square(n, *m) { + continue; + } + if n.dot(*m) < -1.0 + 1e-9 && opposite.is_none() { + opposite = Some(j); + } else { + return None; + } + } + opposite + }; + let (top, bottom) = (0..planes.len()).find_map(|i| caps(i).map(|j| (i, j)))?; + let d = planes.get(top)?.0; + let size = b.size(); + let on = (size * 1e-9).max(1e-9); + let walls: Vec = (0..faces.len()).filter(|i| *i != top && *i != bottom).collect(); + // Which walls go: every sample across the pushed place outside the target. + let mut shift = vec![0.0; faces.len()]; + for &w in &walls { + let (face, (n, o)) = (faces.get(w)?, planes.get(w)?); + let corners: Vec = face.vertex_iter().map(|v| from_p3(v.point())).collect(); + if face.boundaries().len() != 1 || corners.len() != 4 { + return None; + } + // The wall's two ends along its cap edge and its two heights. + let along = d.cross(*n); + let (lo_u, hi_u) = corners.iter().map(|p| p.dot(along)).fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(x), m.1.max(x))); + let (lo_h, hi_h) = corners.iter().map(|p| p.dot(d)).fold((f64::INFINITY, f64::NEG_INFINITY), |m, x| (m.0.min(x), m.1.max(x))); + let base = *n * *o; + let clear = LAYERS.iter().all(|layer| { + (0..SAMPLES).all(|i| { + (0..SAMPLES).all(|j| { + let s = |k: usize, lo: f64, hi: f64| lo + (hi - lo) * (0.02 + 0.96 * k as f64 / (SAMPLES - 1) as f64); + let p = base + along * s(i, lo_u, hi_u) + d * s(j, lo_h, hi_h) + *n * (by * layer); + !target.contains(p) + }) + }) + }); + if clear && let Some(x) = shift.get_mut(w) { + *x = by; + } + } + if shift.iter().all(|x| *x == 0.0) { + return None; + } + let solid = b.deep_copy(); + let copied: Vec<&mt::Face> = solid.face_iter().collect(); + // Each vertex moves square to the walls so that it stays on both of its walls, pushed. + let mut moved = std::collections::HashMap::new(); + for face in &copied { + for v in face.vertex_iter() { + if moved.contains_key(&v.id()) { + continue; + } + let p = from_p3(v.point()); + let on_walls: Vec = walls.iter().copied().filter(|w| planes.get(*w).is_some_and(|(n, o)| (n.dot(p) - o).abs() <= on)).collect(); + let [i, j] = on_walls.as_slice() else { return None }; + let ((ni, _), (nj, _)) = (planes.get(*i)?, planes.get(*j)?); + let (di, dj) = (*shift.get(*i)?, *shift.get(*j)?); + let c = ni.dot(*nj); + let det = 1.0 - c * c; + let s = if di == 0.0 && dj == 0.0 { + Vec3::ZERO + } else if det < 1e-6 { + return None; + } else { + *ni * ((di - c * dj) / det) + *nj * ((dj - c * di) / det) + }; + if s.len() > MAX_MOVE * by { + return None; + } + moved.insert(v.id(), (v.clone(), p + s)); + } + } + for (v, p) in moved.values() { + v.set_point(mt::Point3::new(p.x, p.y, p.z)); + } + // Edges run between their moved ends; each pushed wall is its plane moved along its normal. + for face in &copied { + for wire in face.boundaries() { + for e in wire.edge_iter() { + let mt::Curve::Line(_) = e.curve() else { return None }; + e.set_curve(mt::Curve::Line(mt::Line(e.absolute_front().point(), e.absolute_back().point()))); + } + } + } + let mut pushed = Vec::new(); + for (k, face) in copied.iter().enumerate() { + let (Some(&x), Some((n, o))) = (shift.get(k), planes.get(k)) else { return None }; + if x == 0.0 { + continue; + } + let mt::Surface::Plane(p) = face.surface() else { return None }; + let t = mt::Vector3::new(n.x, n.y, n.z) * x; + face.set_surface(mt::Surface::Plane(mt::Plane::new(p.origin() + t, p.origin() + p.u_axis() + t, p.origin() + p.v_axis() + t))); + pushed.push((*n, o + x)); + } + let tool = Body::new(solid).ok()?; + if !tool.validity().is_empty() { + return None; + } + Some(Pushed { tool, planes: pushed }) +} diff --git a/crates/kernel/src/watch.rs b/crates/kernel/src/watch.rs index 840988c2..97f96fb9 100644 --- a/crates/kernel/src/watch.rs +++ b/crates/kernel/src/watch.rs @@ -34,7 +34,7 @@ pub fn watching(watch: Arc, f: impl FnOnce() -> R) -> R { } /// The watch over this thread's work, to hand to the worker threads it starts. -pub(crate) fn current() -> Option> { +pub fn current() -> Option> { CURRENT.with(|c| c.borrow().clone()) } diff --git a/crates/kernel/tests/boolean_props.proptest-regressions b/crates/kernel/tests/boolean_props.proptest-regressions index 83c0147e..78fbcbed 100644 --- a/crates/kernel/tests/boolean_props.proptest-regressions +++ b/crates/kernel/tests/boolean_props.proptest-regressions @@ -5,3 +5,5 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 739d5cbd9e471e0ff65bc2b7c1b9a323c2e769939c73def2e74fb17b0536c1d0 # shrinks to a = [0.0, 2.5, 30.0], sa = [22.5, 5.0, 5.0], b = [22.5, 7.5, 15.0], sb = [5.0, 5.0, 17.5] +cc 0f8ee4d4597db6bdec2ad2cc7e5cfe7d4ebba351e24642118135da34bf489d0c # shrinks to a = Sph { at: [20.0, 10.0, 27.5], r: 3.75 }, b = Box { at: [15.0, 0.0, 22.5], size: [7.5, 20.0, 5.0] } +cc 2adfe5ac6b6916fa22a415348bdd7ece29ad33e8114b94dea86ba8bc39156dee # shrinks to a = Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }, b = Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] } diff --git a/crates/kernel/tests/boolean_props.rs b/crates/kernel/tests/boolean_props.rs index 57a7d465..db634b33 100644 --- a/crates/kernel/tests/boolean_props.rs +++ b/crates/kernel/tests/boolean_props.rs @@ -67,6 +67,18 @@ fn prim(c: impl Strategy + Clone, s: impl Strategy + C fn grid_prim() -> impl Strategy { prim(grid(), grid_size()) } + +/// A union across only a box edge or corner is non-manifold, so the kernel may +/// refuse it. This is the same analytic boundary case allowed by box_box_exact. +fn edge_only_boxes(a: &Prim, b: &Prim) -> bool { + let (Prim::Box { at: a, size: sa }, Prim::Box { at: b, size: sb }) = (a, b) else { return false }; + let gap = [0, 1, 2].map(|i| a[i].max(b[i]) - (a[i] + sa[i]).min(b[i] + sb[i])); + gap.iter().all(|g| *g <= 0.0) && gap.iter().filter(|g| **g == 0.0).count() >= 2 +} + +fn edge_contact_refusal(a: &Prim, b: &Prim, op: BoolOp, result: &Result) -> bool { + op == BoolOp::Union && edge_only_boxes(a, b) && result.as_ref().is_err_and(|e| e.to_string().contains("zero-thickness edge contact")) +} fn free_prim() -> impl Strategy { prim(free(), free_size()) } @@ -95,8 +107,13 @@ fn close(x: f64, y: f64, scale: f64) -> bool { fn identity(a: &Prim, b: &Prim) -> Result<(), TestCaseError> { let (ba, bb) = (a.body(), b.body()); let (va, vb) = (vol(&ba), vol(&bb)); - let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union)?, run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?); - prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb); + let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union), run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?); + if edge_contact_refusal(a, b, BoolOp::Union, &vu) { + prop_assert!(close(vi, 0.0, va + vb), "edge-only contact must have no intersection volume"); + } else { + let vu = vu?; + prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb); + } prop_assert!(close(vc, va - vi, va + vb), "cut {vc:.3} != A-I {:.3}", va - vi); Ok(()) } @@ -137,7 +154,11 @@ proptest! { let (ba, bb) = (a.body(), b.body()); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect] { - let (ab, ba_) = (run(&ba, &bb, op)?, run(&bb, &ba, op)?); + let (ab, ba_) = (run(&ba, &bb, op), run(&bb, &ba, op)); + if edge_contact_refusal(&a, &b, op, &ab) && edge_contact_refusal(&a, &b, op, &ba_) { + continue; + } + let (ab, ba_) = (ab?, ba_?); prop_assert!(close(ab, ba_, s), "{op:?}: A,B gives {ab:.4} but B,A gives {ba_:.4}"); } } @@ -152,7 +173,11 @@ proptest! { let (ta, tb) = (mv(&ba), mv(&bb)); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] { - let (here, there) = (run(&ba, &bb, op)?, run(&ta, &tb, op)?); + let (here, there) = (run(&ba, &bb, op), run(&ta, &tb, op)); + if edge_contact_refusal(&a, &b, op, &here) && edge_contact_refusal(&a, &b, op, &there) { + continue; + } + let (here, there) = (here?, there?); prop_assert!(close(here, there, s), "{op:?} at origin {here:.4}, moved by {off} gives {there:.4}"); } } @@ -169,7 +194,11 @@ proptest! { let (ba, bb, ka, kb) = (a.body(), b.body(), sc(&a).body(), sc(&b).body()); let s = vol(&ba) + vol(&bb); for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] { - let (one, big) = (run(&ba, &bb, op)?, run(&ka, &kb, op)? / (k * k * k)); + let (one, big) = (run(&ba, &bb, op), run(&ka, &kb, op)); + if edge_contact_refusal(&a, &b, op, &one) && edge_contact_refusal(&a, &b, op, &big) { + continue; + } + let (one, big) = (one?, big? / (k * k * k)); prop_assert!(close(one, big, s), "{op:?} at scale 1 gives {one:.4}, at scale {k} gives {big:.4} (normalised)"); } } @@ -233,15 +262,32 @@ proptest! { // Cases the property tests found, as plain tests. Ignored until the kernel bug is fixed: run them // with `cargo test -p solidcraft-kernel --test boolean_props -- --ignored`. +/// The saved commutativity case is an intentional non-manifold join refusal, +/// not a failure to join overlapping volumes. Intersection and cut remain valid. +#[test] +fn edge_only_box_join_is_refused_in_both_orders() { + let a = Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 17.5] }; + let b = Prim::Box { at: [2.5, 12.5, 0.0], size: [12.5, 10.0, 5.0] }; + assert!(edge_only_boxes(&a, &b)); + assert!(!edge_only_boxes(&a, &Prim::Box { at: [15.0, 22.5, 0.0], size: [5.0, 5.0, 5.0] })); + let (a, b) = (a.body(), b.body()); + for (left, right) in [(&a, &b), (&b, &a)] { + let error = run(left, right, BoolOp::Union).expect_err("an edge-only join is non-manifold"); + assert!(error.to_string().contains("zero-thickness edge contact"), "{error}"); + assert_eq!(run(left, right, BoolOp::Intersect).unwrap(), 0.0); + assert!(close(run(left, right, BoolOp::Cut).unwrap(), vol(left), vol(left))); + } +} + fn check(r: Result<(), TestCaseError>) { if let Err(e) = r { panic!("{e}"); } } -/// A box and a cylinder in general position: the intersect is refused as an implausible volume. +/// A box and a cylinder in general position: the intersect is a sliver 0.5 mm thick. It was +/// refused as an implausible volume, because the meshes of the check were faceted at 0.017 mm. #[test] -#[ignore = "kernel bug, #1647"] fn generic_box_cylinder_intersect() { let a = Prim::Box { at: [11.483767761089219, 4.801612107504224, 3.6313476467611787], size: [4.789088988834864, 3.0, 10.419335961063835] }; let b = Prim::Cyl { at: [29.2480632732879, 8.3683289461485, 6.947922225984966], axis: 2, r: 13.502884497756853, h: 19.719218725477496 }; diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 7d107c16..e3b2c207 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -21,6 +21,11 @@ pub trait Backend { fn design_path(&self) -> Option { None } + /// Commands run in this process, on this thread (the kernel's time in them can be measured + /// here, #1833). + fn in_process(&self) -> bool { + false + } } /// Longest reply line accepted from the app. @@ -308,4 +313,7 @@ impl Backend for Headless { fn design_path(&self) -> Option { self.session.path.clone() } + fn in_process(&self) -> bool { + true + } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index bdb3c11b..38d637aa 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -831,6 +831,59 @@ fn bridged_screenshots_with_grid_or_axes_are_model_renders() { let _ = std::fs::remove_dir_all(&dir); } +/// #1677: a hidden window gives no picture. With no `source`, a model render comes back and says +/// it is one (`fallback`); asked for the window, the tool says why there is none, at once. +#[test] +fn a_hidden_window_screenshot_says_so() { + struct Hidden { + calls: Vec, + } + impl crate::backend::Backend for Hidden { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push(method.to_string()); + match method { + "ui.screenshot" => Err("no frame was presented (window hidden?); use ui.render".into()), + "ui.render" => { + let path = params["path"].as_str().ok_or("missing `path`")?; + std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?; + Ok(json!({"path": path, "width": 64, "height": 48})) + } + other => Err(format!("unknown method `{other}`")), + } + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "app".into() + } + } + let dir = std::env::temp_dir().join(format!("solidcraft-mcp-hidden-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("shot.png").to_string_lossy().to_string(); + let shoot = |extra: Value| { + let mut args = json!({"path": path, "width": 64, "height": 48}); + args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let mut app = Hidden { calls: Vec::new() }; + let r = crate::tools::call_tool(&mut app, "screenshot", &args); + (app.calls, r) + }; + // The default: the window first, then a model render that says why. + let (calls, r) = shoot(json!({})); + assert!(r["isError"] != json!(true), "{r}"); + assert_eq!(calls, ["ui.screenshot", "ui.render"]); + let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["source"], "model", "{info}"); + assert!(info["fallback"].as_str().is_some_and(|f| f.contains("window hidden")), "{info}"); + // Asked for the window: an error naming the cause and the way out, and no render. + let (calls, r) = shoot(json!({"source": "window"})); + assert_eq!(r["isError"], json!(true), "{r}"); + let text = r["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("window hidden") && text.contains("source \"model\""), "{text}"); + assert_eq!(calls, ["ui.screenshot"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #530: in a design with many bodies every feature result lists all of them, so a batch reply /// is mostly the same body list once per step. A compact batch reply names only what each step /// added or changed. @@ -1426,3 +1479,44 @@ fn measure_items_gives_diameters_and_centre_distances() { assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}"); assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}"); } + +/// #1833: `timings` / `profile` time every step run, the failing one included, headless with the +/// kernel's share; bridged, the app is asked to time them and its numbers are passed on. +#[test] +fn batch_reports_timings_and_profile() { + let mut s = server(); + let steps = json!([ + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}, + {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}}, + ]); + let r = raw_tool(&mut s, "batch", json!({"commands": steps, "profile": true})); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + let rows = body["timings"].as_array().unwrap(); + assert_eq!(rows.len(), 2, "{body}"); + assert_eq!((rows[1]["index"].as_u64(), rows[1]["command"].as_str()), (Some(1), Some("solid.box"))); + assert!(rows[1]["profile"]["boolean_ms"].as_f64().unwrap() > 0.0, "{body}"); + assert!(body["total_ms"].as_f64().is_some(), "{body}"); + + let r = raw_tool( + &mut s, + "batch", + json!({"timings": true, "commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}, {"command": "NoSuchCommand"}]}), + ); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["timings"].as_array().map(Vec::len), Some(2), "the failing step is timed too: {body}"); + assert!(body["timings"][0].get("profile").is_none()); + // Not asked: no timings. + let d = tool(&mut s, "batch", json!({"commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}]})); + assert!(d.get("timings").is_none(), "{d}"); + + let reply = + json!({"completed": 1, "results": [{"x": 1}], "undone": 0, "timings": [{"index": 0, "command": "a.one", "ms": 2.5}], "total_ms": 2.6}); + let (b, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}], "profile": true})); + assert_eq!(b.calls[0].1["timings"], true); + assert_eq!(b.calls[0].1["profile"], true); + assert_eq!((body["timings"][0]["ms"].as_f64(), body["total_ms"].as_f64()), (Some(2.5), Some(2.6)), "{body}"); + // An app without engine.batch is timed step by step here, without a kernel split it can't measure. + let (_, body, _) = recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}], "profile": true})); + assert_eq!(body["timings"][0]["command"], "a.one", "{body}"); + assert!(body["timings"][0].get("profile").is_none(), "{body}"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 561ecc99..7d97daaa 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::{Map, Value, json}; use solidcraft_engine::io::vfs; +use solidcraft_engine::timing; use crate::backend::Backend; @@ -109,6 +110,8 @@ pub fn tool_definitions() -> Vec { "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}, }, "rollback": {"type": "boolean", "description": "Undo completed steps when one fails (default true)"}, + "timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"}, + "profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"}, "verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"}, }), &["commands"], @@ -496,10 +499,16 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { } let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true); let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true); + let profile = a.get("profile").and_then(Value::as_bool).unwrap_or(false); + let timed = profile || a.get("timings").and_then(Value::as_bool).unwrap_or(false); // An app that runs batches itself answers the whole batch in one request, so one frame // (#743). One too long for a control line goes step by step: the app would refuse it unrun. // The margin covers the envelope and the `allow_paths` the backend adds. - let params = json!({"commands": steps, "rollback": rollback}); + let mut params = json!({"commands": steps, "rollback": rollback}); + if timed && let Some(o) = params.as_object_mut() { + o.insert("timings".into(), Value::Bool(true)); + o.insert("profile".into(), Value::Bool(profile)); + } if params.to_string().len() + 64 * 1024 <= solidcraft_engine::MAX_LINE { match b.call("engine.batch", params) { Ok(r) => return batch_reply(&r, rollback, verbose), @@ -516,42 +525,57 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { } let start = if rollback { Undo::begin(b) } else { Undo::None }; let mut results = Vec::with_capacity(steps.len()); + let mut rows = Vec::new(); + let clock = timing::Clock::start(); + // The kernel's share can only be measured where the commands run: in this process. + let profile = profile && b.in_process(); for (i, step) in steps.iter().enumerate() { let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str); - let r = match command { + let (r, took) = timing::time(profile, || match command { Some(id) => exec(b, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))), None => Err("missing `command`".to_string()), - }; + }); + if timed { + rows.push(took.to_json(i, command.unwrap_or_default())); + } match r { Ok(v) => results.push(if verbose { v } else { compact(v) }), Err(e) => { let undone = start.rollback(b, i); - return text_result( - &json!({ - "ok": false, - "completed": i, - "failed": {"index": i, "command": command, "error": e}, - "rolled_back": rollback, - "undone": undone, - "results": results, - }), - true, - ); + let reply = json!({ + "ok": false, + "completed": i, + "failed": {"index": i, "command": command, "error": e}, + "rolled_back": rollback, + "undone": undone, + "results": results, + }); + return text_result(&with_timings(reply, timed, rows, clock.ms()), true); } } } start.finish(b); - text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false) + let reply = json!({"ok": true, "completed": results.len(), "results": results}); + text_result(&with_timings(reply, timed, rows, clock.ms()), false) +} + +/// `reply` with `timings` and `total_ms` when they were asked for. +fn with_timings(mut reply: Value, timed: bool, rows: Vec, total_ms: f64) -> Value { + if timed && let Some(o) = reply.as_object_mut() { + o.insert("timings".into(), Value::Array(rows)); + o.insert("total_ms".into(), json!(timing::round_ms(total_ms))); + } + reply } /// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it. fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value { let results: Vec = r["results"].as_array().cloned().unwrap_or_default().into_iter().map(|v| if verbose { v } else { compact(v) }).collect(); - match r.get("failed").filter(|f| !f.is_null()) { - None => text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false), - Some(failed) => text_result( - &json!({ + let (mut reply, error) = match r.get("failed").filter(|f| !f.is_null()) { + None => (json!({"ok": true, "completed": results.len(), "results": results}), false), + Some(failed) => ( + json!({ "ok": false, "completed": r["completed"], "failed": failed, @@ -561,7 +585,16 @@ fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value { }), true, ), + }; + // The app timed the steps where they ran. + if let Some(o) = reply.as_object_mut() { + for k in ["timings", "total_ms"] { + if let Some(v) = r.get(k).filter(|v| !v.is_null()) { + o.insert(k.into(), v.clone()); + } + } } + text_result(&reply, error) } /// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a @@ -639,11 +672,21 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu } b.call("ui.render", params) }; + let asked_window = str_arg(a, "source") == Some("window"); + let mut fallback = None; let (info, source) = if window { match b.call("ui.screenshot", json!({"path": path})) { Ok(v) => (v, "window"), - // No presented frame (minimised or hidden window): fall back to a model render. - Err(_) => (render(b)?, "model"), + // Asked for the window: say why there is none, so the caller can show it or ask for + // a model render (#1677). + Err(e) if asked_window => { + return Err(format!("screenshot: the window gave no picture ({e}); show the window, or use source \"model\"")); + } + // No presented frame (minimised or hidden window): a model render, and the reply says so. + Err(e) => { + fallback = Some(e); + (render(b)?, "model") + } } } else { (render(b)?, "model") @@ -659,6 +702,9 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu }; let bytes = png.len(); let mut reply = json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes}); + if let Some(why) = fallback { + reply["fallback"] = json!(format!("the window gave no picture ({why}); this is a model render")); + } // What drew a model render (gpu, gpu-software or cpu), and the warning a CPU render carries. for k in ["renderer", "warning"] { if let Some(v) = info.get(k).filter(|v| !v.is_null()) { diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index b988a4ad..c31f0b8a 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -15,6 +15,7 @@ image = { workspace = true, features = ["jpeg"] } moxcms = { workspace = true } png = { workspace = true } sha2 = { workspace = true } +weezl = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/curve_display.rs b/crates/render/src/curve_display.rs index 08bbebf5..cd5a63cc 100644 --- a/crates/render/src/curve_display.rs +++ b/crates/render/src/curve_display.rs @@ -115,7 +115,9 @@ fn controls(segment: &Seg2, plane: &Plane) -> Option<(Vec>, H)> { Seg2::Line { a, b } => vec![vec![h(a, 1.0), h(b, 1.0)]], Seg2::Cubic { p0, p1, p2, p3 } => vec![vec![h(p0, 1.0), h(p1, 1.0), h(p2, 1.0), h(p3, 1.0)]], Seg2::Conic { a, apex, b, w } if w > 0.0 => vec![vec![h(a, 1.0), h(apex, w), h(b, 1.0)]], - Seg2::Bezier { n, p } if (1..=7).contains(&n) => vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()], + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + vec![p[..=n as usize].iter().map(|p| h(*p, 1.0)).collect()] + } Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && start.is_finite() && sweep.is_finite() && sweep.abs() <= std::f64::consts::TAU + 1e-9 => { diff --git a/crates/render/src/curve_display_tests.rs b/crates/render/src/curve_display_tests.rs index a3f45505..8aa62e69 100644 --- a/crates/render/src/curve_display_tests.rs +++ b/crates/render/src/curve_display_tests.rs @@ -27,7 +27,7 @@ fn dense_error(s: &Seg2, plane: &Plane, v: &PixelCurveView, path: &SampledPath) #[test] fn exact_arcs_ellipses_rational_and_looping_control_hulls_bound_pixels() { - let mut degree7 = [Vec2::ZERO; 8]; + let mut degree7 = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; for (i, p) in degree7.iter_mut().enumerate() { *p = Vec2::new(i as f64 / 4.0 - 0.875, if i % 2 == 0 { -0.8 } else { 0.8 }); } diff --git a/crates/render/src/gif.rs b/crates/render/src/gif.rs new file mode 100644 index 00000000..278708a8 --- /dev/null +++ b/crates/render/src/gif.rs @@ -0,0 +1,321 @@ +//! Animated GIF (GIF89a) from RGBA frames, for motion animations. Every frame shares one palette, +//! so a colour keeps its value from frame to frame, and each frame after the first stores only +//! the rectangle that changed. The palette splits the frames' colours where that removes the +//! most squared colour error, so a small, strongly coloured part (a red part, a thin trace line) +//! keeps its colour instead of being averaged into the shading around it. No dithering: what +//! stands still stays still. + +use std::collections::HashMap; + +/// Colours a GIF palette holds. +const PALETTE: usize = 256; + +/// Frames (straight RGBA, `width × height`, rows top to bottom; alpha is ignored) as a GIF that +/// loops forever and shows each frame for `delay` hundredths of a second. `None` without frames, +/// for a frame of the wrong size, or a side GIF can't store (above 65535). +pub fn gif(width: usize, height: usize, frames: &[Vec], delay: u16) -> Option> { + let (w, h) = (u16::try_from(width).ok().filter(|w| *w > 0)?, u16::try_from(height).ok().filter(|h| *h > 0)?); + let bytes = width.checked_mul(height)?.checked_mul(4)?; + if frames.is_empty() || frames.iter().any(|f| f.len() != bytes) { + return None; + } + let (palette, index) = palette(frames); + let mut out = Vec::with_capacity(1024); + out.extend_from_slice(b"GIF89a"); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + // A global table of 256 colours (8 bits each), background colour 0, square pixels. + out.extend_from_slice(&[0xF7, 0, 0]); + for k in 0..PALETTE { + out.extend_from_slice(palette.get(k).unwrap_or(&[0, 0, 0])); + } + // Loop forever (the NETSCAPE2.0 extension every viewer reads). + out.extend_from_slice(&[0x21, 0xFF, 0x0B]); + out.extend_from_slice(b"NETSCAPE2.0"); + out.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]); + let mut previous: Option> = None; + for frame in frames { + let now = indices(frame, &index); + let (x0, y0, x1, y1) = previous.as_deref().map_or(Some((0, 0, width - 1, height - 1)), |p| changed(p, &now, width)).unwrap_or((0, 0, 0, 0)); + let mut rect = Vec::with_capacity((x1 - x0 + 1) * (y1 - y0 + 1)); + for y in y0..=y1 { + rect.extend_from_slice(now.get(y * width + x0..=y * width + x1)?); + } + // Graphic control: kept on screen under the next frame (disposal 1), shown `delay`. + out.extend_from_slice(&[0x21, 0xF9, 0x04, 0x04]); + out.extend_from_slice(&delay.to_le_bytes()); + out.extend_from_slice(&[0, 0]); + out.push(0x2C); + for v in [x0, y0, x1 - x0 + 1, y1 - y0 + 1] { + out.extend_from_slice(&u16::try_from(v).ok()?.to_le_bytes()); + } + out.push(0); + out.push(8); + let data = weezl::encode::Encoder::new(weezl::BitOrder::Lsb, 8).encode(&rect).ok()?; + for block in data.chunks(255) { + out.push(block.len() as u8); + out.extend_from_slice(block); + } + out.push(0); + previous = Some(now); + } + out.push(0x3B); + Some(out) +} + +/// The pixels' colours, `0xRRGGBB`. +fn colours(frame: &[u8]) -> impl Iterator + '_ { + frame.as_chunks::<4>().0.iter().map(|&[r, g, b, _]| (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) +} + +/// A frame's palette indices. +fn indices(frame: &[u8], index: &HashMap) -> Vec { + let mut last = (u32::MAX, 0); + colours(frame) + .map(|k| { + if k != last.0 { + last = (k, index.get(&k).copied().unwrap_or(0)); + } + last.1 + }) + .collect() +} + +/// The smallest rectangle (x0, y0, x1, y1, inclusive) holding every pixel that differs, or `None` +/// when none does. +fn changed(before: &[u8], now: &[u8], width: usize) -> Option<(usize, usize, usize, usize)> { + let mut r: Option<(usize, usize, usize, usize)> = None; + for (i, _) in before.iter().zip(now).enumerate().filter(|(_, (a, b))| a != b) { + let (x, y) = (i % width, i / width); + r = Some(r.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y)))); + } + r +} + +/// How many pixels have a colour, and the sums its mean and squared error follow from. +#[derive(Clone, Copy, Default)] +struct Sums { + n: f64, + s: [f64; 3], + q: f64, +} + +impl Sums { + fn of(rgb: u32, n: u64) -> Sums { + let n = n as f64; + let c = [f64::from((rgb >> 16) as u8), f64::from((rgb >> 8) as u8), f64::from(rgb as u8)]; + Sums { n, s: c.map(|v| n * v), q: n * c.iter().map(|v| v * v).sum::() } + } + fn add(&mut self, o: &Sums) { + self.n += o.n; + self.q += o.q; + self.s.iter_mut().zip(o.s).for_each(|(a, b)| *a += b); + } + fn minus(&self, o: &Sums) -> Sums { + let mut s = *self; + s.n -= o.n; + s.q -= o.q; + s.s.iter_mut().zip(o.s).for_each(|(a, b)| *a -= b); + s + } + /// The squared distance of the pixels from their mean colour. + fn error(&self) -> f64 { + if self.n <= 0.0 { 0.0 } else { self.q - self.s.iter().map(|s| s * s).sum::() / self.n } + } + fn mean(&self) -> [u8; 3] { + self.s.map(|s| if self.n > 0.0 { (s / self.n).round().clamp(0.0, 255.0) as u8 } else { 0 }) + } +} + +/// A set of the frames' colours that becomes one palette entry, and its best split: (error +/// removed, channel, the largest value of that channel on the low side). +struct Group { + colours: Vec<(u32, Sums)>, + sums: Sums, + split: Option<(f64, u32, u8)>, +} + +impl Group { + fn new(colours: Vec<(u32, Sums)>) -> Group { + let mut sums = Sums::default(); + colours.iter().for_each(|(_, s)| sums.add(s)); + let mut best: Option<(f64, u32, u8)> = None; + for channel in [16u32, 8, 0] { + let mut bins = [Sums::default(); 256]; + for (k, s) in &colours { + if let Some(b) = bins.get_mut(usize::from((k >> channel) as u8)) { + b.add(s); + } + } + let mut low = Sums::default(); + for (v, b) in bins.iter().enumerate() { + low.add(b); + if low.n <= 0.0 || low.n >= sums.n { + continue; + } + let gain = sums.error() - low.error() - sums.minus(&low).error(); + if best.is_none_or(|(g, _, _)| gain > g) { + best = Some((gain, channel, v as u8)); + } + } + } + Group { colours, sums, split: best.filter(|(g, _, _)| *g > 0.0) } + } +} + +/// One palette for all frames (up to 256 colours) and each colour's index in it. Every colour +/// gets its own entry while there are 256 or fewer. +fn palette(frames: &[Vec]) -> (Vec<[u8; 3]>, HashMap) { + let mut counts: HashMap = HashMap::new(); + for frame in frames { + // Runs of one colour (the background) are counted once per run. + let mut run = (u32::MAX, 0u64); + for k in colours(frame) { + if k != run.0 { + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + run = (k, 0); + } + run.1 += 1; + } + if run.1 > 0 { + *counts.entry(run.0).or_default() += run.1; + } + } + let mut groups = vec![Group::new(counts.into_iter().map(|(k, n)| (k, Sums::of(k, n))).collect())]; + while groups.len() < PALETTE { + let Some(i) = groups.iter().enumerate().filter_map(|(i, g)| g.split.map(|s| (i, s.0))).max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) + else { + break; + }; + let g = groups.swap_remove(i); + let Some((_, channel, at)) = g.split else { break }; + let (low, high) = g.colours.into_iter().partition(|(k, _)| (k >> channel) as u8 <= at); + groups.push(Group::new(low)); + groups.push(Group::new(high)); + } + let mut index = HashMap::new(); + for (i, g) in groups.iter().enumerate() { + for (k, _) in &g.colours { + index.insert(*k, i as u8); + } + } + (groups.iter().map(|g| g.sums.mean()).collect(), index) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frames of a GIF this module wrote, as RGBA (each frame drawn over the one before). + fn decode(gif: &[u8]) -> (usize, usize, Vec>, Vec) { + let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]])); + assert_eq!(&gif[..6], b"GIF89a"); + let (w, h) = (u16_at(6), u16_at(8)); + let table = &gif[13..13 + 768]; + let mut at = 13 + 768; + let (mut frames, mut delays) = (Vec::new(), Vec::new()); + let mut canvas = vec![0u8; w * h * 4]; + loop { + match gif[at] { + 0x3B => break, + 0x21 => { + if gif[at + 1] == 0xF9 { + delays.push(u16_at(at + 4) as u16); + } + at += 2; + while gif[at] != 0 { + at += usize::from(gif[at]) + 1; + } + at += 1; + } + 0x2C => { + let (x0, y0, fw, fh) = (u16_at(at + 1), u16_at(at + 3), u16_at(at + 5), u16_at(at + 7)); + at += 11; + let mut data = Vec::new(); + while gif[at] != 0 { + data.extend_from_slice(&gif[at + 1..at + 1 + usize::from(gif[at])]); + at += usize::from(gif[at]) + 1; + } + at += 1; + let pixels = weezl::decode::Decoder::new(weezl::BitOrder::Lsb, 8).decode(&data).unwrap(); + assert_eq!(pixels.len(), fw * fh); + for (i, p) in pixels.iter().enumerate() { + let o = ((y0 + i / fw) * w + x0 + i % fw) * 4; + let c = usize::from(*p) * 3; + canvas[o..o + 4].copy_from_slice(&[table[c], table[c + 1], table[c + 2], 255]); + } + frames.push(canvas.clone()); + } + b => panic!("unexpected block {b:#x} at {at}"), + } + } + (w, h, frames, delays) + } + + fn frame(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec { + (0..w * h).flat_map(|i| f(i % w, i / w).into_iter().chain([255])).collect() + } + + #[test] + fn few_colours_come_back_exactly_and_frames_keep_their_size() { + let (w, h) = (37, 23); + let a = frame(w, h, |x, y| { + if x < 10 { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + // The second frame moves the red band: only that part changes. + let b = frame(w, h, |x, y| { + if (5..15).contains(&x) { + [250, 30, 30] + } else if y > 15 { + [20, 200, 60] + } else { + [220, 224, 230] + } + }); + let bytes = gif(w, h, &[a.clone(), b.clone(), b.clone()], 5).unwrap(); + let (dw, dh, frames, delays) = decode(&bytes); + assert_eq!((dw, dh, delays), (w, h, vec![5, 5, 5])); + assert_eq!(frames, vec![a, b.clone(), b]); + } + + #[test] + fn a_thin_saturated_line_keeps_its_colour_among_many_shades() { + let (w, h) = (300, 200); + // A grey shading with far more than 256 shades, a red part and a 1-pixel green line. + let f = frame(w, h, |x, y| { + if y == 100 { + [30, 220, 40] + } else if (40..80).contains(&x) && (40..80).contains(&y) { + [200 + (y as u8 % 40), 30, 30] + } else { + let g = 60 + ((x * 180) / w) as u8; + [g, g.saturating_add((y / 4) as u8), g.saturating_add(10)] + } + }); + let (_, _, frames, _) = decode(&gif(w, h, std::slice::from_ref(&f), 4).unwrap()); + let px = |img: &[u8], x: usize, y: usize| img[(y * w + x) * 4..(y * w + x) * 4 + 3].to_vec(); + assert_eq!(px(&frames[0], 150, 100), vec![30, 220, 40], "the line keeps its colour"); + let red = px(&frames[0], 60, 60); + assert!(red[0] > 180 && red[1] < 60 && red[2] < 60, "the part stays red: {red:?}"); + // Elsewhere each channel is close to the original. + let worst = f.chunks(4).zip(frames[0].chunks(4)).map(|(a, b)| (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap()).max().unwrap(); + assert!(worst <= 12, "worst channel error {worst}"); + } + + #[test] + fn bad_input_is_none_not_a_crash() { + assert!(gif(4, 4, &[], 5).is_none()); + assert!(gif(4, 4, &[vec![0; 63]], 5).is_none()); + assert!(gif(0, 4, &[vec![]], 5).is_none()); + assert!(gif(70_000, 1, &[vec![0; 280_000]], 5).is_none()); + assert!(gif(usize::MAX, 2, &[vec![0; 8]], 5).is_none()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0e12a95a..4ccf3315 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ mod analysis; mod backend; mod camera; mod curve_display; +mod gif; pub mod job; pub mod lighting; mod product; @@ -28,6 +29,7 @@ pub use backend::{ }; pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; +pub use gif::gif; pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use raster::{Canvas, render, render_png, render_transparent}; pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png}; diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index 885b8786..66a7fb27 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -153,7 +153,7 @@ pub fn spline_point(p: &[Vec2], control: bool, degree: u8, t: f64) -> Vec2 { return p.first().copied().unwrap_or_default(); } if control { - return bspline_at(p, degree.clamp(1, 7) as usize, t); + return bspline_at(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize, t); } let spans = fit_spans(p); let n = spans.len().max(1); @@ -292,7 +292,7 @@ pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec { return p.to_vec(); } if control { - return bspline_polyline(p, degree.clamp(1, 7) as usize); + return bspline_polyline(p, degree.clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE as u8) as usize); } let mut out = Vec::new(); for (p0, p1, t0, t1) in fit_spans(p) { diff --git a/crates/sketch/src/freeform_offset.rs b/crates/sketch/src/freeform_offset.rs index 20adc640..6529cd06 100644 --- a/crates/sketch/src/freeform_offset.rs +++ b/crates/sketch/src/freeform_offset.rs @@ -137,7 +137,9 @@ impl H { let (points, weights) = match *seg { Seg2::Line { a, b } => (vec![a, b], vec![1.0; 2]), Seg2::Cubic { p0, p1, p2, p3 } => (vec![p0, p1, p2, p3], vec![1.0; 4]), - Seg2::Bezier { n, p } if (1..=7).contains(&n) => (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]), + Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => { + (p[..=usize::from(n)].to_vec(), vec![1.0; usize::from(n) + 1]) + } Seg2::Conic { a, apex, b, w } if w.is_finite() && w > 0.0 => (vec![a, apex, b], vec![1.0, w, 1.0]), _ => return Err(bad("unsupported exact free-form offset piece")), }; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 8a8c274a..819a4d3a 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -256,7 +256,7 @@ impl LinkGeom { (!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok())) && (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len()) && pts.iter().all(ok) - && (1..=7).contains(degree) + && (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(degree) } } } diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 2b6d4a96..80346b94 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -639,7 +639,9 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { _ => false, }; let grid = Grid::new(&boxes); - let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; + // Each cut's parameter, and whether it is weak: a crossing next to an end that misses one of + // the two curves (below), which gives way to any other cut it is merged with. + let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; // End points of other curves lying on each shape. let mut end_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); for (k, p) in ends.iter().enumerate() { @@ -657,7 +659,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { && let Some(t) = sh.param_on(*p, tol) && let Some(cu) = cuts.get_mut(i) { - cu.push(t); + cu.push((t, false)); } } } @@ -752,30 +754,35 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { for p in pts { let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol)); let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol)); - // Crossings at (or next to) curve ends are already cuts. - let near_end = || { + // Crossings at (or next to) curve ends are already cuts. Not next to an end of + // one of the two curves that misses the other: nothing cut the other curve + // there, and it would cross without meeting it (a line 3e-5 from the + // junction of two curves of a loop, #755). Such a crossing is a weak cut. + let on = |sh: &Shape, e: Vec2| sh.param_on(e, tol).is_some() || end_param(sh, e, tol).is_some(); + let misses = |e: Vec2| (end_param(x, e, tol).is_some() && !on(y, e)) || (end_param(y, e, tol).is_some() && !on(x, e)); + let near_ends = || { let (cx, cy) = grid.cell(p); - (-1..=1).any(|dx| { - (-1..=1).any(|dy| { - end_cells - .get(&(cx + dx, cy + dy)) - .is_some_and(|v| v.iter().any(|k| ends.get(*k).is_some_and(|e| e.dist(p) < tol * 100.0))) - }) - }) + (-1..=1) + .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) + .flat_map(|c| end_cells.get(&c).map(Vec::as_slice).unwrap_or(&[])) + .filter_map(|k| ends.get(*k).copied()) + .filter(|e| e.dist(p) < tol * 100.0) + .fold((false, false), |(cut, weak), e| if misses(e) { (cut, true) } else { (true, weak) }) }; - if on_x.is_none() || on_y.is_none() || near_end() { + let (already, weak) = near_ends(); + if on_x.is_none() || on_y.is_none() || already { continue; } if let (Some(tx), Some(ty)) = (on_x, on_y) { if x.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(i) { - c.push(tx); + c.push((tx, weak)); } if y.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(j) { - c.push(ty); + c.push((ty, weak)); } if (gi, gj) != (i, j) { anchors.push(p); @@ -815,21 +822,33 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); - ts.sort_by(f64::total_cmp); + ts.sort_by(|a, b| a.0.total_cmp(&b.0)); // Cuts closer than a few tolerances are one cut (tangent touches give near-double roots). + // A weak cut gives way to the other: it only adds a cut where there was none. let scale = match *sh { Shape::Line { a, b } => a.dist(b), Shape::Round { r, .. } => r, }; let close = (tol * 100.0 / scale.max(1e-12)).max(1e-9); - ts.dedup_by(|a, b| (*a - *b).abs() < close); + ts.dedup_by(|a, b| { + let same = (a.0 - b.0).abs() < close; + if same && b.1 && !a.1 { + *b = *a; + } + same + }); if let (Shape::Round { sweep, .. }, Some(&first), Some(&last)) = (*sh, ts.first(), ts.last()) && sweep >= TAU - 1e-12 && ts.len() > 1 - && (first + TAU - last).abs() < close + && (first.0 + TAU - last.0).abs() < close { - ts.pop(); + if first.1 && !last.1 { + ts.remove(0); + } else { + ts.pop(); + } } + let ts: Vec = ts.into_iter().map(|(t, _)| t).collect(); let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); let analytic = analytic(*ci); let coverage = tags.get(k).copied().flatten().or_else(|| { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 823a17a3..c489d0c4 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -1,6 +1,6 @@ use std::f64::consts::{FRAC_PI_2, PI}; -use solidcraft_geom::Vec2; +use solidcraft_geom::{Seg2, Vec2}; use crate::*; @@ -921,3 +921,137 @@ fn projected_boundary_keeps_its_pieces_when_only_stations_go() { assert!(sk.update_link(&j, &smaller).unwrap()); assert_eq!(sk.link_curves(&j).len(), 4); } + +#[test] +fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { + // A G4 blend curve is one degree-9 Bézier; a sketch keeps it as a control spline whose exact + // piece is that Bézier, and profile measurement must keep using the curve, not its chords. + let mut sk = Sketch::new(); + let bump = + [v(0.0, 0.0), v(1.5, 4.0), v(3.0, 7.0), v(5.0, 9.5), v(7.5, 11.0), v(12.5, 11.0), v(15.0, 9.5), v(17.0, 7.0), v(18.5, 4.0), v(20.0, 0.0)]; + let pts: Vec = bump.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); + let s = sk.add_curve(CurveKind::Spline { pts: pts.clone(), control: true, degree: 9, periodic: false }, None).unwrap(); + let pieces = sk.exact_pieces(s); + assert_eq!(pieces.len(), 1, "{pieces:?}"); + let Seg2::Bezier { n, p } = pieces[0] else { panic!("{:?}", pieces[0]) }; + assert_eq!(n, 9, "the highest degree a sketch piece can carry"); + for (i, q) in bump.iter().enumerate() { + assert!(p[i].dist(*q) < 1e-12, "control {i}"); + } + // Evaluation and the exact piece agree at the spline's real degree (a degree clamp would show + // here), and a line's own exact piece still comes back as a line. + let exact = pieces[0]; + for k in 0..=10 { + let t = k as f64 / 10.0; + assert!(crate::curves::spline_point(&bump, true, 9, t).dist(exact.point_at(t)) < 1e-9, "t={t}"); + } + let l = sk.add_line(v(-5.0, 0.0), v(-2.0, 1.0), None, None, None).unwrap(); + assert!(matches!(sk.exact_pieces(l).as_slice(), [Seg2::Line { .. }])); + // Close the loop: the region's area is the shoelace integral of the exact pieces, which a fine + // sampling of the piece reproduces (a chord approximation would miss the bump's sagitta). + sk.add_line(v(20.0, 0.0), v(0.0, 0.0), Some(pts[9]), Some(pts[0]), None).unwrap(); + let ps = find_profiles(&sk); + assert_eq!(ps.len(), 1, "{ps:?}"); + let n = 20_000; + let mut sampled = 0.0; + for i in 0..n { + let (t0, t1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let (a, b) = (exact.point_at(t0), exact.point_at(t1)); + sampled += 0.5 * a.cross(b); + } + sampled -= 0.5 * v(20.0, 0.0).cross(v(0.0, 0.0)); + assert!((ps[0].area - sampled.abs()).abs() < 1e-5, "{} vs {sampled}", ps[0].area); + assert!((ps[0].area - 100.0).abs() > 1e-3, "the region is not its chord: {}", ps[0].area); + // A degree above the cap is still refused. + let mut high: Vec = pts.clone(); + high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); + assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); +} + +/// #755: a line drawn through a junction of a loop, but 2e-5 off it, still splits the loop. The +/// junction lies on the loop's two curves only, so it cuts neither of them where the line +/// crosses, and that crossing, next to the junction, was taken for one already cut there: the +/// line crossed the loop without meeting it, and the sketch had no profiles at all. +#[test] +fn profiles_line_passing_next_to_a_junction_splits_the_loop() { + let o = v(0.0, 0.0); + let at = |r: f64, a: f64| v(r * a.cos(), r * a.sin()); + // A slot: half circles of radius 1 round (0, 0) and (-5, 0), joined by the lines y = 1 and + // y = -1, which meet the arcs tangentially at (0, 1) and (0, -1). + let mut sk = Sketch::new(); + let right = sk.add_arc(o, v(0.0, -1.0), v(0.0, 1.0), [None; 3], None).unwrap(); + let left = sk.add_arc(v(-5.0, 0.0), v(-5.0, 1.0), v(-5.0, -1.0), [None; 3], None).unwrap(); + let ends = |i: usize| match sk.curves[i].kind { + CurveKind::Arc { a, b, .. } => (a, b), + _ => panic!("an arc"), + }; + let ((r0, r1), (l0, l1)) = (ends(right), ends(left)); + sk.add_line_pts(r1, l0, None).unwrap(); + sk.add_line_pts(l1, r0, None).unwrap(); + // Lines from (0, 0) through the junctions to an arc of radius 2. The upper one is turned by + // 2e-5 rad, so it passes 2e-5 from (0, 1) and crosses the line y = 1 next to it. + let d = 2e-5; + let (top, bottom) = (at(2.0, FRAC_PI_2 + d), v(0.0, -2.0)); + sk.add_line(o, top, None, None, None).unwrap(); + sk.add_line(o, bottom, None, None, None).unwrap(); + sk.add_arc(o, bottom, top, [None; 3], None).unwrap(); + let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + areas.sort_by(f64::total_cmp); + // The right half disc, the band round it out to radius 2, and the rest of the slot. + let want = [FRAC_PI_2, FRAC_PI_2 * (4.0 - 1.0), 10.0 + FRAC_PI_2]; + assert_eq!(areas.len(), 3, "{areas:?}"); + for (got, want) in areas.iter().zip(want) { + assert!((got - want).abs() < 1e-3, "{areas:?} against {want}"); + } +} + +/// A line ending 2e-5 from a corner, next to a crossing there: the corner, where the side of the +/// square meets the long line, stays a cut of the long line, and the square stays a profile. +#[test] +fn profiles_crossing_next_to_a_corner_keeps_the_corner() { + let mut sk = Sketch::new(); + let (a, b) = (sk.add_point(v(1.0, 0.0), None).unwrap(), sk.add_point(v(2.0, 0.0), None).unwrap()); + let (c, d) = (sk.add_point(v(2.0, 1.0), None).unwrap(), sk.add_point(v(1.0, 1.0), None).unwrap()); + // The square's base is part of a longer line, which its sides end on. + sk.add_line(v(0.0, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + for (p, q) in [(b, c), (c, d), (d, a)] { + sk.add_line_pts(p, q, None).unwrap(); + } + // A line hanging across the long one, 2e-5 from the square's corner (1, 0). + sk.add_line(v(1.0 - 2e-5, -0.5), v(1.0 - 2e-5, 0.5), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line across a rectangle, and a stray line ending on it 1e-3 from where it crosses the +/// rectangle's side: both the crossing and that end cut the long line, and the rectangle is +/// split into its two halves. +#[test] +fn profiles_crossing_next_to_a_stray_end_still_splits() { + let mut sk = Sketch::new(); + rect(&mut sk, [v(0.0, -1.0), v(1.0, -1.0), v(1.0, 1.0), v(0.0, 1.0)]); + sk.add_line(v(-1.0, 0.0), v(2.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.00099998, 0.0000099), v(0.3, 0.6), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 2, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line whose own end overshoots a crossing line by 2e-5, with a rectangle standing on it 1e-3 +/// further on: the crossing next to that end gives way to the rectangle's corner, which still +/// cuts the line, and the rectangle stays a profile. +#[test] +fn profiles_crossing_next_to_an_overshooting_end_keeps_the_corner_beside_it() { + let mut sk = Sketch::new(); + sk.add_line(v(-2e-5, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.0, -0.5), v(0.0, 0.5), None, None, None).unwrap(); + let x = 0.00099999; + let p = [v(x, 0.0000099), v(x, 1.0), v(2.0, 1.0), v(2.0, 0.0)].map(|q| sk.add_point(q, None).unwrap()); + for w in p.windows(2) { + sk.add_line_pts(w[0], w[1], None).unwrap(); + } + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|a| (a - (2.0 - x)).abs() < 1e-4), "{areas:?}"); +} diff --git a/crates/space-mouse/Cargo.toml b/crates/space-mouse/Cargo.toml new file mode 100644 index 00000000..7ae84345 --- /dev/null +++ b/crates/space-mouse/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "solidcraft-space-mouse" +description = "SolidCraft 3D mouse input: HID report descriptors and reports, independent of the device API and the UI" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/space-mouse/src/descriptor.rs b/crates/space-mouse/src/descriptor.rs new file mode 100644 index 00000000..409f6207 --- /dev/null +++ b/crates/space-mouse/src/descriptor.rs @@ -0,0 +1,816 @@ +//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to find its six +//! motion axes and its buttons in its input reports. Written from the public USB HID +//! specification (Device Class Definition for HID 1.11, section 6.2.2, and the HID Usage Tables). +//! Malformed input never panics; it simply yields less information. + +/// Generic Desktop usage page, and the usages in it that matter here. +const PAGE_GENERIC_DESKTOP: u32 = 0x01; +const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08; +const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35 +const AXIS_COUNT: usize = 6; +/// The Button usage page: usage N is button N, from 1. +const PAGE_BUTTON: u32 = 0x09; +/// Buttons 1 to this are read; [`Report::buttons`] has one bit for each. +pub const MAX_BUTTONS: usize = 32; + +/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored. +const MAX_DESCRIPTOR_BYTES: usize = 4096; +/// Bound on the usages one main item can declare through a usage range, and on its fields. +const MAX_USAGES_PER_ITEM: u32 = 256; +/// Bound on a report's length in bits: hidraw reads at most 4096 bytes. +const MAX_REPORT_BITS: u32 = 4096 * 8; + +/// What decoded axes are scaled to at full deflection: 3Dconnexion's own range, which the rest +/// of the 3D mouse code is tuned for. A device declaring ±350 is passed through unchanged. +pub const FULL_SCALE: i16 = 350; + +/// Where one axis or button sits in the device's input reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Field { + /// The report that carries it; 0 when the device doesn't number its reports. + pub report_id: u8, + /// Its first bit in the report, not counting the report id byte. + pub bit_offset: u32, + /// Its width, 1 to 32 bits. A field of any other width reads as missing. + pub bits: u32, + /// The declared range. 64 bits wide so a 32-bit unsigned maximum fits. + pub logical_min: i64, + pub logical_max: i64, + /// The descriptor's Relative flag. 3Dconnexion sets it on some spring-return caps and not on + /// others, so it does not tell a joystick from a trackball. + pub relative: bool, +} + +impl Field { + /// The `index`th signed 16-bit value of a report, ±350: how 3Dconnexion devices report. + const fn sixteen_bit(report_id: u8, index: u32) -> Self { + Self { report_id, bit_offset: index * 16, bits: 16, logical_min: -350, logical_max: 350, relative: true } + } + + /// The field's raw value in `body` (the report without its id), or None if `body` is too short + /// or the field isn't 1 to 32 bits wide. The fields are public, so a hand-built one is checked + /// here too, not only the ones `Descriptor::parse` makes. + fn read(&self, body: &[u8]) -> Option { + if !(1..=32).contains(&self.bits) { + return None; + } + let mut raw = 0_i64; + for bit in 0..self.bits { + let at = self.bit_offset.checked_add(bit)?; + let byte = *body.get(usize::try_from(at / 8).ok()?)?; + raw |= i64::from((byte >> (at % 8)) & 1) << bit; + } + // Signed fields are two's complement, as the logical minimum says. + Some(if self.logical_min < 0 && (raw >> (self.bits - 1)) & 1 == 1 { raw - (1_i64 << self.bits) } else { raw }) + } + + /// `raw` scaled from the field's logical range so its middle is 0 and its ends are + /// ±[`FULL_SCALE`]. A value past the declared range goes past ±FULL_SCALE rather than being cut + /// off, and a field without a usable range is passed through. Either way the result is clamped + /// to ±`i16::MAX`, never `i16::MIN`, so negating an axis can't overflow. + fn scaled(&self, raw: i64) -> i16 { + // i128: a hand-built Field can declare any i64 range, and `max - min` of i64::MIN..i64::MAX + // overflows an i64. + let (raw, min, max) = (i128::from(raw), i128::from(self.logical_min), i128::from(self.logical_max)); + let value = if min < max { (2 * raw - (min + max)) as f64 / (max - min) as f64 * f64::from(FULL_SCALE) } else { raw as f64 }; + value.round().clamp(-f64::from(i16::MAX), f64::from(i16::MAX)) as i16 + } +} + +/// Where a device puts its six axes and its buttons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Layout { + /// X, Y, Z, Rx, Ry, Rz, for each axis the device has. + pub axes: [Option; AXIS_COUNT], + /// Buttons 1 to [`MAX_BUTTONS`] at indices 0 to 31, for each the device has. Devices number + /// them with gaps (3Dconnexion's do), so the index is the button's own number less one. + pub buttons: [Option; MAX_BUTTONS], + /// The descriptor declares report ids, so every report starts with its id byte. + pub numbered: bool, +} + +impl Default for Layout { + /// 3Dconnexion's: report 1 carries X Y Z and report 2 Rx Ry Rz, three signed 16-bit values + /// each. For a 3Dconnexion device whose descriptor can't be read; a device that isn't one + /// should never be read with it. Where its buttons are differs from model to model, so it has + /// none. + fn default() -> Self { + let translation = |index| Some(Field::sixteen_bit(1, index)); + let rotation = |index| Some(Field::sixteen_bit(2, index)); + Self { + axes: [translation(0), translation(1), translation(2), rotation(0), rotation(1), rotation(2)], + buttons: [None; MAX_BUTTONS], + numbered: true, + } + } +} + +/// What one input report carries: the axes it has, scaled to ±[`FULL_SCALE`], and the buttons. +/// An axis the report doesn't carry is None, not 0, so a device that spreads one group over +/// several reports (X in one, Y in another) doesn't zero the axes it sent a moment ago. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Report { + /// X, Y, Z, Rx, Ry, Rz. + pub axes: [Option; AXIS_COUNT], + /// When the report carries buttons, which are down: bit N for button N + 1. A report with + /// every button up is how a device says they were released, so `Some(0)` matters. + pub buttons: Option, +} + +impl Layout { + /// The axes and buttons in one input report, or None when it carries none of them or is too + /// short for one of its fields. A truncated report is dropped whole, axes included, rather + /// than half read: devices send each report at its full declared size, so a short one is + /// damaged. Padding after the fields is ignored. + pub fn decode(&self, report: &[u8]) -> Option { + let (id, body) = if self.numbered { (*report.first()?, report.get(1..)?) } else { (0, report) }; + let mut decoded = Report::default(); + for (value, field) in decoded.axes.iter_mut().zip(&self.axes) { + if let Some(field) = field.filter(|field| field.report_id == id) { + *value = Some(field.scaled(field.read(body)?)); + } + } + for (bit, field) in self.buttons.iter().enumerate() { + if let Some(field) = field.filter(|field| field.report_id == id) { + let down = field.read(body)? != 0; + decoded.buttons = Some(decoded.buttons.unwrap_or(0) | (u32::from(down) << bit)); + } + } + (decoded != Report::default()).then_some(decoded) + } +} + +#[derive(Clone, Copy)] +struct Item { + /// 0 main, 1 global, 2 local (3 is reserved). + kind: u8, + tag: u8, + /// Data bytes: 0, 1, 2 or 4. + size: u8, + value: u32, +} + +impl Item { + /// The data as a two's complement number of its own size (logical minimum and maximum). + fn signed(self) -> i64 { + match self.size { + 1 => i64::from(self.value as u8 as i8), + 2 => i64::from(self.value as u16 as i16), + _ => i64::from(self.value as i32), + } + } +} + +/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk. +fn items(descriptor: &[u8]) -> impl Iterator + '_ { + let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor); + let mut pos = 0_usize; + std::iter::from_fn(move || { + loop { + let prefix = *descriptor.get(pos)?; + pos += 1; + if prefix == 0xFE { + // Long item: a data length byte, a tag byte, then the data. + let length = usize::from(*descriptor.get(pos)?); + pos = pos.checked_add(2 + length)?; + continue; + } + let size = match prefix & 0b11 { + 0 => 0, + 1 => 1, + 2 => 2, + _ => 4, + }; + let data = descriptor.get(pos..pos + size)?; + pos += size; + let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index))); + return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, size: size as u8, value }); + } + }) +} + +/// The global items in force (PUSH and POP save and restore them). +#[derive(Clone, Copy, Default)] +struct Globals { + page: u32, + logical_min: i64, + logical_max: Option, + report_size: u32, + report_count: u32, + report_id: u8, +} + +impl Globals { + /// The logical maximum. With a non-negative minimum it is unsigned, so `0x26 0xFF 0xFF` is + /// 65535 and `0x27 0xFF 0xFF 0xFF 0xFF` is 4294967295, rather than -1, as devices mean it. + fn logical_max(&self) -> i64 { + let Some(item) = self.logical_max else { return 0 }; + let signed = item.signed(); + if self.logical_min >= 0 && signed < 0 { i64::from(item.value) } else { signed } + } +} + +/// What a descriptor says about a device, as far as 3D mouse support needs to know. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Descriptor { + multi_axis_controller: bool, + axes: [Option; AXIS_COUNT], + buttons: [Option; MAX_BUTTONS], + numbered: bool, +} + +impl Descriptor { + pub fn parse(descriptor: &[u8]) -> Self { + let mut summary = Self { multi_axis_controller: false, axes: [None; AXIS_COUNT], buttons: [None; MAX_BUTTONS], numbered: false }; + let mut globals = Globals::default(); + // Every PUSH is kept, so every POP restores what its own PUSH saved. The descriptor is cut + // at MAX_DESCRIPTOR_BYTES, which bounds the stack at that many entries. + let mut pushed: Vec = Vec::new(); + let mut depth = 0_u32; + // The depth of the open Multi-axis Controller application collection; its fields count. + let mut controller: Option = None; + // The next free input bit of each report id. + let mut offsets: Vec<(u8, u32)> = Vec::new(); + // Local items belong to the next main item only. + let mut usages: Vec = Vec::new(); + let mut usage_min = None; + for item in items(descriptor) { + match (item.kind, item.tag) { + // Global: usage page, logical extent, report size, id and count, push, pop. + (1, 0) => globals.page = item.value, + (1, 1) => globals.logical_min = item.signed(), + (1, 2) => globals.logical_max = Some(item), + (1, 7) => globals.report_size = item.value, + (1, 8) => { + globals.report_id = u8::try_from(item.value).unwrap_or(0); + summary.numbered = true; + } + (1, 9) => globals.report_count = item.value, + (1, 0xA) => pushed.push(globals), + (1, 0xB) => { + if let Some(saved) = pushed.pop() { + globals = saved; + } + } + // Local: usage, usage minimum, usage maximum. + (2, 0) => usages.push(full_usage(globals.page, item)), + (2, 1) => usage_min = Some(full_usage(globals.page, item)), + (2, 2) => { + if let Some(min) = usage_min.take() { + let max = full_usage(globals.page, item); + let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1); + usages.extend((0..=count).map(|offset| min.saturating_add(offset))); + } + } + // Main: input, collection, end collection. + (0, 8) => { + let start = match offsets.iter().position(|(id, _)| *id == globals.report_id) { + Some(index) => offsets.get(index).map_or(0, |(_, bits)| *bits), + None => { + offsets.push((globals.report_id, 0)); + 0 + } + }; + // Data (not constant) and variable (not an array): one value per usage. + if controller.is_some() && item.value & 0b11 == 0b10 { + summary.note_fields(&globals, &usages, start, item.value & 0b100 != 0); + } + let end = start.saturating_add(globals.report_size.saturating_mul(globals.report_count)).min(MAX_REPORT_BITS); + if let Some((_, bits)) = offsets.iter_mut().find(|(id, _)| *id == globals.report_id) { + *bits = end; + } + } + (0, 0xA) => { + // An application collection (1) at the top level names the device. + if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) { + summary.multi_axis_controller = true; + controller = Some(depth); + } + depth = depth.saturating_add(1); + } + (0, 0xC) => { + depth = depth.saturating_sub(1); + if controller == Some(depth) { + controller = None; + } + } + _ => {} + } + if item.kind == 0 { + usages.clear(); + usage_min = None; + } + } + summary + } + + /// Records the axes and buttons among an input item's fields: the `i`th field takes the `i`th + /// usage, and fields past the last usage take the last one (HID 1.11, 6.2.2.8). The first + /// declaration of an axis or button wins. + fn note_fields(&mut self, globals: &Globals, usages: &[u32], start: u32, relative: bool) { + let bits = globals.report_size; + if !(1..=32).contains(&bits) { + return; + } + for index in 0..globals.report_count.min(MAX_USAGES_PER_ITEM) { + let Some(usage) = usages.get(index as usize).or(usages.last()) else { return }; + let Some(bit_offset) = index.checked_mul(bits).and_then(|offset| offset.checked_add(start)) else { return }; + if bit_offset.saturating_add(bits) > MAX_REPORT_BITS { + return; + } + let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE) as usize; + // Button usages start at 1; usage 0 on the page means "no button". + let button = usage.wrapping_sub((PAGE_BUTTON << 16) | 1) as usize; + let slot = match self.axes.get_mut(axis) { + Some(slot) => Some(slot), + None => self.buttons.get_mut(button), + }; + if let Some(slot) = slot + && slot.is_none() + { + *slot = Some(Field { + report_id: globals.report_id, + bit_offset, + bits, + logical_min: globals.logical_min, + logical_max: globals.logical_max(), + relative, + }); + } + } + } + + /// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a + /// 3D mouse's motion interface apart from its keyboard interface and from ordinary mice, + /// whoever made it. + pub fn is_multi_axis_controller(&self) -> bool { + self.multi_axis_controller + } + + /// Where the device's axes and buttons are, or None when the descriptor names no motion axis: + /// a controller with only buttons isn't something to steer the view with, and guessing a + /// layout for it would read its reports as motion. + /// + /// In a descriptor that numbers its reports, fields declared before the first report id carry + /// id 0, which no report can have, so they're left out. + pub fn layout(&self) -> Option { + let keep = |field: &Option| field.filter(|field| !self.numbered || field.report_id != 0); + let axes = self.axes.each_ref().map(keep); + axes.iter().any(Option::is_some).then(|| Layout { axes, buttons: self.buttons.each_ref().map(keep), numbered: self.numbered }) + } +} + +/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page. +fn full_usage(page: u32, item: Item) -> u32 { + if item.size == 4 { item.value } else { (page << 16) | item.value } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values (3Dconnexion's + /// wired devices). + const SPLIT: &[u8] = &[ + 0x05, 0x01, // usage page: generic desktop + 0x09, 0x08, // usage: multi-axis controller + 0xA1, 0x01, // collection: application + 0xA1, 0x00, // collection: physical + 0x85, 0x01, // report id 1 + 0x16, 0xA2, 0xFE, // logical minimum -350 + 0x26, 0x5E, 0x01, // logical maximum 350 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z + 0x75, 0x10, // report size 16 + 0x95, 0x03, // report count 3 + 0x81, 0x06, // input: data, variable, relative + 0xC0, // end collection + 0xA1, 0x00, // collection: physical + 0x85, 0x02, // report id 2 + 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz + 0x75, 0x10, 0x95, 0x03, 0x81, 0x06, // 3 x 16 bit, relative + 0xC0, // end collection + 0xA1, 0x02, // collection: logical + 0x85, 0x03, // report id 3 + 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, // buttons 1 to 15 + 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, // 15 x 1 bit, absolute + 0x95, 0x01, 0x81, 0x03, // 1 bit of padding + 0xC0, 0xC0, + ]; + + /// Report 1 = X Y Z Rx Ry Rz, absolute (3Dconnexion's wireless devices). + const COMBINED: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0xA1, 0x00, 0x85, 0x01, // multi-axis controller, report id 1 + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, // -350..350 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz + 0x75, 0x10, 0x95, 0x06, 0x81, 0x02, // 6 x 16 bit, absolute + 0xC0, 0xC0, + ]; + + /// The same six axes declared as a usage range, with report id 1 after the collection opens. + const COMBINED_RANGE: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, // + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, // + 0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz + 0x81, 0x06, 0xC0, + ]; + + /// A homemade trackball: report 1 is eight button bits, then six 16-bit relative axes over + /// the full signed range. + const BUTTONS_FIRST: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1 + 0x05, 0x09, 0x19, 0x01, 0x29, 0x08, 0x15, 0x00, 0x25, 0x01, // buttons 1 to 8, 0..1 + 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, // 8 x 1 bit + 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X..Rz + 0x16, 0x00, 0x80, 0x26, 0xFF, 0x7F, // -32768..32767 + 0x75, 0x10, 0x95, 0x06, 0x81, 0x06, // 6 x 16 bit, relative + 0xC0, + ]; + + /// A homemade joystick without report ids: four 8-bit axes from 0 to 255, a byte of constant + /// padding first, and the rotation pushed and popped around a narrower range. + const UNNUMBERED_UNSIGNED: &[u8] = &[ + 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, // + 0x75, 0x08, 0x95, 0x01, 0x81, 0x03, // one constant byte + 0x15, 0x00, 0x26, 0xFF, 0x00, // 0..255 + 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x95, 0x03, 0x81, 0x02, // X Y Z + 0xA4, // push + 0x26, 0x7F, 0x00, // 0..127 + 0x09, 0x35, 0x95, 0x01, 0x81, 0x02, // Rz + 0xB4, // pop + 0xC0, + ]; + + /// A boot keyboard-like interface: usage page 7 usages must not count as axes. + const KEYBOARD: &[u8] = + &[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0]; + + /// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2). + const MOUSE: &[u8] = &[ + 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81, + 0x06, 0xC0, 0xC0, + ]; + + fn field(report_id: u8, bit_offset: u32, bits: u32, (logical_min, logical_max): (i64, i64), relative: bool) -> Option { + Some(Field { report_id, bit_offset, bits, logical_min, logical_max, relative }) + } + + fn layout(bytes: &[u8]) -> Layout { + Descriptor::parse(bytes).layout().expect("the descriptor declares motion axes") + } + + /// One report's axes, X to Rz, from a list of (axis, value). + fn axes(values: &[(usize, i16)]) -> [Option; AXIS_COUNT] { + let mut axes = [None; AXIS_COUNT]; + for &(axis, value) in values { + if let Some(slot) = axes.get_mut(axis) { + *slot = Some(value); + } + } + axes + } + + /// A Multi-axis Controller application collection around `body`. + fn controller(body: &[u8]) -> Vec { + [&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01][..], body, &[0xC0]].concat() + } + + #[test] + fn split_reports_put_translation_in_report_one_and_rotation_in_report_two() { + let descriptor = Descriptor::parse(SPLIT); + assert!(descriptor.is_multi_axis_controller()); + let layout = layout(SPLIT); + assert!(layout.numbered); + let r = (-350, 350); + assert_eq!( + layout.axes, + [ + field(1, 0, 16, r, true), + field(1, 16, 16, r, true), + field(1, 32, 16, r, true), + field(2, 0, 16, r, true), + field(2, 16, 16, r, true), + field(2, 32, 16, r, true) + ] + ); + // The same axes as are assumed for a 3Dconnexion device whose descriptor can't be read. + assert_eq!(layout.axes, Layout::default().axes); + // Buttons 1 to 15 are report 3's first 15 bits; the 16th is padding. + assert_eq!(layout.buttons[0], field(3, 0, 1, (0, 1), false)); + assert_eq!(layout.buttons[14], field(3, 14, 1, (0, 1), false)); + assert_eq!(layout.buttons[15], None); + // Buttons 1, 3 and 15 down; then all up, which is a report too. + assert_eq!(layout.decode(&[3, 0b101, 0b0100_0000]), Some(Report { axes: [None; 6], buttons: Some(0b101 | 1 << 14) })); + assert_eq!(layout.decode(&[3, 0, 0]).map(|r| r.buttons), Some(Some(0))); + // A motion report says nothing about the buttons. + assert_eq!(layout.decode(&[1, 1, 0, 2, 0, 3, 0]).map(|r| r.buttons), Some(None)); + } + + #[test] + fn buttons_keep_their_own_numbers_across_gaps() { + // X in report 1; buttons 1 to 3, a constant bit, then buttons 5 and 6 in report 3, as + // 3Dconnexion numbers them. + let layout = layout(&controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X + 0x85, 0x03, 0x05, 0x09, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, // report 3, 1-bit fields + 0x19, 0x01, 0x29, 0x03, 0x95, 0x03, 0x81, 0x02, // buttons 1 to 3 + 0x95, 0x01, 0x81, 0x03, // a constant bit + 0x19, 0x05, 0x29, 0x06, 0x95, 0x02, 0x81, 0x02, // buttons 5 and 6 + ])); + assert_eq!(layout.buttons[3], None); + assert_eq!(layout.buttons[4], field(3, 4, 1, (0, 1), false)); + assert_eq!(layout.buttons[5], field(3, 5, 1, (0, 1), false)); + // Bits 0 and 5 set: buttons 1 and 6. + assert_eq!(layout.decode(&[3, 0b10_0001]).and_then(|r| r.buttons), Some(0b10_0001)); + } + + #[test] + fn a_controller_without_motion_axes_has_no_layout() { + // Buttons only, in report 1. With a guessed 3Dconnexion layout, its report + // `01 01 00 00 00 00 00` would read as X = 1. It must not be read at all. + let buttons_only = controller(&[ + 0x85, 0x01, 0x05, 0x09, 0x19, 0x01, 0x29, 0x08, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, // + 0x75, 0x08, 0x95, 0x05, 0x81, 0x03, // padding up to 6 bytes + ]); + assert!(Descriptor::parse(&buttons_only).is_multi_axis_controller()); + assert_eq!(Descriptor::parse(&buttons_only).layout(), None); + // Axes wider than 32 bits aren't supported, so they don't count either. + let wide = controller(&[0x75, 0x40, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02]); + assert_eq!(Descriptor::parse(&wide).layout(), None); + // A page-7 usage that happens to be 0x30 isn't an axis. + let four_byte = controller(&[0x85, 0x01, 0x75, 0x10, 0x95, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06]); + assert_eq!(Descriptor::parse(&four_byte).layout(), None); + } + + #[test] + fn six_axes_in_report_one_follow_each_other() { + for (bytes, relative) in [(COMBINED, false), (COMBINED_RANGE, true)] { + assert!(Descriptor::parse(bytes).is_multi_axis_controller()); + for (index, axis) in layout(bytes).axes.iter().enumerate() { + assert_eq!(*axis, field(1, 16 * index as u32, 16, (-350, 350), relative), "axis {index}"); + } + } + } + + #[test] + fn axes_after_the_buttons_start_after_the_buttons() { + let layout = layout(BUTTONS_FIRST); + assert_eq!(layout.axes[0], field(1, 8, 16, (-32768, 32767), true)); + assert_eq!(layout.axes[5], field(1, 8 + 5 * 16, 16, (-32768, 32767), true)); + // A report: buttons 0b101, then X = -100, Rz = 32767; the rest 0. + let mut report = vec![1, 0b101]; + report.extend((-100_i16).to_le_bytes()); + report.extend([0; 8]); + report.extend(32767_i16.to_le_bytes()); + let decoded = layout.decode(&report).expect("report 1 carries the axes"); + assert_eq!(decoded.axes, axes(&[(0, -1), (1, 0), (2, 0), (3, 0), (4, 0), (5, 350)])); + // The same report carries buttons 1 to 8: 1 and 3 are down. + assert_eq!(decoded.buttons, Some(0b101)); + } + + #[test] + fn unnumbered_unsigned_axes_after_padding_are_centred() { + let layout = layout(UNNUMBERED_UNSIGNED); + assert!(!layout.numbered); + assert_eq!(layout.axes[0], field(0, 8, 8, (0, 255), false)); + assert_eq!(layout.axes[2], field(0, 24, 8, (0, 255), false)); + assert_eq!(layout.axes[3], None); + // PUSH and POP: Rz has its own range, and the size and count it was declared with. + assert_eq!(layout.axes[5], field(0, 32, 8, (0, 127), false)); + // No id byte: padding, X at its minimum, Y at the top, Z near the middle, Rz at the top. + let decoded = layout.decode(&[0xEE, 0, 255, 128, 127]).expect("one report carries everything"); + assert_eq!(decoded.axes, axes(&[(0, -350), (1, 350), (2, 1), (5, 350)])); + } + + #[test] + fn other_interfaces_are_not_3d_mice() { + for bytes in [KEYBOARD, MOUSE, &[][..]] { + let descriptor = Descriptor::parse(bytes); + assert!(!descriptor.is_multi_axis_controller()); + assert_eq!(descriptor.layout(), None); + } + // A mouse's X and Y aren't axes, even though the generic desktop usages are the same. + assert!(Descriptor::parse(MOUSE).axes.iter().all(Option::is_none)); + } + + #[test] + fn an_end_collection_closes_the_controller() { + // X Y Z in the controller; Rx in a top-level Mouse collection after it isn't the + // controller's. + let bytes = + [ + &controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x03, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x81, 0x02, + ])[..], + &[0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x02, 0x09, 0x33, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, 0xC0], + ] + .concat(); + let layout = layout(&bytes); + assert!(layout.axes[0].is_some()); + assert_eq!(layout.axes[3], None); + } + + #[test] + fn decodes_separate_reports_and_ignores_short_ones_and_padding() { + let layout = Layout::default(); + assert_eq!(layout.decode(&[1, 1, 2]), None); + assert_eq!(layout.decode(&[]), None); + assert_eq!(layout.decode(&[3, 1, 0, 0, 0, 0, 0]), None); + assert_eq!(layout.decode(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0]), Some(Report { axes: axes(&[(0, -100), (1, 300), (2, 0)]), buttons: None })); + assert_eq!(layout.decode(&[2, 0, 0, 1, 0, 0xff, 0xff]), Some(Report { axes: axes(&[(3, 0), (4, 1), (5, -1)]), buttons: None })); + // A padded translation report: its padding is not rotation. + assert_eq!(layout.decode(&[1, 1, 0, 2, 0, 3, 0, 9, 9, 9, 9, 9, 9]), Some(Report { axes: axes(&[(0, 1), (1, 2), (2, 3)]), buttons: None })); + } + + #[test] + fn combined_reports_carry_both_groups() { + let layout = layout(COMBINED); + let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff, 0, 0]; + assert_eq!(layout.decode(&report).map(|r| r.axes), Some(axes(&[(0, -100), (1, 300), (2, 0), (3, 1), (4, 0), (5, -1)]))); + // One byte short of Rz: dropped whole rather than half read. + assert_eq!(layout.decode(&report[..12]), None); + } + + #[test] + fn a_group_spread_over_reports_reports_only_what_each_carries() { + // X in report 1, Y in report 2: each report says nothing about the other axis. + let layout = layout(&controller(&[ + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, // + 0x85, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X + 0x85, 0x02, 0x09, 0x31, 0x81, 0x02, // report 2: Y + ])); + assert_eq!(layout.decode(&[1, 100, 0]).map(|r| r.axes), Some(axes(&[(0, 100)]))); + assert_eq!(layout.decode(&[2, 200, 0]).map(|r| r.axes), Some(axes(&[(1, 200)]))); + } + + #[test] + fn a_short_report_is_dropped_whole_axes_included() { + // Report 1: X (16 bits), then a button. A report one byte short has X but not the button: + // it's damaged, so neither is taken. + let layout = layout(&controller(&[ + 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // X + 0x05, 0x09, 0x09, 0x01, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x81, 0x02, 0x75, 0x07, 0x81, 0x03, // button 1, padding + ])); + assert_eq!(layout.decode(&[1, 100, 0]), None); + assert_eq!(layout.decode(&[1, 100, 0, 1]), Some(Report { axes: axes(&[(0, 100)]), buttons: Some(1) })); + } + + #[test] + fn fields_declared_before_the_first_report_id_are_left_out() { + // X before any report id, then Y in report 1. In a numbered descriptor X would have to be + // in a report with id 0, which can't exist. + let layout = layout(&controller(&[ + 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02, // X, no id yet + 0x85, 0x01, 0x09, 0x31, 0x81, 0x02, // report 1: Y + ])); + assert_eq!(layout.axes[0], None); + assert_eq!(layout.axes[1], field(1, 0, 16, (-350, 350), false)); + assert_eq!(layout.decode(&[0, 100, 0]), None); + assert_eq!(layout.decode(&[1, 100, 0]).map(|r| r.axes), Some(axes(&[(1, 100)]))); + } + + #[test] + fn push_and_pop_restore_the_globals_however_deep() { + // PUSH, a 4-bit constant field, POP: X then takes the pushed 8-bit size, so it starts at + // bit 4 and is 8 bits wide. + let pushed = layout(&controller(&[ + 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, // 0..255, 8 bits, 1 field + 0xA4, 0x75, 0x04, 0x81, 0x03, 0xB4, // push, 4 constant bits, pop + 0x09, 0x30, 0x81, 0x02, // X + ])); + assert_eq!(pushed.axes[0], field(0, 4, 8, (0, 255), false)); + assert_eq!(pushed.decode(&[0xF0, 0x0F]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + // 20 PUSHes, the Nth saving the size before it and then setting the size to N. Every POP + // restores what its own PUSH saved: the first POP gives back 19, the 20th gives back 8. + let mut deep = vec![0x15, 0x00, 0x25, 0x01, 0x95, 0x01, 0x75, 0x08]; + for depth in 1..=20_u8 { + deep.extend([0xA4, 0x75, depth]); + } + deep.extend([0x81, 0x03]); // 20 constant bits at the deepest size + deep.extend([0xB4, 0x09, 0x31, 0x81, 0x02]); // one POP: Y is 19 bits + deep.extend([0xB4; 19]); + deep.extend([0x09, 0x30, 0x81, 0x02]); // 19 more POPs: X is 8 bits + let deep = layout(&controller(&deep)); + assert_eq!(deep.axes[1].map(|f| (f.bit_offset, f.bits)), Some((20, 19))); + assert_eq!(deep.axes[0].map(|f| (f.bit_offset, f.bits)), Some((39, 8))); + } + + #[test] + fn a_long_item_is_skipped_whole() { + // A long item (prefix 0xFE, 3 data bytes) in front changes nothing. + let with_long_item = [&[0xFE, 0x03, 0x00, 0x01, 0x02, 0x03][..], COMBINED].concat(); + assert_eq!(layout(&with_long_item), layout(COMBINED)); + } + + #[test] + fn negative_and_one_byte_ranges_keep_their_signs() { + // -127..127 in one-byte items: 0x81 is -127, the bottom of the range. + let one_byte = layout(&controller(&[0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(one_byte.axes[0].map(|f| (f.logical_min, f.logical_max)), Some((-127, 127))); + assert_eq!(one_byte.decode(&[0x81]).map(|r| r.axes), Some(axes(&[(0, -350)]))); + // -128..-1: a negative maximum stays negative, so 0xFF (-1) is the top. + let negative = layout(&controller(&[0x15, 0x80, 0x25, 0xFF, 0x75, 0x08, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(negative.axes[0].map(|f| f.logical_max), Some(-1)); + assert_eq!(negative.decode(&[0xFF]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + } + + #[test] + fn ranges_scale_to_full_scale() { + let f = |min, max, bits| Field { report_id: 0, bit_offset: 0, bits, logical_min: min, logical_max: max, relative: false }; + assert_eq!(f(-350, 350, 16).scaled(-350), -350); + assert_eq!(f(-350, 350, 16).scaled(123), 123); + assert_eq!(f(-32768, 32767, 16).scaled(32767), 350); + assert_eq!(f(-32768, 32767, 16).scaled(-32768), -350); + assert_eq!(f(0, 1023, 10).scaled(0), -350); + assert_eq!(f(0, 1023, 10).scaled(1023), 350); + // No usable range: passed through, clamped to 16 bits. + assert_eq!(f(0, 0, 32).scaled(40), 40); + assert_eq!(f(5, 5, 32).scaled(1 << 20), i16::MAX); + // Past the declared range: beyond ±350, but never i16::MIN, whose negation overflows. + assert_eq!(f(-350, 350, 32).scaled(700), 700); + assert_eq!(f(-350, 350, 32).scaled(-(1 << 30)), -i16::MAX); + assert_eq!(f(5, 5, 32).scaled(-(1 << 20)), -i16::MAX); + // Unsigned maxima: with a minimum of 0, 0x26 0xFF 0xFF is 65535 and the 4-byte + // 0x27 0xFF 0xFF 0xFF 0xFF is 4294967295, so full deflection of a 32-bit field is 350. + let two_byte = layout(&controller(&[0x15, 0x00, 0x26, 0xFF, 0xFF, 0x75, 0x10, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(two_byte.axes[0].map(|f| f.logical_max), Some(65535)); + assert_eq!(two_byte.decode(&[0xFF, 0xFF]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + let four_byte = layout(&controller(&[0x15, 0x00, 0x27, 0xFF, 0xFF, 0xFF, 0xFF, 0x75, 0x20, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(four_byte.axes[0].map(|f| f.logical_max), Some(4_294_967_295)); + assert_eq!(four_byte.decode(&[0xFF; 4]).map(|r| r.axes), Some(axes(&[(0, 350)]))); + } + + #[test] + fn a_field_must_be_1_to_32_bits_wide() { + // Fields are public, so a hand-built one can be any width; reading it mustn't panic. + for bits in [0, 33, 64, 65, u32::MAX] { + let layout = + Layout { axes: [field(0, 0, bits, (-1, 1), false), None, None, None, None, None], buttons: [None; MAX_BUTTONS], numbered: false }; + assert_eq!(layout.decode(&[0; 16]), None, "{bits} bits"); + } + // Nor can any range: the extremes of i64 don't overflow the scaling. + for (min, max) in [(i64::MIN, i64::MAX), (i64::MIN, 0), (0, i64::MAX), (i64::MAX, i64::MIN)] { + for (bits, report) in [(1, [0xFF; 4]), (32, [0xFF; 4]), (32, [0; 4])] { + let layout = Layout { + axes: [field(0, 0, bits, (min, max), false), None, None, None, None, None], + buttons: [None; MAX_BUTTONS], + numbered: false, + }; + assert!(layout.decode(&report).is_some(), "{min}..{max}, {bits} bits"); + } + } + } + + #[test] + fn fields_reach_the_end_of_the_biggest_report_and_no_further() { + // An 8-bit X after 514 bytes of padding is still inside a report. + let far = layout(&controller(&[0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x96, 0x02, 0x02, 0x81, 0x03, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])); + assert_eq!(far.axes[0].map(|f| f.bit_offset), Some(514 * 8)); + let mut report = vec![0; 515]; + if let Some(last) = report.last_mut() { + *last = 0xFF; + } + assert_eq!(far.decode(&report).map(|r| r.axes), Some(axes(&[(0, 350)]))); + // A 32-bit X that ends exactly at the last bit is kept; one byte later it's outside. + let at_end = |padding: u16| { + let [low, high] = padding.to_le_bytes(); + Descriptor::parse(&controller(&[0x75, 0x08, 0x96, low, high, 0x81, 0x03, 0x75, 0x20, 0x95, 0x01, 0x09, 0x30, 0x81, 0x02])).layout() + }; + assert_eq!(at_end(4092).and_then(|l| l.axes[0]).map(|f| f.bit_offset), Some(4092 * 8)); + assert_eq!(at_end(4093), None); + } + + #[test] + fn truncated_and_hostile_input_never_panics() { + for bytes in [SPLIT, COMBINED, BUTTONS_FIRST, UNNUMBERED_UNSIGNED] { + for end in 0..=bytes.len() { + if let Some(layout) = Descriptor::parse(&bytes[..end]).layout() { + let _ = layout.decode(&[1, 2, 3]); + } + } + } + // Long item with a length running past the end, huge usage ranges and counts, deep nesting, + // pops without pushes, pushes without pops, 32-bit fields at the end of the biggest report. + let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]); + let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]); + let _ = Descriptor::parse(&controller(&[0x77, 0xFF, 0xFF, 0xFF, 0xFF, 0x97, 0xFF, 0xFF, 0xFF, 0xFF, 0x09, 0x30, 0x81, 0x02])); + let _ = Descriptor::parse(&controller(&[0x75, 0x20, 0x96, 0xFF, 0x03, 0x09, 0x30, 0x81, 0x02, 0x09, 0x31, 0x81, 0x02])); + let _ = Descriptor::parse(&[0xC0; 64]); + let _ = Descriptor::parse(&[0xA1; 64]); + let _ = Descriptor::parse(&[0xB4; 64]); + let _ = Descriptor::parse(&[0xA4; MAX_DESCRIPTOR_BYTES * 2]); + let mut state = 0x2545_F491_4F6C_DD1D_u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state.to_le_bytes()[0] + }; + for _ in 0..500 { + let bytes: Vec = [0x05, 0x01, 0x09, 0x08, 0xA1, 0x01].into_iter().chain((0..64).map(|_| next())).collect(); + let report: Vec = (0..16).map(|_| next()).collect(); + if let Some(layout) = Descriptor::parse(&bytes).layout() { + let _ = layout.decode(&report); + } + } + } +} diff --git a/crates/space-mouse/src/lib.rs b/crates/space-mouse/src/lib.rs new file mode 100644 index 00000000..6ab8099d --- /dev/null +++ b/crates/space-mouse/src/lib.rs @@ -0,0 +1,15 @@ +//! 3D mouse input that doesn't depend on how the device is read or what the motion drives. The +//! apps open the device their own way (hidraw on Linux, hidapi on Windows), pass its report +//! descriptor to [`Descriptor::parse`] and each input report to [`Layout::decode`], and apply the +//! six axes and the buttons they get back to their own camera and commands. +//! +//! Any device that declares a HID Multi-axis Controller is read the same way: 3Dconnexion's, +//! which send translation and rotation in two reports and their buttons in a third, and homemade +//! ones, which often send all six axes in one report, sometimes after their buttons. The +//! descriptor says where each axis and button is. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod descriptor; + +pub use descriptor::{Descriptor, FULL_SCALE, Field, Layout, MAX_BUTTONS, Report}; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1fc0cf8c..37563db2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,13 @@ semver = { workspace = true } sha2 = { workspace = true } minisign-verify = "0.3" zip = { workspace = true } +# Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn. +tiny-skia = { workspace = true } + +[target.'cfg(windows)'.dependencies] +# Updates: the folder the Windows installer recorded, so an install outside Program Files is still +# updated by its installer (MIT; already in the tree as a build dependency). +winreg = "0.56" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] # The offscreen viewport render test opens a device without a window. @@ -32,3 +39,5 @@ pollster = { workspace = true } [build-dependencies] serde_json = { workspace = true } +# Reads assets/icons/ui/design/palette.toml and aliases.toml into compiled tables. +toml = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 63bc3b20..797da051 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -36,6 +36,10 @@ fn main() { } println!("cargo:rerun-if-changed=build.rs"); scenario_tests(); + if let Err(e) = icon_data() { + eprintln!("icon data: {e}"); + std::process::exit(1); + } } /// `$OUT_DIR/scenario_tests.rs`: a `#[test]` per scenario file, named after it; a scenario with @@ -67,3 +71,155 @@ fn scenario_tests() { let path = std::path::Path::new(&std::env::var("OUT_DIR").unwrap_or_else(|_| ".".into())).join("scenario_tests.rs"); let _ = std::fs::write(path, out); } + +/// `assets/icons/ui/design`: the SVG masters, `palette.toml` (every colour role in light and dark, +/// and the accents) and `aliases.toml`, compiled into `$OUT_DIR/icon_data.rs` for `icon_svg.rs`. +/// A missing hex, a role an accent does not know or an alias to a missing master fails the build. +fn icon_data() -> Result<(), String> { + use std::fmt::Write as _; + let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let root = std::path::Path::new(&manifest).join("../../assets/icons/ui/design"); + let read = |name: &str| std::fs::read_to_string(root.join(name)).map_err(|e| format!("{name}: {e}")); + let table = |name: &str| read(name)?.parse::().map_err(|e| format!("{name}: {e}")); + let hex = |v: Option<&toml::Value>, what: &str| -> Result { + let s = v.and_then(toml::Value::as_str).ok_or_else(|| format!("{what}: missing hex"))?; + let ok = s.len() == 7 && s.starts_with('#') && s[1..].bytes().all(|b| b.is_ascii_hexdigit()); + if ok { Ok(s.to_ascii_lowercase()) } else { Err(format!("{what}: `{s}` is not #rrggbb")) } + }; + let rgb = |h: &str| format!("[0x{}, 0x{}, 0x{}]", &h[1..3], &h[3..5], &h[5..7]); + let palette = table("palette.toml")?; + let roles = palette.get("roles").and_then(toml::Value::as_table).ok_or("palette.toml: no [roles]")?; + let names: Vec<&String> = roles.keys().collect(); + let mut out = String::new(); + let _ = writeln!(out, "pub(crate) const ROLE_NAMES: &[&str] = &{:?};", names); + let (mut lights, mut darks, mut light_hex) = (Vec::new(), Vec::new(), Vec::new()); + for (name, role) in roles { + let l = hex(role.get("light"), &format!("role {name} light"))?; + let d = hex(role.get("dark"), &format!("role {name} dark"))?; + lights.push(rgb(&l)); + darks.push(rgb(&d)); + light_hex.push(hex(role.get("source").or_else(|| role.get("light")), &format!("role {name} source"))?); + } + let _ = writeln!(out, "pub(crate) const ROLE_SOURCE_HEX: &[&str] = &{:?};", light_hex); + let _ = writeln!(out, "pub(crate) const PALETTE: [&[[u8; 3]]; 2] = [&[{}], &[{}]];", lights.join(", "), darks.join(", ")); + let mut accent_names = Vec::new(); + let mut accents = Vec::new(); + if let Some(table) = palette.get("accents").and_then(toml::Value::as_table) { + for (acc, roles_of) in table { + let roles_of = roles_of.as_table().ok_or_else(|| format!("accent {acc}: not a table"))?; + let mut items = Vec::new(); + for (role, v) in roles_of { + let idx = names.iter().position(|n| *n == role).ok_or_else(|| format!("accent {acc}: unknown role {role}"))?; + let v = if let Some(link) = v.get("use").and_then(toml::Value::as_str) { + roles_of.get(link).ok_or_else(|| format!("accent {acc}: unknown linked role {link}"))? + } else { + v + }; + let l = hex(v.get("light"), &format!("accent {acc} {role} light"))?; + let d = hex(v.get("dark"), &format!("accent {acc} {role} dark"))?; + items.push(format!("({idx}, {}, {})", rgb(&l), rgb(&d))); + } + accent_names.push(acc.clone()); + accents.push(format!("&[{}]", items.join(", "))); + } + } + let _ = writeln!(out, "pub(crate) const ACCENT_NAMES: &[&str] = &{:?};", accent_names); + let _ = writeln!(out, "pub(crate) const ACCENTS: &[&[(usize, [u8; 3], [u8; 3])]] = &[{}];", accents.join(", ")); + // UI tokens and SVGs share the same primary strokes, including accessibility overrides. + for (constant, value) in [ + ("SKETCH_PRIMARY", palette.get("accents").and_then(|v| v.get("sketch"))), + ("CONSTRAINT_PRIMARY", palette.get("accents").and_then(|v| v.get("constraint"))), + ("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))), + ("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))), + ("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))), + ("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))), + ("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))), + ] { + let role = value.and_then(|v| v.get("active-3")); + let l = hex(role.and_then(|v| v.get("light")), constant)?; + let d = hex(role.and_then(|v| v.get("dark")), constant)?; + let _ = writeln!(out, "pub(crate) const {constant}: [[u8; 3]; 2] = [{}, {}];", rgb(&l), rgb(&d)); + } + let mut variants = Vec::new(); + if let Some(groups) = palette.get("variants").and_then(toml::Value::as_table) { + for (vision, group) in groups { + if !matches!(vision.as_str(), "red-green" | "blue-yellow") { + return Err(format!("unknown icon colour-vision group {vision}")); + } + for (accent, overrides) in group.as_table().ok_or("invalid variant group")? { + let a = accent_names.iter().position(|n| n == accent).ok_or_else(|| format!("variant: unknown accent {accent}"))?; + for (role, value) in overrides.as_table().ok_or("invalid variant roles")? { + let r = names.iter().position(|n| *n == role).ok_or_else(|| format!("variant: unknown role {role}"))?; + let l = hex(value.get("light"), "variant light")?; + let d = hex(value.get("dark"), "variant dark")?; + variants.push(format!("({vision:?}, {a}, {r}, {}, {})", rgb(&l), rgb(&d))); + } + } + } + } + let _ = writeln!(out, "pub(crate) const ACCENT_VARIANTS: &[(&str, usize, usize, [u8; 3], [u8; 3])] = &[{}];", variants.join(", ")); + // The masters, by family. + let mut svgs = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + let svg_dir = root.join("svg"); + println!("cargo:rerun-if-changed={}", root.display()); + for fam in ["model", "small", "flat", "workspace"] { + let dir = svg_dir.join(fam); + let mut files: Vec<_> = + std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.filter_map(Result::ok).map(|e| e.path()).collect(); + files.sort(); + for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == "svg")) { + let id = f.file_stem().and_then(|s| s.to_str()).ok_or("bad file name")?; + if fam != "small" { + ids.insert(id.to_owned()); + } + let abs = f.canonicalize().map_err(|e| e.to_string())?; + println!("cargo:rerun-if-changed={}", abs.display()); + svgs.push(format!("({id:?}, {fam:?}, include_str!({:?}))", abs.display().to_string())); + } + } + let _ = writeln!(out, "pub(crate) static SVGS: &[(&str, &str, &str)] = &[{}];", svgs.join(", ")); + let aliases = table("aliases.toml")?; + let mut al = Vec::new(); + if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { + for (alias, v) in t { + let (master, accent) = match v { + toml::Value::String(m) => (m.clone(), String::new()), + toml::Value::Table(t) => ( + t.get("master").and_then(toml::Value::as_str).ok_or_else(|| format!("alias {alias}: no master"))?.to_string(), + t.get("accent").and_then(toml::Value::as_str).unwrap_or("").to_string(), + ), + _ => return Err(format!("alias {alias}: expected a string or a table")), + }; + if !accent.is_empty() && !accent_names.contains(&accent) { + return Err(format!("alias {alias}: unknown accent {accent}")); + } + if !ids.contains(&master) { + return Err(format!("alias {alias}: unknown master {master}")); + } + al.push(format!("({alias:?}, {master:?}, {accent:?})")); + } + } + let _ = writeln!(out, "pub(crate) const ALIASES: &[(&str, &str, &str)] = &[{}];", al.join(", ")); + if let Some(t) = aliases.get("alias").and_then(toml::Value::as_table) { + ids.extend(t.keys().cloned()); + } + let mut defaults = std::collections::BTreeMap::new(); + if let Some(groups) = palette.get("icon-accents").and_then(toml::Value::as_table) { + for (accent, icons) in groups { + if !accent_names.contains(accent) { + return Err(format!("icon-accents: unknown accent {accent}")); + } + for icon in icons.as_array().ok_or("icon-accents: expected icon array")? { + let icon = icon.as_str().ok_or("icon-accents: expected icon id")?; + if !ids.contains(icon) || defaults.insert(icon, accent).is_some() { + return Err(format!("icon-accents: unknown or repeated icon {icon}")); + } + } + } + } + let defaults: Vec<_> = defaults.iter().map(|(id, accent)| format!("({id:?}, {accent:?})")).collect(); + let _ = writeln!(out, "pub(crate) const ICON_ACCENTS: &[(&str, &str)] = &[{}];", defaults.join(", ")); + let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs"); + std::fs::write(dest, out).map_err(|e| e.to_string()) +} diff --git a/crates/ui-egui/src/agent_chat/agents.rs b/crates/ui-egui/src/agent_chat/agents.rs index 865f7c47..d2a65d19 100644 --- a/crates/ui-egui/src/agent_chat/agents.rs +++ b/crates/ui-egui/src/agent_chat/agents.rs @@ -1,5 +1,5 @@ //! The agents the chat can use: the installed ones it finds by itself (Claude Code, Codex, -//! Gemini CLI, OpenCode, …) and the ones the user adds (another ACP command, or an +//! Antigravity, OpenCode, …) and the ones the user adds (another ACP command, or an //! OpenAI-compatible endpoint such as Ollama, LM Studio or OpenAI). Also the permission modes. //! Preferences only: no design data, no keys (those are in the system keychain). diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs index 3b028ca2..07bfa6a3 100644 --- a/crates/ui-egui/src/agent_chat/discover.rs +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -24,6 +24,11 @@ pub struct Known { pub config_dir_var: Option<(&'static str, &'static str)>, /// Variables holding a key it uses instead of a login. pub key_vars: &'static [&'static str], + /// Retired by its maker: still used when it is installed, never offered for install. + pub retired: bool, + /// Whether `--version` prints its version. Antigravity's server prints its build date and takes + /// seconds to start, so it is not asked. + pub reports_version: bool, } /// The agents shown in the picker. The first one found is the default. @@ -37,6 +42,8 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".claude/.credentials.json"), config_dir_var: Some(("CLAUDE_CONFIG_DIR", ".credentials.json")), key_vars: &["ANTHROPIC_API_KEY"], + retired: false, + reports_version: true, }, Known { id: "codex", @@ -47,7 +54,25 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".codex/auth.json"), config_dir_var: Some(("CODEX_HOME", "auth.json")), key_vars: &["OPENAI_API_KEY", "CODEX_API_KEY"], + retired: false, + reports_version: true, }, + // Google's `agy` command line has no ACP mode; its ACP server is a separate download that the + // ACP registry lists (`agy_acp_server.par`, `agy_acp_server.exe` on Windows). + Known { + id: "antigravity", + name: "Google Antigravity", + programs: &["agy_acp_server.par", "agy_acp_server"], + args: &[], + install: "download the Antigravity ACP server for your system (see agentclientprotocol.com/registry), unzip it and put agy_acp_server.par on your PATH", + credentials: Some(".gemini/antigravity-acp/acp_token.json"), + config_dir_var: None, + key_vars: &["GEMINI_API_KEY"], + retired: false, + reports_version: false, + }, + // Google retired it on 18 June 2026 in favour of Antigravity (#1790). Enterprise licences may + // still work, so an installed one is found, but nobody is sent to install it. Known { id: "gemini", name: "Gemini CLI", @@ -57,6 +82,8 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".gemini/oauth_creds.json"), config_dir_var: None, key_vars: &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + retired: true, + reports_version: true, }, Known { id: "opencode", @@ -67,6 +94,8 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".local/share/opencode/auth.json"), config_dir_var: None, key_vars: &[], + retired: false, + reports_version: true, }, Known { id: "grok", @@ -77,6 +106,8 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".grok/auth.json"), config_dir_var: None, key_vars: &["XAI_API_KEY"], + retired: false, + reports_version: true, }, Known { id: "copilot", @@ -87,6 +118,8 @@ pub const KNOWN: &[Known] = &[ credentials: None, config_dir_var: None, key_vars: &["GH_TOKEN", "GITHUB_TOKEN"], + retired: false, + reports_version: true, }, Known { id: "qwen", @@ -97,6 +130,8 @@ pub const KNOWN: &[Known] = &[ credentials: Some(".qwen/oauth_creds.json"), config_dir_var: None, key_vars: &["DASHSCOPE_API_KEY"], + retired: false, + reports_version: true, }, Known { id: "goose", @@ -107,6 +142,8 @@ pub const KNOWN: &[Known] = &[ credentials: None, config_dir_var: None, key_vars: &[], + retired: false, + reports_version: true, }, ]; @@ -292,19 +329,24 @@ fn login_of(k: &Known, home: Option<&Path>) -> Login { /// Look for every known agent. Blocking (each probe has a time limit): run it off the UI thread. pub fn detect() -> Detection { + detect_in(&search_dirs(), home().as_deref()) +} + +fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection { let t0 = Instant::now(); - let dirs = search_dirs(); - let home = home(); let mut d = Detection::default(); for k in KNOWN { - let Some(path) = k.programs.iter().find_map(|p| find_in(p, &dirs)) else { - d.missing.push(Missing { id: k.id, name: k.name, install: k.install }); + let Some(path) = k.programs.iter().find_map(|p| find_in(p, dirs)) else { + if !k.retired { + d.missing.push(Missing { id: k.id, name: k.name, install: k.install }); + } continue; }; - let login = login_of(k, home.as_deref()); + let login = login_of(k, home); let mut argv = vec![path.to_string_lossy().into_owned()]; argv.extend(k.args.iter().map(|a| a.to_string())); - d.found.push(Found { id: k.id, name: k.name, version: version(&path, Duration::from_secs(3)), argv, login }); + let version = if k.reports_version { version(&path, Duration::from_secs(3)) } else { None }; + d.found.push(Found { id: k.id, name: k.name, version, argv, login }); } d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX); d @@ -376,6 +418,14 @@ mod tests { if std::env::var_os("XAI_API_KEY").is_none() { assert_eq!(login_of(grok, Some(&home)), Login::NotLoggedIn); } + // Antigravity's server keeps its own login, apart from the retired Gemini CLI's. + let agy = KNOWN.iter().find(|k| k.id == "antigravity").unwrap(); + if std::env::var_os("GEMINI_API_KEY").is_none() { + assert_eq!(login_of(agy, Some(&home)), Login::NotLoggedIn, "the Gemini CLI's file is not its login"); + } + std::fs::create_dir_all(home.join(".gemini/antigravity-acp")).unwrap(); + std::fs::write(home.join(".gemini/antigravity-acp/acp_token.json"), "not credential data").unwrap(); + assert_eq!(login_of(agy, Some(&home)), Login::LooksLoggedIn); std::fs::create_dir_all(home.join(".grok")).unwrap(); // Discovery checks only for the file; it never needs to parse the user's tokens. std::fs::write(home.join(".grok/auth.json"), "not credential data").unwrap(); @@ -383,6 +433,42 @@ mod tests { let _ = std::fs::remove_dir_all(home); } + /// Google retired the Gemini CLI (#1790): one that is installed is still found, but nobody is + /// told to install it. Antigravity's ACP server is offered, and found under its file name. + #[test] + #[cfg(unix)] + fn a_retired_agent_is_found_but_not_offered_and_antigravity_is() { + use std::os::unix::fs::PermissionsExt; + let root = std::env::temp_dir().join(format!("sc-agent-retired-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let run = |name: &str, body: &str| { + let path = root.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + let ids = |d: &Detection| (d.found.iter().map(|f| f.id).collect::>(), d.missing.iter().map(|m| m.id).collect::>()); + + let none = detect_in(std::slice::from_ref(&root), None); + let (found, missing) = ids(&none); + assert!(found.is_empty()); + assert!(missing.contains(&"antigravity") && !missing.contains(&"gemini"), "{missing:?}"); + let hint = none.missing.iter().find(|m| m.id == "antigravity").map(|m| m.install).unwrap_or_default(); + assert!(hint.contains("agy_acp_server.par") && !hint.contains("npm"), "{hint}"); + + run("gemini", "echo gemini 0.64.0"); + // A server that would take seconds to answer `--version`, which Antigravity is never asked. + run("agy_acp_server.par", "sleep 30"); + let both = detect_in(std::slice::from_ref(&root), None); + let (found, missing) = ids(&both); + assert_eq!(found, ["antigravity", "gemini"]); + assert!(!missing.contains(&"antigravity") && !missing.contains(&"gemini"), "{missing:?}"); + let agy = both.found.iter().find(|f| f.id == "antigravity"); + assert_eq!(agy.map(|f| f.version.clone()), Some(None), "its --version is a build date, not asked"); + assert_eq!(agy.and_then(|f| f.argv.get(1)), None, "the registry starts it without arguments"); + assert!(both.took_ms < 5000, "Antigravity's server was started to read a version"); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn every_known_agent_has_a_program_and_an_install_hint() { for k in KNOWN { diff --git a/crates/ui-egui/src/agent_chat/markdown.rs b/crates/ui-egui/src/agent_chat/markdown.rs new file mode 100644 index 00000000..62710c5f --- /dev/null +++ b/crates/ui-egui/src/agent_chat/markdown.rs @@ -0,0 +1,395 @@ +//! A small Markdown reader for assistant replies: paragraphs, headings, lists, quotes, fenced +//! code, rules, and bold, italic, code and link spans. Tables are kept as preformatted text. +//! +//! It takes untrusted text and never fails: anything it does not understand stays text, and the +//! amount of work and output is bounded. + +/// Longest input parsed; the rest is ignored. +pub const MAX_INPUT: usize = 256 * 1024; +/// Most blocks produced. +pub const MAX_BLOCKS: usize = 4000; +/// Longest link target kept. +const MAX_URL: usize = 2048; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Span { + Text(String), + Bold(String), + Italic(String), + Code(String), + /// A link with an `http` or `https` target; any other target is shown as its label only. + Link { + label: String, + url: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Block { + Paragraph(Vec), + Heading { + level: u8, + spans: Vec, + }, + Code { + lang: String, + text: String, + }, + List { + ordered: bool, + start: u64, + items: Vec>, + }, + Quote(Vec), + /// Preformatted text (tables). + Pre(String), + Rule, +} + +/// Parse `src` into blocks. +pub fn parse(src: &str) -> Vec { + let src = cut(src, MAX_INPUT); + let lines: Vec<&str> = src.lines().collect(); + let mut out: Vec = Vec::new(); + let mut i = 0; + let mut para: Vec<&str> = Vec::new(); + macro_rules! flush { + () => { + if !para.is_empty() { + out.push(Block::Paragraph(spans(¶.join(" ")))); + para.clear(); + } + }; + } + while i < lines.len() && out.len() < MAX_BLOCKS { + let line = lines[i]; + let t = line.trim_start(); + if let Some(fence) = fence_of(t) { + flush!(); + let lang = t[fence.len()..].trim().to_string(); + let mut body = Vec::new(); + i += 1; + while i < lines.len() && !lines[i].trim_start().starts_with(&fence) { + body.push(lines[i]); + i += 1; + } + i += 1; + out.push(Block::Code { lang, text: body.join("\n") }); + continue; + } + if t.is_empty() { + flush!(); + i += 1; + continue; + } + if is_rule(t) { + flush!(); + out.push(Block::Rule); + i += 1; + continue; + } + if let Some((level, rest)) = heading(t) { + flush!(); + out.push(Block::Heading { level, spans: spans(rest) }); + i += 1; + continue; + } + if t.starts_with('|') { + flush!(); + let mut rows = Vec::new(); + while i < lines.len() && lines[i].trim_start().starts_with('|') { + rows.push(lines[i].trim_end()); + i += 1; + } + out.push(Block::Pre(rows.join("\n"))); + continue; + } + if let Some(rest) = t.strip_prefix('>') { + flush!(); + let mut q = vec![rest.trim()]; + i += 1; + while i < lines.len() && lines[i].trim_start().starts_with('>') { + q.push(lines[i].trim_start()[1..].trim()); + i += 1; + } + out.push(Block::Quote(spans(&q.join(" ")))); + continue; + } + if let Some((ordered, n, rest)) = list_item(t) { + flush!(); + let mut items = vec![spans(rest)]; + i += 1; + while i < lines.len() { + let l = lines[i].trim_start(); + match list_item(l) { + Some((o, _, r)) if o == ordered => { + items.push(spans(r)); + i += 1; + } + // A wrapped line of the previous item. + None if !l.is_empty() && lines[i].starts_with(' ') && fence_of(l).is_none() && !l.starts_with('|') => { + if let Some(last) = items.last_mut() { + last.push(Span::Text(" ".into())); + last.extend(spans(l)); + } + i += 1; + } + _ => break, + } + if items.len() >= MAX_BLOCKS { + break; + } + } + out.push(Block::List { ordered, start: n, items }); + continue; + } + para.push(t); + i += 1; + } + flush!(); + out +} + +fn cut(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut e = max; + while e > 0 && !s.is_char_boundary(e) { + e -= 1; + } + &s[..e] +} + +fn fence_of(t: &str) -> Option { + let c = t.chars().next().filter(|c| *c == '`' || *c == '~')?; + let n = t.chars().take_while(|x| *x == c).count(); + (n >= 3).then(|| c.to_string().repeat(n)) +} + +fn is_rule(t: &str) -> bool { + let s: String = t.chars().filter(|c| !c.is_whitespace()).collect(); + s.len() >= 3 && (s.chars().all(|c| c == '-') || s.chars().all(|c| c == '*') || s.chars().all(|c| c == '_')) +} + +fn heading(t: &str) -> Option<(u8, &str)> { + let n = t.chars().take_while(|c| *c == '#').count(); + if (1..=6).contains(&n) && t[n..].starts_with(' ') { Some((n as u8, t[n..].trim())) } else { None } +} + +fn list_item(t: &str) -> Option<(bool, u64, &str)> { + for m in ["- ", "* ", "+ "] { + if let Some(r) = t.strip_prefix(m) { + return Some((false, 1, r.trim())); + } + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + if (1..=9).contains(&digits) { + let rest = &t[digits..]; + if let Some(r) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) { + return Some((true, t[..digits].parse().unwrap_or(1), r.trim())); + } + } + None +} + +/// Inline spans of `s`. +pub fn spans(s: &str) -> Vec { + let c: Vec = s.chars().collect(); + let mut out: Vec = Vec::new(); + let mut text = String::new(); + let mut i = 0; + fn flush(out: &mut Vec, text: &mut String) { + if !text.is_empty() { + out.push(Span::Text(std::mem::take(text))); + } + } + while i < c.len() { + let rest = &c[i..]; + // Inline code. + if c[i] == '`' { + let n = rest.iter().take_while(|x| **x == '`').count(); + if let Some(end) = find_run(&c, i + n, '`', n) { + flush(&mut out, &mut text); + out.push(Span::Code(c[i + n..end].iter().collect::().trim().to_string())); + i = end + n; + continue; + } + } + // Bold. + if (rest.starts_with(&['*', '*']) || rest.starts_with(&['_', '_'])) && rest.len() > 4 { + let d = c[i]; + if let Some(end) = find_pair(&c, i + 2, d) + && end > i + 2 + { + flush(&mut out, &mut text); + out.push(Span::Bold(c[i + 2..end].iter().collect())); + i = end + 2; + continue; + } + } + // Italic (not inside words for `_`, so snake_case stays text). + if (c[i] == '*' || (c[i] == '_' && (i == 0 || !c[i - 1].is_alphanumeric()))) && rest.len() > 2 && !rest[1].is_whitespace() { + let d = c[i]; + let closes = |j: &usize| c[*j] == d && !c[*j - 1].is_whitespace() && (d == '*' || c.get(*j + 1).is_none_or(|n| !n.is_alphanumeric())); + if let Some(end) = (i + 1..c.len()).find(closes) + && end > i + 1 + { + flush(&mut out, &mut text); + out.push(Span::Italic(c[i + 1..end].iter().collect())); + i = end + 1; + continue; + } + } + // Link. + if c[i] == '[' + && let Some(close) = c[i + 1..].iter().position(|x| *x == ']').map(|p| p + i + 1) + && c.get(close + 1) == Some(&'(') + && let Some(end) = c[close + 2..].iter().position(|x| *x == ')').map(|p| p + close + 2) + { + let label: String = c[i + 1..close].iter().collect(); + let url: String = c[close + 2..end].iter().collect::().trim().to_string(); + flush(&mut out, &mut text); + let lower = url.to_ascii_lowercase(); + let web = lower.starts_with("https://") || lower.starts_with("http://"); + if web && url.len() <= MAX_URL && !url.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + out.push(Span::Link { label, url }); + } else { + out.push(Span::Text(label)); + } + i = end + 1; + continue; + } + // A backslash escapes the next marker. + if c[i] == '\\' && c.get(i + 1).is_some_and(|n| "\\`*_[]()#-".contains(*n)) { + text.push(c[i + 1]); + i += 2; + continue; + } + text.push(c[i]); + i += 1; + } + flush(&mut out, &mut text); + out +} + +/// The index of the next run of exactly `n` `ch` at or after `from`. +fn find_run(c: &[char], from: usize, ch: char, n: usize) -> Option { + let mut j = from; + while j < c.len() { + if c[j] == ch { + let run = c[j..].iter().take_while(|x| **x == ch).count(); + if run == n { + return Some(j); + } + j += run; + } else { + j += 1; + } + } + None +} + +fn find_pair(c: &[char], from: usize, d: char) -> Option { + (from..c.len().saturating_sub(1)).find(|&j| c[j] == d && c[j + 1] == d) +} + +/// The plain text of spans, for copying and for exports. +pub fn plain(spans: &[Span]) -> String { + spans + .iter() + .map(|s| match s { + Span::Text(t) | Span::Bold(t) | Span::Italic(t) | Span::Code(t) => t.clone(), + Span::Link { label, url } => format!("{label} ({url})"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(s: &str) -> Span { + Span::Text(s.into()) + } + + #[test] + fn blocks() { + let md = "# Title\n\nSome *emphasis* and **bold** with `code`.\nSecond line.\n\n- one\n- two\n wrapped\n\n1. a\n2. b\n\n> quoted\n\n---\n\n```rust\nlet x = 1;\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let b = parse(md); + assert_eq!(b[0], Block::Heading { level: 1, spans: vec![t("Title")] }); + assert_eq!( + b[1], + Block::Paragraph(vec![ + t("Some "), + Span::Italic("emphasis".into()), + t(" and "), + Span::Bold("bold".into()), + t(" with "), + Span::Code("code".into()), + t(". Second line.") + ]) + ); + assert_eq!(b[2], Block::List { ordered: false, start: 1, items: vec![vec![t("one")], vec![t("two"), t(" "), t("wrapped")]] }); + assert_eq!(b[3], Block::List { ordered: true, start: 1, items: vec![vec![t("a")], vec![t("b")]] }); + assert_eq!(b[4], Block::Quote(vec![t("quoted")])); + assert_eq!(b[5], Block::Rule); + assert_eq!(b[6], Block::Code { lang: "rust".into(), text: "let x = 1;".into() }); + assert!(matches!(&b[7], Block::Pre(s) if s.lines().count() == 3)); + assert_eq!(b.len(), 8); + } + + #[test] + fn snake_case_and_stray_markers_stay_text() { + assert_eq!(spans("use set_parameter and body_topology"), vec![t("use set_parameter and body_topology")]); + assert_eq!(spans("2 * 3 * 4"), vec![t("2 * 3 * 4")]); + assert_eq!(spans("unclosed **bold"), vec![t("unclosed **bold")]); + assert_eq!(spans("a `b"), vec![t("a `b")]); + assert_eq!(spans(r"\*not italic\*"), vec![t("*not italic*")]); + } + + #[test] + fn only_web_links_are_links() { + assert_eq!(spans("[docs](https://example.com/a)"), vec![Span::Link { label: "docs".into(), url: "https://example.com/a".into() }]); + for bad in ["[x](javascript:alert(1))", "[x](file:///etc/passwd)", "[x](ftp://a)", "[x](/relative)", "[x](https://a b)"] { + let s = spans(bad); + assert_eq!(s[0], t("x"), "{bad}"); + assert!(!s.iter().any(|x| matches!(x, Span::Link { .. })), "{bad}"); + } + } + + #[test] + fn code_blocks_keep_everything() { + let b = parse("```\n# not a heading\n- not a list\n```"); + assert_eq!(b, vec![Block::Code { lang: String::new(), text: "# not a heading\n- not a list".into() }]); + // An unterminated fence runs to the end. + let b = parse("```\nstill code"); + assert_eq!(b, vec![Block::Code { lang: String::new(), text: "still code".into() }]); + } + + #[test] + fn hostile_input_is_bounded_and_never_panics() { + let mut seed = 0x1234_5678_u32; + let alphabet: Vec = "*_`[]()#->|\\~ \n\t1.ä🌍a".chars().collect(); + for _ in 0..300 { + let mut s = String::new(); + for _ in 0..200 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + s.push(alphabet[(seed >> 16) as usize % alphabet.len()]); + } + let _ = parse(&s); + } + let huge = "- x\n".repeat(MAX_INPUT); + let blocks = parse(&huge); + assert!(blocks.len() <= MAX_BLOCKS); + let nested = "*".repeat(100_000); + let _ = parse(&nested); + let _ = parse(&"[".repeat(100_000)); + let _ = parse(&"`a".repeat(50_000)); + } + + #[test] + fn plain_text_of_spans() { + assert_eq!(plain(&spans("**a** [b](https://c.d) `e`")), "a b (https://c.d) e"); + } +} diff --git a/crates/ui-egui/src/agent_chat/markdown_view.rs b/crates/ui-egui/src/agent_chat/markdown_view.rs new file mode 100644 index 00000000..2d8d6ebf --- /dev/null +++ b/crates/ui-egui/src/agent_chat/markdown_view.rs @@ -0,0 +1,138 @@ +//! An assistant's text as the panel shows it: Markdown (paragraphs, headings, lists, quotes, +//! fenced code, bold, italic, code, links), parsed by [`super::markdown`]. +//! +//! Parsing and laying out a long reply every frame would show in the frame time while it streams, +//! so the parsed blocks are kept per conversation item and parsed again only when its text +//! changes. + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use super::markdown::{self, Block, Span}; +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, FontId, Margin, RichText, Stroke, vec2}; + +use crate::i18n::tr; +use crate::theme::Tokens; + +/// The parsed blocks of `src`, kept in the context's temporary memory under `slot` (the item's +/// index) and replaced when the text changes. +fn blocks(ctx: &egui::Context, slot: usize, src: &str) -> Arc> { + let mut h = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut h); + let key = h.finish(); + let id = egui::Id::new(("agent_chat_markdown", slot)); + if let Some((k, b)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) + && k == key + { + return b; + } + let parsed = Arc::new(markdown::parse(src)); + ctx.data_mut(|d| d.insert_temp(id, (key, parsed.clone()))); + parsed +} + +fn job(ui: &egui::Ui, spans: &[Span], size: f32, color: Color32) -> LayoutJob { + let t = Tokens::get(); + let mut j = LayoutJob::default(); + j.wrap.max_width = ui.available_width(); + let base = FontId::proportional(size); + for s in spans { + let plain = TextFormat { font_id: base.clone(), color, ..Default::default() }; + match s { + Span::Text(x) => j.append(x, 0.0, plain), + Span::Bold(x) => j.append(x, 0.0, TextFormat { color: t.text, ..plain }), + Span::Italic(x) => j.append(x, 0.0, TextFormat { italics: true, ..plain }), + Span::Code(x) => j.append(x, 0.0, TextFormat { font_id: FontId::monospace(size - 1.0), background: t.field, ..plain }), + Span::Link { label, .. } => j.append(label, 0.0, TextFormat { color: t.accent, underline: Stroke::new(1.0, t.accent), ..plain }), + } + } + j +} + +/// The host of a web address: `example.com` for `https://example.com/a?b`. +fn host(url: &str) -> &str { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + rest.split(['/', '?', '#']).next().unwrap_or(rest) +} + +/// A paragraph's text, then its links as buttons (a laid-out text cannot be clicked in parts). +fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) { + ui.add(egui::Label::new(job(ui, spans, size, color)).selectable(true).wrap()); + let links: Vec<(&str, &str)> = + spans.iter().filter_map(|s| if let Span::Link { label, url } = s { Some((label.as_str(), url.as_str())) } else { None }).collect(); + if !links.is_empty() { + ui.horizontal_wrapped(|ui| { + for (label, url) in links { + // The text above already shows the label; the button says where it goes. + let to = format!("↗ {}", host(url)); + ui.hyperlink_to(RichText::new(to).size(size - 1.0), url).on_hover_text(format!("{label}: {url}")); + } + }); + } +} + +/// Draw `src` as Markdown. `slot`: the conversation item it belongs to. +pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { + let t = Tokens::get(); + let size = 13.0; + let parsed = blocks(ui.ctx(), slot, src); + for block in parsed.iter() { + match block { + Block::Paragraph(s) => spans(ui, s, size, t.text), + Block::Heading { level, spans: s } => { + let size = match level { + 1 => 17.0, + 2 => 15.0, + _ => 14.0, + }; + ui.add_space(4.0); + ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).strong().color(t.text)).selectable(true).wrap()); + } + Block::Code { text, .. } => { + egui::Frame::NONE.fill(t.field).corner_radius(4.0).inner_margin(Margin::same(6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 1.0).color(t.text)).selectable(true).wrap()); + if ui.small_button(tr("ui.agent_chat_copy")).clicked() { + ui.ctx().copy_text(text.clone()); + } + }); + } + Block::List { ordered, start, items } => { + for (i, item) in items.iter().enumerate() { + ui.horizontal_top(|ui| { + let mark = if *ordered { format!("{}.", start.saturating_add(i as u64)) } else { "•".to_string() }; + ui.label(RichText::new(mark).size(size).color(t.text_dim)); + ui.vertical(|ui| spans(ui, item, size, t.text)); + }); + } + } + Block::Quote(s) => { + ui.horizontal_top(|ui| { + let (bar, _) = ui.allocate_exact_size(vec2(3.0, 16.0), egui::Sense::hover()); + ui.painter().rect_filled(bar, 1.0, t.border); + ui.vertical(|ui| spans(ui, s, size, t.text_dim)); + }); + } + Block::Pre(text) => { + egui::ScrollArea::horizontal().id_salt(("agent_chat_pre", slot, text.len())).show(ui, |ui| { + ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 2.0).color(t.text)).selectable(true)); + }); + } + Block::Rule => { + ui.separator(); + } + } + ui.add_space(3.0); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_link_button_names_the_host() { + assert_eq!(super::host("https://example.com/a?b#c"), "example.com"); + assert_eq!(super::host("http://localhost:8080/x"), "localhost:8080"); + assert_eq!(super::host("https://a.b"), "a.b"); + } +} diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index e4be8689..2734ccfc 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -1,5 +1,5 @@ //! Agent chat: a side panel that talks to a coding agent the user already has (Claude Code, -//! Codex, Gemini CLI, OpenCode, …) over the Agent Client Protocol, and gives that agent +//! Codex, Antigravity, OpenCode, …) over the Agent Client Protocol, and gives that agent //! SolidCraft's MCP server, bridged to this window, as its way to read and change the open design //! (docs/agent-chat.md, docs/design/in-app-agent-chat.md). //! @@ -26,6 +26,8 @@ pub mod backend; #[cfg(not(target_arch = "wasm32"))] pub mod discover; pub mod gate; +mod markdown; +mod markdown_view; mod panel; #[cfg(not(target_arch = "wasm32"))] mod process; diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 087a26dc..ad70ac13 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -639,8 +639,11 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { }); } Item::Agent { thought, text } => { - let rt = if *thought { RichText::new(text).italics().color(t.text_dim) } else { RichText::new(text).color(t.text) }; - ui.add(egui::Label::new(rt).selectable(true).wrap()); + if *thought { + ui.add(egui::Label::new(RichText::new(text).italics().color(t.text_dim)).selectable(true).wrap()); + } else { + super::markdown_view::show(ui, n, text); + } } Item::Tool { title, kind, status, output, .. } => { tool_card(ui, n, title, kind, *status, output.as_deref()); diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 52eb2a90..fb380f0f 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -756,3 +756,22 @@ done disconnect(&mut app); let _ = std::fs::remove_dir_all(&dir); } + +/// An agent's text is Markdown: a code block shows with its Copy button and a link is a button +/// of its own, instead of the raw markers. +#[test] +fn an_agents_markdown_is_drawn_as_blocks() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.agent_chat", json!({"open": true})); + h.frames(3); + h.app.agent_chat.items.push(Item::Agent { + thought: false, + text: "# Result\n\n- a **bold** point\n- another\n\n```\nlet x = 1;\n```\n\nSee [the docs](https://example.com/a).".into(), + }); + h.frames(3); + assert!(h.widget("Copy").is_some(), "the code block has its Copy button"); + assert!(h.widget("↗ example.com").is_some(), "the link is a button that names where it goes"); + // A hostile reply draws too (no panic, bounded work). + h.app.agent_chat.items.push(Item::Agent { thought: false, text: "*".repeat(50_000) + &"[".repeat(5_000) + &"`a".repeat(5_000) }); + h.frames(3); +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 39df45a2..dd4c35b1 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -10,6 +10,13 @@ #[path = "occurrence_ui_tests.rs"] mod occurrence_ui_tests; +#[path = "browser_model.rs"] +mod model; + +#[cfg(test)] +#[path = "browser_model_tests.rs"] +mod model_tests; + use crate::icons::glyphs::badge; use std::collections::BTreeSet; @@ -42,6 +49,8 @@ pub fn drawn_occurrences() -> Vec<(Vec, String, Rect, bool)> { /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { + /// Geometry and assembly queries, independent of the camera, selection and row visibility. + model: Option, /// Folders closed by the user (keys like `c0/bodies`). pub collapsed: BTreeSet, /// Folders opened by the user (folders that start closed). @@ -547,7 +556,60 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) { browser_rows(app, ui, acts); } +fn prepare_model(app: &mut SolveApp) { + if app.tree.model.as_ref().is_none_or(|model| !model.matches(&app.session)) { + app.tree.model = Some(model::Model::new(&app.session)); + } +} + +fn model_placement(app: &mut SolveApp, path: &[u64]) -> Option> { + prepare_model(app); + app.tree.model.as_mut()?.placement(&app.session, path) +} + +fn model_children(app: &SolveApp, component: u64) -> Vec { + match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) { + Some(model) => model.children.get(&component).cloned().unwrap_or_default(), + None => app.session.doc.occurrences.iter().filter(|o| o.parent == component).map(|o| o.id).collect(), + } +} + +fn model_body_indices(app: &SolveApp, component: u64) -> Vec { + match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) { + Some(model) => model.bodies.get(&component).cloned().unwrap_or_default(), + None => app + .session + .model + .state() + .bodies + .iter() + .enumerate() + .filter(|(_, b)| app.session.doc.model_body_component(b) == component) + .map(|(i, _)| i) + .collect(), + } +} + +fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec { + match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) { + Some(model) => (if sketches { &model.sketches } else { &model.planes }).get(&component).cloned().unwrap_or_default(), + None => app + .session + .doc + .features + .iter() + .enumerate() + .filter_map(|(i, f)| { + let kind = + if sketches { matches!(f.kind, FeatureKind::Sketch { .. }) } else { matches!(f.kind, FeatureKind::ConstructionPlane { .. }) }; + (f.component == component && kind).then_some(i) + }) + .collect(), + } +} + fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) { + prepare_model(app); egui::ScrollArea::vertical() .auto_shrink([false, false]) .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE }) @@ -668,7 +730,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, if id == 0 { crate::dialogs_assembly::browser_joints(app, ui, d); } - let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == id).map(|o| o.id).collect(); + let children = model_children(app, id); for occurrence in children { occurrence_rows(app, ui, vec![occurrence], d, acts); } @@ -681,7 +743,7 @@ fn occurrence_key(path: &[u64]) -> String { /// A placement, rather than a component definition: repeated child IDs remain distinct /// because their complete paths are the selection and row identity. fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: usize, acts: &mut Actions) { - let Ok(placement) = app.session.occurrence_placement(&path) else { return }; + let Some(placement) = model_placement(app, &path) else { return }; let key = occurrence_key(&path); let open = is_open(app, &key, true); let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice()) @@ -765,7 +827,7 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: for folder in ["bodies", "sketches", "construction"] { occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts); } - let children: Vec = app.session.doc.occurrences.iter().filter(|o| o.parent == placement.component).map(|o| o.id).collect(); + let children = model_children(app, placement.component); if path.len() < 64 { for occurrence in children { let mut child = path.clone(); @@ -1246,14 +1308,12 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { let t = Tokens::get(); let doc = &app.session.doc; let sel = &app.session.selection; + let state = app.session.model.state(); match folder { - "bodies" => app - .session - .model - .state() - .bodies - .iter() - .filter(|b| doc.model_body_component(b) == comp && !app.session.body_removed_later(&b.name)) + "bodies" => model_body_indices(app, comp) + .into_iter() + .filter_map(|i| state.bodies.get(i)) + .filter(|b| !app.session.body_removed_later(&b.name)) .map(|b| Entry { key: b.name.clone(), row_key: format!("b:{}", b.name), @@ -1268,10 +1328,9 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { bodies: vec![b.name.clone()], }) .collect(), - "sketches" => doc - .features - .iter() - .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) + "sketches" => model_feature_indices(app, comp, true) + .into_iter() + .filter_map(|i| doc.features.get(i)) .map(|f| { let active = app.session.active_sketch == Some(f.id); let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some()) @@ -1297,15 +1356,14 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec { } }) .collect(), - _ => doc - .features - .iter() - .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) + _ => model_feature_indices(app, comp, false) + .into_iter() + .filter_map(|i| doc.features.get(i)) .map(|f| Entry { key: f.id.to_string(), row_key: format!("p:{}", f.id), label: f.name.clone(), - icon: "plane", + icon: crate::timeline::icon_of(&f.kind), visible: !app.session.visibility.hidden_origin.contains(&f.name), selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)), color: None, @@ -1396,7 +1454,7 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option let mut all = entries(app, comp, folder); let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}")); if let Some(path) = path { - let Ok(placement) = app.session.occurrence_placement(path) else { return }; + let Some(placement) = model_placement(app, path) else { return }; // Each row's placed body by its definition's name (a search per row was quadratic in a // component's bodies, every frame). let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len()); diff --git a/crates/ui-egui/src/browser_model.rs b/crates/ui-egui/src/browser_model.rs new file mode 100644 index 00000000..5505d817 --- /dev/null +++ b/crates/ui-egui/src/browser_model.rs @@ -0,0 +1,64 @@ +//! Model-only browser queries, reused while navigating. Resolving a placement walks the +//! definition's bodies; doing that for every offscreen row and folder made large imports +//! quadratic on every frame. Keep the document and evaluated state alive with the index so +//! neither a new document nor a later allocation can accidentally reuse its answers. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use solidcraft_engine::Session; +use solidcraft_engine::doc::{Document, FeatureKind, Mat, ModelState}; +use solidcraft_engine::occurrences::OccurrencePlacement; + +pub(super) struct Model { + doc: Arc, + state: Arc, + pending_moves: BTreeMap, + pub bodies: HashMap>, + pub sketches: HashMap>, + pub planes: HashMap>, + pub children: HashMap>, + placements: HashMap, Option>>, +} + +impl Model { + pub fn new(session: &Session) -> Self { + let state = session.model.state(); + let mut bodies: HashMap> = HashMap::new(); + for (i, body) in state.bodies.iter().enumerate() { + bodies.entry(session.doc.model_body_component(body)).or_default().push(i); + } + let mut sketches: HashMap> = HashMap::new(); + let mut planes: HashMap> = HashMap::new(); + for (i, feature) in session.doc.features.iter().enumerate() { + let index = match feature.kind { + FeatureKind::Sketch { .. } => &mut sketches, + FeatureKind::ConstructionPlane { .. } => &mut planes, + _ => continue, + }; + index.entry(feature.component).or_default().push(i); + } + let mut children: HashMap> = HashMap::new(); + for occurrence in &session.doc.occurrences { + children.entry(occurrence.parent).or_default().push(occurrence.id); + } + Self { + doc: session.doc.clone(), + state, + pending_moves: session.pending_moves.clone(), + bodies, + sketches, + planes, + children, + placements: HashMap::new(), + } + } + + pub fn matches(&self, session: &Session) -> bool { + Arc::ptr_eq(&self.doc, &session.doc) && Arc::ptr_eq(&self.state, &session.model.state()) && self.pending_moves == session.pending_moves + } + + pub fn placement(&mut self, session: &Session, path: &[u64]) -> Option> { + self.placements.entry(path.to_vec()).or_insert_with(|| session.occurrence_placement(path).ok().map(Arc::new)).clone() + } +} diff --git a/crates/ui-egui/src/browser_model_tests.rs b/crates/ui-egui/src/browser_model_tests.rs new file mode 100644 index 00000000..8f4ceadc --- /dev/null +++ b/crates/ui-egui/src/browser_model_tests.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use serde_json::json; +use solidcraft_engine::Sel; + +use crate::scenario::Harness; + +fn fixture() -> (Harness, u64, u64) { + let mut h = Harness::new(); + let part = h.app.run("component.create", json!({"name":"Block"})).unwrap(); + let component = part["component"].as_u64().unwrap(); + let occurrence = part["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + (h, component, occurrence) +} + +#[test] +fn navigating_and_selecting_reuse_placements_but_rows_read_live_view_state() { + let (mut h, component, occurrence) = fixture(); + let first = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + h.app.cam.yaw += 0.25; + h.app.run("select.set", json!({"items":[Sel::Body{name:"Body1".into()}]})).unwrap(); + h.app.session.visibility.hidden_bodies.push("Body1".into()); + h.app.ui.locked_bodies.push("Body1".into()); + let after = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + assert!(Arc::ptr_eq(&first, &after), "view-only changes resolved the same placement again"); + let rows = super::entries(&h.app, component, "bodies"); + assert_eq!(rows.len(), 1); + assert!(rows[0].selected && rows[0].locked && !rows[0].visible); + h.frames(2); + assert!(Arc::ptr_eq(&after, &super::model_placement(&mut h.app, &[occurrence]).unwrap())); +} + +#[test] +fn pending_and_reverted_moves_replace_cached_transforms() { + let (mut h, _, occurrence) = fixture(); + let first = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + h.app.run("occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0],"capture":false})).unwrap(); + let moved = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + assert!(!Arc::ptr_eq(&first, &moved)); + assert!((solidcraft_engine::doc::apply_point(&moved.world, solidcraft_engine::geom::Vec3::ZERO).x - 20.0).abs() < 1e-9); + h.app.run("component.revert_position", json!({"occurrences":[occurrence]})).unwrap(); + let reverted = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + assert!(!Arc::ptr_eq(&moved, &reverted)); + assert_eq!(first.world, reverted.world); +} + +#[test] +fn feature_indices_follow_component_edits_and_undo() { + let (mut h, component, _) = fixture(); + super::prepare_model(&mut h.app); + assert!(super::model_feature_indices(&h.app, 0, true).is_empty()); + h.app.run("sketch.create", json!({"plane":"XY", "name":"Root sketch"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + super::prepare_model(&mut h.app); + assert_eq!(super::entries(&h.app, 0, "sketches")[0].label, "Root sketch"); + assert!(super::entries(&h.app, component, "sketches").is_empty()); + h.app.run("component.activate", json!({"component":component})).unwrap(); + h.app.run("sketch.create", json!({"plane":"YZ", "name":"Part sketch"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + super::prepare_model(&mut h.app); + assert_eq!(super::entries(&h.app, component, "sketches")[0].label, "Part sketch"); + assert_eq!(super::entries(&h.app, 0, "sketches").len(), 1); + h.app.run("edit.undo", json!({})).unwrap(); + super::prepare_model(&mut h.app); + assert!(super::entries(&h.app, component, "sketches").is_empty()); + assert_eq!(super::entries(&h.app, 0, "sketches").len(), 1); +} + +#[test] +fn body_index_and_placements_follow_geometry_undo_and_document_identity() { + let (mut h, component, occurrence) = fixture(); + let first = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + let copy = h.app.run("occurrence.copy", json!({"occurrence":occurrence,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + let second = super::model_placement(&mut h.app, &[copy]).unwrap(); + assert_ne!(first.path, second.path); + assert_ne!(first.bodies[0].world, second.bodies[0].world); + assert_eq!(super::model_children(&h.app, 0), [occurrence, copy]); + h.app.run("component.activate", json!({"component":component})).unwrap(); + h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0]})).unwrap(); + let changed = super::model_placement(&mut h.app, &[occurrence]).unwrap(); + assert_eq!(changed.bodies.len(), 2); + assert_eq!(super::entries(&h.app, component, "bodies").len(), 2); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(super::model_placement(&mut h.app, &[occurrence]).unwrap().bodies.len(), 1); + assert_eq!(super::entries(&h.app, component, "bodies").len(), 1); + // A separate session can have the same revision and the same occurrence ids. + h.app.session = solidcraft_engine::Session::default(); + assert!(super::model_placement(&mut h.app, &[occurrence]).is_none()); + assert!(super::model_children(&h.app, 0).is_empty()); + assert!(super::entries(&h.app, component, "bodies").is_empty()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 300a5d45..52c677f8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -522,8 +522,20 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec }); } +/// How far (logical points) from an arrow's stem a press still takes the arrow. +const ARROW_STEM_REACH: f32 = 6.0; + +/// The distance from `p` to the segment from `a` to `b` on screen. +fn to_segment(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let v = b - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) +} + /// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes -/// the snapped length into `value`. Returns the tip's screen position. +/// the snapped length into `value`. The tip and the stem take the drag (#1740): pressed on the +/// stem, the arrow keeps its length until the pointer moves, and then moves with it as far as the +/// pointer does. Returns the tip's screen position. #[allow(clippy::too_many_arguments)] fn drag_arrow( ui: &mut egui::Ui, @@ -545,11 +557,28 @@ fn drag_arrow( proj.to_screen(base + n * shown) }; let mut ts = tip(l)?; - let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag()); + let at_tip = egui::Rect::from_center_size(ts, vec2(20.0, 20.0)); + // Over the stem, the arrow answers where the pointer is. + let on_stem = ui.input(|i| i.pointer.hover_pos()).filter(|p| !at_tip.contains(*p) && to_segment(*p, bs, ts) <= ARROW_STEM_REACH); + let area = on_stem.map_or(at_tip, |p| egui::Rect::from_center_size(p, vec2(2.0 * ARROW_STEM_REACH, 2.0 * ARROW_STEM_REACH))); + let r = ui.interact(area, id, egui::Sense::drag()); + // How far along the arrow the press was short of its end: the drag keeps that gap. + let grab = id.with("stem grab"); + if r.drag_started() { + let gap = on_stem.zip(r.interact_pointer_pos()).and_then(|(_, p)| along(proj, p, base, n)).map(|v| v - l); + ui.data_mut(|d| { + if let Some(g) = gap.filter(|g| g.is_finite()) { + d.insert_temp(grab, g); + } else { + d.remove::(grab); + } + }); + } if r.dragged() && let Some(p) = r.interact_pointer_pos() && let Some(v) = along(proj, p, base, n) { + let v = v - ui.data(|d| d.get_temp::(grab)).unwrap_or(0.0); let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); let step = snap_step(half_height); let mut v = if free { v } else { (v / step).round() * step }; diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index e4fbc6df..f6fc0bb5 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -2,11 +2,11 @@ //! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that //! the tokens equal what was drawn before, and that a new colour cannot skip them. -use egui::{Color32, Pos2, Rect, vec2}; +use egui::Color32; use solidcraft_engine::render::Rgb; use solidcraft_engine::view::colors; -use crate::theme::{IconColors, Tokens}; +use crate::theme::Tokens; fn rgb(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) @@ -56,103 +56,10 @@ fn analysis_colours_reach_the_shader_in_its_order() { assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0); } -/// Every name a `match` arm in the icon source draws. -fn icon_names() -> Vec<&'static str> { - let src = include_str!("../../../assets/icons/ui/source/icons.rs"); - let body = src.split("#[cfg(test)]").next().unwrap_or(src); - let mut names = Vec::new(); - for line in body.lines() { - let line = line.trim_start(); - if !line.starts_with('"') { - continue; - } - let Some((pattern, _)) = line.split_once("=>") else { continue }; - let pattern = pattern.split(" if ").next().unwrap_or(pattern); - for part in pattern.split('|') { - let part = part.trim(); - if part.len() > 2 && part.starts_with('"') && part.ends_with('"') { - names.push(&part[1..part.len() - 1]); - } - } - } - names.sort_unstable(); - names.dedup(); - names -} - -/// Marker icon colours: every field its own colour, from `seed`. -fn marker_icons(seed: u8) -> IconColors { - let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i); - IconColors { - sheet: c(1), - sheet_2: c(2), - sheet_light: c(3), - sheet_line: c(4), - highlight: c(5), - glint: c(6), - folder: c(7), - plane: c(8), - fold: c(9), - stripe: c(10), - sky: c(11), - floor: c(12), - paint: [c(13), c(14), c(15)], - axes: [c(16), c(17), c(18)], - access: [c(19), c(20)], - draft: [c(21), c(22)], - curvature: [c(23), c(24), c(25)], - interference: c(26), - warning: c(27), - agent: c(28), - marker_halo: c(29), - } -} - -/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order). -fn drawn_colours(name: &str, seed: u8) -> Vec { - let ctx = egui::Context::default(); - let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() }; - let icons = marker_icons(seed); - let out = ctx.run_ui(input, |ui| { - let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)); - let ink = Color32::from_rgb(seed, 101, 1); - let fill = Color32::from_rgb(seed, 102, 2); - let accent = Color32::from_rgb(seed, 103, 3); - crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons); - }); - let text = format!("{:?}", out.shapes); - let b = text.as_bytes(); - let mut colours = Vec::new(); - for i in 0..b.len() { - let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit); - if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) { - colours.push(text[i..i + 12].to_string()); - } - } - colours -} - -/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the -/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made -/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the -/// same both times was written into the icon, and a mode could not reach it. -#[test] -fn every_icon_draws_only_colours_a_mode_can_reach() { - let names = icon_names(); - assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len()); - let mut fixed = Vec::new(); - for name in &names { - let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200)); - assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape"); - for (x, y) in a.iter().zip(&b) { - if x == y && !x.ends_with("_00") { - fixed.push(format!("{name}: {x}")); - } - } - } - fixed.dedup(); - assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}"); -} +// Icons no longer take their colours from the caller or from `IconColors`: the SVG masters name +// palette roles (`assets/icons/ui/design/palette.toml`, light and dark, plus accents) that +// `icon_svg` resolves. `icon_svg`'s own tests hold that every master parses and that only the +// palette's colours exist; the colour vision modes do not reach them yet. /// The most colour literals each UI file may hold, and why. None of them carries a meaning that a /// colour vision mode must change. A file with more fails here: make the new colour a token in @@ -171,6 +78,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[ ("documents.rs", 4, "white hover overlays on document tabs"), ("frame.rs", 1, "the window shadow"), ("gizmo.rs", 2, "the gizmo's neutral grey ball"), + ("icons.rs", 1, "the white tint that carries an icon image's opacity"), ("home.rs", 4, "white ink on accent buttons, and thumbnail tints"), ("ref_images.rs", 1, "a reference image's opacity tint"), ("sketch_palette.rs", 1, "white text on the accent button"), diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index c0ea5f5c..cbb98da3 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -4,8 +4,9 @@ //! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published //! with their paper, Brettel on white and its own anchors. //! -//! When a token edit breaks a mode here, give that token a value in the mode's table -//! (`assets/styles/ui/palettes.rs`) that passes. +//! When a token edit breaks a mode here, adjust its measured variant. Shared icon accents live +//! in `assets/icons/ui/design/palette.toml`; `assets/styles/ui/palettes.rs` links those generated +//! values to UI tokens and owns the remaining UI-only colour-vision adjustments. use std::collections::BTreeSet; @@ -291,11 +292,22 @@ fn groups(t: &Tokens) -> Vec { ("body_selected", t.body_selected), ("hover_face_lift", lift(t.body)), ("preview_add", t.preview_add), + ("preview_new", t.preview_new), ], &[], ), group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]), group("interference", &[("interference", t.interference)], &[("body", t.body)]), + group( + "continuity seams", + &[ + ("continuity[0]", t.continuity[0]), + ("continuity[1]", t.continuity[1]), + ("continuity[2]", t.continuity[2]), + ("continuity[3]", t.continuity[3]), + ], + &[("body", t.body), ("viewport_top", t.viewport_top)], + ), // The scale's three stops, unlit. Shaded (a stop at one angle to the light, the next at // another), middle and tight merge for a red-green loss (3.3 protan, 5.2 deutan) and flat // and middle for tritan (4.6). That is the scale's own fault, in Off too: #975. @@ -627,14 +639,14 @@ fn each_mode_changes_only_its_table() { "preview_sketch", ]; let mut red_green_light = red_green_both.to_vec(); - red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]); + red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]); for (dark, mode, want) in [ (false, ColorVision::Protan, set(&red_green_light)), (true, ColorVision::Protan, set(&red_green_both)), (false, ColorVision::Deutan, set(&red_green_light)), (true, ColorVision::Deutan, set(&red_green_both)), - (false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])), - (true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])), + (false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "preview_sketch", "analysis.hatch"])), + (true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "glyph_text", "preview_sketch", "analysis.hatch"])), ] { let base = Tokens::themed(dark, ColorVision::Off); assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}"); @@ -709,6 +721,7 @@ fn the_sections_messages_are_in_every_catalog() { .chain(["ui.colour_vision", "ui.colour_vision_mode", "ui.colour_vision_sample", "ui.colour_vision_note"]) .collect(); for (lang, text) in [ + ("cs", include_str!("../../../assets/locales/cs.json")), ("de", include_str!("../../../assets/locales/de.json")), ("ja", include_str!("../../../assets/locales/ja.json")), ("ko", include_str!("../../../assets/locales/ko.json")), diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index cc1d18e4..db0ad1ad 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -342,8 +342,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Workspace => { let rendering = crate::render_workspace::active(app); let mut v = vec![ - act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }), - act("ui.workspace.render", "Render", "camera") + act("ui.workspace.design", "Design", "ws_design").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }), + act("ui.workspace.render", "Render", "ws_render") .with(json!({"workspace":"render"})) .key(if rendering { "current" } else { "" }) .on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()), @@ -719,6 +719,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { v } +/// Explode Text (#1842): the selected text's outlines become normal sketch lines, arcs and +/// curves, no longer tied to the text, so they can be picked, trimmed and edited one by one. +fn explode_text(app: &SolveApp) -> Option { + let link = crate::sketch_text::selected_link(app)?; + Some(cmd(app, "sketch.break_link", "Explode Text").with(json!({ "link": link }))) +} + /// Sketch entity items: construction, fix, delete, dimension and the constraints the selection /// can take. fn sketch_entity_items(app: &SolveApp) -> Vec { @@ -754,6 +761,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { if curves.is_empty() && points.is_empty() { if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); + v.extend(explode_text(app)); } v.push(cmd(app, "sketch.line", "Line")); v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle")); @@ -773,6 +781,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { } if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); + v.extend(explode_text(app)); } v.push(cmd(app, "sketch.move", "Move/Copy").key("M")); v.push(act("ui.cut", "Cut", "").key("Ctrl+X")); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f7cb7ebe..0ed0b48f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -44,6 +44,7 @@ use std::sync::mpsc::Sender; use serde_json::{Value, json}; use solidcraft_engine::io::vfs; use solidcraft_engine::render::StandardView; +use solidcraft_engine::timing; use crate::SolveApp; @@ -121,7 +122,9 @@ const MAX_BATCH: usize = 10_000; /// bypass. const LOADS_A_FILE: [&str; 3] = ["doc.open", "file.insert_step", "file.insert_mesh"]; -/// `engine.batch {commands, rollback?}`: the steps in one request, so in one frame (#743). Each +/// `engine.batch {commands, rollback?, timings?, profile?}`: the steps in one request, so in one +/// frame (#743). With `timings` the reply adds each step's time and `total_ms`, with `profile` +/// each step's kernel share too (`solidcraft_engine::timing`, #1833). Each /// step runs as `engine.execute` runs it; the first failure stops the batch and, with /// `rollback` (the default), the design goes back to its checkpoint. Step failures are part of /// the reply; refusals (`engine.batch unavailable: …`) come before anything runs, so the client @@ -138,26 +141,43 @@ fn batch(app: &mut SolveApp, p: &Value) -> Outcome { return err(format!("engine.batch unavailable: step {i} ({c}) loads a file")); } let rollback = p.get("rollback").and_then(Value::as_bool).unwrap_or(true); + let profile = p.get("profile").and_then(Value::as_bool).unwrap_or(false); + let timed = profile || p.get("timings").and_then(Value::as_bool).unwrap_or(false); + let mut rows = Vec::new(); + let clock = timing::Clock::start(); let checkpoint = if rollback { app.run("edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) } else { None }; let mut results = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { - let r = match command(step) { + let (r, took) = timing::time(profile, || match command(step) { Some(id) => execute(app, id, step.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))), None => Err("missing `command`".to_string()), - }; + }); + if timed { + rows.push(took.to_json(i, command(step).unwrap_or_default())); + } match r { Ok(v) => results.push(v), Err(e) => { let restored = checkpoint.is_some_and(|id| app.run("edit.restore_checkpoint", json!({"checkpoint": id})).is_ok()); let failed = json!({"index": i, "command": command(step), "error": e}); - return ok(json!({"completed": i, "results": results, "failed": failed, "undone": if restored { i } else { 0 }})); + let reply = json!({"completed": i, "results": results, "failed": failed, "undone": if restored { i } else { 0 }}); + return ok(with_timings(reply, timed, rows, clock.ms())); } } } if let Some(id) = checkpoint { let _ = app.run("edit.restore_checkpoint", json!({"checkpoint": id, "forget": true})); } - ok(json!({"completed": results.len(), "results": results, "undone": 0})) + ok(with_timings(json!({"completed": results.len(), "results": results, "undone": 0}), timed, rows, clock.ms())) +} + +/// A batch reply with `timings` and `total_ms` when they were asked for. +fn with_timings(mut reply: Value, timed: bool, rows: Vec, total_ms: f64) -> Value { + if timed && let Some(o) = reply.as_object_mut() { + o.insert("timings".into(), Value::Array(rows)); + o.insert("total_ms".into(), json!(timing::round_ms(total_ms))); + } + reply } /// A request from an MCP server carries the folders its agent may use (`allow_paths`); every @@ -187,7 +207,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides } /// The grid and the origin's axes as the window shows them (the Grid and Origin switches). -fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { +pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() } } @@ -371,11 +391,19 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> let Some(id) = s("command") else { return err("missing `command`") }; // `params`: what the dialog opens on. Refused: nothing starts, or the dialog closes. let params = p.get("params").filter(|v| !v.is_null()); - if params.is_some() && id != "joint.drive" { - return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values)")); + if params.is_some() && id != "joint.drive" && id != "motion.study" { + return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values; motion.study takes study)")); } app.start(id); - if let Some(params) = params { + if let Some(params) = params.filter(|_| id == "motion.study") { + let Some(crate::dialogs::Kind::Motion(k)) = app.dialog.as_mut().map(|d| &mut d.kind) else { + return err("Motion Study did not open"); + }; + if let Err(e) = crate::dialogs_motion::preset(&app.session, k, params) { + crate::dialogs::cancel(app); + return err(e); + } + } else if let Some(params) = params { if app.tree.capture_prompt.is_some() { return err("Drive Joints waits for the Capture Position question (ui.capture)"); } @@ -1107,6 +1135,28 @@ mod tests { assert_eq!(inspect(&mut h)["sketches"].as_array().map(Vec::len), Some(40)); } + /// #1833: `engine.batch` times its steps where they run, the failing one included, with the + /// kernel's share when `profile` asks; unasked, the reply is as before. + #[test] + fn engine_batch_times_its_steps() { + let mut h = crate::scenario::Harness::new(); + let steps = json!([ + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}, + {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}}, + ]); + let v = batch(&mut h, json!({"commands": steps, "profile": true})); + let rows = v["result"]["timings"].as_array().cloned().unwrap_or_default(); + assert_eq!(rows.len(), 2, "{v}"); + assert_eq!(rows[1]["index"], 1); + assert!(rows[1]["profile"]["boolean_ms"].as_f64().is_some_and(|ms| ms > 0.0), "{v}"); + assert!(v["result"]["total_ms"].as_f64().is_some(), "{v}"); + let v = batch(&mut h, json!({"timings": true, "commands": [{"command": "NoSuchCommand"}]})); + assert_eq!(v["result"]["timings"].as_array().map(Vec::len), Some(1), "{v}"); + assert!(v["result"]["timings"][0].get("profile").is_none(), "{v}"); + let v = batch(&mut h, json!({"commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}]})); + assert!(v["result"].get("timings").is_none() && v["result"].get("total_ms").is_none(), "{v}"); + } + #[test] fn engine_batch_rolls_back_at_the_first_failure() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/deferred.rs b/crates/ui-egui/src/deferred.rs new file mode 100644 index 00000000..12140f86 --- /dev/null +++ b/crates/ui-egui/src/deferred.rs @@ -0,0 +1,124 @@ +//! Commands that recompute the history, computed off the window thread as OK is (#1201): the +//! history marker dragged or clicked, a feature suppressed, reordered or deleted from the +//! timeline, undo and redo, the marker put back after a sketch. On a part with a slow feature +//! after the marker (a line of text cut into a block), each of these ran that feature's boolean +//! on the window thread, and the window froze until it was done. +//! +//! The command is evaluated on a copy of the session ([`solidcraft_engine::Session::ahead`]), +//! which leaves what it computes in the evaluation cache the session shares. Done within the +//! app's wait ([`crate::dialogs::APPLY_AT_ONCE`]), the command just runs; otherwise the window +//! goes on, saying it computes, and the command runs for real once the evaluation is in, finding +//! its features in the cache. Commands asked for meanwhile wait their turn; a marker moved again +//! replaces the move still waiting, so a drag computes where it ends rather than every slot it +//! crossed. + +use serde_json::Value; + +use crate::SolveApp; + +/// A command computing off the window thread. +pub struct Deferred { + done: std::sync::mpsc::Receiver<()>, + /// When it started (ms). + pub(crate) started: f64, + command: (String, Value), + /// The design's revision it was computed for: a design changed meanwhile computes it again. + revision: u64, +} + +impl Deferred { + fn finished(&self) -> bool { + !matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)) + } +} + +/// Commands a later one of the same kind replaces while they wait: where the marker ends is all +/// that counts. +fn replaced_by_a_later_one(id: &str) -> bool { + id == "timeline.roll_to" +} + +impl SolveApp { + /// Run a command that may recompute much of the history, off the window thread when it takes + /// a while (see the module notes). `Ok(Null)` when it is left computing or waiting its turn: + /// its outcome then shows as any command's does (an error in the status line). + pub fn run_heavy(&mut self, id: &str, params: Value) -> Result { + if self.deferred.is_some() { + if replaced_by_a_later_one(id) && self.deferred_queue.last().is_some_and(|(q, _)| q == id) { + self.deferred_queue.pop(); + } + self.deferred_queue.push((id.to_string(), params)); + return Ok(Value::Null); + } + match self.begin_deferred(id, ¶ms) { + Some(job) => { + self.deferred = Some(job); + Ok(Value::Null) + } + None => self.run(id, params), + } + } + + /// Since when a command has been computing off the window thread (ms), if one is. + pub fn computing_since(&self) -> Option { + self.deferred.as_ref().map(|d| d.started) + } + + /// Each frame: a command whose evaluation is in runs, then the next one waiting starts. + pub(crate) fn poll_deferred(&mut self, ctx: &egui::Context) { + if let Some(job) = &self.deferred { + if !job.finished() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + if let Some(job) = self.deferred.take() { + let (id, params) = job.command; + let superseded = replaced_by_a_later_one(&id) && self.deferred_queue.first().is_some_and(|(q, _)| *q == id); + if job.revision != self.session.revision { + // The design changed meanwhile: computed again for the design as it is now. + let _ = self.run_heavy(&id, params); + } else if !superseded { + let _ = self.run(&id, params); + } + } + } + if self.deferred.is_none() && !self.deferred_queue.is_empty() { + let (id, params) = self.deferred_queue.remove(0); + let _ = self.run_heavy(&id, params); + } + } + + /// Start evaluating a command ahead. `None` when it should just run: not asked for + /// ([`SolveApp::apply_off_thread`]), a file operation in progress, no threads (the web), or + /// done within the wait. + #[cfg(not(target_arch = "wasm32"))] + fn begin_deferred(&self, id: &str, params: &Value) -> Option { + let wait = self.apply_off_thread?; + if self.file_load.busy() || self.file_picker.is_some() { + return None; + } + let ahead = self.session.ahead(); + let command = (id.to_string(), params.clone()); + let commands = vec![command.clone()]; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("history".into()) + .spawn(move || { + let _ = ahead.evaluate(&commands); + let _ = tx.send(()); + }) + .ok()?; + // Zero explicitly requests off-thread application, even if a fast worker + // finishes before this thread is scheduled again. Apply on the next frame. + if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { + Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision }) + } else { + None + } + } + + #[cfg(target_arch = "wasm32")] + fn begin_deferred(&self, _id: &str, _params: &Value) -> Option { + None + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dbe7edf6..b4ef0387 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -114,6 +114,8 @@ pub enum Kind { OffsetPlane { offset: String, }, + /// Halfway between two planes or planar faces. + Midplane, /// A construction plane through an axis at an angle to a plane. AnglePlane { angle: String, @@ -137,6 +139,11 @@ pub enum Kind { Thread { designation: String, length: String, + /// The last automatic size; a different value is a manual override. Keep automatic + /// features automatic when applying, even though their resolved size is visible. + suggested: Option, + /// The picks used for the suggestion, so an unchanged dialog does no geometry work. + of: Vec, }, /// A rib (one wall along open curves) or a web (a wall per curve). Rib { @@ -260,6 +267,7 @@ pub enum Kind { SaveMesh(crate::dialogs_save_mesh::SaveMesh), ViewDxf { dashed: bool, + tolerance: f64, }, /// Plastic features (`dialogs_plastic`). Plastic(crate::dialogs_plastic::Pl), @@ -515,6 +523,10 @@ impl Dialog { "construct.plane.offset" => { Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } + "construct.plane.midplane" => Dialog::new( + Kind::Midplane, + vec![SelInput::new("First plane", PLANES | PLANAR_FACES, false), SelInput::new("Second plane", PLANES | PLANAR_FACES, false)], + ), "construct.plane.along_path" => { Dialog::new(Kind::PathPlane { t: "0.5".into(), sketch: None }, vec![SelInput::new("Path", CURVES | EDGES, false)]) } @@ -528,9 +540,10 @@ impl Dialog { ), "solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]), "solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]), - "solid.thread" => { - Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)]) - } + "solid.thread" => Dialog::new( + Kind::Thread { designation: String::new(), length: String::new(), suggested: None, of: Vec::new() }, + vec![SelInput::new("Cylindrical face", FACES, false)], + ), "material.assign" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]), "inspect.section" => { Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) @@ -552,10 +565,16 @@ impl Dialog { vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)], ), "solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]), - "solid.sweep" => Dialog::new( - Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, - vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()], - ), + "solid.sweep" => Dialog::new(Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, { + let [rail, rail2] = crate::dialogs_sweep::rail_inputs(); + vec![ + SelInput::new("Profile", PROFILES, true), + SelInput::new("Path", CURVES, true), + crate::dialogs_sweep::direction_input(), + rail, + rail2, + ] + }), "solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]), "solid.box" => Dialog::new( @@ -699,10 +718,30 @@ impl Dialog { { inp.items = default_profiles(s); } + d.sync_thread(s); d.advance(); Some(d) } + /// Resolve a newly selected face without replacing an authored size or changing the + /// document. Clearing the face also clears a suggestion that no longer applies. + fn sync_thread(&mut self, s: &Session) { + let Kind::Thread { designation, suggested, of, .. } = &mut self.kind else { return }; + if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) { + return; + } + let picks = self.inputs.first().map(|i| i.items.as_slice()).unwrap_or_default(); + if picks == of.as_slice() { + return; + } + *of = picks.to_vec(); + *suggested = picks.first().and_then(|sel| match sel { + Sel::Face { point, .. } => solidcraft_engine::doc::suggested_thread(&s.world_state(), *point).ok(), + _ => None, + }); + *designation = suggested.clone().unwrap_or_default(); + } + pub fn rename(feature: u64, name: &str) -> Dialog { Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![]) } @@ -763,6 +802,7 @@ impl Dialog { | Kind::Loft { .. } | Kind::Sweep { .. } | Kind::PathPlane { .. } + | Kind::Midplane | Kind::Move { .. } | Kind::Hole { .. } | Kind::Primitive { .. } @@ -945,6 +985,7 @@ impl Dialog { self.advance(); } self.error = None; + self.sync_thread(s); } /// Box selection result: replace (or add to) the active input. @@ -975,6 +1016,7 @@ impl Dialog { inp.items.push(x); } } + self.sync_thread(s); } } @@ -1350,6 +1392,7 @@ fn title(k: &Kind) -> &'static str { Kind::Measure { .. } => "MEASURE", Kind::Material { .. } => "PHYSICAL MATERIAL", Kind::OffsetPlane { .. } => "OFFSET PLANE", + Kind::Midplane => "MIDPLANE", Kind::PathPlane { .. } => "PLANE ALONG PATH", Kind::Rib { web: false, .. } => "RIB", Kind::Rib { web: true, .. } => "WEB", @@ -1711,6 +1754,7 @@ fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std:: } d.active = i; } + d.sync_thread(s); } /// Each live reference sits with the independent values it drives. @@ -2054,16 +2098,18 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { } let Kind::Extrude { direction, distance, .. } = &d.kind else { return }; let Ok(dist) = app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length) else { return }; - let side = crate::smart_extrude::side(dist, *direction); + let mut side = crate::smart_extrude::side(dist, *direction); if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) { return; } - let Some(fresh) = dialog_commands(app, d) - .ok() - .and_then(|c| c.into_iter().next()) - .and_then(|(_, p)| solidcraft_engine::auto_operation(&app.session, &p)) - .and_then(|op| OPS.iter().position(|o| *o == op)) - else { + let Some(params) = dialog_commands(app, d).ok().and_then(|c| c.into_iter().next()).map(|(_, p)| p) else { return }; + // To an object, the extrude goes to it on whichever side it is: that is the side (#1633). + if params.get("to").is_some() + && let Some(reach) = solidcraft_engine::extrude_reach(&app.session, ¶ms) + { + side = crate::smart_extrude::side(reach, 0); + } + let Some(fresh) = solidcraft_engine::auto_operation(&app.session, ¶ms).and_then(|op| OPS.iter().position(|o| *o == op)) else { return; }; if let Kind::Extrude { operation, smart, .. } = &mut d.kind { @@ -2107,6 +2153,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { return; } auto_operation(app, &mut d); + d.sync_thread(&app.session); update_measure(app, &mut d); update_fill_cells(app, &mut d); // A refused OK's error is about the inputs it ran with (#641). Once a value (in the dialog, @@ -2653,7 +2700,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { enter |= field(ui, t); ui.end_row(); } - Kind::Split | Kind::ReplaceFace | Kind::Remove => {} + Kind::Split | Kind::ReplaceFace | Kind::Remove | Kind::Midplane => {} Kind::Rib { thickness, depth, flip, .. } => { row_label(ui, "Thickness"); enter |= field(ui, thickness); @@ -2747,22 +2794,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { enter |= field(ui, distance); ui.end_row(); } - Kind::Thread { designation, length } => { - // Fusion's order: Modeled, Full Length, then the size; a length only when not - // full. + Kind::Thread { designation, length, suggested, of } => { row_label(ui, "Modeled"); extra_check(ui, &mut d.extra, "modeled"); ui.end_row(); row_label(ui, "Full Length"); let mut full = length.trim().is_empty(); - if ui.checkbox(&mut full, "").changed() { + let response = ui.checkbox(&mut full, ""); + publish_row(&response); + if response.changed() { *length = if full { String::new() } else { "10 mm".into() }; } ui.end_row(); - row_label(ui, "Size"); - let r = ui.add(egui::TextEdit::singleline(designation).hint_text(tr("fits the face, e.g. M8"))); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); + let (submitted, reset) = crate::dialogs_thread::rows(ui, designation, suggested); + enter |= submitted; + if reset { + of.clear(); + } if !full { row_label(ui, "Length"); enter |= field(ui, length); @@ -2946,7 +2994,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k), Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k), Kind::SaveMesh(k) => enter |= crate::dialogs_save_mesh::rows(app, ui, k), - Kind::ViewDxf { dashed } => crate::dialogs_view_dxf::rows(app, ui, dashed), + Kind::ViewDxf { dashed, tolerance } => crate::dialogs_view_dxf::rows(app, ui, dashed, tolerance), Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), @@ -3457,26 +3505,43 @@ fn plane_by_name(app: &SolveApp, name: &str) -> Option 0.999 { + if n.z.abs() > 0.999 { let x = ss.plane.x; - to.yaw = if ss.plane.normal().z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; + to.yaw = if n.z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; } to.target = ss.plane.origin; app.animate_to(to); } } +/// The side to look from at a plane with normal `n`: its own side, or with `again` the other +/// side when the view already looks from `n`, so a second Look At turns the view round (#1886). +pub(crate) fn facing(app: &SolveApp, n: Vec3, again: bool) -> Vec3 { + if again && app.cam.back().dot(n) > 0.999 { -n } else { n } +} + fn sels(d: &Dialog, i: usize) -> &[Sel] { d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) } pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { - if let Kind::ViewDxf { dashed } = &d.kind { - return crate::dialogs_view_dxf::run(app, *dashed); + if let Kind::ViewDxf { dashed, tolerance } = &d.kind { + return crate::dialogs_view_dxf::run(app, *dashed, *tolerance); } // Saving asks for a file name first; `Err("")` is the user backing out of that. if let Kind::SaveMesh(k) = &d.kind { @@ -3858,20 +3923,21 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S let path: Vec = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?; let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}); - crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?; + crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), [sels(d, 3), sels(d, 4)], &mut p)?; ("solid.sweep", p) } Kind::OffsetPlane { offset } => { need(0, "a plane")?; - let base = match sels(d, 0).first() { - Some(Sel::Plane { name }) => json!(name), - Some(face @ Sel::Face { .. }) => { - crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model")? - } - _ => return Err("pick an origin or construction plane, or a planar face".into()), - }; + let base = plane_arg(app, sels(d, 0).first())?; ("construct.plane.offset", json!({"base": base, "offset": offset})) } + Kind::Midplane => { + need(0, "the first plane")?; + need(1, "the second plane")?; + let a = plane_arg(app, sels(d, 0).first())?; + let b = plane_arg(app, sels(d, 1).first())?; + ("construct.plane.midplane", json!({"a": a, "b": b})) + } Kind::PathPlane { t, sketch } => { need(0, "a path")?; let path = match sels(d, 0).first() { @@ -4027,10 +4093,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S need(0, "planar faces")?; ("solid.offset_face", json!({"faces": face_points(0), "distance": distance})) } - Kind::Thread { designation, length } => { + Kind::Thread { designation, length, suggested, .. } => { need(0, "a cylindrical face")?; let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)}); - if !designation.trim().is_empty() { + if !designation.trim().is_empty() && suggested.as_deref() != Some(designation.as_str()) { p["designation"] = json!(designation.trim()); } if !length.trim().is_empty() { @@ -4107,7 +4173,8 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S continue; } taken.extend(crate::viewport::grouped_selection(s, x)); - places.extend(face_point(x).map(|p| json!({ "position": p }))); + let kept = d.extra.get("position_exprs").filter(|k| face_point(x).is_some_and(|p| p == k["point"])).map(|k| k["at"].clone()); + places.extend(face_point(x).map(|p| json!({ "position": kept.unwrap_or(p) }))); } } let mut out = Vec::new(); @@ -4460,6 +4527,24 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solidcraft_engine::doc::E Some(Sel::Face { body: world_name, index, point }) } +/// A picked plane or planar face as `construct.plane.*` takes it. +fn plane_arg(app: &SolveApp, sel: Option<&Sel>) -> Result { + match sel { + Some(Sel::Plane { name }) => Ok(json!(name)), + Some(face @ Sel::Face { .. }) => crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model".into()), + _ => Err("pick an origin or construction plane, or a planar face".into()), + } +} + +/// A stored construction reference back into a dialog pick. +fn geo_sel(s: &Session, g: &solidcraft_engine::doc::construct::GeoRef) -> Option { + match g { + solidcraft_engine::doc::construct::GeoRef::Plane { plane } => plane_sel(s, plane), + solidcraft_engine::doc::construct::GeoRef::Face { at, .. } => face_sel(s, *at), + _ => None, + } +} + pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } => Some(Sel::Plane { name: name.clone() }), @@ -4695,7 +4780,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, to } => { let mut d = start("solid.hole")?; let mut k = hole_defaults(); if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k { @@ -4743,6 +4828,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option = at.iter().map(|e| e.as_str()).collect(); + d.extra.insert("position_exprs".into(), json!({"at": exprs, "point": pt3(position.0)})); + } } } if to.is_some() { @@ -4830,14 +4921,20 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Mirror { features, plane, bodies, placements, .. } => { let mut d = start("solid.mirror")?; let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); // Replay references stay features, including features that created a body. let as_features = !features.is_empty(); if let Some(inp) = d.inputs.get_mut(0) { if !bodies.is_empty() { - inp.items = bodies.iter().map(|name| Sel::Body { name: name.to_string() }).collect(); + // Each body as the placement it was mirrored at shows it (an instance by its world name). + let own = s.model.state(); + let world = |k: usize, name: &str| match (placements.get(k), own.body(name)) { + (Some(path), Some(b)) => s.doc.world_body_name(&own, name, s.doc.model_body_component(b), path), + _ => name.to_string(), + }; + inp.items = bodies.iter().enumerate().map(|(k, name)| Sel::Body { name: world(k, name) }).collect(); } else if as_features { inp.accept = selection::FEATURES; inp.label = objects_label(inp.accept); @@ -4937,13 +5034,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, rails, rail_fit, .. } => { let mut d = start("solid.sweep")?; - let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component)); + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, &feature_frame(s, f.component)); d.kind = Kind::Sweep { operation: op_index(operation), opts }; if let Some(inp) = d.inputs.get_mut(2) { inp.items = direction.into_iter().collect(); } + for (inp, items) in d.inputs.iter_mut().skip(3).zip(crate::dialogs_sweep::rail_sels(rails)) { + inp.items = items; + } let items = st.sketch(sketch.0).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.get_mut(0) { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: sketch.0, index }).collect(); @@ -4981,6 +5081,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + let mut d = start("construct.plane.midplane")?; + if let Some(inp) = d.inputs.first_mut() { + inp.items = geo_sel(s, a).into_iter().collect(); + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = geo_sel(s, b).into_iter().collect(); + } + d + } FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => { let mut d = start("construct.plane.at_angle")?; d.kind = Kind::AnglePlane { angle: angle.to_string() }; @@ -5048,10 +5158,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 7a51992f..75136aec 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -1,5 +1,5 @@ //! Assembly dialogs: New Component, Joint, As-built Joint, Joint Origin, Rigid Group, Drive -//! Joints, Motion Link, Gear, Rack and Pinion, Screw (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face +//! Joints, Motion Link, Gear, Rack and Pinion, Screw, Cam (`dialogs_coupling`) and Interference. Joint origins are picked by hovering geometry: a face //! snaps to its centre (or a round face to the centre of its nearer end), an edge to its middle //! (a round edge to its centre), a vertex to itself; the snap shows as a glyph with its z axis //! before the click. Joints and drives preview on the placed (world) model: the moving @@ -23,15 +23,15 @@ use crate::viewport::Proj; #[path = "dialogs_trace.rs"] mod trace; -pub use trace::TraceForm; +pub use trace::{TraceForm, picked_point}; /// Joint types in the order the Type box lists them (command names). pub const TYPES: [&str; 7] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball"]; const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "Pin-slot", "Planar", "Ball"]; /// What a joint origin input accepts (snaps on faces, edges and vertices). -const SNAPS: u16 = FACES | EDGES | VERTICES; +pub const SNAPS: u16 = FACES | EDGES | VERTICES; /// Commands whose effect is where occurrences sit (previewed on the placed model). -const MOVES: [&str; 11] = [ +const MOVES: [&str; 12] = [ "joint.create", "joint.as_built", "joint.drive", @@ -41,6 +41,7 @@ const MOVES: [&str; 11] = [ "joint.gear", "joint.rack_pinion", "joint.screw", + "joint.cam", "parts.insert", "parts.fastener", ]; @@ -72,10 +73,13 @@ pub enum Asm { offset: String, }, Coupling(Box), + Cam(Box), Interference { result: Option, overlaps: Vec, of: Vec, + /// Pairs closer than this (mm, as typed) are listed too. + clearance: String, }, /// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline. Trace(Box), @@ -151,13 +155,14 @@ impl Asm { "joint.rack_pinion" => "RACK AND PINION", _ => "SCREW", }, + Asm::Cam(_) => "CAM", Asm::Interference { .. } => "INTERFERENCE", Asm::Trace(_) => "MOTION TRACE", } } pub fn previews(&self) -> bool { - matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_)) + matches!(self, Asm::Joint(_) | Asm::Drive { .. } | Asm::RigidGroup | Asm::Coupling(_) | Asm::Cam(_)) } } @@ -185,9 +190,11 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { (Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![]) } "joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?, - "inspect.interference" => { - (Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]) - } + "joint.cam" => crate::dialogs_coupling::cam::start(app), + "inspect.interference" => ( + Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() }, + vec![SelInput::new("Bodies (all if none)", BODIES, true)], + ), _ => return None, }; Some((Kind::Assembly(asm), inputs)) @@ -261,7 +268,7 @@ fn current_values(s: &Session, index: usize) -> Vec { } /// A value with its unit, without trailing zeros ("12 mm", "37.5 deg"). -fn num(v: f64, unit: &str) -> String { +pub(crate) fn num(v: f64, unit: &str) -> String { let s = format!("{v:.4}"); let s = s.trim_end_matches('0').trim_end_matches('.'); format!("{} {unit}", if s == "-0" { "0" } else { s }) @@ -686,12 +693,16 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se ui.end_row(); } Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs), - Asm::Interference { result, overlaps, of } => { + Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs), + Asm::Interference { result, overlaps, of, clearance } => { let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); + row_label(ui, "Clearance"); + field(ui, clearance); + ui.end_row(); row_label(ui, ""); let stale = result.is_some() && *of != picked; if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() { - let (r, o) = interference(app, &picked); + let (r, o) = interference(app, &picked, clearance); *result = Some(r); *overlaps = o; *of = picked; @@ -711,16 +722,26 @@ fn capital(s: &str) -> String { c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } -/// Run the check and find the overlapping volumes (world placement) to show in red. -fn interference(app: &mut SolveApp, picked: &[Sel]) -> (Value, Vec) { +/// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and +/// find the overlapping volumes (world placement) to show in red. +fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value, Vec) { let names: Vec = picked.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); - let r = match app.run("inspect.interference", json!({ "bodies": names })) { + let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units); + let clearance = match crate::prefs::parse_length_mm(clearance, scale) { + Some(v) if v >= 0.0 => v, + _ => return (json!({ "error": "the clearance must be a length of 0 or more" }), Vec::new()), + }; + let mut p = json!({ "clearance": clearance, "nearest": 1 }); + if !names.is_empty() { + p["bodies"] = json!(names); + } + let r = match app.run("inspect.clearance", p) { Ok(v) => v, Err(e) => return (json!({ "error": e }), Vec::new()), }; let st = app.session.world_state(); let mut meshes = Vec::new(); - for hit in r["interferences"].as_array().into_iter().flatten() { + for hit in r["overlaps"].as_array().into_iter().flatten() { let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue }; if let Ok(Some(o)) = solidcraft_engine::kernel::boolean(&a.body, &b.body, solidcraft_engine::kernel::BoolOp::Intersect) && let Ok(m) = o.tessellate((o.size() * 2e-3).max(1e-3)) @@ -739,15 +760,43 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) { ui.end_row(); return; } - let hits = r["interferences"].as_array().cloned().unwrap_or_default(); + let hits = r["overlaps"].as_array().cloned().unwrap_or_default(); row_label(ui, "Checked"); - ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0))); + ui.label(format!("{} bodies", r["bodies"].as_u64().unwrap_or(0))); ui.end_row(); let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default(); + let near = r["near"].as_array().cloned().unwrap_or_default(); + let pair = |h: &Value| format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or("")); if hits.is_empty() && unchecked.is_empty() { row_label(ui, "Result"); ui.label(RichText::new(crate::i18n::tr_label("no interference")).color(t.text_dim)); ui.end_row(); + } + // Pairs closer than the clearance, nearest first. + if !near.is_empty() { + row_label(ui, "Too close"); + ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong()); + ui.end_row(); + } + for h in &near { + row_label(ui, &pair(h)); + ui.label(RichText::new(crate::prefs::show_mm(app, h["distance_mm"].as_f64().unwrap_or(0.0), 1)).color(t.warning)); + ui.end_row(); + } + // Each body's nearest neighbour. + let nearest = r["nearest"].as_object().cloned().unwrap_or_default(); + if !nearest.is_empty() { + row_label(ui, "Nearest"); + ui.label(RichText::new(crate::i18n::tr("ui.distance")).strong()); + ui.end_row(); + } + for (body, list) in &nearest { + let Some(n) = list.get(0) else { continue }; + row_label(ui, &format!("{body} → {}", n["body"].as_str().unwrap_or(""))); + ui.label(crate::prefs::show_mm(app, n["distance_mm"].as_f64().unwrap_or(0.0), 1)); + ui.end_row(); + } + if hits.is_empty() && unchecked.is_empty() { return; } if !hits.is_empty() { @@ -775,7 +824,7 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] { } /// The body a pick is on. -fn body_of(sel: &Sel) -> Option<&str> { +pub(crate) fn body_of(sel: &Sel) -> Option<&str> { match sel { Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body), Sel::Body { name } => Some(name), @@ -914,6 +963,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result return crate::dialogs_coupling::commands(app, f, inputs), + Asm::Cam(f) => return crate::dialogs_coupling::cam::commands(app, f, inputs), // The check runs from its Compute button; OK only closes. Asm::Interference { .. } => Vec::new(), Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?), @@ -925,6 +975,9 @@ pub fn animate(app: &SolveApp, k: &Asm, cmds: &mut Vec<(String, Value)>) { if let Asm::Coupling(f) = k { crate::dialogs_coupling::animate(&app.session, f, cmds); } + if let Asm::Cam(f) = k { + crate::dialogs_coupling::cam::animate(&app.session, f, cmds); + } let Asm::Joint(f) = k else { return }; if !f.animate { return; @@ -992,7 +1045,7 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O .map(|(_, r)| r.clone()); let (before, after) = (preview.before, preview.after); // A moved component looks like itself (no new surface), with its old place as the ghost. - let colors = Colors { added: colors.body, ..colors }; + let colors = Colors { added: colors.body, new_body: colors.body, ..colors }; let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors))); Some(match built { Ok(mut b) => { diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 2e36408c..c8dffb0a 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -74,7 +74,13 @@ fn joint_and_drive_preview_on_the_placed_model() { let mut d = app.dialog.clone().unwrap(); d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap()); d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap()); - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let cmds = preview_commands(&app, &d).unwrap(); let b = world_preview(&app.session, &cmds, colors).unwrap().unwrap(); assert_eq!(b.replaced, vec!["Body2".to_string()], "B moves"); @@ -461,7 +467,13 @@ fn four_bar() -> SolveApp { #[test] fn drive_says_where_a_loop_stopped_it() { let mut app = four_bar(); - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let rocker = app.session.doc.assembly.joints.iter().position(|j| j.name == "Rocker").unwrap(); let mut d = drive_joint(&app, app.session.doc.assembly.joints[rocker].id).unwrap(); let ask = |d: &mut Dialog, v: &str| { @@ -514,7 +526,13 @@ fn drive_says_when_its_joint_is_joined_to_nothing_grounded() { if let Kind::Assembly(Asm::Drive { values, .. }) = &mut d.kind { values[0] = "45 deg".into(); } - let colors = crate::preview::Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }; + let colors = crate::preview::Colors { + body: [1, 1, 1, 255], + added: [2, 2, 2, 255], + new_body: [2, 2, 2, 255], + removed: [3, 3, 3, 100], + edge: [0, 0, 0, 255], + }; let b = world_preview(&app.session, &apply_commands(&app, &d).unwrap(), colors).unwrap().unwrap(); let why = b.stopped.as_ref().and_then(drive_stop).unwrap(); assert!(why.starts_with("Moves nothing") && why.contains("C:1"), "{why}"); diff --git a/crates/ui-egui/src/dialogs_cam.rs b/crates/ui-egui/src/dialogs_cam.rs new file mode 100644 index 00000000..983e8779 --- /dev/null +++ b/crates/ui-egui/src/dialogs_cam.rs @@ -0,0 +1,197 @@ +//! The Cam dialog (`joint.cam`): a cam turning on a revolute joint drives a follower, a slider or +//! a revolute, along the cam's profile (a closed sketch profile, or edges of the cam's body). The +//! follower is a knife edge, a roller (its radius typed, or its round face picked, which is also +//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part +//! turns on. Play turns the cam a full turn so the follower shows its motion before OK. + +use egui::RichText; +use serde_json::{Value, json}; +use solidcraft_engine::Sel; +use solidcraft_engine::Session; +use solidcraft_engine::doc::joints::JointKind; + +use super::{ROUND, joint_row, occurrence, pick, picked_radius, trim, turn}; +use crate::SolveApp; +use crate::dialogs::{check, combo, field, row_label}; +use crate::dialogs_assembly::Asm; +use crate::selection::{EDGES, FACES, PROFILES, SelInput, VERTICES}; +use crate::theme::Tokens; + +/// The follower types, as `joint.cam` takes them, and their labels. +const FOLLOWERS: [&str; 3] = ["knife", "roller", "flat"]; +const FOLLOWER_LABELS: [&str; 3] = ["Knife edge", "Roller", "Flat face"]; + +/// The inputs: the cam's profile, the roller's round face, the follower's tip. +const PROFILE: usize = 0; +const ROLLER: usize = 1; +const TIP: usize = 2; + +#[derive(Clone, Debug)] +pub struct CamForm { + /// The cam's joint and the follower's (ids). + pub a: u64, + pub b: u64, + /// Index into `FOLLOWERS`. + pub follower: usize, + /// The roller's radius, used where no round face is picked. + pub radius: String, + /// Ride the cam from the other side. + pub flip: bool, + /// The body of the profile pick the cam's joint was last chosen from, so a pick chooses once. + pub picked: Option, + pub animate: bool, + /// Animation phase, 0…1. + pub phase: f64, +} + +/// Joints of a kind: (id, label). +fn joints_of(s: &Session, kinds: &[JointKind]) -> Vec<(u64, String)> { + s.doc.assembly.joints.iter().filter(|j| kinds.contains(&j.kind)).map(|j| (j.id, format!("{} ({:?})", j.name, j.kind))).collect() +} + +pub fn start(app: &SolveApp) -> (Asm, Vec) { + let s = &app.session; + let cams = joints_of(s, &[JointKind::Revolute]); + let followers = joints_of(s, &[JointKind::Slider, JointKind::Revolute]); + let a = cams.first().map(|j| j.0).unwrap_or(0); + // A slider first, else another revolute than the cam's. + let b = followers.iter().find(|j| j.0 != a).map(|j| j.0).unwrap_or(0); + let f = CamForm { a, b, follower: 0, radius: "5 mm".into(), flip: false, picked: None, animate: false, phase: 0.0 }; + let inputs = vec![ + SelInput::new("Profile", PROFILES | EDGES, true), + SelInput::new("Roller (optional)", ROUND, false), + SelInput::new("Tip (optional)", FACES | EDGES | VERTICES, false), + ]; + (Asm::Cam(Box::new(f)), inputs) +} + +/// The occurrence the profile picked in `inputs` is on: an edge's body's, or a sketch's component's +/// first. +fn profile_occurrence(s: &Session, inputs: &[SelInput]) -> Option<(String, u64)> { + match inputs.get(PROFILE)?.items.first()? { + Sel::Edge { body, .. } => Some((body.clone(), occurrence(s, body)?)), + Sel::Profile { sketch, .. } => { + let comp = s.doc.feature(*sketch).map(|f| f.component)?; + let occ = if comp == 0 { 0 } else { s.doc.occurrence_of(comp)?.id }; + Some((format!("sketch {sketch}"), occ)) + } + _ => None, + } +} + +/// The dialog's rows; true when Enter was pressed in a value. +pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[SelInput]) -> bool { + let t = Tokens::get(); + let s = &app.session; + let (cams, followers) = (joints_of(s, &[JointKind::Revolute]), joints_of(s, &[JointKind::Slider, JointKind::Revolute])); + if cams.is_empty() || followers.len() < 2 && followers.iter().all(|j| cams.iter().any(|c| c.0 == j.0)) { + row_label(ui, ""); + ui.label(RichText::new(crate::i18n::tr_label("needs a joint that turns and one that slides or swings")).color(t.text_dim)); + ui.end_row(); + return false; + } + // A profile picked on a part chooses the joint that part turns on, once. + let at = profile_occurrence(s, inputs); + if at.as_ref().map(|x| &x.0) != f.picked.as_ref() { + if let Some(j) = at.as_ref().and_then(|(_, o)| { + let turning = s.doc.assembly.joints.iter().filter(|j| j.kind == JointKind::Revolute); + turning.clone().find(|j| j.b.occurrence == *o).or(turning.clone().find(|j| j.a.occurrence == *o)) + }) { + f.a = j.id; + if f.b == f.a { + f.b = followers.iter().find(|x| x.0 != f.a).map(|x| x.0).unwrap_or(0); + } + } + f.picked = at.map(|x| x.0); + } + let mut enter = false; + joint_row(ui, "Cam", "cam_a", &cams, &mut f.a); + joint_row(ui, "Follower", "cam_b", &followers, &mut f.b); + row_label(ui, "Type"); + let labels: Vec<&str> = FOLLOWER_LABELS.iter().map(|l| crate::i18n::tr_label(l)).collect(); + combo(ui, "cam_follower", &labels, &mut f.follower); + ui.end_row(); + if FOLLOWERS.get(f.follower) == Some(&"roller") { + row_label(ui, "Radius"); + match (pick(inputs, ROLLER), picked_radius(s, inputs, ROLLER)) { + (_, Some(r)) => { + ui.label(format!("{} mm ({})", trim(r), crate::i18n::tr_label("picked"))); + } + (Some(_), None) => { + ui.label(RichText::new(crate::i18n::tr_label("not a round face")).color(t.warning)); + } + (None, None) => enter |= field(ui, &mut f.radius), + } + ui.end_row(); + } + row_label(ui, "Other side"); + check(ui, &mut f.flip); + ui.end_row(); + row_label(ui, "Animate"); + let label = if f.animate { "■ Stop" } else { "▶ Play" }; + let r = ui.button(label).on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view")); + crate::scenario::publish_handle("dialog:Animate", r.rect.center()); + if r.clicked() { + f.animate = !f.animate; + } + ui.end_row(); + if f.animate { + let now = ui.input(|i| i.time); + // 72 steps over 6 s: each one a preview. + f.phase = ((now / 6.0).fract() * 72.0).floor() / 72.0; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } + enter +} + +/// The command OK runs. +pub fn commands(app: &SolveApp, f: &CamForm, inputs: &[SelInput]) -> Result, String> { + let s = &app.session; + let joint = |id: u64| { + s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.id).ok_or_else(|| "choose the cam's and the follower's joints".to_string()) + }; + let (a, b) = (joint(f.a)?, joint(f.b)?); + let items = inputs.get(PROFILE).map(|i| i.items.as_slice()).unwrap_or_default(); + let profile = match items.first() { + Some(Sel::Profile { sketch, index }) => json!({"sketch": sketch, "index": index}), + Some(Sel::Edge { body, .. }) => { + let pts: Vec = + items.iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(json!([point.x, point.y, point.z])) } else { None }).collect(); + let mut p = json!({ "edges": pts }); + if let Some(o) = occurrence(s, body) { + p["occurrence"] = json!(o); + } + p + } + _ => return Err("select the cam's profile (a sketch profile or the cam's edges)".into()), + }; + let follower = FOLLOWERS.get(f.follower).copied().unwrap_or("knife"); + let mut p = json!({"a": a, "b": b, "profile": profile, "follower": follower, "flip": f.flip}); + if follower == "roller" { + p["radius"] = match pick(inputs, ROLLER) { + Some((body, at)) => { + let mut v = json!({"circle": [at.x, at.y, at.z]}); + if let Some(o) = occurrence(s, body) { + v["occurrence"] = json!(o); + } + v + } + None => json!(f.radius.trim()), + }; + } + if let Some(snap) = inputs.get(TIP).and_then(|i| i.items.first()).and_then(|x| crate::dialogs_assembly::snap_of(s, x)) { + p["tip"] = snap.param; + } + Ok(vec![("joint.cam".to_string(), p)]) +} + +/// While animating, the preview turns the cam a full turn. +pub fn animate(s: &Session, f: &CamForm, cmds: &mut Vec<(String, Value)>) { + if f.animate { + turn(s, f.a, f.phase, cmds); + } +} + +#[cfg(test)] +#[path = "dialogs_cam_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_cam_tests.rs b/crates/ui-egui/src/dialogs_cam_tests.rs new file mode 100644 index 00000000..dd8808e7 --- /dev/null +++ b/crates/ui-egui/src/dialogs_cam_tests.rs @@ -0,0 +1,112 @@ +use serde_json::json; +use solidcraft_engine::Session; +use solidcraft_engine::geom::Vec3; + +use super::*; +use crate::Services; +use crate::dialogs::{Kind, apply_commands, preview_commands}; + +/// A grounded base, a cam (a disc of radius 30 about (10, 0)) turning on a revolute joint about +Z +/// through the origin, a tappet on a slider along +X, its end on the cam, and a spare part on a +/// revolute joint of its own. +fn valve() -> SolveApp { + let mut s = Session::default(); + let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + run("component.create", json!({"name": "Base"})); + run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, -80, -20]})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Cam"})); + run("solid.cylinder", json!({"base": [10, 0, 0], "axis": [0, 0, 1], "radius": 30, "height": 5})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Tappet"})); + run("solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [40, -2, 0]})); + run("component.activate", json!({"component": "root"})); + run("component.create", json!({"name": "Spare"})); + run("solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [-80, 60, 0]})); + run("component.activate", json!({"component": "root"})); + run("occurrence.ground", json!({"occurrence": "Base:1", "grounded": true})); + for (kind, name, z) in [("revolute", "Cam", [0, 0, 1]), ("slider", "Tappet", [1, 0, 0]), ("revolute", "Spare", [0, 0, 1])] { + let at = |o: &str| json!({"occurrence": o, "point": [0, 0, 2.5], "z": z}); + run("joint.create", json!({"type": kind, "a": at("Base"), "b": at(name), "flip": true, "name": name})); + } + SolveApp::new(s, Services::default()) +} + +/// A pick of the mesh edge nearest a point. +fn edge(app: &SolveApp, p: Vec3) -> Sel { + let st = app.session.world_state(); + let mut best: Option<(f64, Sel)> = None; + for b in &st.bodies { + for (index, e) in b.mesh().edges.iter().enumerate() { + let d = e.windows(2).filter_map(|w| Some(p.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::MAX, f64::min); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Edge { body: b.name.clone(), index, point: p })); + } + } + } + best.map(|x| x.1).unwrap() +} + +fn joint(app: &SolveApp, name: &str) -> u64 { + app.session.doc.assembly.joints.iter().find(|j| j.name == name).map(|j| j.id).unwrap_or(0) +} + +/// Picking the cam's edge chooses the joint it turns on; OK makes the cam, and Play turns the cam +/// in the preview. +#[test] +fn a_picked_cam_edge_makes_the_cam() { + let mut app = valve(); + app.start("joint.cam"); + let mut d = app.dialog.take().unwrap(); + // A second revolute joint for the cam to start on: the pick puts the cam right. + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + (f.a, f.b) = (joint(&app, "Spare"), joint(&app, "Tappet")); + } + d.active = 0; + // The far side of the cam's top edge (its near side touches the tappet). + let pick = edge(&app, Vec3::new(-20.0, 0.0, 5.0)); + d.pick(&app.session, pick); + app.dialog = Some(d); + let ctx = egui::Context::default(); + for _ in 0..3 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let mut d = app.dialog.clone().expect("the dialog stays open"); + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + // The joint the picked part turns on, and a follower that is another joint. + assert_eq!((f.a, f.b), (joint(&app, "Cam"), joint(&app, "Tappet"))); + (f.follower, f.radius) = (1, "4 mm".into()); + } + let c = apply_commands(&app, &d).unwrap(); + let (id, p) = &c[0]; + assert_eq!((id.as_str(), p["follower"].clone(), p["radius"].clone()), ("joint.cam", json!("roller"), json!("4 mm")), "{p}"); + assert!(p["profile"]["edges"][0].is_array() && p["profile"]["occurrence"].is_u64(), "{p}"); + if let Kind::Assembly(Asm::Cam(f)) = &mut d.kind { + (f.follower, f.animate, f.phase) = (0, true, 0.25); + } + let cmds = preview_commands(&app, &d).unwrap(); + assert_eq!(cmds.get(1).map(|c| (c.0.as_str(), c.1["values"].clone())), Some(("joint.drive", json!(["90 deg"]))), "{cmds:?}"); + let c = apply_commands(&app, &d).unwrap(); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + let l = app.session.doc.assembly.links.first().cloned().unwrap(); + assert_eq!(l.lift.len(), 360); + // A quarter turn lowers the knife tappet from 40 to √(30² − 10²). + app.run("joint.drive", json!({"joint": "Cam", "value": 90})).unwrap(); + let v = solidcraft_engine::doc::joints::joint_values(&app.session.doc.assembly).get(&joint(&app, "Tappet")).cloned().unwrap_or_default(); + assert!((v.first().copied().unwrap_or(f64::NAN) - (800f64.sqrt() - 40.0)).abs() < 1e-3, "{v:?}"); +} + +/// The dialog's rows pass their English text, which the catalogs translate by its key. +#[test] +fn the_cam_dialog_labels_translate() { + let de = crate::i18n::Locale::for_language("de"); + for (english, german) in [("Cam", "Nocken"), ("Follower", "Stößel"), ("Roller", "Rolle"), ("Tip (optional)", "Spitze (optional)")] { + assert_eq!(de.label(english), german); + } + assert_eq!(crate::i18n::Locale::for_language("zh").label("Knife edge"), "尖顶"); +} diff --git a/crates/ui-egui/src/dialogs_coupling.rs b/crates/ui-egui/src/dialogs_coupling.rs index 2cda97f6..bcb03d6b 100644 --- a/crates/ui-egui/src/dialogs_coupling.rs +++ b/crates/ui-egui/src/dialogs_coupling.rs @@ -1,5 +1,5 @@ //! The Gear, Rack and Pinion and Screw dialogs: two joints coupled by radii or a pitch -//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`). A radius is typed, or comes from the round +//! (`joint.gear`, `joint.rack_pinion`, `joint.screw`); the Cam dialog is `dialogs_cam.rs`. A radius is typed, or comes from the round //! face picked on the part, and then follows that face when the design changes. Picking a gear's //! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its //! motion before OK. @@ -259,21 +259,31 @@ pub fn commands(app: &SolveApp, f: &CouplingForm, inputs: &[SelInput]) -> Result Ok(vec![(f.command.to_string(), p)]) } -/// While animating, the preview turns the driving joint a full turn: its first value that turns -/// (a planar joint's is its third), the values before it as they are. +/// While animating, the preview turns the driving joint a full turn. pub fn animate(s: &Session, f: &CouplingForm, cmds: &mut Vec<(String, Value)>) { - let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == f.a).filter(|_| f.animate) else { return }; + if f.animate { + turn(s, f.a, f.phase, cmds); + } +} + +/// Drive `joint` to `phase` of a full turn: its first value that turns (a planar joint's is its +/// third), the values before it as they are. +fn turn(s: &Session, joint: u64, phase: f64, cmds: &mut Vec<(String, Value)>) { + let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == joint) else { return }; let Some(turn) = (0..j.kind.dofs().len()).find(|i| j.kind.is_angle(*i)) else { return }; let values: Vec = (0..=turn) .map(|i| match j.values.get(i).copied().unwrap_or(0.0) { - _ if i == turn => format!("{} deg", f.phase * 360.0), + _ if i == turn => format!("{} deg", phase * 360.0), v if j.kind.is_angle(i) => format!("{} deg", v.to_degrees()), v => format!("{v} mm"), }) .collect(); - cmds.push(("joint.drive".into(), json!({"joint": f.a, "values": values}))); + cmds.push(("joint.drive".into(), json!({"joint": joint, "values": values}))); } +#[path = "dialogs_cam.rs"] +pub mod cam; + #[cfg(test)] #[path = "dialogs_coupling_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 523d8c68..de13ec34 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -18,6 +18,8 @@ use crate::theme::Tokens; #[path = "configuration_rules_ui.rs"] mod configuration_rules_ui; +#[path = "dialogs_motion_plot.rs"] +pub mod plot; /// One keyed value of a motion study: a joint's value (index) at steps. #[derive(Clone, Debug, PartialEq)] @@ -38,7 +40,7 @@ pub enum Mo { }, MotionStudy { name: String, - /// Editing this study (its name). + /// Editing this study (its id). study: Option, steps: String, tracks: Vec, @@ -52,6 +54,8 @@ pub enum Mo { shown: Option<(u64, u64)>, saved: Option>, error: Option, + /// The chart and the collision check by the timeline. + analysis: Box, }, Explode { name: String, @@ -114,8 +118,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { shown: None, saved: None, error: None, + analysis: Box::default(), }, - vec![], + // A point to plot, and the parts to check for collisions (each optional). + vec![ + SelInput::new("Point", crate::dialogs_assembly::SNAPS, false), + SelInput::new("Components", BODIES, true), + SelInput::new("Against", BODIES, true), + ], ), "explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]), "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]), @@ -193,6 +203,57 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response { resp.on_hover_text(tip) } +/// Study `key` as the Motion Study dialog edits it: (id, name, steps, keys as tracks with values +/// in degrees and mm). `key` is an id first when `id_first`, else a name first (as +/// `motion.study` reads a number and text). The dialog keys whole steps of its timeline, so a +/// study keyed between them or off it (made by a command) is refused rather than changed. +pub fn study_tracks(s: &Session, key: &str, id_first: bool) -> Result<(u64, String, String, Vec), String> { + let studies = &s.doc.assembly.studies; + let (by_id, by_name) = (studies.iter().find(|m| m.id.to_string() == key), studies.iter().find(|m| m.name == key)); + let m = if id_first { by_id.or(by_name) } else { by_name.or(by_id) }.ok_or_else(|| format!("no motion study `{key}`"))?; + let last = f64::from(m.steps); + if m.keys.iter().flat_map(|k| &k.points).any(|(st, _)| st.fract() != 0.0 || !(0.0..=last).contains(st)) { + return Err(format!("{} has keys between whole steps or off its timeline, which this dialog can't keep: edit it with motion.study", m.name)); + } + let tracks = m + .keys + .iter() + .filter_map(|k| { + let joint = s.doc.assembly.joints.iter().position(|j| j.id == k.joint)?; + let angle = s.doc.assembly.joints.get(joint)?.kind.is_angle(k.index); + let value = |v: f64| { + let (x, unit) = if angle { (v.to_degrees(), "deg") } else { (v, "mm") }; + // Short where that keeps the value; else all its digits, so OK keeps the key. + let short = crate::dialogs_assembly::num(x, unit); + let kept = short.split(' ').next().and_then(|n| n.parse::().ok()).is_some_and(|y| (y - x).abs() <= 1e-9 * x.abs().max(1.0)); + if kept { short } else { format!("{x} {unit}") } + }; + let points = k.points.iter().map(|(st, v)| (st.round().clamp(0.0, f64::from(m.steps)) as u32, value(*v))).collect(); + Some(Track { joint, index: k.index, points }) + }) + .collect(); + Ok((m.id, m.name.clone(), m.steps.to_string(), tracks)) +} + +/// What the Motion Study dialog opens on when the control channel's `ui.start` passes `params`: +/// `study` (an id or a name), opened to edit. +pub fn preset(s: &Session, k: &mut Mo, p: &Value) -> Result<(), String> { + let Mo::MotionStudy { name, study, steps, tracks, shown, .. } = k else { return Err("not a Motion Study dialog".into()) }; + let o = p.as_object().ok_or("`params` must be an object")?; + if let Some(key) = o.keys().find(|k| k.as_str() != "study") { + return Err(format!("unknown param `{key}` (Motion Study takes study)")); + } + let (key, id_first) = match o.get("study") { + Some(Value::Number(n)) => (n.to_string(), true), + Some(Value::String(x)) => (x.clone(), false), + Some(_) => return Err("`study` must be an id or a name".into()), + None => return Ok(()), + }; + let (id, n, st, t) = study_tracks(s, &key, id_first)?; + (*study, *name, *steps, *tracks, *shown) = (Some(id.to_string()), n, st, t, None); + Ok(()) +} + /// The study command's parameters. pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result { let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?; @@ -208,8 +269,9 @@ pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, t return Err("add a key for a joint first".into()); } let mut p = json!({"name": name, "steps": steps, "keys": keys}); + // The id as a number: a study may be named like another's id. if let Some(st) = study { - p["study"] = json!(st); + p["study"] = st.parse::().map_or_else(|_| json!(st), |id| json!(id)); } Ok(p) } @@ -320,12 +382,45 @@ fn moved(before: &Session, after: &Session) -> BTreeMap { /// The study's pose at `step`, computed on a scratch copy. fn study_pose(app: &SolveApp, params: &Value, step: u32) -> Result, String> { let mut sc = app.session.scratch(); - let name = params["study"].as_str().or(params["name"].as_str()).unwrap_or_default().to_string(); - sc.execute("motion.study", params).map_err(|e| e.to_string())?; - sc.execute("motion.play", &json!({"study": name, "step": step})).map_err(|e| e.to_string())?; + let id = scratch_study(&mut sc, params)?; + sc.execute("motion.play", &json!({"study": id, "step": step})).map_err(|e| e.to_string())?; Ok(moved(&app.session, &sc)) } +/// Save the study being edited as an animated GIF, seen from the viewport's camera. Drawn on a +/// scratch copy, so the dialog's keys need not be saved first and the design is not changed. +fn export_animation(app: &mut SolveApp, params: Value, name: &str) { + // The view as the viewport shows it: its camera, and its grid and axes on or off. + let (camera, guides) = (app.cam, crate::control::window_guides(app)); + let file = format!("{}.gif", Some(name.trim()).filter(|n| !n.is_empty()).unwrap_or("Motion Study")); + app.pick_save(&file, &["gif"], move |app, path| { + let Some(mut path) = path else { return }; + // A name typed without the extension is still a GIF (not a folder of frames). + if !path.to_ascii_lowercase().ends_with(".gif") { + path.push_str(".gif"); + } + let mut sc = app.session.scratch(); + // Cut as the viewport is (a copy leaves the section out). + sc.section = app.session.section; + let made = sc.execute("motion.study", ¶ms).and_then(|r| { + sc.execute( + "motion.export_animation", + &json!({"study": r["study"], "path": path, "camera": camera, "grid": guides.grid, "axes": guides.axes}), + ) + }); + match made { + Ok(_) => app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false), + Err(e) => app.set_status(e.to_string(), true), + } + }); +} + +/// Makes (or edits) the study on a scratch copy, as OK would: its id there (a name may be +/// another study's too). +pub fn scratch_study(sc: &mut Session, params: &Value) -> Result { + Ok(sc.execute("motion.study", params).map_err(|e| e.to_string())?["study"].clone()) +} + /// The explosion at `scale`, computed on a scratch copy. fn explode_pose(app: &SolveApp, scale: f64) -> Result, String> { let mut sc = app.session.scratch(); @@ -404,7 +499,27 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => { + Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error, analysis } => { + // An existing study opens to edit (and to chart); New makes another from these keys. + // By id: two studies may share a name. + let studies: Vec<(String, String)> = app.session.doc.assembly.studies.iter().map(|m| (m.id.to_string(), m.name.clone())).collect(); + if !studies.is_empty() { + let labels: Vec<&str> = std::iter::once("New study").chain(studies.iter().map(|(_, n)| n.as_str())).collect(); + let was = study.as_ref().and_then(|id| studies.iter().position(|(x, _)| x == id)).map_or(0, |i| i + 1); + let mut pick = was; + row_label(ui, "Study"); + combo(ui, "ms_study", &labels, &mut pick); + ui.end_row(); + if pick != was { + match pick.checked_sub(1).and_then(|i| studies.get(i)).map(|(id, _)| study_tracks(&app.session, id, true)) { + Some(Ok((id, n, st, t))) => { + (*study, *name, *steps, *tracks, *shown, *playing) = (Some(id.to_string()), n, st, t, None, false) + } + Some(Err(e)) => app.set_status(e, true), + None => *study = None, + } + } + } row_label(ui, "Name"); ui.text_edit_singleline(name); ui.end_row(); @@ -469,6 +584,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel (*step, *playing) = advance(*step, dt * fps, total, *looping); ui.ctx().request_repaint(); } + // The chart and the collision check, by the timeline. + let study_now = study_params(&app.session, name, study.as_deref(), steps, tracks); + plot::rows(app, ui, analysis, &study_now, step, playing, total, inputs); // Tracks: a row per keyed joint value, its keys on a strip under the steps. let now = step.round() as u32; let mut remove: Option = None; @@ -532,15 +650,28 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel tracks.remove(i); } row_label(ui, ""); - if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() - && let Some((ji, _, kind)) = moving.first() - { - let unit = if kind.is_angle(0) { "deg" } else { "mm" }; - tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); - } + let mut export = false; + ui.horizontal(|ui| { + if ui.button(crate::i18n::tr_label("Add Key")).on_hover_text(crate::i18n::tr_label("Key a joint's value at this step")).clicked() + && let Some((ji, _, kind)) = moving.first() + { + let unit = if kind.is_angle(0) { "deg" } else { "mm" }; + tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] }); + } + export = ui + .button(crate::i18n::tr_label("Export Animation…")) + .on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it")) + .clicked(); + }); ui.end_row(); // Show the pose at this step (again when the step or the keys change). let params = study_params(&app.session, name, study.as_deref(), steps, tracks); + if export { + match ¶ms { + Ok(p) => export_animation(app, p.clone(), name), + Err(e) => *error = Some(e.clone()), + } + } let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -1002,6 +1133,7 @@ mod tests { shown: None, saved, error: None, + analysis: Box::default(), } } else { Mo::Explode { name: "E".into(), scale: 1.0, shown: None, saved, error: None } @@ -1276,6 +1408,48 @@ mod tests { } } + /// Export Animation saves the study the dialog shows (its keys need not be saved) as a GIF, + /// also when the name is typed without `.gif`, and the design keeps no study. + #[test] + fn export_animation_saves_the_dialogs_study_as_a_gif() { + // Drawn as the CLI draws (the headless device), as scenario shots are. + #[cfg(not(target_arch = "wasm32"))] + if solidcraft_engine::capture::installed().is_none() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer }) + })); + } + let mut h = crate::scenario::Harness::new(); + for (name, x) in [("A", 0), ("B", 30)] { + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("component.create", json!({ "name": name })).unwrap(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + let a = json!({"occurrence": "A:1", "face": [10, 10, 10]}); + h.app.run("joint.create", json!({"type": "revolute", "a": a, "b": {"occurrence": "B:1", "face": [40, 10, 0]}})).unwrap(); + h.app.start("motion.study"); + h.frames(3); + let Some(Kind::Motion(Mo::MotionStudy { name, steps, tracks, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { + panic!("no Motion Study dialog"); + }; + *name = "Swing".into(); + *steps = "4".into(); + *tracks = vec![Track { joint: 0, index: 0, points: vec![(0, "0 deg".into()), (4, "90 deg".into())] }]; + let dir = h.dir.clone(); + h.app.services.pick_save = Some(Box::new(move |name, extensions| { + assert_eq!((name, extensions), ("Swing.gif", &["gif"][..])); + Box::pin(std::future::ready(Some(dir.join("swing").to_string_lossy().into_owned()))) + })); + h.frames(2); + h.step(&json!({"widget": "Export Animation…"})).unwrap(); + h.frames(3); + let gif = std::fs::read(h.dir.join("swing.gif")).unwrap(); + assert!(gif.starts_with(b"GIF89a"), "{:?}", h.app.status); + assert!(h.app.status.as_ref().is_some_and(|s| !s.2), "{:?}", h.app.status); + assert!(h.app.session.doc.assembly.studies.is_empty(), "exporting saves no study"); + } + #[test] fn study_keys_by_joint_id() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs new file mode 100644 index 00000000..28929992 --- /dev/null +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -0,0 +1,508 @@ +//! Motion Study analysis rows: a chart over the study's timeline of a joint's value, or of a +//! picked point's travel, as value, velocity or acceleration (`motion.plot`), with a cursor at +//! the step shown; and the parts that collide as the study plays (`motion.interference`). Both +//! are worked out on a scratch copy, again only when what they are for changes. + +use std::hash::{Hash, Hasher}; + +use egui::{Align2, FontId, Rect, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; +use solidcraft_engine::Session; +use solidcraft_engine::doc::joints::JointKind; + +use crate::SolveApp; +use crate::dialogs::{combo, row_label}; +use crate::selection::SelInput; +use crate::theme::Tokens; + +/// Most samples the chart takes (each is a solve). +const SAMPLES: u64 = 201; +pub const QUANTITIES: [&str; 3] = ["Value", "Velocity", "Acceleration"]; + +/// What the chart shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Plotted { + Nothing, + /// A joint's value: (joint id, value index). + Joint(u64, usize), + /// The picked point: its distance from where it starts. + Point, +} + +#[derive(Clone, Debug, Default)] +pub struct Analysis { + /// Index into `choices`. + pub what: usize, + /// Index into `QUANTITIES`. + pub quantity: usize, + /// Seconds the whole study takes (empty: rates per step). + pub duration: String, + /// Collisions among all components (0) or in the design's contact sets (1). + pub among: usize, + /// The joint last picked in the browser, which the chart then shows. + followed: Option, + /// The chart's `motion.plot` answer (or why none) and what it was for. + plot: Option<(u64, Result)>, + /// The last collision check's answer and what it was for. + collisions: Option<(u64, Result)>, +} + +/// What the chart can show: (label, what). +pub fn choices(s: &Session, point: bool) -> Vec<(String, Plotted)> { + let mut out = vec![("None".to_string(), Plotted::Nothing)]; + for j in s.doc.assembly.joints.iter().filter(|j| j.kind != JointKind::Rigid && !j.suppressed) { + let dofs = j.kind.dofs(); + for (i, d) in dofs.iter().enumerate() { + out.push((if dofs.len() > 1 { format!("{} · {d}", j.name) } else { j.name.clone() }, Plotted::Joint(j.id, i))); + } + } + if point { + out.push(("Picked point".into(), Plotted::Point)); + } + out +} + +/// `motion.plot`'s parameters for `study` (the study command's parameters). +pub fn plot_params(s: &Session, what: Plotted, study: &Value, duration: &str, inputs: &[SelInput]) -> Result { + let name = study.get("study").or_else(|| study.get("name")).cloned().unwrap_or(Value::Null); + let steps = study["steps"].as_u64().unwrap_or(100); + let mut p = json!({"study": name, "samples": steps.saturating_add(1).clamp(3, SAMPLES)}); + match what { + Plotted::Nothing => return Err(String::new()), + Plotted::Joint(id, _) => p["joints"] = json!([id]), + Plotted::Point => { + let picked = crate::dialogs_assembly::picked_point(s, inputs)?; + p["joints"] = json!([]); + p["occurrence"] = picked["occurrence"].clone(); + p["point"] = picked["point"].clone(); + } + } + let d = duration.trim(); + if !d.is_empty() { + let seconds = d.parse::().ok().filter(|x| (1e-3..=1e9).contains(x)).ok_or("the duration must be a number of seconds, 0.001 or more")?; + p["duration"] = json!(seconds); + } + Ok(p) +} + +fn nums(v: &Value) -> Vec { + v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() +} + +/// The chart's line from `motion.plot`'s answer: (study steps, values, unit). +pub fn line(v: &Value, what: Plotted, quantity: usize) -> Option<(Vec, Vec, String)> { + let series = match what { + Plotted::Nothing => return None, + Plotted::Joint(_, i) => v["joints"].as_array()?.iter().find(|j| j["index"] == i)?, + Plotted::Point => &v["point"], + }; + let unit = series[["unit", "velocity_unit", "acceleration_unit"].get(quantity)?].as_str()?.to_string(); + let ys = match (what, quantity) { + (Plotted::Point, 0) => { + let at = |p: &Value| [0, 1, 2].map(|c| p[c].as_f64().unwrap_or(f64::NAN)); + let pts: Vec<[f64; 3]> = series["position"].as_array()?.iter().map(at).collect(); + let start = *pts.first()?; + pts.iter().map(|p| p.iter().zip(&start).map(|(a, b)| (a - b).powi(2)).sum::().sqrt()).collect() + } + (Plotted::Point, 1) => nums(&series["speed"]), + (Plotted::Point, _) => nums(&series["acceleration_norm"]), + (_, q) => nums(&series[*["value", "velocity", "acceleration"].get(q)?]), + }; + Some((nums(&v["step"]), ys, unit)) +} + +/// A short number for an axis or a readout. +fn short(v: f64) -> String { + let a = v.abs(); + if a >= 1000.0 { + format!("{v:.0}") + } else if a >= 10.0 { + format!("{v:.1}") + } else { + format!("{v:.3}") + } +} + +/// The value of a line at step `at` (linear between samples; `None` outside it). +pub fn value_at(xs: &[f64], ys: &[f64], at: f64) -> Option { + let k = xs.iter().position(|x| *x >= at)?; + let (x1, y1) = (*xs.get(k)?, *ys.get(k)?); + if k == 0 || x1 <= at { + return Some(y1); + } + let (x0, y0) = (*xs.get(k - 1)?, *ys.get(k - 1)?); + Some(y0 + (y1 - y0) * (at - x0) / (x1 - x0).max(1e-12)) +} + +/// A line chart of `ys` over study steps `xs` (0…`total`), with the step shown as a cursor and +/// `bands` (step ranges where parts collide) shaded. Returns the step clicked or dragged to. +fn chart(ui: &mut egui::Ui, xs: &[f64], ys: &[f64], unit: &str, total: u32, now: f64, bands: &[[f64; 2]], t: &Tokens) -> Option { + let (r, resp) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::click_and_drag()); + crate::scenario::publish_rect("motion:chart", r); + let p = ui.painter_at(r); + p.rect(r, 3.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let plot = Rect::from_min_max(r.min + vec2(6.0, 16.0), r.max - vec2(6.0, 16.0)); + let total = f64::from(total.max(1)); + let x = |s: f64| plot.left() + plot.width() * (s / total).clamp(0.0, 1.0) as f32; + for [a, b] in bands { + let (a, b) = (x(*a), x(*b)); + p.rect_filled(Rect::from_x_y_ranges((a - 1.0)..=(b + 1.0), r.y_range()), 0.0, t.interference.gamma_multiply(0.35)); + } + let (lo, hi) = ys.iter().filter(|v| v.is_finite()).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v))); + if lo.is_finite() { + // A flat line sits in the middle. + let pad = if hi - lo < 1e-9 * hi.abs().max(1.0) { 1.0f64.max(hi.abs() * 0.1) } else { 0.0 }; + let (lo, hi) = (lo - pad, hi + pad); + let y = |v: f64| plot.bottom() - plot.height() * ((v - lo) / (hi - lo)) as f32; + if lo < 0.0 && hi > 0.0 { + p.line_segment([pos2(plot.left(), y(0.0)), pos2(plot.right(), y(0.0))], Stroke::new(1.0, t.border)); + } + let pts: Vec = xs.iter().zip(ys).filter(|(_, v)| v.is_finite()).map(|(s, v)| pos2(x(*s), y(*v))).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, t.accent))); + let font = FontId::proportional(10.0); + p.text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, format!("{} {unit}", short(hi)), font.clone(), t.text_dim); + p.text(r.left_bottom() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, format!("{} {unit}", short(lo)), font.clone(), t.text_dim); + if let Some(v) = value_at(xs, ys, now) { + p.text(r.right_top() + vec2(-4.0, 2.0), Align2::RIGHT_TOP, format!("{} {unit}", short(v)), font, t.text); + } + } + // Where steps 0 and `total` fall, for a scenario or an agent to click between. + crate::scenario::publish_handle("motion:chart_start", pos2(x(0.0), plot.center().y)); + crate::scenario::publish_handle("motion:chart_end", pos2(x(total), plot.center().y)); + let cx = x(now); + p.line_segment([pos2(cx, r.top()), pos2(cx, r.bottom())], Stroke::new(1.5, t.warning)); + let pos = resp.interact_pointer_pos().filter(|_| resp.clicked() || resp.dragged())?; + Some((f64::from((pos.x - plot.left()) / plot.width().max(1.0)).clamp(0.0, 1.0) * total).round()) +} + +/// What the motion stopping short ends the chart with (`None`: it went all the way). +fn stop_text(v: &Value) -> Option { + let at = v["stopped_at"].as_u64()?.to_string(); + let name = |x: &Value| x.as_str().unwrap_or_default().to_string(); + if let Some(l) = v["stopped_by_loop"].as_object() { + return Some(crate::i18n::format("trace.stopped_by_loop", &[("sample", &at), ("joint", &name(&l["joint"]))])); + } + let c = v["stopped_by_contact"].as_object()?; + Some(crate::i18n::format("trace.stopped_by_contact", &[("sample", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))])) +} + +fn key_of(x: impl std::fmt::Debug) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + format!("{x:?}").hash(&mut h); + h.finish() +} + +/// The occurrences of the bodies picked in input `i` (the instance each pick is on). +fn picked_occurrences(s: &Session, inputs: &[SelInput], i: usize) -> Vec { + let names: Vec<&str> = inputs.get(i).map(|inp| inp.items.iter().filter_map(crate::dialogs_assembly::body_of).collect()).unwrap_or_default(); + if names.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for (name, _, path, _) in s.doc.body_frames(&s.model.state()) { + if let Some(o) = path.last().filter(|_| names.contains(&name.as_str())) + && !out.contains(o) + { + out.push(*o); + } + } + out +} + +/// The step ranges where any pair collides, from `motion.interference`'s answer. +pub fn collision_bands(v: &Value) -> Vec<[f64; 2]> { + let ranges = v["collisions"].as_array().into_iter().flatten().flat_map(|c| c["steps"].as_array().cloned().unwrap_or_default()); + ranges.filter_map(|r| Some([r.get(0)?.as_f64()?, r.get(1)?.as_f64()?])).collect() +} + +/// The analysis rows by a study's timeline: `study` is the study command's parameters (or why +/// there are none yet), `step` the step shown (a click on the chart or a collision moves it). +#[allow(clippy::too_many_arguments)] +pub fn rows( + app: &SolveApp, + ui: &mut egui::Ui, + a: &mut Analysis, + study: &Result, + step: &mut f64, + playing: &mut bool, + total: u32, + inputs: &[SelInput], +) { + let t = Tokens::get(); + // The collision check first: the chart below it shades what it finds. + collision_rows(app, ui, a, study, step, playing, inputs, &t); + plot_rows(app, ui, a, study, step, playing, total, inputs, &t); +} + +/// The Collisions rows: Check collisions and what it found (a click on a pair shows its first step). +#[allow(clippy::too_many_arguments)] +fn collision_rows( + app: &SolveApp, + ui: &mut egui::Ui, + a: &mut Analysis, + study: &Result, + step: &mut f64, + playing: &mut bool, + inputs: &[SelInput], + t: &Tokens, +) { + // Collisions over the whole study: of the picked components (against the other picks, if + // any), else among all components or in the contact sets. + // Offered when there is a set to check (suppressed ones don't count). + let assembly = &app.session.doc.assembly; + let sets = assembly.contact_all || assembly.contact_sets.iter().any(|c| !c.suppressed); + a.among = if sets { a.among.min(1) } else { 0 }; + let (parts, against) = (picked_occurrences(&app.session, inputs, 1), picked_occurrences(&app.session, inputs, 2)); + let among = if !parts.is_empty() { + let mut p = json!({"occurrences": parts}); + if !against.is_empty() { + p["against"] = json!(against); + } + p + } else { + json!({"contact_sets": a.among == 1}) + }; + let check = study.as_ref().ok().map(|st| (st.clone(), among)); + let key = key_of(&check); + if a.collisions.as_ref().is_some_and(|(k, _)| *k != key) { + a.collisions = None; + } + row_label(ui, "Collisions"); + ui.horizontal(|ui| { + if sets && parts.is_empty() { + combo(ui, "ms_among", &["All components", "Contact sets"], &mut a.among); + } + let button = ui.add_enabled(check.is_some(), egui::Button::new(crate::i18n::tr_label("Check collisions"))); + crate::scenario::publish_handle("motion:check", button.rect.center()); + if button.clicked() + && let Some((st, mut p)) = check + { + let mut sc = app.session.scratch(); + let r = super::scratch_study(&mut sc, &st).and_then(|id| { + p["study"] = id; + sc.execute("motion.interference", &p).map_err(|e| e.to_string()) + }); + a.collisions = Some((key, r)); + } + }); + ui.end_row(); + match a.collisions.as_ref().map(|(_, r)| r) { + Some(Ok(v)) => { + let hits = v["collisions"].as_array().cloned().unwrap_or_default(); + row_label(ui, ""); + ui.vertical(|ui| { + if hits.is_empty() { + ui.label(RichText::new(crate::i18n::tr("motion.no_collisions")).color(t.text_dim)); + } + for (i, c) in hits.iter().enumerate() { + let steps: Vec = c["steps"] + .as_array() + .into_iter() + .flatten() + .map(|r| { + let (from, to) = (r[0].as_f64().unwrap_or(0.0).round(), r[1].as_f64().unwrap_or(0.0).round()); + if from == to { format!("{from}") } else { format!("{from}-{to}") } + }) + .collect(); + let name = |k: &str| c[k].as_str().unwrap_or_default().to_string(); + let text = crate::i18n::format("motion.collision", &[("a", &name("a")), ("b", &name("b")), ("steps", &steps.join(", "))]); + // A click shows where they first collide. + let link = ui.link(RichText::new(text).color(t.error)); + crate::scenario::publish_handle(&format!("motion:collision:{i}"), link.rect.center()); + if link.clicked() + && let Some(first) = c["first_step"].as_f64() + { + (*step, *playing) = (first, false); + } + } + if v["truncated"] == true { + let to = v["checked_to"].as_f64().map(|x| x.round().to_string()).unwrap_or_default(); + ui.label(RichText::new(crate::i18n::format("motion.checked_to", &[("step", &to)])).color(t.warning)); + } + }); + ui.end_row(); + } + Some(Err(e)) => { + row_label(ui, ""); + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + ui.end_row(); + } + None => {} + } +} + +/// The Plot rows: what to chart, and the chart (a click on it moves the step). +#[allow(clippy::too_many_arguments)] +fn plot_rows( + app: &SolveApp, + ui: &mut egui::Ui, + a: &mut Analysis, + study: &Result, + step: &mut f64, + playing: &mut bool, + total: u32, + inputs: &[SelInput], + t: &Tokens, +) { + let s = &app.session; + let picked = inputs.first().is_some_and(|i| !i.items.is_empty()); + let list = choices(s, picked); + a.what = a.what.min(list.len().saturating_sub(1)); + // A joint picked in the browser is charted (until the box or another pick says otherwise). + let browsed = app.tree.picked_joints.first().copied(); + if browsed != a.followed { + a.followed = browsed; + if let Some(i) = browsed.and_then(|id| list.iter().position(|(_, w)| *w == Plotted::Joint(id, 0))) { + a.what = i; + } + } + row_label(ui, "Plot"); + let labels: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect(); + combo(ui, "ms_plot", &labels, &mut a.what); + ui.end_row(); + let what = list.get(a.what).map_or(Plotted::Nothing, |(_, w)| *w); + let bands = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).map(collision_bands).unwrap_or_default(); + if what != Plotted::Nothing { + row_label(ui, "Quantity"); + combo(ui, "ms_quantity", &QUANTITIES, &mut a.quantity); + ui.end_row(); + row_label(ui, "Duration"); + let r = ui.add(egui::TextEdit::singleline(&mut a.duration).hint_text(crate::i18n::tr("motion.duration_hint"))); + crate::scenario::publish_handle("dialog:Duration", r.rect.center()); + ui.end_row(); + let params = study.clone().and_then(|st| plot_params(s, what, &st, &a.duration, inputs).map(|p| (st, p))); + let key = key_of(¶ms); + if a.plot.as_ref().is_none_or(|(k, _)| *k != key) { + let r = params.and_then(|(st, mut p)| { + let mut sc = s.scratch(); + p["study"] = super::scratch_study(&mut sc, &st)?; + sc.execute("motion.plot", &p).map_err(|e| e.to_string()) + }); + a.plot = Some((key, r)); + } + row_label(ui, ""); + match a.plot.as_ref().map(|(_, r)| r) { + Some(Ok(v)) => { + ui.vertical(|ui| { + if let Some((xs, ys, unit)) = line(v, what, a.quantity) + && let Some(to) = chart(ui, &xs, &ys, &unit, total, *step, &bands, t) + { + (*step, *playing) = (to, false); + } + if let Some(why) = stop_text(v) { + ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap()); + } + }); + } + Some(Err(e)) => { + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.text_dim)).wrap()); + } + None => {} + } + ui.end_row(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The chart's line: a joint's quantity with its unit, or a point's distance from its start. + #[test] + fn lines_come_from_the_plot_answer() { + let v = json!({"step": [0.0, 1.0, 2.0], + "joints": [{"index": 0, "unit": "deg", "velocity_unit": "deg/s", "acceleration_unit": "deg/s²", + "value": [0, 10, 30], "velocity": [5, 15, 25], "acceleration": [10, 10, 10]}], + "point": {"unit": "mm", "velocity_unit": "mm/step", "acceleration_unit": "mm/step²", + "position": [[0, 0, 0], [3, 4, 0], [6, 8, 0]], "speed": [5, 5, 5], "acceleration_norm": [0, 0, 0]}}); + assert_eq!(line(&v, Plotted::Joint(7, 0), 1), Some((vec![0.0, 1.0, 2.0], vec![5.0, 15.0, 25.0], "deg/s".into()))); + assert_eq!(line(&v, Plotted::Point, 0).map(|l| l.1), Some(vec![0.0, 5.0, 10.0])); + assert_eq!(line(&v, Plotted::Point, 2).map(|l| l.2), Some("mm/step²".into())); + assert_eq!(line(&v, Plotted::Joint(7, 1), 0), None); + assert_eq!(line(&v, Plotted::Nothing, 0), None); + assert_eq!(value_at(&[0.0, 2.0, 4.0], &[0.0, 10.0, 30.0], 3.0), Some(20.0)); + assert_eq!(value_at(&[0.0, 2.0], &[1.0, 3.0], 9.0), None); + let hits = json!({"collisions": [{"steps": [[11.0, 15.0], [20.0, 20.0]]}]}); + assert_eq!(collision_bands(&hits), vec![[11.0, 15.0], [20.0, 20.0]]); + // A duration is a number of seconds, more than 0; empty is per step. + let s = Session::default(); + let study = json!({"name": "S", "steps": 10}); + let p = |d: &str| plot_params(&s, Plotted::Joint(7, 0), &study, d, &[]); + assert_eq!(p("2").unwrap()["duration"], json!(2.0)); + assert!(p("").unwrap().get("duration").is_none()); + for bad in ["1e400", "nan", "-1", "0", "soon"] { + assert!(p(bad).is_err(), "{bad}"); + } + } + + /// The analysis of the open Motion Study dialog, and its step. + fn shown(h: &crate::scenario::Harness) -> (Analysis, f64) { + match h.app.dialog.as_ref().map(|d| &d.kind) { + Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { analysis, step, .. })) => ((**analysis).clone(), *step), + _ => panic!("no Motion Study dialog"), + } + } + + /// In the dialog: a slider pushing B 1 mm a step from 10 mm off A to 5 mm into it. Plotting + /// the slider's velocity draws a chart of -1 mm/step; a click on the chart moves the step + /// there, and Check collisions names A and B from step 11. + #[test] + fn the_study_dialog_charts_a_joint_and_finds_collisions() { + let mut h = crate::scenario::Harness::new(); + for (name, x) in [("A", 0), ("B", 20)] { + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("component.create", json!({ "name": name })).unwrap(); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("occurrence.ground", json!({"occurrence": "A:1", "grounded": true})).unwrap(); + h.app.run("joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})).unwrap(); + h.app.run("joint.drive", json!({"joint": "Rail", "value": 10})).unwrap(); + let push = h + .app + .run("motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})) + .unwrap()["study"] + .to_string(); + // A study keyed between whole steps or off its timeline is not opened (the dialog would move its keys). + h.app.run("motion.study", json!({"name": "Fine", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [7.5, -5]]}]})).unwrap(); + h.app.run("motion.study", json!({"name": "Off", "steps": 15, "keys": [{"joint": "Rail", "points": [[-1, 10], [15, -5]]}]})).unwrap(); + // A tiny key keeps its value in the form (OK writes the study back from it). + h.app.run("motion.study", json!({"name": "Tiny", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 0.00004], [15, 3]]}]})).unwrap(); + let (_, _, _, tiny) = crate::dialogs_motion::study_tracks(&h.app.session, "Tiny", false).unwrap(); + assert_eq!(tiny[0].points, vec![(0, "0.00004 mm".to_string()), (15, "3 mm".to_string())]); + for bad in + [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]}), json!({"study": "Fine"}), json!({"study": "Off"})] + { + let r = h.call("ui.start", json!({"command": "motion.study", "params": bad})); + assert!(r["ok"] == false && h.app.dialog.is_none(), "{bad}: {r}"); + } + // The control channel opens the saved study to edit (and chart). + let r = h.call("ui.start", json!({"command": "motion.study", "params": {"study": "Push"}})); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { study, steps, tracks, analysis, .. })) = + h.app.dialog.as_mut().map(|d| &mut d.kind) + { + assert_eq!((study.as_deref(), steps.as_str()), (Some(push.as_str()), "15")); + assert_eq!(*tracks, vec![crate::dialogs_motion::Track { joint: 0, index: 0, points: vec![(0, "10 mm".into()), (15, "-5 mm".into())] }]); + (analysis.what, analysis.quantity) = (1, 1); + } + h.frames(4); + let (a, _) = shown(&h); + let plot = a.plot.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); + let (_, ys, unit) = line(&plot, Plotted::Joint(0, 0), 1).unwrap(); + assert!(unit == "mm/step" && ys.len() == 16 && ys.iter().all(|v| (v + 1.0).abs() < 1e-9), "{plot}"); + let chart = crate::scenario::rect_of("motion:chart").unwrap(); + // The plot area starts 6 points in: two thirds of the way along is step 10. + let x = chart.left() + 6.0 + (chart.width() - 12.0) * 2.0 / 3.0; + h.step(&json!({"click": [x, chart.center().y]})).unwrap(); + h.frames(2); + assert_eq!(shown(&h).1, 10.0); + let [bx, by] = h.widget("Check collisions").unwrap(); + h.step(&json!({"click": [bx, by]})).unwrap(); + h.frames(2); + let (a, _) = shown(&h); + let found = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); + assert_eq!((&found["collisions"][0]["a"], &found["collisions"][0]["first_step"]), (&json!("A:1"), &json!(11.0)), "{:?}", a.collisions); + assert_eq!(collision_bands(&found), vec![[11.0, 15.0]]); + } +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 7694ad36..2c5375fc 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -117,11 +117,16 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)], - Surf::Sweep(_) => vec![ - SelInput::new("Profiles or curves", PROFILES | CURVES, true), - SelInput::new("Path", CURVES, true), - crate::dialogs_sweep::direction_input(), - ], + Surf::Sweep(_) => { + let [rail, rail2] = crate::dialogs_sweep::rail_inputs(); + vec![ + SelInput::new("Profiles or curves", PROFILES | CURVES, true), + SelInput::new("Path", CURVES, true), + crate::dialogs_sweep::direction_input(), + rail, + rail2, + ] + } Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)], Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], @@ -371,7 +376,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { @@ -610,18 +615,21 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K } return Some((Kind::Surface(k), ins, extra)); } - FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => { - let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame); + FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, rails, rail_fit, .. } => { + let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, *rail_fit, frame); + let [rail, rail2] = crate::dialogs_sweep::rail_sels(rails); ( Surf::Sweep(opts), vec![ sheet_sels(app, sketch.0, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(), direction.into_iter().collect(), + rail, + rail2, ], ) } - FeatureKind::SurfaceLoft { sections } => { + FeatureKind::SurfaceLoft { sections, .. } => { (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()]) } FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { diff --git a/crates/ui-egui/src/dialogs_sweep.rs b/crates/ui-egui/src/dialogs_sweep.rs index dd5e2334..cc3a716e 100644 --- a/crates/ui-egui/src/dialogs_sweep.rs +++ b/crates/ui-egui/src/dialogs_sweep.rs @@ -1,14 +1,15 @@ //! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a -//! twist over the path's length and the profile's scale at the end. +//! twist over the path's length, the profile's scale at the end, and guide rails with how they +//! reshape the profile. use serde_json::{Value, json}; use solidcraft_engine::Sel; -use solidcraft_engine::doc::{Expr, Mat, SweepOrientation}; +use solidcraft_engine::doc::{Expr, Mat, RailFit, SweepOrientation, SweepRail}; use solidcraft_engine::geom::Vec3; use crate::SolveApp; use crate::dialogs::{combo, field, row_label}; -use crate::selection::{AXES, EDGES, SelInput}; +use crate::selection::{AXES, CURVES, EDGES, SelInput}; /// The orientation choices, in combo order: perpendicular, parallel, a direction. const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"]; @@ -18,6 +19,8 @@ const DIRECTION: usize = 2; const NO_TWIST: &str = "0 deg"; /// An end scale field that means no scaling. const NO_SCALE: &str = "1"; +/// The rail fits, in combo order: scale, stretch. +const RAIL_FITS: [&str; 2] = ["Scale", "Stretch"]; /// A sweep dialog's options. #[derive(Clone, Debug, PartialEq)] @@ -28,11 +31,13 @@ pub struct SweepOpts { pub scale: String, /// A stored direction (world), used while no direction is picked. pub direction: Option, + /// An index into [`RAIL_FITS`]. + pub rail_fit: usize, } impl Default for SweepOpts { fn default() -> Self { - SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None } + SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None, rail_fit: 0 } } } @@ -40,10 +45,17 @@ impl SweepOpts { /// A feature's stored options as dialog values; `frame` maps the feature's component frame to /// the world (a stored direction is in the component's frame). A direction along an origin /// axis comes back as a pick of that axis. - pub fn of(orientation: &SweepOrientation, twist: &Option, scale: &Option, frame: &Mat) -> (SweepOpts, Option) { + pub fn of( + orientation: &SweepOrientation, + twist: &Option, + scale: &Option, + rail_fit: RailFit, + frame: &Mat, + ) -> (SweepOpts, Option) { let mut o = SweepOpts { twist: twist.as_ref().map_or_else(|| NO_TWIST.into(), |t| t.to_string()), scale: scale.as_ref().map_or_else(|| NO_SCALE.into(), |k| k.to_string()), + rail_fit: usize::from(rail_fit == RailFit::Stretch), ..SweepOpts::default() }; let mut pick = None; @@ -74,6 +86,18 @@ pub fn direction_input() -> SelInput { SelInput::new("Direction", AXES | EDGES, false) } +/// The inputs that pick a sweep's guide rails: the first rail's curves and the second's, in order +/// along each rail; both optional. +pub fn rail_inputs() -> [SelInput; 2] { + [SelInput::new("Rail", CURVES, true), SelInput::new("Second Rail", CURVES, true)] +} + +/// A feature's stored rails as the rail inputs' selections. +pub fn rail_sels(rails: &[SweepRail]) -> [Vec; 2] { + let sels = |i: usize| rails.get(i).map(|r| r.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()).unwrap_or_default(); + [sels(0), sels(1)] +} + /// The option rows; true when Enter was pressed in a field. pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool { let mut enter = false; @@ -86,12 +110,34 @@ pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool { row_label(ui, "End Scale"); enter |= field(ui, &mut o.scale); ui.end_row(); + row_label(ui, "Rail Fit"); + combo(ui, &format!("{id}_rail_fit"), &RAIL_FITS, &mut o.rail_fit); + ui.end_row(); enter } /// The options as command parameters, added to `p`: the orientation, and the twist and end -/// scale when they change anything. `picked` is the direction input's selection. -pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> { +/// scale when they change anything; with rails picked, the rails (each in the sketch its curves +/// are in) and the fit instead. `picked` is the direction input's selection, `rails` the rail +/// inputs'. +pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, rails: [&[Sel]; 2], p: &mut Value) -> Result<(), String> { + let mut given = Vec::new(); + for sels in rails { + let curves: Vec = sels.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); + if curves.is_empty() { + continue; + } + let sketch = crate::dialogs::curves_sketch(app, &curves, None).ok_or("each rail's curves must be in one sketch")?; + given.push(json!({"sketch": sketch, "curves": curves})); + } + if !given.is_empty() { + // Rails set the profile's turn and size: the other options don't apply. + p["rails"] = json!(given); + if o.rail_fit == 1 { + p["rail_fit"] = json!("stretch"); + } + return Ok(()); + } match o.orientation { 0 => {} 1 => p["orientation"] = json!("parallel"), diff --git a/crates/ui-egui/src/dialogs_thread.rs b/crates/ui-egui/src/dialogs_thread.rs new file mode 100644 index 00000000..b9d0cdf1 --- /dev/null +++ b/crates/ui-egui/src/dialogs_thread.rs @@ -0,0 +1,95 @@ +//! Metric thread choices use the document's sizing table and the existing designation +//! parameter. Browsing choices does not edit the model or freeze an automatic feature. + +use solidcraft_engine::doc::{metric_thread_sizes, parse_metric_thread}; + +use crate::dialogs::{check, combo, row_label}; +use crate::i18n::tr; + +/// Common pitch presets, not an exhaustive catalogue of standard diameter/pitch pairs. +/// The coarse and current pitches are included separately; custom pairs remain editable. +const PITCHES: &[f64] = &[0.2, 0.25, 0.35, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + +/// Returns (Enter pressed, automatic sizing requested). +pub(crate) fn rows(ui: &mut egui::Ui, designation: &mut String, suggested: &mut Option) -> (bool, bool) { + row_label(ui, "Profile"); + ui.label(tr("ISO metric")); + ui.end_row(); + + row_label(ui, "Automatic size"); + let before = designation.trim().is_empty() || suggested.as_deref() == Some(designation.as_str()); + let mut automatic = before; + ui.add_enabled_ui(!designation.trim().is_empty(), |ui| check(ui, &mut automatic)); + let reset = automatic && !before; + if automatic != before { + *suggested = None; + if automatic { + designation.clear(); + } + } + ui.end_row(); + + let current = parse_metric_thread(designation); + let mut diameters: Vec = metric_thread_sizes().iter().map(|(d, _)| *d).collect(); + if let Some((d, _)) = current + && !diameters.contains(&d) + { + diameters.push(d); + diameters.sort_by(f64::total_cmp); + } + row_label(ui, "Size"); + let labels: Vec = diameters.iter().map(|d| format!("{d} mm")).collect(); + let mut selected = current.and_then(|(d, _)| diameters.iter().position(|n| *n == d)).unwrap_or(diameters.len()); + let before = selected; + let mut labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + if current.is_none() { + labels.push("Select size"); + } + combo(ui, "thread_diameter", &labels, &mut selected); + // A new diameter starts with its coarse pitch. The current custom diameter, if any, + // is already selected and retains its explicit pitch. + if selected != before + && let Some(d) = diameters.get(selected) + && let Some((_, coarse)) = metric_thread_sizes().iter().find(|(n, _)| n == d) + { + *designation = format!("M{d}x{coarse}"); + *suggested = None; + } + ui.end_row(); + + let current = parse_metric_thread(designation); + row_label(ui, "Pitch"); + ui.add_enabled_ui(current.is_some(), |ui| { + let mut pitches = Vec::new(); + if let Some((d, p)) = current { + let coarse = metric_thread_sizes().iter().find(|(n, _)| *n == d).map(|(_, p)| *p); + pitches.extend(PITCHES.iter().copied().filter(|p| *p < d && *p <= coarse.unwrap_or(d))); + pitches.extend(coarse); + pitches.push(p); + } + pitches.sort_by(|a, b| b.total_cmp(a)); + pitches.dedup(); + let labels: Vec = pitches.iter().map(|p| format!("{p} mm")).collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + let mut selected = current.and_then(|(_, p)| pitches.iter().position(|n| *n == p)).unwrap_or(0); + let before = selected; + combo(ui, "thread_pitch", &labels, &mut selected); + if selected != before + && let (Some((d, _)), Some(p)) = (current, pitches.get(selected)) + { + *designation = format!("M{d}x{p}"); + *suggested = None; + } + }); + ui.end_row(); + + row_label(ui, "Designation"); + let response = ui.add(egui::TextEdit::singleline(designation).hint_text(tr("fits the face, e.g. M8"))); + crate::scenario::publish_handle("dialog:thread_size", response.rect.center()); + if response.changed() { + *suggested = None; + } + let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + (enter, reset) +} diff --git a/crates/ui-egui/src/dialogs_trace.rs b/crates/ui-egui/src/dialogs_trace.rs index 32c88ceb..0433434a 100644 --- a/crates/ui-egui/src/dialogs_trace.rs +++ b/crates/ui-egui/src/dialogs_trace.rs @@ -71,11 +71,7 @@ pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec, V /// The trace's parameters (OK's `motion.trace.keep` and the preview's `motion.trace`). pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result { - let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?; - // The trace starts where the part is placed, not where it may be shown. - let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?; - let occurrence = if path.is_empty() { json!(0) } else { json!(path) }; - let mut p = json!({"occurrence": occurrence, "point": pt(at)}); + let mut p = picked_point(s, inputs)?; if f.by_study { p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id); } else { @@ -91,6 +87,15 @@ pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result Result { + let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?; + // The point starts where the part is placed, not where it may be shown. + let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?; + let occurrence = if path.is_empty() { json!(0) } else { json!(path) }; + Ok(json!({"occurrence": occurrence, "point": pt(at)})) +} + /// Where and why the motion stopped short, ending the trace (`None`: it went all the way). fn stop_text(v: &Value) -> Option { let at = v["stopped_at"].as_u64()?.to_string(); diff --git a/crates/ui-egui/src/dialogs_view_dxf.rs b/crates/ui-egui/src/dialogs_view_dxf.rs index cbf0222b..7a7a757c 100644 --- a/crates/ui-egui/src/dialogs_view_dxf.rs +++ b/crates/ui-egui/src/dialogs_view_dxf.rs @@ -3,10 +3,14 @@ use crate::{SolveApp, dialogs::Kind, selection::SelInput}; use serde_json::json; pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { - (id == "file.export_view_dxf").then(|| (Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed }, Vec::new())) + (id == "file.export_view_dxf").then(|| { + let tolerance = app.preferences.view_dxf_tolerance; + let tolerance = if tolerance.is_finite() && (0.0001..=1.0).contains(&tolerance) { tolerance } else { 0.01 }; + (Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed, tolerance }, Vec::new()) + }) } -pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) { +pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool, tolerance: &mut f64) { crate::dialogs::row_label(ui, "Hidden edges"); ui.vertical(|ui| { let r = ui.radio_value(dashed, false, app.tr_label("Do not export hidden edges")); @@ -15,34 +19,54 @@ pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) { crate::scenario::publish_handle("dialog:dxf:dashed", r.rect.center()); }); ui.end_row(); + crate::dialogs::row_label(ui, "dxf.tolerance"); + let r = ui.add(egui::DragValue::new(tolerance).range(0.0001..=1.0).speed(0.001).max_decimals(4).suffix(" mm")); + crate::scenario::publish_handle("dialog:dxf:tolerance", r.rect.center()); + ui.end_row(); crate::dialogs::row_label(ui, "Projection"); ui.label(app.tr_label(if app.cam.fov > 0.0 { "Current perspective view" } else { "Current orthographic view (1:1 mm)" })); ui.end_row(); crate::dialogs::row_label(ui, ""); - ui.label(app.tr_label("Visible bodies only. Curves use line segments (0.01 mm tolerance).")); + ui.add(egui::Label::new(app.tr_label("dxf.tolerance_help")).wrap()); ui.end_row(); } -pub fn run(app: &mut SolveApp, dashed: bool) -> Result<(), String> { +pub fn run(app: &mut SolveApp, dashed: bool, tolerance: f64) -> Result<(), String> { if crate::viewport::section_plane(app).is_some() { return Err(app.tr_label("Turn off section analysis before exporting the view.").into()); } - if !app.session.world_state().bodies.iter().any(|b| app.session.body_visible(&b.name)) { + if app.session.model.state().bodies.is_empty() { return Err(app.tr_label("No visible bodies to export.").into()); } + if !tolerance.is_finite() || !(0.0001..=1.0).contains(&tolerance) { + return Err(app.tr_label("dxf.invalid_tolerance").into()); + } app.preferences.view_dxf_dashed = dashed; + app.preferences.view_dxf_tolerance = tolerance; let camera = app.cam; app.pick_save(&format!("{}-view.dxf", app.session.doc.name), &["dxf"], move |app, path| { if let Some(path) = path { - let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"path":path}); - if app.run("file.export_view_dxf", params).is_ok() { - app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false); - } + let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"tolerance":tolerance,"path":path}); + let mut request = crate::file_load::Request::new(&path, crate::file_load::Kind::ExportDxf); + request.options = params.as_object().cloned().unwrap_or_default(); + let _ = crate::file_load::begin_request(app, request); } }); Ok(()) } +pub(crate) fn progress_text(work: Option<(&str, usize, usize)>) -> String { + let (stage, done, total) = work.unwrap_or(("dxf_meshes", 0, 1)); + let label = match stage { + "dxf_index" => "dxf.progress.index", + "dxf_projection" => "dxf.progress.projection", + "dxf_occlusion" => "dxf.progress.occlusion", + "dxf_write" => "dxf.progress.write", + _ => "dxf.progress.meshes", + }; + format!("{}: {done}/{total}", crate::i18n::tr_label(label)) +} + #[cfg(test)] mod tests { use super::*; @@ -58,7 +82,11 @@ mod tests { h.frames(3); h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap(); h.step(&json!({"widget":"Export Current View as DXF…"})).unwrap(); - assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false }))); + assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false, .. }))); + h.step(&json!({"click":{"handle":"dialog:dxf:tolerance"},"double":true})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"0.1"})).unwrap(); + h.step(&json!({"key":"Tab"})).unwrap(); h.step(&json!({"click":{"handle":"dialog:dxf:dashed"}})).unwrap(); let dir = h.dir.clone(); h.app.services.pick_save = Some(Box::new(move |name, extensions| { @@ -67,7 +95,7 @@ mod tests { Box::pin(std::future::ready(Some(dir.join("view.dxf").to_string_lossy().into_owned()))) })); h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap(); - h.frames(3); + wait_for_export(&mut h); let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap(); assert!(text.contains("LWPOLYLINE") && text.contains("HIDDEN")); assert!(h.app.dialog.is_none() && h.app.preferences.view_dxf_dashed); @@ -75,14 +103,27 @@ mod tests { let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&saved); assert!(restored.preferences.view_dxf_dashed); + assert_eq!(restored.preferences.view_dxf_tolerance, 0.1); h.app.start("file.export_view_dxf"); h.frames(3); - assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true }))); + assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true, tolerance: 0.1 }))); h.step(&json!({"click":{"handle":"dialog:dxf:omit"}})).unwrap(); h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap(); - h.frames(3); + wait_for_export(&mut h); let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap(); assert!(!text.contains("LWPOLYLINE") && !text.contains("HIDDEN")); assert!(!h.app.preferences.view_dxf_dashed); } + + fn wait_for_export(h: &mut crate::scenario::Harness) { + let began = std::time::Instant::now(); + loop { + h.frames(1); + if h.app.file_load.idle() && !h.app.file_picker_busy() { + break; + } + assert!(began.elapsed().as_secs() < 10, "export did not finish"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } } diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 7cfd1910..fc013b22 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -26,8 +26,39 @@ fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) { } fn type_value(h: &mut Harness, text: &str) { h.step(&json!({"key":"A","cmd":true})).unwrap(); - h.step(&json!({"text":text})).unwrap(); + if text.is_empty() { + h.step(&json!({"key":"Backspace"})).unwrap(); + } else { + h.step(&json!({"text":text})).unwrap(); + } } +#[test] +fn dimension_parameters_use_typing_and_ctrl_space_completion() { + for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] { + let mut h = line([20., 0.]); + h.app.run("parameters.add", json!({"name":"width","expression":"25 mm"})).unwrap(); + if prefix == "d" { + h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap(); + h.settle(); + } + place(&mut h, &[[10., 0.]], [10., 6.]); + let baseline = h.app.session.doc.to_json(); + type_value(&mut h, prefix); + if prefix.is_empty() { + h.step(&json!({"key":"Space","cmd":true})).unwrap(); + } + assert!(h.widget(expected).is_some(), "parameter completion is available for {prefix:?}: {}", h.call("ui.inspect", json!({}))); + h.step(&json!({"key":"Tab"})).unwrap(); + assert!(crate::dim_view::editing(), "accepting completion does not apply the dimension"); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.doc.params.iter().rev().find(|p| p.model).unwrap().expr, expected); + crate::dim_view::cancel(); + } +} + #[test] fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() { for (end, picks, at) in [ diff --git a/crates/ui-egui/src/expression_complete.rs b/crates/ui-egui/src/expression_complete.rs index 26df7cd5..67388312 100644 --- a/crates/ui-egui/src/expression_complete.rs +++ b/crates/ui-egui/src/expression_complete.rs @@ -2,7 +2,7 @@ use egui::{Key, RichText}; -use crate::params_dialog::{suggestions, word_before}; +use crate::params_dialog::{suggestions_for, word_before}; use crate::theme::Tokens; #[derive(Clone)] @@ -15,6 +15,7 @@ struct Popup { count: usize, accept: bool, dismissed: bool, + explicit: bool, } fn popup_id() -> egui::Id { @@ -25,6 +26,17 @@ fn popup_id() -> egui::Id { /// owns these keys. Raw events are removed too: several expression editors inspect raw Tab. pub(crate) fn begin_frame(ctx: &egui::Context) { let Some(mut popup) = ctx.data(|d| d.get_temp::(popup_id())) else { return }; + if ctx.memory(|m| m.has_focus(popup.editor)) + && popup.frame.saturating_add(1) >= ctx.cumulative_frame_nr() + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, Key::Space)) + { + ctx.input_mut(|i| { + i.raw.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Space, modifiers, .. } if modifiers.ctrl)); + }); + popup.explicit = true; + popup.dismissed = false; + ctx.data_mut(|d| d.insert_temp(popup_id(), popup.clone())); + } let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor)); let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred; if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 { @@ -88,7 +100,6 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri let state = egui::TextEdit::load_state(&ctx, resp.id); let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count()); let (start, word) = word_before(text, cursor); - let list = suggestions(&ctx, &word); let previous = ctx.data(|d| d.get_temp::(popup_id())); let mut popup = previous.filter(|p| p.editor == resp.id && p.word == word && p.cursor == cursor).unwrap_or(Popup { editor: resp.id, @@ -96,10 +107,12 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri word, cursor, selected: 0, - count: list.len(), + count: 0, accept: false, dismissed: false, + explicit: false, }); + let list = suggestions_for(&ctx, &popup.word, popup.explicit); popup.frame = ctx.cumulative_frame_nr(); popup.count = list.len(); popup.selected = popup.selected.min(list.len().saturating_sub(1)); @@ -153,6 +166,7 @@ pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut Stri #[cfg(test)] mod tests { use super::*; + use crate::params_dialog::suggestions; use crate::{SolveApp, params_dialog}; use serde_json::json; @@ -308,6 +322,23 @@ mod tests { assert!(e.leaked.contains(&Key::Enter)); } + #[test] + fn ctrl_space_opens_empty_expression_suggestions_and_reopens_dismissed_ones() { + for prefix in ["", "wi"] { + let mut e = Editor::new(prefix); + if !prefix.is_empty() { + e.key(Key::Escape); + } + e.frame(vec![egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::CTRL }]); + let popup = e.ctx.data(|d| d.get_temp::(popup_id())).unwrap(); + assert!(popup.explicit && !popup.dismissed && popup.count > 0); + assert_eq!(e.text, prefix, "Ctrl+Space does not add a space"); + e.key(Key::Tab); + assert!(e.accepted); + assert!(!e.text.is_empty()); + } + } + #[test] fn modified_navigation_remains_available_to_the_editor() { let mut e = Editor::new("wi"); diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 5064292b..739631c3 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -394,3 +394,62 @@ fn extrusion_arrow_tracks_the_current_drag_frame() { assert!((drawn[0] - expected[0]).hypot(drawn[1] - expected[1]) < 0.1, "arrow {drawn:?} trails current value {expected:?}"); } } + +/// The world point `at` mm along the open extrude's arrow. +fn on_arrow(h: &Harness, at: f64) -> Value { + let d = h.app.dialog.as_ref().unwrap(); + let (base, normal) = crate::canvas::anchor(&h.app, d).unwrap(); + let p = base + normal.unwrap() * at; + json!({"world":[p.x, p.y, p.z]}) +} + +fn dialog_distance(h: &Harness) -> f64 { + let d = h.app.dialog.as_ref().unwrap(); + let crate::dialogs::Kind::Extrude { distance, .. } = &d.kind else { panic!("extrude") }; + h.app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length).unwrap() +} + +/// The arrow's stem takes the drag as its tip does (#1740): pressed 4 mm along a 10 mm arrow and +/// moved to 9 mm, it grows by the 5 mm the pointer moved, not to where the pointer is. +#[test] +fn the_extrude_arrow_drags_from_its_stem() { + let mut h = make(json!({"distance":10})); + open(&mut h); + let (from, to) = (on_arrow(&h, 4.0), on_arrow(&h, 9.0)); + h.step(&json!({"drag":[from, to]})).unwrap(); + assert!((dialog_distance(&h) - 15.0).abs() < 1e-9, "{}", dialog_distance(&h)); + h.step(&json!({"widget":"OK"})).unwrap(); + let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds(); + assert!((bounds.max.z - 15.0).abs() < 1e-7, "{bounds:?}"); +} + +/// A click on the arrow's stem is the arrow's (#1740): a new extrude of a square sketched on a +/// block keeps its profile picked and its distance. Before, the click went through to the square +/// under the stem and unpicked it. +#[test] +fn a_click_on_the_extrude_arrow_stem_keeps_the_profile() { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, p) in [ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 10})), + ("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})), + ("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]})), + ] { + h.app.run(id, p).unwrap(); + } + h.app.finish_sketch(); + h.app.start("solid.extrude"); + h.frames(4); + let picked = |h: &Harness| h.app.dialog.as_ref().unwrap().inputs.iter().map(|i| i.items.len()).collect::>(); + let before = picked(&h); + assert!(before.iter().any(|n| *n > 0), "{before:?}"); + let distance = dialog_distance(&h); + let at = on_arrow(&h, distance * 0.3); + h.step(&json!({"click": at})).unwrap(); + h.frames(2); + assert_eq!(picked(&h), before); + assert!((dialog_distance(&h) - distance).abs() < 1e-9, "{} for {distance}", dialog_distance(&h)); +} diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index bfe3b711..f9dc3cca 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -29,6 +29,8 @@ pub enum Kind { Insert, /// Open a design an earlier run autosaved before it closed. Recover { id: String, dir: Option }, + /// Export a snapshot of the current design without adopting the worker's session. + ExportDxf, } pub struct Request { @@ -63,6 +65,7 @@ impl Request { Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})), Kind::Insert => ("file.insert_step", self.params()), Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})), + Kind::ExportDxf => ("file.export_view_dxf", Value::Object(self.options.clone())), } } fn answer(&self, r: Result) { @@ -99,6 +102,14 @@ struct Running { staged: Option, } +impl Drop for Running { + fn drop(&mut self) { + if self.request.kind == Kind::ExportDxf { + self.progress.cancel(); + } + } +} + /// A STEP import done a piece per frame, for the web app: it has one thread, which must keep /// drawing (and offer Cancel) while a large file comes in. The file is parsed in one frame, /// then its solids are converted a few at a time, each frame for about [`STAGED_FRAME_MS`]; @@ -208,6 +219,14 @@ pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> } r } + Kind::ExportDxf => { + let (command, params) = request.command(); + let r = app.run(command, params); + if r.is_ok() && !request.path.is_empty() { + app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false); + } + r + } }; request.answer(r); Ok(()) @@ -236,10 +255,15 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio let kind = match command { Some("doc.open") => Kind::Open, Some("file.insert_step" | "file.insert_mesh") => Kind::Insert, + Some("file.export_view_dxf") => Kind::ExportDxf, _ => return Some(req), }; let params = req.params.get("params"); - let Some(path) = params.and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) }; + let path = match params.and_then(|p| p.get("path")).and_then(Value::as_str) { + Some(path) => path, + None if kind == Kind::ExportDxf => "", + None => return Some(req), + }; let scope = match crate::control::request_scope(app, &req.params) { Ok(scope) => scope, Err(e) => { @@ -247,7 +271,11 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio return None; } }; - let options = ["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect(); + let options = if kind == Kind::ExportDxf { + params.and_then(Value::as_object).cloned().unwrap_or_default() + } else { + ["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect() + }; let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options }); None } @@ -281,7 +309,10 @@ fn start(app: &mut SolveApp) { return; } let Some(request) = app.file_load.queued.pop_front() else { return }; - if !request.insert() && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) { + if matches!(request.kind, Kind::Open | Kind::Recover { .. }) + && app.docs.count() >= crate::documents::MAX_OPEN + && !crate::documents::active_is_blank(app) + { let e = "too many open designs; close a tab before opening another"; app.set_status(e, true); request.answer(Err(e.into())); @@ -289,7 +320,7 @@ fn start(app: &mut SolveApp) { } let insert = request.insert(); let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; - let mut scratch = if insert { app.session.scratch() } else { Session::default() }; + let mut scratch = if insert || request.kind == Kind::ExportDxf { app.session.scratch() } else { Session::default() }; if insert { scratch.selection = app.session.selection.clone(); } @@ -679,6 +710,11 @@ fn compute( let result = solidcraft_engine::io::vfs::scoped(scope, || solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params))) .map_err(|e| e.to_string())?; + if command == "file.export_view_dxf" { + // A successful export has already published its complete file. A Cancel arriving + // after that commit must not turn a completed save into a misleading cancellation. + return Ok(Made { session, result, image: None }); + } if progress.is_cancelled() { return cancelled(); } @@ -739,6 +775,12 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { { let request = &running.request; match job { + Ok(made) if request.kind == Kind::ExportDxf => { + if !request.path.is_empty() { + app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false); + } + request.answer(Ok(made.result)); + } _ if running.cancelled() => request.answer(Err("file operation cancelled".into())), Ok(made) if running.matches(&app.session) => { if request.insert() { @@ -813,7 +855,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let phase = r.phase.load(Ordering::Relaxed); let (done, total) = r.progress.features(); // Long work inside one feature (a large STEP file) has progress of its own. - let work = r.progress.work().filter(|(_, d, t)| phase == 0 && d < t); + let export = r.request.kind == Kind::ExportDxf; + let work = r.progress.work().filter(|(_, d, t)| export || (phase == 0 && d < t)); let screen = ctx.content_rect(); // Leave the title bar to the window. The resize edges are a foreground layer above this. let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max); @@ -848,20 +891,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.set_width(width - 16.0); ui.horizontal(|ui| { ui.label( - egui::RichText::new(if cancelled { + egui::RichText::new(if cancelled && export { + crate::i18n::tr_label("dxf.cancelling") + } else if cancelled { "Waiting for file operation" } else { match r.request.kind { Kind::Insert => "Inserting geometry", Kind::Open => "Opening design", Kind::Recover { .. } => "Recovering design", + Kind::ExportDxf => crate::i18n::tr_label("dxf.exporting"), } }) .strong() .size(14.0), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let b = ui.button("Cancel"); + let b = ui.button(crate::i18n::tr("ui.cancel")); crate::scenario::publish_handle("file_loading:cancel", b.rect.center()); cancel |= b.clicked(); }); @@ -879,10 +925,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.add_space(4.0); ui.horizontal(|ui| { ui.spinner(); - ui.label(if cancelled { + ui.label(if cancelled && export { + crate::i18n::tr_label("dxf.cancel_wait").to_string() + } else if cancelled { "Waiting for the previous operation to stop...".to_string() } else { match (phase, work) { + (_, work) if r.request.kind == Kind::ExportDxf => crate::dialogs_view_dxf::progress_text(work), (_, Some(("reading", d, t))) => format!("Reading the file... {}%", d * 100 / t.max(1)), (_, Some((_, d, t))) => format!("Converting solid {} of {t}", d + 1), (1, _) => "Preparing the view...".into(), @@ -912,7 +961,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // import to overlap it in memory. The rebuild stops at its next feature and the // current design stays as it was. app.file_load.cancel(); - app.set_status("file operation cancelled", false); + app.set_status(if export { crate::i18n::tr_label("dxf.cancelled") } else { "file operation cancelled" }, false); } } @@ -920,6 +969,77 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn dxf_export_progress_keeps_frames_alive_and_never_adopts_a_worker_document() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.frames(3); + let base = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + let cam = h.app.cam; + let progress = Arc::new(Progress::default()); + solidcraft_engine::doc::progress::watch(progress.clone(), || { + solidcraft_engine::kernel::watch::current().unwrap().progress("dxf_occlusion", 64, 128); + }); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.file_load.running = Some(Running { + request: Request::new("drawing.dxf", Kind::ExportDxf), + base: base.clone(), + base_revision: h.app.session.revision, + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + progress: progress.clone(), + preview_rx: None, + preview: None, + partial_rx: None, + built: None, + staged: None, + }); + // No worker result is available: drawing a frame must not wait for one. + h.frames(4); + assert!(h.app.file_load.busy()); + let at = crate::scenario::handle_at("file_loading:cancel").unwrap(); + let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":at.x,"y":at.y})); + let _ = crate::control::handle(&mut h.app, &h.ctx, &request); + h.frames(4); + assert!(progress.is_cancelled()); + tx.send(Err("DXF export cancelled".into())).unwrap(); + h.frames(2); + assert!(h.app.file_load.idle()); + assert!(Arc::ptr_eq(&base, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.cam, cam); + + // The success path is equally read-only, even if the worker has another session. + let mut other = Session::default(); + other.execute("file.new", &json!({"name":"Worker must not replace the current design"})).unwrap(); + let mut request = Request::new("drawing.dxf", Kind::ExportDxf); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + request.reply = Some(reply_tx); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.file_load.running = Some(Running { + request, + base: base.clone(), + base_revision: h.app.session.revision, + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + progress: Arc::new(Progress::default()), + preview_rx: None, + preview: None, + partial_rx: None, + built: None, + staged: None, + }); + tx.send(Ok(Made { session: other, result: json!({"visible":12}), image: None })).unwrap(); + h.frames(2); + assert_eq!(reply_rx.try_recv().unwrap()["result"]["visible"], 12); + assert!(Arc::ptr_eq(&base, &h.app.session.doc)); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.cam, cam); + } + #[test] fn opening_result_and_preview_cannot_overwrite_a_newer_design() { for (insert, change_document) in [(false, true), (true, true), (false, false)] { diff --git a/crates/ui-egui/src/file_picker.rs b/crates/ui-egui/src/file_picker.rs index f508ed58..e880c07f 100644 --- a/crates/ui-egui/src/file_picker.rs +++ b/crates/ui-egui/src/file_picker.rs @@ -34,10 +34,15 @@ impl SolveApp { } pub(crate) fn pick_open(&mut self, complete: impl FnOnce(&mut SolveApp, Option) + 'static) { + self.pick_open_for(&[], complete); + } + + /// Open a file of this type: the picker offers these extensions first (#1801). + pub(crate) fn pick_open_for(&mut self, extensions: &[&str], complete: impl FnOnce(&mut SolveApp, Option) + 'static) { if self.file_picker_busy() { return; } - let future = self.services.pick_open.as_ref().map(|pick| pick()); + let future = self.services.pick_open.as_ref().map(|pick| pick(extensions)); self.begin_picker(future, Box::new(complete)); } @@ -66,9 +71,9 @@ impl SolveApp { self.file_picker = Some(Pending { future, complete }); } - pub(crate) fn pick_open_command(&mut self, command: &str, params: Value) { + pub(crate) fn pick_open_command(&mut self, command: &str, params: Value, extensions: &[&str]) { let command = command.to_string(); - self.pick_open(move |app, path| { + self.pick_open_for(extensions, move |app, path| { if let Some(path) = path { let mut params = params; params["path"] = json!(path); @@ -144,7 +149,7 @@ mod tests { if save { app.services.pick_save = Some(Box::new(move |_, _| Box::pin(Delayed(captured.clone())))); } else { - app.services.pick_open = Some(Box::new(move || Box::pin(Delayed(captured.clone())))); + app.services.pick_open = Some(Box::new(move |_| Box::pin(Delayed(captured.clone())))); } answer } @@ -275,6 +280,25 @@ mod tests { assert!(!crate::documents::any_dirty(&h.app)); } + /// #1801: Insert SVG and Insert DXF ask the host for their own file type first; other + /// opens keep the default list. + #[test] + fn sketch_inserts_ask_for_their_file_type() { + let mut h = crate::scenario::Harness::new(); + let asked: Rc>>> = Rc::default(); + let log = asked.clone(); + h.app.services.pick_open = Some(Box::new(move |extensions| { + log.borrow_mut().push(extensions.iter().map(|e| e.to_string()).collect()); + Box::pin(std::future::ready(None)) + })); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + for (command, want) in [("sketch.insert_svg", vec!["svg"]), ("sketch.insert_dxf", vec!["dxf"]), ("file.insert_step", vec![])] { + h.app.start(command); + tick(&mut h.app, &h.ctx); + assert_eq!(asked.borrow().last().cloned(), Some(want.iter().map(|e| e.to_string()).collect::>()), "{command}"); + } + } + #[test] fn picker_holds_engine_requests_but_answers_window_resize_requests() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index b02fb892..1881f651 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -1,9 +1,10 @@ -//! System font loading and fallback setup for multi-language (CJK: Japanese, Chinese, Korean) support. +//! System font loading and fallback setup for multi-language (CJK: Japanese, Chinese, Korean; +//! Thai) support. //! -//! The UI's Latin font always comes first; a CJK font (then a Hangul font) is only ever appended as -//! a fallback, so English text renders exactly as before and only glyphs the Latin font lacks use -//! the CJK face. The Hangul fallback covers Korean where the CJK font found (Meiryo, Hiragino) -//! lacks Hangul. The order of the CJK and Chinese faces follows the UI language +//! The UI's Latin font always comes first; a CJK font (then a Hangul font, then a Thai font) is only +//! ever appended as a fallback, so English text renders exactly as before and only glyphs the Latin +//! font lacks use the CJK face. The Hangul fallback covers Korean where the CJK font found (Meiryo, +//! Hiragino) lacks Hangul; the Thai fallback covers Thai typed in a name or the agent chat (#1902). The order of the CJK and Chinese faces follows the UI language //! (see [`fallback_order`]): one face drawing a whole word keeps its glyphs on one baseline. use std::sync::Arc; @@ -44,9 +45,12 @@ pub fn with_ui_font(mut fonts: egui::FontDefinitions, font: egui::FontData) -> e fonts } -/// Name of the Hangul fallback entry in [`egui::FontDefinitions`], last. +/// Name of the Hangul fallback entry in [`egui::FontDefinitions`], after the CJK ones. pub const HANGUL_FALLBACK: &str = "hangul_fallback"; +/// Name of the Thai fallback entry in [`egui::FontDefinitions`], last (#1902). +pub const THAI_FALLBACK: &str = "thai_fallback"; + /// UI languages whose text needs the CJK font. The web app's bundled font covers Japanese only; /// add Chinese or Korean here together with a font that covers them. pub const CJK_LANGUAGES: &[&str] = &["ja", "zh", "ko"]; @@ -318,6 +322,32 @@ const HANGUL_FONT_CANDIDATES: &[&str] = &[ "/usr/share/fonts/nanum/NanumGothic.ttf", ]; +/// Thai fonts, appended last: none of the Latin or CJK faces draw Thai, so without one Thai text +/// shows as empty boxes (#1902). +#[cfg(not(target_arch = "wasm32"))] +const THAI_FONT_CANDIDATES: &[&str] = &[ + // Windows: Leelawadee UI (Windows 8 and later), Leelawadee, Tahoma + "C:\\Windows\\Fonts\\LeelawUI.ttf", + "C:\\Windows\\Fonts\\leelawad.ttf", + "C:\\Windows\\Fonts\\tahoma.ttf", + // macOS: Thonburi, Ayuthaya + "/System/Library/Fonts/Thonburi.ttc", + "/System/Library/Fonts/Supplemental/Thonburi.ttc", + "/System/Library/Fonts/Supplemental/Ayuthaya.ttf", + "/Library/Fonts/Thonburi.ttf", + // Linux: Noto Sans Thai, TLWG Loma and Garuda + "/usr/share/fonts/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", + "/usr/share/fonts/opentype/noto/NotoSansThai-Regular.ttf", + "/usr/share/fonts/truetype/tlwg/Loma.ttf", + "/usr/share/fonts/opentype/tlwg/Loma.otf", + "/usr/share/fonts/truetype/tlwg/Garuda.ttf", + "/usr/share/fonts/opentype/tlwg/Garuda.otf", + "/usr/share/fonts/tlwg/Loma.otf", +]; + /// Fonts covering simplified Chinese, appended after the CJK fallback so Chinese renders even /// when the CJK font found is a Japanese one without simplified characters (Hiragino Gothic, /// Meiryo, Yu Gothic). @@ -464,11 +494,16 @@ fn find_system_cjk_font() -> Option> { /// epaint centres each face's own line height in the row, so glyphs from two faces of very /// different height (Meiryo ≈ 2.0 em, Microsoft YaHei ≈ 1.4 em) sit at different heights /// inside one word. One face for the whole word keeps them on one baseline. -pub fn fallback_order(language: &str) -> [&'static str; 3] { - if language == "zh" { [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK] } else { [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK] } +/// Thai comes last whatever the language: no other face draws it. +pub fn fallback_order(language: &str) -> [&'static str; 4] { + if language == "zh" { + [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK] + } else { + [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK] + } } -/// Configure font definitions with system CJK, Chinese and Hangul fallbacks where available, +/// Configure font definitions with system CJK, Chinese, Hangul and Thai fallbacks where available, /// ordered for the UI language (see [`fallback_order`]), after the interface font `ui_font` /// when one is chosen (see [`ui_font`]). #[cfg(not(target_arch = "wasm32"))] @@ -476,6 +511,7 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let cjk = find_system_cjk_font(); let chinese = find_system_chinese_font().filter(|bytes| cjk.as_ref() != Some(bytes)); let hangul = HANGUL_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); + let thai = THAI_FONT_CANDIDATES.iter().find_map(|path| try_read_font(std::path::Path::new(path))); let mut fonts = egui::FontDefinitions::default(); // Set the fonts also when only the interface font changes, so going back to the default // takes the chosen one away. @@ -487,7 +523,8 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) { let bytes = match name { CJK_FALLBACK => cjk.clone(), CHINESE_FALLBACK => chinese.clone(), - _ => hangul.clone(), + HANGUL_FALLBACK => hangul.clone(), + _ => thai.clone(), }; if let Some(bytes) = bytes && let Some(with_fallback) = with_fallback(fonts.clone(), name, bytes) @@ -586,9 +623,13 @@ mod tests { #[test] fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() { - assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); + assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]); for lang in ["en", "ja", "ko", "de"] { - assert_eq!(fallback_order(lang), [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK], "{lang} keeps the Japanese font first"); + assert_eq!( + fallback_order(lang), + [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK], + "{lang} keeps the Japanese font first" + ); } let defaults = egui::FontDefinitions::default(); let mut fonts = defaults.clone(); @@ -598,10 +639,32 @@ mod tests { for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { let list = &fonts.families[&family]; assert_eq!(list.first(), defaults.families[&family].first(), "the Latin UI font stays first"); - assert_eq!(&list[list.len() - 3..], [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK]); + assert_eq!(&list[list.len() - 4..], [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]); } } + /// Thai typed into the agent chat drew as empty boxes (#1902): every Thai candidate this + /// machine has must parse and cover Thai, and the fonts set up must then draw it. + #[test] + fn a_thai_fallback_draws_thai() { + let found: Vec> = THAI_FONT_CANDIDATES.iter().filter_map(|path| try_read_font(std::path::Path::new(path))).collect(); + for bytes in &found { + let face = ttf_parser::Face::parse(bytes, 0).unwrap(); + for c in "สวัสดี".chars() { + assert!(face.glyph_index(c).is_some(), "{c:?} is in the Thai font"); + } + } + let thai = |fonts: egui::FontDefinitions| { + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "สวัสดี")) + }; + assert!(!thai(egui::FontDefinitions::default()), "the built-in faces have no Thai"); + let Some(bytes) = found.into_iter().next() else { return }; + assert!(thai(with_fallback(egui::FontDefinitions::default(), THAI_FALLBACK, bytes).unwrap()), "Thai draws with the fallback"); + } + #[test] fn hangul_font_falls_back_after_the_cjk_font() { let defaults = egui::FontDefinitions::default(); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 83e2e3f8..222938e4 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -136,12 +136,13 @@ pub(crate) fn offscreen_backends() -> wgpu::Backends { /// A device of its own for an offscreen render or check (`None` without a GPU adapter), on /// [`offscreen_backends`]. #[cfg(all(test, not(target_arch = "wasm32")))] -fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { +pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env(); desc.backends = offscreen_backends(); let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features: solidcraft_gpu::depth_features(&adapter), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ..Default::default() })) @@ -253,7 +254,7 @@ fn offscreen( let (adapter, device, queue) = offscreen_device()?; eprintln!("offscreen adapter: {:?}", adapter.get_info().backend); let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm }; - let depth = wgpu::TextureFormat::Depth24PlusStencil8; + let depth = solidcraft_gpu::depth_format(&device, true); let compute = tess.and_then(|shader| solidcraft_gpu::view::tessellation(&adapter, &device, &queue, shader)); let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() }; let mut resources = egui_wgpu::CallbackResources::default(); diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs index 2d66e603..5b751a1e 100644 --- a/crates/ui-egui/src/help.rs +++ b/crates/ui-egui/src/help.rs @@ -11,7 +11,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const COMMIT: &str = env!("SOLIDCRAFT_COMMIT"); pub const BUILD_DATE: &str = env!("SOLIDCRAFT_BUILD_DATE"); /// The user and developer guide (docs/book, published by .github/workflows/pages.yml). -pub const DOCS_URL: &str = "https://bherbruck.github.io/solidcraft/"; +pub const DOCS_URL: &str = "https://docs.solidcraft.io/"; const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md"); use solidcraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY}; /// Fonts: what this build carries. diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs new file mode 100644 index 00000000..99f98a77 --- /dev/null +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -0,0 +1,100 @@ +//! Commands that recompute the history off the window thread (`deferred`): the marker moved, +//! undo, each computed beside the window and run once in; a marker moved again while one move +//! computes replaces the move still waiting. + +use std::time::Duration; + +use serde_json::json; + +use crate::scenario::Harness; + +/// A 60 × 20 × 10 block, a 10 × 10 × 5 boss on it, and a 4 mm hole through the boss, the +/// marker at the end. Every command computes off the window thread (no wait at all). +fn block_boss_hole() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, p) in [ + ("solid.box", json!({"length": 60, "width": 20, "height": 10})), + ("solid.box", json!({"corner": [5, 5, 10], "length": 10, "width": 10, "height": 5, "operation": "join"})), + ("solid.cylinder", json!({"base": [10, 10, -1], "axis": [0, 0, 1], "radius": 2, "height": 20, "operation": "cut"})), + ] { + h.app.run(id, p).unwrap(); + } + h.app.apply_off_thread = Some(Duration::ZERO); + h +} + +/// Frames until nothing computes or waits (a few seconds at most). +fn until_settled(h: &mut Harness) { + for _ in 0..500 { + if h.app.deferred.is_none() && h.app.deferred_queue.is_empty() { + break; + } + h.frames(1); + std::thread::sleep(Duration::from_millis(10)); + } + h.frames(1); +} + +fn marker(h: &Harness) -> Option { + h.app.session.doc.marker +} + +#[test] +fn the_marker_moved_computes_off_the_window_thread_then_moves() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap(); + assert!(h.app.computing_since().is_some(), "computing"); + assert_eq!(marker(&h), None, "the design is as it was while it computes"); + until_settled(&mut h); + assert_eq!(marker(&h), Some(1)); + h.step(&json!({"expect": {"volume": 12000}})).unwrap(); + // And back to the end, the boss and the hole recomputed (or found in the cache). + h.app.run_heavy("timeline.roll_to", json!({})).unwrap(); + until_settled(&mut h); + assert_eq!(marker(&h), None); + let hole = std::f64::consts::PI * 4.0 * 15.0; + let v = h.app.run("inspect.measure", json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); + assert!((v - (12500.0 - hole)).abs() < 1.0, "{v}"); +} + +#[test] +fn a_marker_moved_again_replaces_the_move_still_waiting() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 1})).unwrap(); + assert!(h.app.computing_since().is_some()); + // A drag: two more slots while the first computes. Only the last one waits. + h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap(); + h.app.run_heavy("timeline.roll_to", json!({"position": 3})).unwrap(); + assert_eq!(h.app.deferred_queue.len(), 1, "{:?}", h.app.deferred_queue); + until_settled(&mut h); + assert_eq!(marker(&h), None, "where the drag ended: the end of the history"); +} + +#[test] +fn undo_computes_off_the_window_thread() { + let mut h = block_boss_hole(); + let features = h.app.session.doc.features.len(); + h.app.start("edit.undo"); + assert!(h.app.computing_since().is_some(), "undo computing"); + assert_eq!(h.app.session.doc.features.len(), features, "nothing undone yet"); + until_settled(&mut h); + assert_eq!(h.app.session.doc.features.len(), features - 1, "the hole undone"); + h.step(&json!({"expect": {"volume": 12500}})).unwrap(); + h.app.start("edit.redo"); + until_settled(&mut h); + assert_eq!(h.app.session.doc.features.len(), features, "and redone"); +} + +#[test] +fn a_design_changed_while_a_move_computes_is_computed_again() { + let mut h = block_boss_hole(); + h.app.run_heavy("timeline.roll_to", json!({"position": 2})).unwrap(); + assert!(h.app.computing_since().is_some()); + // Something else changes the design meanwhile (a command that just runs). + h.app.run("solid.box", json!({"corner": [40, 5, 10], "length": 5, "width": 5, "height": 5, "operation": "join"})).unwrap(); + let revision = h.app.session.revision; + until_settled(&mut h); + assert!(h.app.session.revision > revision, "the move ran after the change"); + assert_eq!(marker(&h), Some(2)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ff8ac778..49fddeef 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -32,10 +32,15 @@ impl Catalog { } static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json"))); +static CZECH: Catalog = Catalog::new("cs", "Čeština", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/cs.json"))); static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json"))); +static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json"))); static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json"))); +static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/gl.json"))); static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json"))); static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json"))); +static PORTUGUESE_BR: Catalog = + Catalog::new("pt-BR", "Português (Brasil)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/pt-BR.json"))); static RUSSIAN: Catalog = Catalog::new("ru", "Русский", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ru.json"))); static TURKISH: Catalog = Catalog::new("tr", "Türkçe", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/tr.json"))); static UKRAINIAN: Catalog = Catalog::new("uk", "Українська", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/uk.json"))); @@ -45,10 +50,14 @@ static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env! #[rustfmt::skip] pub const LOCALES: &[&Catalog] = &[ &ENGLISH, + &CZECH, &GERMAN, + &SPANISH, &FRENCH, + &GALICIAN, &JAPANESE, &KOREAN, + &PORTUGUESE_BR, &RUSSIAN, &TURKISH, &UKRAINIAN, @@ -230,7 +239,7 @@ mod tests { } /// Catalogs delivered complete. English is the source catalog; Japanese was completed - /// upstream and Chinese lands complete here. German, Korean, Russian, French and Ukrainian + /// upstream, Chinese and Czech land complete here. German, Korean, Russian, French and Ukrainian /// are partial (German and Korean predate the coverage guards; Russian, French and Ukrainian /// are kept partial so a new label doesn't need more translations), so their gaps fall back /// to English and are reported by `cargo xtask i18n` without failing the build, while @@ -308,6 +317,7 @@ mod tests { #[test] fn newly_catalogued_command_labels_translate() { let cases = [ + ("cs", "Příruba", "Zebra analýza", "Studie pohybu"), ("zh", "法兰", "斑马纹分析", "运动研究"), ("ja", "フランジ", "ゼブラ解析", "モーション スタディ"), ("de", "Flansch", "Zebra-Analyse", "Bewegungsuntersuchung"), @@ -466,6 +476,18 @@ mod tests { assert_ne!(de.given_name("Sketch3"), "Sketch3"); } + #[test] + fn spanish_catalog_is_registered_and_translates_cad_labels() { + let es = Locale::for_language("es"); + assert_eq!(es.name(), "Español (España)"); + assert!(is_supported("es")); + assert_eq!(es.text("ui.preferences"), "Preferencias"); + assert_eq!(es.label("Extrude"), "Extruir"); + assert_eq!(es.label("Fillet"), "Redondeo"); + assert_eq!(es.dynamic("Sketch1"), "Boceto1"); + assert_eq!(es.format("document.save_changes", &[("name", "Pieza")]), "¿Guardar los cambios de Pieza?"); + } + #[test] fn korean_catalog_translates_keys_labels_and_browser_patterns() { let ko = Locale::for_language("ko"); @@ -542,6 +564,29 @@ mod tests { assert_eq!(tr.label("NonExistentKey"), "NonExistentKey"); } + #[test] + fn brazilian_portuguese_catalog_is_registered_and_translates_keys() { + let pt = Locale::for_language("pt-BR"); + assert_eq!(pt.name(), "Português (Brasil)"); + assert!(is_supported("pt-BR")); + assert_eq!(pt.text("ui.preferences"), "Preferências"); + assert_eq!(pt.format("document.save_changes", &[("name", "My {design}")]), "Salvar as alterações em My {design}?"); + assert_eq!(pt.format("dialog.selected_count", &[("count", "2")]), "↖ 2 selecionados"); + assert_eq!(pt.label("NonExistentKey"), "NonExistentKey"); + } + + #[test] + fn galician_catalog_is_registered_and_translates_keys() { + let gl = Locale::for_language("gl"); + assert_eq!(gl.name(), "Galego"); + assert!(is_supported("gl")); + assert_eq!(gl.text("ui.preferences"), "Preferencias"); + assert_eq!(gl.format("document.save_changes", &[("name", "My {design}")]), "Gardar os cambios en My {design}?"); + assert_eq!(gl.format("dialog.selected_count", &[("count", "2")]), "↖ 2 seleccionados"); + assert_eq!(gl.label("Fillet"), "Arredondamento"); + assert_eq!(gl.label("NonExistentKey"), "NonExistentKey"); + } + #[test] fn ukrainian_catalog_translates_keys_labels_and_browser_patterns() { let uk = Locale::for_language("uk"); diff --git a/crates/ui-egui/src/icon_svg.rs b/crates/ui-egui/src/icon_svg.rs new file mode 100644 index 00000000..b4708be6 --- /dev/null +++ b/crates/ui-egui/src/icon_svg.rs @@ -0,0 +1,894 @@ +//! The UI icon renderer: the SVG masters in `assets/icons/ui/design/svg`, drawn at the pixel size +//! they are shown. +//! +//! The masters are a small, linted SVG subset (`tools/lint_icons.py`): absolute `M L C Q A Z` +//! paths, circles, groups with a clip or opacity, masks and clip paths. They are embedded at build +//! time together with `palette.toml` (every colour role in light and dark, and the accents) and +//! `aliases.toml` (see `build.rs`), parsed once, and rasterised with tiny-skia into a texture per +//! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a +//! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*` +//! roles. GAP instead erases preceding artwork to transparency; it never paints a background. +//! Changing a colour is an edit to `palette.toml` only. +//! +//! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the +//! placeholder, and `every_master_parses` holds that none do. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use tiny_skia as sk; + +include!(concat!(env!("OUT_DIR"), "/icon_data.rs")); + +/// The id drawn for every icon that has no master yet. +pub const PLACEHOLDER: &str = "placeholder"; + +/// How a shape is painted. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Paint { + None, + /// Erase earlier artwork; never paint a theme background into the texture. + Knockout, + /// `currentColor`: the `line` role. + Line, + /// A palette role (index into `ROLE_NAMES`). + Role(usize), + /// A fixed colour (mask artwork). + Fixed([u8; 3]), +} + +#[derive(Debug)] +struct Node { + path: sk::Path, + fill: Paint, + rule: sk::FillRule, + stroke: Paint, + width: f32, + dash: Vec, + opacity: f32, + mask: Option, +} + +#[derive(Debug)] +enum Item { + Node(Node), + Group { clip: Option, opacity: f32, items: Vec }, +} + +/// A parsed master. +#[derive(Debug)] +pub struct Doc { + items: Vec, + masks: Vec>, + clips: Vec>, +} + +// --------------------------------------------------------------------------------------------- +// Parsing + +struct Tag<'a> { + name: &'a str, + attrs: Vec<(&'a str, &'a str)>, + close: bool, + selfclose: bool, +} + +impl Tag<'_> { + fn get(&self, k: &str) -> Option<&str> { + self.attrs.iter().find(|(n, _)| *n == k).map(|(_, v)| *v) + } + fn num(&self, k: &str) -> Result { + self.get(k).ok_or_else(|| format!("missing {k}"))?.trim().parse::().map_err(|e| format!("{k}: {e}")) + } +} + +fn tags(src: &str) -> Result>, String> { + let mut out = Vec::new(); + let mut rest = src; + while let Some(i) = rest.find('<') { + let after = rest.get(i + 1..).ok_or("truncated")?; + let j = after.find('>').ok_or("unterminated tag")?; + let body = after.get(..j).ok_or("truncated")?; + rest = after.get(j + 1..).ok_or("truncated")?; + if body.starts_with('?') || body.starts_with('!') { + continue; + } + let (close, body) = match body.strip_prefix('/') { + Some(b) => (true, b), + None => (false, body), + }; + let (selfclose, body) = match body.strip_suffix('/') { + Some(b) => (true, b), + None => (false, body), + }; + let body = body.trim(); + let name_end = body.find(char::is_whitespace).unwrap_or(body.len()); + let name = body.get(..name_end).ok_or("bad tag")?; + let mut attrs = Vec::new(); + let mut a = body.get(name_end..).unwrap_or(""); + while let Some(eq) = a.find('=') { + let key = a.get(..eq).ok_or("bad attribute")?.trim(); + let v = a.get(eq + 1..).ok_or("bad attribute")?.trim_start(); + let q = v.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute")?; + let v = v.get(1..).ok_or("bad attribute")?; + let end = v.find(q).ok_or("unterminated attribute")?; + attrs.push((key, v.get(..end).ok_or("bad attribute")?)); + a = v.get(end + 1..).unwrap_or(""); + } + out.push(Tag { name, attrs, close, selfclose }); + } + Ok(out) +} + +/// An SVG elliptical arc as cubic Béziers appended to `pb`. +#[allow(clippy::too_many_arguments)] +fn arc(pb: &mut sk::PathBuilder, x1: f32, y1: f32, rx: f32, ry: f32, rot_deg: f32, large: bool, sweep: bool, x2: f32, y2: f32) { + let (mut rx, mut ry) = (rx.abs(), ry.abs()); + if rx < 1e-6 || ry < 1e-6 || ((x1 - x2).abs() < 1e-6 && (y1 - y2).abs() < 1e-6) { + pb.line_to(x2, y2); + return; + } + let phi = rot_deg.to_radians(); + let (sp, cp) = phi.sin_cos(); + let (dx, dy) = ((x1 - x2) / 2.0, (y1 - y2) / 2.0); + let (x1p, y1p) = (cp * dx + sp * dy, -sp * dx + cp * dy); + let lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + if lam > 1.0 { + let s = lam.sqrt(); + rx *= s; + ry *= s; + } + let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p; + let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p; + let sign = if large == sweep { -1.0 } else { 1.0 }; + let coef = sign * (num / den).max(0.0).sqrt(); + let (cxp, cyp) = (coef * rx * y1p / ry, -coef * ry * x1p / rx); + let (cx, cy) = (cp * cxp - sp * cyp + (x1 + x2) / 2.0, sp * cxp + cp * cyp + (y1 + y2) / 2.0); + let ang = |ux: f32, uy: f32, vx: f32, vy: f32| (ux * vy - uy * vx).atan2(ux * vx + uy * vy); + let (ux, uy) = ((x1p - cxp) / rx, (y1p - cyp) / ry); + let (vx, vy) = ((-x1p - cxp) / rx, (-y1p - cyp) / ry); + let theta = ang(1.0, 0.0, ux, uy); + let mut dtheta = ang(ux, uy, vx, vy); + if !sweep && dtheta > 0.0 { + dtheta -= std::f32::consts::TAU; + } else if sweep && dtheta < 0.0 { + dtheta += std::f32::consts::TAU; + } + let n = ((dtheta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8); + let delta = dtheta / n as f32; + let t = 4.0 / 3.0 * (delta / 4.0).tan(); + let map = |x: f32, y: f32| (cx + rx * cp * x - ry * sp * y, cy + rx * sp * x + ry * cp * y); + for i in 0..n { + let a1 = theta + delta * i as f32; + let a2 = a1 + delta; + let (s1, c1) = a1.sin_cos(); + let (s2, c2) = a2.sin_cos(); + let (p1x, p1y) = map(c1 - t * s1, s1 + t * c1); + let (p2x, p2y) = map(c2 + t * s2, s2 - t * c2); + let (ex, ey) = map(c2, s2); + pb.cubic_to(p1x, p1y, p2x, p2y, ex, ey); + } +} + +fn parse_path(d: &str) -> Result { + let mut toks: Vec<&str> = Vec::new(); + let b = d.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = *b.get(i).ok_or("path")?; + if c.is_ascii_alphabetic() { + toks.push(d.get(i..=i).ok_or("path")?); + i += 1; + } else if c == b'-' || c == b'.' || c.is_ascii_digit() { + let s = i; + i += 1; + while let Some(&n) = b.get(i) { + if n.is_ascii_digit() + || n == b'.' + || ((n == b'-' || n == b'+') && matches!(b.get(i - 1), Some(b'e' | b'E'))) + || n == b'e' + || n == b'E' + { + i += 1; + } else { + break; + } + } + toks.push(d.get(s..i).ok_or("path")?); + } else { + i += 1; + } + } + let mut pb = sk::PathBuilder::new(); + let (mut k, mut cmd) = (0usize, ' '); + let (mut x, mut y) = (0.0f32, 0.0f32); + let mut open = false; + let num = |k: &mut usize| -> Result { + let t = toks.get(*k).ok_or("path: missing number")?; + *k += 1; + t.parse::().map_err(|e| format!("path: {e}")) + }; + while k < toks.len() { + let t = *toks.get(k).ok_or("path")?; + if t.len() == 1 && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) { + cmd = t.chars().next().ok_or("path")?; + k += 1; + } + match cmd { + 'M' => { + (x, y) = (num(&mut k)?, num(&mut k)?); + pb.move_to(x, y); + open = true; + cmd = 'L'; + } + 'L' => { + (x, y) = (num(&mut k)?, num(&mut k)?); + pb.line_to(x, y); + } + 'C' => { + let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?]; + pb.cubic_to(v[0], v[1], v[2], v[3], v[4], v[5]); + (x, y) = (v[4], v[5]); + } + 'Q' => { + let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?]; + pb.quad_to(v[0], v[1], v[2], v[3]); + (x, y) = (v[2], v[3]); + } + 'A' => { + let (rx, ry, rot) = (num(&mut k)?, num(&mut k)?, num(&mut k)?); + let (fa, fs) = (num(&mut k)? != 0.0, num(&mut k)? != 0.0); + let (ex, ey) = (num(&mut k)?, num(&mut k)?); + arc(&mut pb, x, y, rx, ry, rot, fa, fs, ex, ey); + (x, y) = (ex, ey); + } + 'Z' => { + if open { + pb.close(); + } + } + other => return Err(format!("path command {other} is not supported")), + } + } + pb.finish().ok_or_else(|| "empty path".to_string()) +} + +fn parse_paint(v: Option<&str>, in_mask: bool) -> Result { + let Some(v) = v else { return Ok(Paint::None) }; + match v { + "none" => Ok(Paint::None), + "currentColor" => Ok(Paint::Line), + "white" if in_mask => Ok(Paint::Fixed([255, 255, 255])), + "black" if in_mask => Ok(Paint::Fixed([0, 0, 0])), + _ => { + let lower = v.to_ascii_lowercase(); + ROLE_SOURCE_HEX + .iter() + .position(|h| *h == lower) + .map(|role| if ROLE_NAMES.get(role) == Some(&"gap") { Paint::Knockout } else { Paint::Role(role) }) + .ok_or_else(|| format!("colour {v} is not a palette colour")) + } + } +} + +fn parse_dash(v: Option<&str>) -> Vec { + let mut d: Vec = v.map(|s| s.split([' ', ',']).filter_map(|n| n.parse().ok()).collect()).unwrap_or_default(); + if d.len() % 2 == 1 { + d.extend(d.clone()); + } + d +} + +fn url_ref(v: &str) -> Option<&str> { + v.strip_prefix("url(#")?.strip_suffix(')') +} + +enum Frame { + Group { clip: Option, opacity: f32, items: Vec }, + Mask(usize), + Clip(usize), + Defs, +} + +impl Doc { + pub fn parse(src: &str) -> Result { + let mut doc = Doc { items: Vec::new(), masks: Vec::new(), clips: Vec::new() }; + let (mut mask_ids, mut clip_ids): (HashMap, HashMap) = (HashMap::new(), HashMap::new()); + let mut stack: Vec = Vec::new(); + for t in tags(src)? { + match (t.name, t.close) { + ("svg" | "title", _) => {} + ("defs", false) => stack.push(Frame::Defs), + ("mask", false) => { + doc.masks.push(Vec::new()); + mask_ids.insert(t.get("id").ok_or("mask without id")?.to_string(), doc.masks.len() - 1); + stack.push(Frame::Mask(doc.masks.len() - 1)); + } + ("clipPath", false) => { + doc.clips.push(Vec::new()); + clip_ids.insert(t.get("id").ok_or("clipPath without id")?.to_string(), doc.clips.len() - 1); + stack.push(Frame::Clip(doc.clips.len() - 1)); + } + ("g", false) => { + let clip = match t.get("clip-path").and_then(url_ref) { + Some(id) => Some(*clip_ids.get(id).ok_or("unknown clip")?), + None => None, + }; + let opacity = t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0); + stack.push(Frame::Group { clip, opacity, items: Vec::new() }); + } + ("defs" | "mask" | "clipPath" | "g", true) => match stack.pop() { + Some(Frame::Group { clip, opacity, items }) => { + let item = Item::Group { clip, opacity, items }; + match stack.last_mut() { + Some(Frame::Group { items, .. }) => items.push(item), + _ => doc.items.push(item), + } + } + Some(_) => {} + None => return Err("unbalanced tags".into()), + }, + ("path" | "circle", _) => { + let in_mask = matches!(stack.last(), Some(Frame::Mask(_))); + let path = if t.name == "path" { + parse_path(t.get("d").ok_or("path without d")?)? + } else { + sk::PathBuilder::from_circle(t.num("cx")?, t.num("cy")?, t.num("r")?).ok_or("bad circle")? + }; + let mask = match t.get("mask").and_then(url_ref) { + Some(id) => Some(*mask_ids.get(id).ok_or("unknown mask")?), + None => None, + }; + let dash = parse_dash(t.get("stroke-dasharray")); + let mut stroke = parse_paint(t.get("stroke"), in_mask)?; + let visible_stroke = match stroke { + Paint::Line => true, + Paint::Role(role) => !matches!(ROLE_NAMES.get(role).copied(), Some("gap" | "active-1" | "active-2" | "active-3")), + _ => false, + }; + // Non-blue dotted/dashed references share the guide colour. + // Blue source paints retain their semantic accent; keep + // background knockouts and literal mask paints unchanged. + if !dash.is_empty() && visible_stroke && !in_mask { + stroke = Paint::Role(ROLE_NAMES.iter().position(|name| *name == "guide").ok_or("missing guide colour")?); + } + let node = Node { + path, + fill: if t.get("fill").is_none() && in_mask { Paint::None } else { parse_paint(t.get("fill").or(Some("none")), in_mask)? }, + rule: if t.get("fill-rule") == Some("evenodd") { sk::FillRule::EvenOdd } else { sk::FillRule::Winding }, + stroke, + width: t.get("stroke-width").and_then(|w| w.parse().ok()).unwrap_or(1.0), + dash, + opacity: t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0), + mask, + }; + match stack.last_mut() { + Some(Frame::Mask(i)) => doc.masks.get_mut(*i).ok_or("mask")?.push(node), + Some(Frame::Clip(i)) => doc.clips.get_mut(*i).ok_or("clip")?.push(node), + Some(Frame::Group { items, .. }) => items.push(Item::Node(node)), + _ => doc.items.push(Item::Node(node)), + } + } + (other, _) => return Err(format!("element <{other}> is not supported")), + } + let _ = t.selfclose; + } + if !stack.is_empty() { + return Err("unbalanced tags".into()); + } + Ok(doc) + } +} + +// --------------------------------------------------------------------------------------------- +// Rendering + +/// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for +/// the base palette). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Look { + pub dark: bool, + pub accent: Option, + pub vision: crate::theme::ColorVision, +} + +impl Look { + fn rgb(self, role: usize) -> [u8; 3] { + let theme = usize::from(self.dark); + if ROLE_NAMES.get(role) == Some(&"guide") { + return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]); + } + let vision = match self.vision { + crate::theme::ColorVision::Off => "", + crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green", + crate::theme::ColorVision::Tritan => "blue-yellow", + }; + if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) { + return if self.dark { *d } else { *l }; + } + if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) { + return if self.dark { *d } else { *l }; + } + PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]) + } + + fn resolve(self, p: Paint) -> Option<[u8; 3]> { + match p { + Paint::None | Paint::Knockout => None, + Paint::Fixed(c) => Some(c), + Paint::Role(r) => Some(self.rgb(r)), + Paint::Line => ROLE_NAMES.iter().position(|n| *n == "line").map(|r| self.rgb(r)), + } + } +} + +fn paint_of(c: [u8; 3], alpha: f32) -> sk::Paint<'static> { + let mut p = sk::Paint::default(); + p.set_color_rgba8(c[0], c[1], c[2], (alpha.clamp(0.0, 1.0) * 255.0).round() as u8); + p.anti_alias = true; + p +} + +fn shape_paint(paint: Paint, look: Look, alpha: f32) -> Option> { + if paint == Paint::Knockout { + let mut paint = paint_of([0, 0, 0], alpha); + paint.blend_mode = sk::BlendMode::DestinationOut; + Some(paint) + } else { + look.resolve(paint).map(|colour| paint_of(colour, alpha)) + } +} + +fn draw_node(pm: &mut sk::Pixmap, n: &Node, look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) { + let own; + let mask = if let Some(m) = n.mask { + own = doc.masks.get(m).and_then(|nodes| { + let mut sub = sk::Pixmap::new(pm.width(), pm.height())?; + for node in nodes { + draw_node(&mut sub, node, look, tf, doc, None, 1.0); + } + let mut own = sk::Mask::from_pixmap(sub.as_ref(), sk::MaskType::Luminance); + if let Some(parent) = clip { + for (value, parent) in own.data_mut().iter_mut().zip(parent.data()) { + *value = ((u16::from(*value) * u16::from(*parent) + 127) / 255) as u8; + } + } + Some(own) + }); + own.as_ref() + } else { + clip + }; + let alpha = n.opacity * opacity; + if let Some(paint) = shape_paint(n.fill, look, alpha) { + pm.fill_path(&n.path, &paint, n.rule, tf, mask); + } + if let Some(paint) = shape_paint(n.stroke, look, alpha) { + let stroke = sk::Stroke { + width: n.width, + line_cap: sk::LineCap::Round, + line_join: sk::LineJoin::Round, + dash: if n.dash.is_empty() { None } else { sk::StrokeDash::new(n.dash.clone(), 0.0) }, + ..sk::Stroke::default() + }; + pm.stroke_path(&n.path, &paint, &stroke, tf, mask); + } +} + +fn draw_items(pm: &mut sk::Pixmap, items: &[Item], look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) { + for item in items { + match item { + Item::Node(n) => draw_node(pm, n, look, tf, doc, clip, opacity), + Item::Group { clip: c, opacity: o, items } => { + let mask = c.and_then(|c| doc.clips.get(c)).and_then(|nodes| { + let mut m = sk::Mask::new(pm.width(), pm.height())?; + for n in nodes { + m.fill_path(&n.path, n.rule, true, tf); + } + Some(m) + }); + draw_items(pm, items, look, tf, doc, mask.as_ref().or(clip), opacity * o); + } + } + } +} + +/// Rasterise `doc` into a square `px` × `px` premultiplied RGBA image. +pub fn render(doc: &Doc, look: Look, px: u32) -> Option { + let mut pm = sk::Pixmap::new(px, px)?; + let s = px as f32 / 64.0; + draw_items(&mut pm, &doc.items, look, sk::Transform::from_scale(s, s), doc, None, 1.0); + Some(pm) +} + +// --------------------------------------------------------------------------------------------- +// The icon table + +struct Entry { + doc: Doc, +} + +fn table() -> &'static (HashMap<&'static str, Entry>, HashMap<&'static str, Entry>) { + static T: OnceLock<(HashMap<&'static str, Entry>, HashMap<&'static str, Entry>)> = OnceLock::new(); + T.get_or_init(|| { + let (mut big, mut small) = (HashMap::new(), HashMap::new()); + for (id, fam, src) in SVGS { + match Doc::parse(src) { + Ok(doc) => { + let map = if *fam == "small" { &mut small } else { &mut big }; + // Model masters precede flat glyphs: keep the reviewed command artwork. + map.entry(*id).or_insert(Entry { doc }); + } + Err(e) => log::warn!("icon {fam}/{id}: {e}"), + } + } + (big, small) + }) +} + +/// A resolved icon: the master to draw and the accent it is drawn in. +#[derive(Clone, Copy, Debug)] +pub struct Resolved { + pub master: &'static str, + pub accent: Option, + pub placeholder: bool, +} + +fn accent_index(name: &str) -> Option { + if name.is_empty() { None } else { ACCENT_NAMES.iter().position(|a| *a == name) } +} + +/// A semantic default belongs to the requested id, never to the caller's current workspace. +fn default_accent(name: &str) -> Option { + ICON_ACCENTS.iter().find(|(id, _)| *id == name).and_then(|(_, accent)| accent_index(accent)) +} + +/// Primary semantic stroke, also used by toolbar affordances. The palette owns its value. +pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 { + let look = Look { dark, accent: resolve(name).accent, vision }; + role_colour("active-3", look) +} + +pub(crate) fn role_colour(name: &str, look: Look) -> egui::Color32 { + if name == "gap" { + return egui::Color32::TRANSPARENT; + } + let role = ROLE_NAMES.iter().position(|n| *n == name); + let [r, g, b] = role.map(|role| look.rgb(role)).unwrap_or([255, 0, 255]); + crate::theme::icon_color([r, g, b]) +} + +/// The master and accent for an app icon id: an alias (with its accent), a master of that name, +/// or the placeholder. +pub fn resolve(name: &str) -> Resolved { + let (big, _) = table(); + let placeholder = Resolved { master: PLACEHOLDER, accent: None, placeholder: true }; + if let Some((_, master, accent)) = ALIASES.iter().find(|(a, _, _)| *a == name) { + return match big.get_key_value(master) { + Some((k, _)) => Resolved { master: k, accent: accent_index(accent).or_else(|| default_accent(name)), placeholder: false }, + None => placeholder, + }; + } + match big.get_key_value(name) { + Some((k, _)) if name != PLACEHOLDER => Resolved { master: k, accent: default_accent(name), placeholder: false }, + _ => placeholder, + } +} + +/// The master to draw at `px` pixels: the small-slot variant where one exists. +pub fn doc_for(master: &str, px: u32) -> Option<&'static Doc> { + let (big, small) = table(); + if let Some(e) = small.get(master).filter(|_| px <= 24) { + return Some(&e.doc); + } + big.get(master).or_else(|| big.get(PLACEHOLDER)).map(|e| &e.doc) +} + +/// Every master id (not the small variants), for tests and tooling. +pub fn master_ids() -> Vec<&'static str> { + let mut v: Vec<_> = table().0.keys().copied().collect(); + v.sort_unstable(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_master_parses() { + for (id, fam, src) in SVGS { + if let Err(e) = Doc::parse(src) { + panic!("{fam}/{id}: {e}"); + } + } + assert!(table().0.contains_key(PLACEHOLDER), "the placeholder master exists"); + } + + #[test] + fn aliases_point_at_masters_and_known_accents() { + for (alias, master, accent) in ALIASES { + assert!(table().0.contains_key(master), "{alias} -> {master}"); + assert!(accent.is_empty() || ACCENT_NAMES.contains(accent), "{alias}: accent {accent}"); + assert!(!resolve(alias).placeholder); + } + } + + #[test] + fn non_blue_dashed_strokes_use_guides_but_preserve_accents_and_knockouts() { + let svg = ""; + let doc = Doc::parse(svg).unwrap(); + let guide = ROLE_NAMES.iter().position(|name| *name == "guide").unwrap(); + let active = ROLE_NAMES.iter().position(|name| *name == "active-3").unwrap(); + let Item::Node(line) = &doc.items[0] else { panic!("expected line") }; + let Item::Node(knockout) = &doc.items[1] else { panic!("expected knockout") }; + let Item::Node(blue) = &doc.items[2] else { panic!("expected blue line") }; + assert_eq!(line.stroke, Paint::Role(guide)); + assert_eq!(knockout.stroke, Paint::Knockout); + assert_eq!(blue.stroke, Paint::Role(active)); + for dark in [false, true] { + for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]); + let pm = render(&doc, look, 64).unwrap(); + assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide)); + assert_eq!([pm.pixel(9, 52).unwrap().red(), pm.pixel(9, 52).unwrap().green(), pm.pixel(9, 52).unwrap().blue()], look.rgb(active)); + } + } + } + + #[test] + fn knockout_fills_and_strokes_erase_earlier_layers_and_allow_later_foreground() { + let doc = Doc::parse("").unwrap(); + for dark in [false, true] { + for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + let pm = render(&doc, look, 64).unwrap(); + assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background"); + assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background"); + assert_eq!(pm.pixel(20, 20).unwrap().alpha(), 255, "foreground is drawn after erasure"); + assert_eq!(pm.pixel(2, 2).unwrap().alpha(), 255, "unaffected base stays opaque"); + assert!((126..=128).contains(&pm.pixel(44, 20).unwrap().alpha()), "partial opacity erases partially"); + } + } + } + + #[test] + fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() { + let doc = Doc::parse("").unwrap(); + let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0); + assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip"); + } + + #[test] + fn accepted_badges_and_environment_sun_have_transparent_holes() { + for dark in [false, true] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT); + for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] { + let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap(); + assert_eq!(pm.pixel(x, y).unwrap().alpha(), 0, "{id}: GAP must not be a painted theme colour"); + } + } + } + + #[test] + fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() { + for dark in [false, true] { + for px in [16, 24, 64] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] { + let doc = doc_for(id, px).unwrap(); + let pm = render(doc, look, px).unwrap(); + assert_eq!(pm.pixel(px - 2, px - 2).unwrap().alpha(), 0, "{id} escapes its native clip"); + assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents"); + } + } + let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer"); + } + } + + #[test] + fn command_meanings_choose_the_same_accent_on_every_surface() { + let accent = |name| resolve(name).accent.map(|i| ACCENT_NAMES[i]); + for c in solidcraft_engine::command_specs() { + let want = if c.id.starts_with("surface.") { + Some("surface") + } else if c.panel == "CONSTRAINTS" { + Some("constraint") + } else if matches!(c.id, "sketch.construction" | "sketch.centerline") { + Some("construction") + } else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") { + Some("sketch") + } else if c.id == "file.save_mesh" { + Some("mesh") + } else { + continue; + }; + assert_eq!(accent(c.icon), want, "{} ({})", c.id, c.icon); + } + for id in ["move", "scale", "copy", "delete", "export", "ground", "axis_two_planes"] { + assert_eq!(accent(id), None, "shared utility / modelling icon {id}"); + } + for (alias, master) in [("sketch_move", "move"), ("sketch_scale", "scale"), ("surface_fair", "spline_cv"), ("mesh_export", "export")] { + assert_eq!(resolve(alias).master, resolve(master).master, "reuse accepted shapes"); + } + } + + #[test] + fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() { + for dark in [false, true] { + for vision in crate::theme::ColorVision::ALL { + let tokens = crate::theme::Tokens::themed(dark, vision); + assert_eq!(primary("line", dark, vision), tokens.sketch_accent); + assert_eq!(primary("c_parallel", dark, vision), tokens.glyph_text); + assert_eq!(primary("centerline", dark, vision), tokens.construction_geom); + assert_eq!(primary("construction", dark, vision), tokens.construction_geom); + } + } + } + + #[test] + fn semantic_ramps_preserve_silhouettes_and_passive_faces_in_all_themes() { + use crate::theme::ColorVision; + for id in ["line", "c_parallel", "construction", "centerline", "sketch_rect_pattern", "surface_extrude", "mesh_export"] { + let resolved = resolve(id); + for dark in [false, true] { + for vision in ColorVision::ALL { + let look = Look { dark, accent: resolved.accent, vision }; + let base = Look { accent: None, ..look }; + for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] { + let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap(); + assert_eq!(look.rgb(role), base.rgb(role), "{id} changes passive paint"); + } + for px in [16, 24, 64] { + let doc = doc_for(resolved.master, px).unwrap(); + let changed = render(doc, look, px).unwrap(); + let original = render(doc, base, px).unwrap(); + assert!(changed.pixels().iter().zip(original.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{id}: silhouette at {px}px"); + } + } + } + } + } + + #[test] + fn command_artwork_resolves_and_construct_methods_are_distinct() { + let specs = solidcraft_engine::command_specs(); + for command in &specs { + assert!(!resolve(command.icon).placeholder, "{}: {}", command.id, command.icon); + } + let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect(); + let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect(); + assert_eq!(construction.len(), 19); + assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities"); + } + + #[test] + fn helper_controls_resolve_without_command_registry_entries() { + for name in ["agent_chat", "normal", "infinite", "pattern_flip", "pattern_center"] { + assert!(!resolve(name).placeholder, "UI helper: {name}"); + } + } + + #[test] + fn sketch_construction_modes_have_distinct_icons() { + let families: &[&[&str]] = &[ + &["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"], + &["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"], + &["sketch.slot.arc_three_point", "sketch.slot.arc_center"], + &["sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"], + ]; + for commands in families { + let icons: std::collections::HashSet<_> = commands.iter().map(|id| solidcraft_engine::find_command(id).unwrap().icon).collect(); + assert_eq!(icons.len(), commands.len(), "Sketch modes share an icon: {commands:?}"); + for icon in icons { + assert!(!resolve(icon).placeholder, "{icon}"); + } + } + } + + #[test] + fn contact_actions_have_distinct_icons_from_interference_analysis() { + let mut icons = std::collections::HashSet::new(); + let interference = solidcraft_engine::find_command("inspect.interference").unwrap().icon; + for id in ["contact.enable_sets", "contact.enable_all", "contact.disable_all", "contact.create"] { + let icon = solidcraft_engine::find_command(id).unwrap().icon; + assert_ne!(icon, interference, "{id} is a contact control, not an interference result"); + assert!(icons.insert(icon), "contact controls share an icon: {id}"); + assert!(!resolve(icon).placeholder, "{id}: {icon}"); + } + } + + #[test] + fn reviewed_model_folder_is_also_the_folder_drawn_in_the_app() { + let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2; + let expected = Doc::parse(source).unwrap(); + for dark in [false, true] { + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap(); + let reviewed = render(&expected, look, 64).unwrap(); + assert_eq!(actual.data(), reviewed.data()); + } + } + + #[test] + fn unknown_names_draw_the_placeholder() { + assert!(resolve("no_such_icon").placeholder); + assert!(resolve("").placeholder); + assert!(!resolve("extrude").placeholder); + } + + fn coverage(pm: &sk::Pixmap) -> usize { + pm.pixels().iter().filter(|p| p.alpha() > 0).count() + } + + #[test] + fn every_master_draws_something_at_every_size_in_both_themes() { + for id in master_ids() { + for dark in [false, true] { + for px in [16, 20, 26, 64] { + let Some(doc) = doc_for(id, px) else { panic!("{id}") }; + let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px) + .unwrap_or_else(|| panic!("{id} at {px}")); + assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank"); + } + } + } + } + + fn rgba(pm: &sk::Pixmap) -> Vec { + pm.data().to_vec() + } + + #[test] + fn dark_theme_and_accents_change_colour_not_shape() { + let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude")); + let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + assert_ne!(rgba(&light), rgba(&dark)); + let r = resolve("surface_extrude"); + assert_eq!(r.master, "extrude"); + let surface = + render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + assert!(r.accent.is_some()); + assert_ne!(rgba(&light), rgba(&surface)); + // Same silhouette: the alpha channel is identical. + let alpha = |pm: &sk::Pixmap| pm.pixels().iter().map(|p| p.alpha()).collect::>(); + assert_eq!(alpha(&light), alpha(&surface)); + } + + #[test] + fn hostile_artwork_is_an_error_not_a_panic() { + for bad in [ + "", + "<", + "", + "", + "", + "", + "", + "", + ] { + let _ = Doc::parse(bad); + } + assert!(Doc::parse("").is_err()); + assert!(Doc::parse("").is_err()); + } + + #[test] + fn palette_roles_are_distinct_and_complete() { + assert_eq!(ROLE_NAMES.len(), ROLE_SOURCE_HEX.len()); + let mut seen = std::collections::HashSet::new(); + assert!( + ROLE_NAMES.iter().zip(ROLE_SOURCE_HEX).filter(|(n, _)| **n != "line").all(|(_, h)| seen.insert(*h)), + "two roles share a source identifier" + ); + assert!(ROLE_NAMES.contains(&"line")); + assert_eq!(PALETTE.len(), 2); + assert!(PALETTE.iter().all(|p| p.len() == ROLE_NAMES.len())); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2a5bab03..089fa4da 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,9 +1,16 @@ -//! Stable UI rendering boundary for the collected artwork. Compiled only at L5. -#[path = "../../../assets/icons/ui/source/icons.rs"] -mod source; -pub use source::paint; -#[cfg(test)] -pub(crate) use source::paint_with; +//! Stable UI rendering boundary for the icon artwork. Compiled only at L5. +//! +//! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`, +//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the +//! accents), never from the caller. An icon with no master yet draws the placeholder. +//! The glyph, marker, navigation and pointer sources below are not command icons. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use egui::{Color32, Painter, Rect, TextureHandle, TextureOptions, pos2}; + +use crate::icon_svg::{self, Look}; + #[path = "../../../assets/icons/ui/source/glyphs.rs"] pub(crate) mod glyphs; #[path = "../../../assets/icons/ui/source/markers.rs"] @@ -12,3 +19,96 @@ pub(crate) mod markers; pub(crate) mod navigation; #[path = "../../../assets/icons/ui/source/pointer.rs"] pub(crate) mod pointer; + +/// Textures already rasterised: one per (master, look, pixel size). +#[derive(Clone, Default)] +struct Cache(Arc>>); + +/// Draw the icon `name` in `r`. The ink, fill and accent colours the callers pass are no longer +/// used: the palette decides. Only the ink's opacity is kept, so a dimmed (disabled) button dims +/// its icon. +pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, _fill: Color32, _accent: Color32) { + paint_icon(p, r, name, f32::from(ink.a()) / 255.0); +} + +/// [`paint`] with an explicit opacity (0 to 1). +pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) { + paint_on(p, r, name, None, opacity); +} + +/// Draw the icon `name` for a surface that is always dark (the application bar), whatever the +/// theme: `dark: Some(true)`. `None` follows the theme. +pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32) { + let ctx = p.ctx(); + let ppp = ctx.pixels_per_point().max(0.5); + let side = r.width().min(r.height()); + let px = (side * ppp).round().clamp(8.0, 512.0) as u32; + let resolved = icon_svg::resolve(name); + let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() }; + let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone()); + let Ok(mut map) = cache.0.lock() else { return }; + let key = (resolved.master, look, px); + if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) { + let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return }; + let Some(pm) = icon_svg::render(doc, look, px) else { return }; + let image = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data()); + slot.insert(ctx.load_texture(format!("icon:{}:{px}", resolved.master), image, TextureOptions::LINEAR)); + } + let Some(tex) = map.get(&key) else { return }; + // Snap the image to whole physical pixels so it stays crisp. + let size = px as f32 / ppp; + let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp); + let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8); + p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::{self, ColorVision}; + + #[test] + fn actual_uploaded_icon_pixels_follow_semantics_and_vision_cache_changes() { + let ctx = egui::Context::default(); + let previous = theme::vision(); + for dark in [false, true] { + for vision in [ColorVision::Off, ColorVision::Protan, ColorVision::Tritan] { + theme::set_vision(vision); + for icon in ["line", "c_parallel", "construction", "centerline", "surface_extrude", "mesh_export", "contact_new"] { + let expected = if icon == "construction" { + icon_svg::role_colour("guide", Look { dark, accent: None, vision }) + } else { + icon_svg::primary(icon, dark, vision) + }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0)); + paint_on(ui.painter(), r, icon, Some(dark), 1.0); + }); + assert!( + output.textures_delta.set.iter().any(|(_, deltas)| deltas.iter().any(|delta| match &delta.image { + egui::ImageData::Color(image) => image.pixels.contains(&expected), + })), + "{icon}, {dark}, {vision:?}: actual texture misses its semantic stroke or reused a stale cache entry" + ); + if icon == "contact_new" { + let image = output + .textures_delta + .set + .values() + .flatten() + .find_map(|delta| match &delta.image { + egui::ImageData::Color(image) if image.size == [64, 64] => Some(image), + _ => None, + }) + .unwrap(); + assert_eq!(image.pixels.get(48 * 64 + 48), Some(&Color32::TRANSPARENT), "uploaded plus must stay transparent"); + } + // This headless test inspects uploads instead of sending them + // to a GPU; acknowledge the deltas before they are dropped. + output.textures_delta.clear(); + } + } + } + theme::set_vision(previous); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e5a2a43..2a40f6e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ mod colour_vision_tests; pub mod context_menu; pub mod control; pub mod crash_ui; +pub mod deferred; pub mod delete; pub mod design_properties; pub mod dialogs; @@ -35,6 +36,7 @@ pub mod dialogs_save_mesh; pub mod dialogs_sheet; pub mod dialogs_surface; pub mod dialogs_sweep; +mod dialogs_thread; pub mod dialogs_view_dxf; pub mod dim_view; #[cfg(test)] @@ -58,6 +60,7 @@ pub mod i18n; mod i18n_menu_tests; #[cfg(test)] mod i18n_ui_tests; +pub mod icon_svg; pub mod icons; pub mod inference; pub mod install; @@ -97,6 +100,8 @@ mod expression_complete; mod extrude_edit_tests; mod file_load; mod file_picker; +#[cfg(test)] +mod history_off_thread_tests; pub use file_picker::FilePickerFuture; #[cfg(test)] mod blend_arrow_tests; @@ -108,6 +113,8 @@ mod fillet_spline_handle_tests; pub(crate) mod navigation; pub mod palette; pub mod params_dialog; +pub(crate) mod pick_buffer; +pub(crate) mod pick_index; pub mod prefs; #[cfg(test)] mod preselect_tests; @@ -248,7 +255,9 @@ impl Default for UiState { /// Host services injected by the app so this crate stays portable. #[derive(Default)] pub struct Services { - pub pick_open: Option FilePickerFuture>>, + /// Ask for a file to open. The extensions are the file type the command reads, offered as + /// the picker's first filter (`&[]`: designs, CAD files and meshes, #1801). + pub pick_open: Option FilePickerFuture>>, pub pick_save: Option FilePickerFuture>>, /// Open a dialog asking for a program (a slicer); `None` when the host has no such dialog. pub pick_program: Option FilePickerFuture>>, @@ -321,6 +330,10 @@ pub struct SolveApp { pub preview: preview::PreviewState, /// OK pressed on a feature that takes a while: its evaluation runs off the window thread. pub applying: Option, + /// A command recomputing the history (the marker moved, undo) computing off the window + /// thread, and those asked for meanwhile ([`deferred`]). + pub deferred: Option, + pub deferred_queue: Vec<(String, Value)>, /// How long OK waits for a feature before computing it off the window thread, saying so; /// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it. pub apply_off_thread: Option, @@ -435,6 +448,8 @@ impl SolveApp { slicer_menu: dialogs_save_mesh::SlicerMenu::default(), preview: preview::PreviewState::default(), applying: None, + deferred: None, + deferred_queue: Vec::new(), apply_off_thread: None, last_command: None, esc_handled: false, @@ -724,8 +739,8 @@ impl SolveApp { dim_view::cancel(); self.tool = None; self.home.open = false; - if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") { - self.pick_open_command(id, json!({})); + if let Some(ext) = id.strip_prefix("sketch.insert_").filter(|e| matches!(*e, "dxf" | "svg")) { + self.pick_open_command(id, json!({}), &[ext]); return; } if id == "component.insert_design" { @@ -843,6 +858,11 @@ impl SolveApp { self.dialog = Some(d); return; } + // Undo and redo can recompute much of the history: off the window thread when slow. + if matches!(id, "edit.undo" | "edit.redo") { + let _ = self.run_heavy(id, json!({})); + return; + } let _ = self.run(id, json!({})); } @@ -928,7 +948,9 @@ impl SolveApp { } fn restore_marker(&mut self, marker: Option) { - let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + // Rolling forward past the features after the sketch recomputes them (an edited sketch + // under a text cut): off the window thread when it takes a while. + let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); } /// Everything shown as selected: the selection plus the open dialog's inputs. @@ -942,13 +964,22 @@ impl SolveApp { v } - /// Is the origin widget shown (always while picking a sketch plane)? - /// Origin planes and axes are drawn and pickable. Hidden ones only come back while Create - /// Sketch has nothing else to offer (no body shown). + /// Is the origin widget shown? Origin planes and axes are drawn and pickable. While Create + /// Sketch picks its plane the Origin is always shown, drawn through the model (a plane + /// inside a body stays visible), even when the browser hides it. pub fn origin_visible(&self) -> bool { - self.ui.show_origin - || (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) - && !self.session.world_state().bodies.iter().any(|b| self.session.body_visible(&b.name))) + self.ui.show_origin || self.picking_sketch_plane() + } + + /// Is Create Sketch open, waiting for its plane? + pub fn picking_sketch_plane(&self) -> bool { + self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) + } + + /// Is the view drawn in perspective? The perspective preference, except while a sketch is + /// open: a sketch is drawn and measured on its plane, so the view is parallel (no foreshortening). + pub fn perspective_view(&self) -> bool { + self.ui.perspective && self.session.active_sketch.is_none() } pub fn set_status(&mut self, s: impl Into, error: bool) { @@ -1052,7 +1083,7 @@ impl SolveApp { fn fitted_bounds(&self, mut cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Camera { if let Some(layout) = &self.viewport.layout { - return viewport_layout::fit_bounds(cam, bounds, layout, self.ui.perspective).unwrap_or(cam); + return viewport_layout::fit_bounds(cam, bounds, layout, self.perspective_view()).unwrap_or(cam); } let previous = cam; cam.fit(bounds); @@ -1320,6 +1351,7 @@ impl SolveApp { fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option) { scenario::clear_handles(); + self.poll_deferred(ui.ctx()); toolbar::app_bar(self, ui); issue_report::CadInput::block(self, ui, report_input); file_load::show(self, ui.ctx()); @@ -1428,6 +1460,11 @@ impl SolveApp { control::Outcome::Done(v) => { let _ = reply.send(v); } + // A window that is minimised or covered (another macOS Space) presents no frame: + // say so at once rather than after the deadline (#1677). + control::Outcome::Screenshot { .. } if window_hidden(ctx) => { + let _ = reply.send(json!({"ok": false, "hidden": true, "error": "no frame was presented (window hidden?); use ui.render"})); + } control::Outcome::Screenshot { path } => { self.shot_token += 1; let token = self.shot_token; @@ -1481,8 +1518,10 @@ impl SolveApp { } } let now = now_ms(); + // Hidden while waiting: no frame is coming. + let hidden = window_hidden(ctx); self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { + if now < *deadline && !hidden { return true; } let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); @@ -1491,6 +1530,12 @@ impl SolveApp { } } +/// The window is minimised or fully covered, so it presents no frames (#1677). Unknown counts as +/// shown: the screenshot deadline still ends a wait for a frame that never comes. +fn window_hidden(ctx: &egui::Context) -> bool { + ctx.input(|i| i.viewport().visible()) == Some(false) +} + /// A command's name as people see it (its label), for the status bar. pub fn command_label(id: &str) -> &str { solidcraft_engine::find_command(id).map_or(id, |c| c.label) diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 7f401cf9..6f443da3 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -165,8 +165,7 @@ pub(crate) fn modifier_motion( && app.viewport.boxsel.is_none() && !(picking && hover.is_some_and(|p| app.viewport.rect.is_some_and(|rect| crate::canvas::owns_pointer(app, &projection(app, rect), p)))) && !hover.is_some_and(|p| app.viewport.layout.as_ref().is_some_and(|l| l.navigation.contains(p))); - let next = - hover.filter(|p| owns && p.is_finite()).and_then(|p| crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).map(|mode| (mode, p))); + let next = crate::prefs::nav_preset(&app.preferences.nav).motion_mode(mods).zip(hover.filter(|p| owns && p.is_finite())); let previous = app.viewport.modifier_nav; if previous.map(|(m, _)| m) != next.map(|(m, _)| m) { app.viewport.orbit_pivot = None; @@ -388,8 +387,19 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e } else if !primary_down && !primary_released { app.viewport.primary_nav = None; } - // The fixed navigation mode (navigation bar) or the preset's left-button binding. - let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); + // Hold Space for a momentary pan: the hand tool of design apps. It overrides whatever the + // left button would otherwise do - selecting, dragging geometry, the preset's binding or the + // navigation bar's fixed mode - and lasts only while the key is held. Space still types in a + // text field, and while a dimension's text is being edited, so those keep the keyboard. + let space_pan = (inside || primary_down) + && !ui.ctx().egui_wants_keyboard_input() + && !crate::dim_view::editing() + && ui.input(|i| i.key_down(egui::Key::Space)); + if space_pan { + ui.ctx().set_cursor_icon(if primary_down { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + // The fixed navigation mode (navigation bar), a held Space, or the preset's left-button binding. + let primary_nav = if space_pan { Some(NavMode::Pan) } else { app.viewport.nav.or(app.viewport.primary_nav) }; let button = if middle { Some(crate::prefs::NavButton::Middle) } else if secondary_down { @@ -916,4 +926,26 @@ mod view_key_tests { } assert!(crate::keymap::RESERVED.contains(&crate::keymap::key_name(egui::Key::Num8, egui::Modifiers::COMMAND).as_str())); } + + /// #1886: Ctrl+8 looks straight at the sketch; pressed again while the view faces it, it + /// looks from the other side, and a third press turns it back. Opening a sketch never flips. + #[test] + fn ctrl_8_again_looks_from_the_other_side() { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.frames(2); + let back_z = |h: &Harness| h.app.cam.back().z; + for want in [1.0, -1.0, 1.0] { + h.step(&json!({"key": "8", "cmd": true})).unwrap(); + h.frames(2); + assert!((back_z(&h) - want).abs() < 1e-3, "looking from z {want}: back {:?}", h.app.cam.back()); + } + // Programmatic look-at (as on opening a sketch) keeps the sketch's own side. + crate::dialogs::look_at_sketch(&mut h.app); + h.frames(2); + crate::dialogs::look_at_sketch(&mut h.app); + h.frames(2); + assert!((back_z(&h) - 1.0).abs() < 1e-3, "{:?}", h.app.cam.back()); + } } diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index d2cc46e9..d50af95c 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -144,7 +144,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.add(egui::TextEdit::singleline(&mut d.filter).hint_text(crate::i18n::tr_label("Filter")).desired_width(180.0)); ui.separator(); if ui.button("Import…").on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones")).clicked() { - app.pick_open_command("parameters.import", json!({})); + app.pick_open_command("parameters.import", json!({}), &[]); } for (label, ext) in [("Export CSV…", "csv"), ("Export JSON…", "json")] { if ui.button(label).clicked() { @@ -280,7 +280,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if r.source == "user" { let mut sel = UNITS.iter().position(|u| *u == r.unit).unwrap_or(0); let before = sel; - egui::ComboBox::from_id_salt(("punit", &r.name)).width(56.0).selected_text(unit_label(&r.unit)).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt(("punit", salt, &r.name)).width(56.0).selected_text(unit_label(&r.unit)).show_ui(ui, |ui| { for (i, u) in UNITS.iter().enumerate() { ui.selectable_value(&mut sel, i, unit_label(u)); } @@ -301,10 +301,15 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if bad { te = te.text_color(t.error); } - let id = ui.make_persistent_id(("pexpr", &r.name)); + // Per table: a favourite is listed twice, and one id would give both boxes the focus and + // each key to both (#1767). + let id = ui.make_persistent_id(("pexpr", salt, &r.name)); let focused = ui.memory(|m| m.has_focus(id)); let resp = ui.add_sized([170.0, 20.0], te.id(id)); crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center()); + if salt == "fav" { + crate::scenario::publish_handle(&format!("param:favorite:{}", r.name), resp.rect.center()); + } let completed = complete(ui, &resp, &mut e); if resp.changed() || completed { d.editing.insert(r.name.clone(), e.clone()); @@ -498,16 +503,24 @@ pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) { /// Suggestions for the word being typed. pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec { - if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { + suggestions_for(ctx, word, false) +} + +pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -> Vec { + if (!explicit && word.is_empty()) || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Vec::new(); } let all = ctx.data(|d| d.get_temp::(cache_id())).map(|c| c.all).unwrap_or_default(); // A complete token is ready to submit, even when longer names share its prefix. - if all.iter().any(|s| s.text == word) { + if !explicit && all.iter().any(|s| s.text == word) { return Vec::new(); } let w = word.to_ascii_lowercase(); - all.iter().filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && s.text.trim_end_matches('(') != word).take(8).cloned().collect() + all.iter() + .filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && (explicit || s.text.trim_end_matches('(') != word)) + .take(8) + .cloned() + .collect() } /// Offer keyboard and pointer completions under a focused expression box. @@ -540,4 +553,29 @@ mod tests { assert!(check(&mut app, "width / 2", "mm").is_ok()); assert!(check(&mut app, "width + 1 deg", "mm").is_err()); } + + /// A favourite is listed twice, under Favorites and in its own table; typing into either of its + /// expression boxes enters each key once (#1767: `1.1` came out `1.11.1`). + #[test] + fn a_favourite_parameter_takes_each_key_once() { + for (favourite, row) in [(false, 0), (true, 0), (true, 1)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name": "width", "expression": "10 mm"})).unwrap(); + if favourite { + h.app.run("parameters.favorite", json!({"name": "width", "favorite": true})).unwrap(); + } + h.step(&json!({"start": "parameters.change"})).unwrap(); + h.frames(4); + // `param:width` is the box in the parameter's own table, `param:favorite:width` the one + // under Favorites. + h.step(&json!({"click": {"handle": if row == 0 { "param:width" } else { "param:favorite:width" }}})).unwrap(); + h.frames(2); + h.step(&json!({"key": "A", "cmd": true})).unwrap(); + h.step(&json!({"text": "1.1"})).unwrap(); + h.frames(2); + let editing = h.app.params.as_ref().unwrap().editing.get("width").cloned(); + assert_eq!(editing.as_deref(), Some("1.1"), "favourite {favourite}, row {row}"); + } + } } diff --git a/crates/ui-egui/src/pick_buffer.rs b/crates/ui-egui/src/pick_buffer.rs new file mode 100644 index 00000000..373018f2 --- /dev/null +++ b/crates/ui-egui/src/pick_buffer.rs @@ -0,0 +1,259 @@ +//! The GPU pick buffer in the viewport ([`solidcraft_gpu::pick`]): which model edges are seen at +//! each pixel, so a pick over a large model tests only the edges near the cursor that are not +//! hidden, instead of every edge the cursor's ray passes near. +//! +//! The pass is drawn for a view once it holds still (the same view two frames running), and a +//! tile around the cursor is read back; the tile arrives a frame or two later, and until it +//! does (and while the view moves) the picker answers from the CPU indexes +//! ([`crate::pick_index`]). The tile only narrows the candidates: the picker's exact tests +//! still decide. Not used for Select Other (every target along the ray), in wireframe (nothing +//! hides an edge there), without a GPU, or for a model past [`MAX_TRIANGLES`]. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use egui::Pos2; +use solidcraft_engine::doc::ModelState; +use solidcraft_gpu::pick::{PickBuffer, PickOut, PickScene, PickView}; + +use crate::SolveApp; +use crate::viewport::Proj; + +/// Most surface triangles the pass draws (beyond them the CPU indexes answer alone). +pub const MAX_TRIANGLES: usize = 6_000_000; +/// How near the cursor an edge's pixels are looked for (points): vertices pick within 7, edges 6. +const WINDOW: f32 = 8.0; +/// Frames kept coming after a tile is asked for, so its read-back lands. +const FRAMES: u8 = 4; + +/// The pass's scene for one model state: the ids it draws stand for (body, edge). +struct Scene { + state: Arc, + key: u64, + scene: Arc, + /// Per id − 1: (body index in the state, edge index). + edges: Vec<(u32, u32)>, + /// One-point edges (no line to draw): always candidates. + points: Vec<(u32, u32)>, +} + +#[derive(Default)] +pub(crate) struct State { + scene: Mutex>>, + out: PickOut, + /// A pick found no tile for its cursor: the view it was in and the pixel. + want: Mutex>, + /// The view of the last frame (the pass is drawn for a view that held still). + last_view: u64, + /// Frames still to request so read-backs land. + frames: u8, +} + +/// The edges a tile names near the cursor, per body index of the world state. +pub(crate) struct NearEdges { + by_body: HashMap>, +} + +impl NearEdges { + /// Every segment of the named edges of body `index` of `mesh`, as (edge, first point), in + /// edge order. + pub(crate) fn segments(&self, index: usize, mesh: &solidcraft_engine::geom::Mesh) -> Vec<(usize, usize)> { + let mut v = Vec::new(); + for &ei in self.by_body.get(&index).map_or(&[][..], |e| &e[..]) { + let n = mesh.edges.get(ei).map_or(0, |e| e.len().max(2) - 1); + v.extend((0..n).map(|si| (ei, si))); + } + v + } +} + +/// What the pass's scene depends on: the world state, and which bodies show. +fn model_key(app: &SolveApp, st: &Arc) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (Arc::as_ptr(st) as usize).hash(&mut h); + app.session.revision.hash(&mut h); + app.session.visibility.hidden_bodies.hash(&mut h); + app.session.visibility.hidden_occurrences.hash(&mut h); + h.finish() +} + +/// The view's pixels: the viewport's size in physical pixels. +fn view_size(app: &SolveApp, proj: &Proj) -> [u32; 2] { + let ppp = app.viewport.pixels_per_point(); + [(proj.rect.width() * ppp).round().max(0.0) as u32, (proj.rect.height() * ppp).round().max(0.0) as u32] +} + +/// How far edges are lifted toward the eye: the picker's slack for an edge behind a face. +fn lift(app: &SolveApp) -> f64 { + crate::viewport::scene_radius(app) * 0.02 +} + +/// Everything the drawn ids depend on. +fn view_key(app: &SolveApp, proj: &Proj, model: u64) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + model.hash(&mut h); + for col in proj.vp.to_f32() { + for x in col { + x.to_bits().hash(&mut h); + } + } + view_size(app, proj).hash(&mut h); + lift(app).to_bits().hash(&mut h); + h.finish() +} + +fn usable(app: &SolveApp) -> bool { + app.viewport.gpu.is_some() && app.ui.visual_style != 2 +} + +/// The edges seen within reach of `pos`, when a tile of this view is in; otherwise `None`, and +/// a tile around `pos` is asked for. +pub(crate) fn edges_near(app: &SolveApp, proj: &Proj, pos: Pos2, st: &Arc) -> Option { + if !usable(app) { + return None; + } + let model = model_key(app, st); + let scene = app.viewport.pick_buffer.scene.lock().ok()?.clone().filter(|s| s.key == model && Arc::ptr_eq(&s.state, st)); + let key = view_key(app, proj, model); + let size = view_size(app, proj); + let ppp = app.viewport.pixels_per_point(); + let (x, y) = ((pos.x - proj.rect.left()) * ppp, (pos.y - proj.rect.top()) * ppp); + if !(x >= 0.0 && y >= 0.0 && x < size[0] as f32 && y < size[1] as f32) { + return None; + } + let (x, y) = (x as i64, y as i64); + let r = (WINDOW * ppp).ceil() as i64; + let window = [x - r, y - r, x + r, y + r]; + let ids = scene.as_ref().and_then(|_| app.viewport.pick_buffer.out.lock().ok()?.tile(key, size, window).map(|t| t.ids_in(window))); + let (Some(scene), Some(ids)) = (scene, ids) else { + if let Ok(mut w) = app.viewport.pick_buffer.want.lock() { + *w = Some((key, [x as u32, y as u32])); + } + return None; + }; + let mut by_body: HashMap> = HashMap::new(); + for (b, e) in ids.iter().filter_map(|id| scene.edges.get((*id as usize).checked_sub(1)?)).chain(&scene.points) { + by_body.entry(*b as usize).or_default().push(*e as usize); + } + for v in by_body.values_mut() { + v.sort_unstable(); + v.dedup(); + } + Some(NearEdges { by_body }) +} + +/// The pass's scene for the world state: visible bodies' triangles, and their pickable edges. +/// `None` past [`MAX_TRIANGLES`] or without edges. +fn scene(app: &SolveApp, st: &Arc, key: u64) -> Option> { + let f = |p: solidcraft_engine::geom::Vec3| [p.x as f32, p.y as f32, p.z as f32]; + let mut scene = PickScene::default(); + let (mut edges, mut points, mut triangles) = (Vec::new(), Vec::new(), 0usize); + for (bi, b) in st.bodies.iter().enumerate() { + if !app.session.body_visible(&b.name) { + continue; + } + let m = b.mesh(); + triangles += m.triangles.len(); + if triangles > MAX_TRIANGLES { + return None; + } + for t in &m.triangles { + if let Some([a, b, c]) = m.tri(t) { + scene.triangle([f(a), f(b), f(c)]); + } + } + for (ei, e) in m.edges.iter().enumerate() { + if !m.edge_pickable(ei) || edges.len() >= u32::MAX as usize - 1 { + continue; + } + if e.len() == 1 { + points.push((bi as u32, ei as u32)); + continue; + } + edges.push((bi as u32, ei as u32)); + let id = edges.len() as u32; + for w in e.windows(2) { + scene.segment(f(w[0]), f(w[1]), id); + } + } + } + (!scene.is_empty()).then(|| Arc::new(Scene { state: st.clone(), key, scene: Arc::new(scene), edges, points })) +} + +/// In the frame's GPU paint: draw the pass for a view that held still when a pick asked for a +/// tile, and keep frames coming until the read-back lands. +pub(crate) fn paint(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, proj: &Proj) { + if !usable(app) { + return; + } + let st = app.session.world_state(); + let model = model_key(app, &st); + let key = view_key(app, proj, model); + let state = &mut app.viewport.pick_buffer; + let still = std::mem::replace(&mut state.last_view, key) == key; + let want = state.want.lock().ok().and_then(|mut w| w.take()).filter(|(k, _)| *k == key && still); + if want.is_none() && state.frames == 0 { + return; + } + state.frames = if want.is_some() { FRAMES } else { state.frames.saturating_sub(1) }; + let cached = state.scene.lock().ok().and_then(|s| s.clone()).filter(|s| s.key == model && Arc::ptr_eq(&s.state, &st)); + let scene = match cached { + Some(s) => s, + None => { + let Some(s) = scene(app, &st, model) else { return }; + if let Ok(mut slot) = app.viewport.pick_buffer.scene.lock() { + *slot = Some(s.clone()); + } + s + } + }; + let e = proj.cam.eye().to_f32(); + let view = PickView { + key, + view_proj: proj.vp.to_f32(), + eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }], + back: proj.cam.back().to_f32(), + lift: lift(app) as f32, + size: view_size(app, proj), + }; + let cb = + PickCallback { scene_key: scene.key, scene: scene.scene.clone(), view, centre: want.map(|w| w.1), out: app.viewport.pick_buffer.out.clone() }; + painter.add(egui_wgpu::Callback::new_paint_callback(proj.rect, cb)); + ctx.request_repaint(); +} + +/// The pass as an egui paint callback: all its work is in `prepare` (it draws into its own +/// textures, not the frame). +struct PickCallback { + scene_key: u64, + scene: Arc, + view: PickView, + centre: Option<[u32; 2]>, + out: PickOut, +} + +impl egui_wgpu::CallbackTrait for PickCallback { + fn prepare( + &self, + device: &egui_wgpu::wgpu::Device, + queue: &egui_wgpu::wgpu::Queue, + _screen: &egui_wgpu::ScreenDescriptor, + encoder: &mut egui_wgpu::wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + if resources.get::().is_none() { + resources.insert(PickBuffer::new(device)); + } + if let Some(pb) = resources.get_mut::() { + pb.prepare(device, queue, encoder, self.scene_key, &self.scene, &self.view, self.centre, &self.out); + } + Vec::new() + } + + fn paint(&self, _info: egui::PaintCallbackInfo, _pass: &mut egui_wgpu::wgpu::RenderPass<'static>, _resources: &egui_wgpu::CallbackResources) {} +} + +#[cfg(test)] +#[path = "pick_buffer_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/pick_buffer_tests.rs b/crates/ui-egui/src/pick_buffer_tests.rs new file mode 100644 index 00000000..393c9498 --- /dev/null +++ b/crates/ui-egui/src/pick_buffer_tests.rs @@ -0,0 +1,141 @@ +use egui::{Rect, pos2}; +use serde_json::json; +use solidcraft_engine::geom::Vec3; +use solidcraft_gpu::pick::{PickTile, TILE, tile_around}; + +use super::*; +use crate::viewport::{Hit, pick, projection}; + +fn app() -> SolveApp { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[40, 15, 20]], "radius": 4})).unwrap(); + s.execute("solid.cylinder", &json!({"radius": 8, "height": 35, "base": [10, 10, 0], "operation": "new_body"})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + app.cam.target = Vec3::new(20.0, 15.0, 12.0); + app.cam.distance = 110.0; + // A GPU viewport (no device is needed to hand the picker a tile). + app.viewport.gpu = Some(crate::gpu::GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + app +} + +const RECT: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(640.0, 480.0) }; + +/// Installs the pass's scene, as `paint` makes it, and returns the view's key. +fn install_scene(app: &SolveApp, proj: &Proj) -> u64 { + let st = app.session.world_state(); + let model = model_key(app, &st); + let s = scene(app, &st, model).unwrap(); + *app.viewport.pick_buffer.scene.lock().unwrap() = Some(s); + view_key(app, proj, model) +} + +/// A tile only narrows the edges: the edge it names near the cursor is picked as the CPU picks +/// it, an edge it does not name (hidden there) is not, and without a tile the CPU indexes answer +/// and a tile is asked for. +#[test] +fn a_tile_names_the_edges_the_picker_tests() { + let app = app(); + let proj = projection(&app, RECT); + // A point on the box's top front edge (y = 0, z = 20). + let at = proj.to_screen(Vec3::new(20.0, 0.0, 20.0)).unwrap(); + let cpu = pick(&app, &proj, at); + let Some(Hit::Edge { index, body, .. }) = cpu.iter().find(|h| matches!(h, Hit::Edge { .. })).cloned() else { panic!("{cpu:?}") }; + assert!(app.viewport.pick_buffer.want.lock().unwrap().is_some(), "no tile yet: one is asked for"); + let key = install_scene(&app, &proj); + let st = app.session.world_state(); + let bi = st.bodies.iter().position(|b| b.name == body).unwrap(); + let s = app.viewport.pick_buffer.scene.lock().unwrap().clone().unwrap(); + let id = s.edges.iter().position(|&(b, e)| b as usize == bi && e as usize == index).unwrap() as u32 + 1; + let size = view_size(&app, &proj); + let (origin, tsize) = tile_around([at.x as u32, at.y as u32], size); + let tile = |ids: Vec| PickTile { key, origin, size: tsize, ids }; + let n = (tsize[0] * tsize[1]) as usize; + // The edge's id at the cursor's pixel. + let mut ids = vec![0; n]; + ids[((at.y as u32 - origin[1]) * tsize[0] + (at.x as u32 - origin[0])) as usize] = id; + app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(ids)]; + assert_eq!(pick(&app, &proj, at), cpu, "the named edge is picked as the CPU picks it"); + // A tile naming no edge there: no edge or vertex is picked. + app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(vec![0; n])]; + let none = pick(&app, &proj, at); + assert!(!none.iter().any(|h| matches!(h, Hit::Edge { .. } | Hit::Vertex { .. })), "{none:?}"); + assert!(none.iter().any(|h| matches!(h, Hit::Face { .. })), "faces are still picked: {none:?}"); + // A tile of another view does not answer. + app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![PickTile { key: key ^ 1, ..tile(vec![0; n]) }]; + assert_eq!(pick(&app, &proj, at), cpu); + // Wireframe: nothing hides an edge, and the buffer is not used. + let mut wire = app; + wire.ui.visual_style = 2; + wire.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(vec![0; n])]; + assert!(edges_near(&wire, &proj, at, &wire.session.world_state()).is_none()); +} + +/// On a real device: the pass drawn for the view, and picks over a grid of the view helped by its +/// tiles answer as the CPU picks (an edge's pixels and the CPU's visibility test can disagree by +/// a pixel at a silhouette, so a few may differ). Skipped without a GPU adapter. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picks_through_the_pass_match_the_cpu() { + let Some((_, device, queue)) = crate::gpu::offscreen_device() else { + assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "a GPU is required here"); + eprintln!("skipping: no GPU adapter"); + return; + }; + let mut app = app(); + for (perspective, yaw, pitch) in [(false, 0.6, 0.5), (true, 2.4, 0.3)] { + app.ui.perspective = perspective; + app.cam.yaw = yaw; + app.cam.pitch = pitch; + let proj = projection(&app, RECT); + let key = install_scene(&app, &proj); + let s = app.viewport.pick_buffer.scene.lock().unwrap().clone().unwrap(); + let e = proj.cam.eye().to_f32(); + let view = PickView { + key, + view_proj: proj.vp.to_f32(), + eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }], + back: proj.cam.back().to_f32(), + lift: lift(&app) as f32, + size: view_size(&app, &proj), + }; + let mut pb = PickBuffer::new(&device); + let out = app.viewport.pick_buffer.out.clone(); + // Tiles over the whole view. + let mut wanted = Vec::new(); + for ty in (0..view.size[1]).step_by(TILE as usize - 32) { + for tx in (0..view.size[0]).step_by(TILE as usize - 32) { + wanted.push([tx + TILE / 2, ty + TILE / 2]); + } + } + let mut tiles = Vec::new(); + for c in wanted { + for _ in 0..6 { + let mut encoder = device.create_command_encoder(&Default::default()); + pb.prepare(&device, &queue, &mut encoder, s.key, &s.scene, &view, Some(c), &out); + queue.submit([encoder.finish()]); + let _ = device.poll(egui_wgpu::wgpu::PollType::wait_indefinitely()); + } + tiles.append(&mut out.lock().unwrap().tiles); + } + let (mut same, mut differ, mut edges) = (0, 0, 0); + for j in 0..46 { + for i in 0..62 { + let p = pos2(10.0 + i as f32 * 10.0 + (j % 3) as f32, 10.0 + j as f32 * 10.0); + let r = 8i64; + let w = [p.x as i64 - r, p.y as i64 - r, p.x as i64 + r, p.y as i64 + r]; + let Some(t) = tiles.iter().find(|t| t.covers(key, view.size, w)).cloned() else { continue }; + out.lock().unwrap().tiles = vec![t]; + let through = pick(&app, &proj, p); + out.lock().unwrap().tiles.clear(); + let cpu = pick(&app, &proj, p); + edges += usize::from(cpu.iter().any(|h| matches!(h, Hit::Edge { .. }))); + if through == cpu { same += 1 } else { differ += 1 } + } + } + eprintln!("perspective {perspective}: {same} same, {differ} differ, {edges} with an edge"); + assert!(edges > 100, "the grid reaches edges ({edges})"); + assert!(differ * 100 <= same + differ, "{differ} of {} picks differ", same + differ); + } +} diff --git a/crates/ui-egui/src/pick_index.rs b/crates/ui-egui/src/pick_index.rs new file mode 100644 index 00000000..f19c44ea --- /dev/null +++ b/crates/ui-egui/src/pick_index.rs @@ -0,0 +1,102 @@ +//! Picking without scanning every triangle and edge: each body mesh's [`MeshIndex`] (its +//! triangles and edge segments in hierarchies), made the first time a pick reaches the body and +//! kept while the mesh is. The viewport's picker asks it for the nearest triangle along the +//! cursor's ray and for the edge segments near that ray, then runs its exact screen tests on +//! those alone. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use egui::{Pos2, vec2}; +use solidcraft_engine::geom::{Mesh, MeshIndex, Vec3}; + +use crate::viewport::Proj; + +/// Indexes by mesh address, each with its mesh held (so the address stays its own). +#[derive(Default)] +pub(crate) struct PickIndexes(Mutex, Arc)>>); + +impl PickIndexes { + /// The index of `mesh`, made now when there is none yet. + pub(crate) fn of(&self, mesh: &Arc) -> Arc { + let key = Arc::as_ptr(mesh) as usize; + let Ok(mut map) = self.0.lock() else { return Arc::new(MeshIndex::new(mesh)) }; + if let Some((_, index)) = map.get(&key) { + return index.clone(); + } + let index = Arc::new(MeshIndex::new(mesh)); + map.insert(key, (mesh.clone(), index.clone())); + index + } + + /// Drops the indexes of meshes `keep` no longer names (a new model state). + pub(crate) fn retain(&self, keep: impl Fn(usize) -> bool) { + if let Ok(mut map) = self.0.lock() { + map.retain(|k, _| keep(*k)); + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.0.lock().map_or(0, |m| m.len()) + } +} + +/// How far from the ray through `pos` a point may lie and still come within `px` points of it on +/// screen: `a + b·t` at distance `t` along the ray (constant for an orthographic view, widening +/// with depth in perspective), with room to spare. +pub(crate) fn reach(proj: &Proj, pos: Pos2, px: f32) -> (f64, f64) { + let (o, d) = proj.ray(pos); + let (mut a, mut b) = (0.0f64, 0.0f64); + for step in [vec2(px, 0.0), vec2(0.0, px), vec2(-px, 0.0), vec2(0.0, -px)] { + let (o2, d2) = proj.ray(pos + step); + a = a.max(o2.dist(o)); + b = b.max(d2.dist(d)); + } + // A screen offset of `px` along a diagonal, and rounding: half again. + (a * 1.5, b * 1.5) +} + +/// The edge segments of `index` that may come within reach of the ray, as (edge, first point), +/// in edge order and then along each edge: the order a scan of every edge would meet them in. +pub(crate) fn segments_near(index: &MeshIndex, mesh: &Mesh, o: Vec3, d: Vec3, (a, b): (f64, f64)) -> Vec<(usize, usize)> { + let mut v = Vec::new(); + if scanning() { + for (ei, e) in mesh.edges.iter().enumerate() { + v.extend((0..e.len().max(2) - 1).map(|si| (ei, si))); + } + return v; + } + index.segments_near(o, d, a, b, |e, s| v.push((e, s))); + v.sort_unstable(); + v +} + +/// The nearest triangle of `mesh` along the ray, through its index. +pub(crate) fn raycast(index: &MeshIndex, mesh: &Mesh, o: Vec3, d: Vec3) -> Option<(f64, usize)> { + if scanning() { + return mesh.raycast(o, d); + } + index.raycast(mesh, o, d) +} + +#[cfg(test)] +thread_local! { + /// Tests: pick by scanning every triangle and edge, as before the indexes, to compare. + pub(crate) static SCAN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn scanning() -> bool { + #[cfg(test)] + { + SCAN.with(|s| s.get()) + } + #[cfg(not(test))] + { + false + } +} + +#[cfg(test)] +#[path = "pick_index_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/pick_index_tests.rs b/crates/ui-egui/src/pick_index_tests.rs new file mode 100644 index 00000000..ed966233 --- /dev/null +++ b/crates/ui-egui/src/pick_index_tests.rs @@ -0,0 +1,122 @@ +use egui::{Rect, pos2, vec2}; +use serde_json::json; + +use crate::SolveApp; +use crate::viewport::{Hit, pick, pick_all, projection}; + +/// Overlapping bodies with flat and curved faces, filleted edges and seams. +fn assembly() -> SolveApp { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[40, 15, 20]], "radius": 4})).unwrap(); + s.execute("solid.cylinder", &json!({"radius": 8, "height": 35, "base": [10, 10, 0], "operation": "new_body"})).unwrap(); + s.execute("solid.sphere", &json!({"radius": 9, "center": [30, 25, 22]})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + app +} + +/// Every cursor position on a grid over the view, for each camera: the indexed pick answers what +/// scanning every triangle and edge answers (the nearest of each kind, and Select Other's list). +#[test] +fn indexed_picks_match_the_full_scan() { + let mut app = assembly(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(640.0, 480.0)); + let mut compared = (0, 0, 0); + for (perspective, yaw, pitch) in [(false, 0.6, 0.5), (true, 0.6, 0.5), (true, 2.4, -0.3), (false, 0.0, 1.5)] { + app.ui.perspective = perspective; + app.cam.yaw = yaw; + app.cam.pitch = pitch; + app.cam.target = solidcraft_engine::geom::Vec3::new(20.0, 15.0, 12.0); + app.cam.distance = 110.0; + let proj = projection(&app, rect); + for j in 0..48 { + for i in 0..64 { + let p = pos2(5.0 + i as f32 * 10.0 + (j % 3) as f32, 5.0 + j as f32 * 10.0); + let fast = (pick(&app, &proj, p), pick_all(&app, &proj, p)); + super::SCAN.with(|s| s.set(true)); + let scan = (pick(&app, &proj, p), pick_all(&app, &proj, p)); + super::SCAN.with(|s| s.set(false)); + assert_eq!(fast, scan, "at {p:?} (perspective {perspective}, yaw {yaw}, pitch {pitch})"); + compared.0 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Face { .. }))); + compared.1 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Edge { .. }))); + compared.2 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Vertex { .. }))); + } + } + } + // The grid reaches every kind many times over. + assert!(compared.0 > 2000 && compared.1 > 100 && compared.2 > 10, "{compared:?}"); + assert_eq!(app.viewport.pick_index.len(), 3, "one index per body mesh"); +} + +/// A new model state lets go of the indexes of meshes it no longer has. +#[test] +fn indexes_follow_the_model_state() { + let mut app = assembly(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(640.0, 480.0)); + app.cam.target = solidcraft_engine::geom::Vec3::new(20.0, 15.0, 12.0); + let proj = projection(&app, rect); + let centre = proj.to_screen(solidcraft_engine::geom::Vec3::new(20.0, 15.0, 10.0)).unwrap(); + pick(&app, &proj, centre); + assert!(app.viewport.pick_index.len() >= 1); + app.session.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [-20, -20, 0], "operation": "new_body"})).unwrap(); + pick(&app, &proj, centre); + assert!(app.viewport.pick_index.len() <= 4, "indexes of meshes gone with the old state are dropped"); + app.session.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [-40, -20, 0], "operation": "new_body"})).unwrap(); + pick(&app, &proj, centre); + assert!(app.viewport.pick_index.len() <= 5); +} + +/// Timing, not a check (`cargo test -p solidcraft-ui-egui --lib pick_timing -- --ignored +/// --nocapture`): picks over a grid of the view, scanning every triangle and edge versus through +/// the indexes, on many curved bodies and on one body with many curved faces. +#[test] +#[ignore] +fn pick_timing_on_curved_models() { + let mut many = solidcraft_engine::Session::default(); + for i in 0..12 { + for j in 0..12 { + let (x, y) = (f64::from(i) * 30.0, f64::from(j) * 30.0); + many.execute("solid.cylinder", &json!({"radius": 9, "height": 30, "base": [x, y, 0], "operation": "new_body"})).unwrap(); + many.execute("solid.sphere", &json!({"radius": 8, "center": [x, y, 40], "operation": "new_body"})).unwrap(); + } + } + let mut one = solidcraft_engine::Session::default(); + one.execute("solid.box", &json!({"length": 340, "width": 340, "height": 10, "corner": [-15, -15, -5]})).unwrap(); + for i in 0..8 { + for j in 0..8 { + let (x, y) = (f64::from(i) * 42.0, f64::from(j) * 42.0); + one.execute("solid.cylinder", &json!({"radius": 14, "height": 30, "base": [x, y, 0], "operation": "join"})).unwrap(); + } + } + for (name, s) in [("many bodies", many), ("one body", one)] { + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + app.cam.target = solidcraft_engine::geom::Vec3::new(160.0, 160.0, 15.0); + app.cam.distance = 900.0; + let st = app.session.world_state(); + let (tris, segs) = st + .bodies + .iter() + .map(|b| b.mesh()) + .fold((0, 0), |(t, s), m| (t + m.triangles.len(), s + m.edges.iter().map(|e| e.len().saturating_sub(1)).sum::())); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 900.0)); + let proj = projection(&app, rect); + let points: Vec<_> = (0..20).flat_map(|j| (0..30).map(move |i| pos2(30.0 + i as f32 * 45.0, 30.0 + j as f32 * 42.0))).collect(); + // Indexes made once, before timing. + for p in &points { + pick(&app, &proj, *p); + } + let time = |scan: bool| { + super::SCAN.with(|s| s.set(scan)); + let t = std::time::Instant::now(); + for p in &points { + std::hint::black_box(pick(&app, &proj, *p)); + } + super::SCAN.with(|s| s.set(false)); + t.elapsed().as_secs_f64() * 1000.0 / points.len() as f64 + }; + let (scan, indexed) = (time(true), time(false)); + eprintln!("{name}: {} bodies, {tris} triangles, {segs} edge segments: scan {scan:.3} ms per pick, indexed {indexed:.3} ms", st.bodies.len()); + } +} diff --git a/crates/ui-egui/src/picker_test_support.rs b/crates/ui-egui/src/picker_test_support.rs index efd53678..c4ffd275 100644 --- a/crates/ui-egui/src/picker_test_support.rs +++ b/crates/ui-egui/src/picker_test_support.rs @@ -21,5 +21,5 @@ pub fn save(app: &mut SolveApp, slot: &Slot) { } pub fn open(app: &mut SolveApp, slot: &Slot) { let slot = slot.clone(); - app.services.pick_open = Some(Box::new(move || Box::pin(Manual(slot.clone())) as FilePickerFuture)); + app.services.pick_open = Some(Box::new(move |_| Box::pin(Manual(slot.clone())) as FilePickerFuture)); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index c5288792..ed8b8768 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -273,8 +273,16 @@ pub struct Prefs { pub space_mouse_enabled: bool, /// Speed multiplier for 3D mouse navigation. pub space_mouse_speed: f32, + /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view + /// the other way. + pub space_mouse_reverse: [bool; 6], + /// Per physical axis of the cap, a multiplier on top of `space_mouse_speed`; 0 switches it off. + pub space_mouse_axis_speed: [f32; 6], /// Anti-aliasing samples (applies on restart). pub msaa: u32, + /// The grid turns to the upright origin plane facing a side view (#1470). Off by default: the + /// grid stays on the ground plane (XY) whatever the view. + pub grid_follows_view: bool, /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or /// `ultra` (#1287). Only the display plan the viewport draws; never the design or exports. pub display_quality: String, @@ -332,6 +340,8 @@ pub struct Prefs { pub save_mesh_format: String, /// Hidden edges in the current-view DXF export (false: omit, true: dashed). pub view_dxf_dashed: bool, + /// Model-space chord tolerance for current-view DXF curves, in millimetres. + pub view_dxf_tolerance: f64, pub save_mesh_send: bool, pub save_mesh_slicer: String, pub custom_slicers: Vec, @@ -400,7 +410,10 @@ impl Default for Prefs { orbit_cursor: false, space_mouse_enabled: true, space_mouse_speed: 1.0, + space_mouse_reverse: [false; 6], + space_mouse_axis_speed: [1.0; 6], msaa: 4, + grid_follows_view: false, display_quality: "normal".into(), supersampling: 1.0, supersampling_adaptive: true, @@ -428,6 +441,7 @@ impl Default for Prefs { update: crate::update::Memo::default(), save_mesh_format: "stl".into(), view_dxf_dashed: false, + view_dxf_tolerance: 0.01, save_mesh_send: false, save_mesh_slicer: String::new(), custom_slicers: Vec::new(), @@ -615,6 +629,8 @@ fn defaults(app: &mut SolveApp, page: usize) { p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_speed = d.space_mouse_speed; + p.space_mouse_reverse = d.space_mouse_reverse; + p.space_mouse_axis_speed = d.space_mouse_axis_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; p.middle_double_click = d.middle_double_click; @@ -1136,7 +1152,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { note(ui, crate::i18n::tr("nav.touchpad.help")); } else { let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; - let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help); + let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; hold Space to pan; the wheel zooms", nav_preset(&app.preferences.nav).help); note(ui, crate::i18n::tr_label(&raw_help)); } ui.label(crate::i18n::tr_label("Context menu")); @@ -1211,6 +1227,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); + for (axis, name) in SPACE_MOUSE_AXES.iter().enumerate() { + ui.label(crate::i18n::tr_label(name)); + ui.horizontal(|ui| { + ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { + if let Some(reverse) = app.preferences.space_mouse_reverse.get_mut(axis) { + ui.checkbox(reverse, crate::i18n::tr_label("Invert")); + } + if let Some(speed) = app.preferences.space_mouse_axis_speed.get_mut(axis) { + ui.add(egui::Slider::new(speed, 0.0..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))); + } + }); + }); + ui.end_row(); + } note(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only.")); } PAGE_DESIGN => { @@ -1239,6 +1269,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.label(crate::i18n::tr_label("Grid")); ui.checkbox(&mut app.ui.show_grid, ""); ui.end_row(); + ui.label(crate::i18n::tr_label("Grid follows the view")); + ui.add_enabled(app.ui.show_grid, egui::Checkbox::without_text(&mut app.preferences.grid_follows_view)) + .on_hover_text(crate::i18n::tr_label("Side views show the grid on the upright plane facing them instead of the ground")); + ui.end_row(); ui.label(crate::i18n::tr_label("Ground shadow")); ui.checkbox(&mut app.ui.ground_shadow, ""); ui.end_row(); @@ -1412,8 +1446,9 @@ fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) { } /// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button` -/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer; -/// any other keys set preferences. Returns them all, the open page and the offer. +/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer +/// (`false` leaves the file as it is, like "Leave it"); any other keys set preferences. Returns +/// them all, the open page and the offer. pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result { let mut rest = p.as_object().cloned().unwrap_or_default(); if let Some(page) = rest.remove("open") { @@ -1424,8 +1459,10 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result crate::stand_up::stand_up(app)?, + Some(false) => crate::stand_up::leave(app), + None => {} } let button = rest.remove("button"); let mut out = set(app, &serde_json::Value::Object(rest))?; @@ -1450,6 +1487,9 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result Prefs { if p.orbit_cursor { p.orbit_around = OrbitAround::Cursor; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index f9433aec..c621c594 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -905,6 +905,235 @@ fn variable_fillet_mid_radii_round_trip() { assert_eq!(p["params"]["mid"], json!([]), "{p}"); } +/// Use an actual mesh point, as a viewport pick does; analytic cylinder points need not lie +/// on the display mesh's facets. +fn thread_face(app: &SolveApp, body: &str) -> Sel { + let state = app.session.world_state(); + let body_state = state.body(body).unwrap(); + let index = solidcraft_engine::doc::naming::face_spots(body_state) + .into_iter() + .enumerate() + .find_map(|(index, spot)| { + spot.filter(|spot| matches!(spot.surface, Some(solidcraft_engine::kernel::FaceSurface::Cylinder { .. }))).map(|_| index) + }) + .unwrap(); + let mesh = body_state.mesh(); + let point = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .find_map(|(triangle, face)| { + if *face as usize != index { + return None; + } + mesh.tri(triangle).map(|[a, b, c]| (a + b + c) / 3.0) + }) + .unwrap(); + Sel::Face { body: body.into(), index, point } +} + +#[test] +fn thread_size_suggestion_matches_the_feature_for_shafts_and_holes() { + for (diameter, internal, expected) in [(8.0, false, "M8"), (20.0, false, "M20"), (5.0, true, "M6")] { + let mut app = app_with(json!([])); + if internal { + app.run("solid.box", json!({"corner":[-10,-10,0],"length":20,"width":20,"height":10})).unwrap(); + } + app.run("solid.cylinder", json!({"radius":diameter / 2.0,"height":10,"body_name":"Cylinder"})).unwrap(); + if internal { + app.run("solid.combine", json!({"target":"Body1","tools":["Cylinder"],"operation":"cut"})).unwrap(); + } + let pick = thread_face(&app, if internal { "Body1" } else { "Cylinder" }); + let saved = app.session.doc.to_json(); + let d = start(&mut app, vec![pick], "solid.thread"); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == expected), "{:?}", d.kind); + assert_eq!(app.session.doc.to_json(), saved, "suggesting a size is read-only"); + let (command, params) = apply_commands(&app, &d).unwrap().remove(0); + assert!(params.get("designation").is_none(), "an automatic size stays automatic"); + let result = app.session.execute(&command, ¶ms).unwrap(); + assert_eq!(result["thread"]["designation"], expected); + assert_eq!(result["thread"]["internal"], internal); + } +} + +#[test] +fn thread_size_suggestion_follows_picks_without_overwriting_manual_sizes() { + let mut app = app_with(json!([ + {"command":"solid.cylinder","params":{"radius":4,"height":10}}, + {"command":"solid.cylinder","params":{"base":[30,0,0],"radius":10,"height":10}}, + {"command":"solid.cylinder","params":{"base":[200,0,0],"radius":50,"height":10}} + ])); + let small = thread_face(&app, "Body1"); + let large = thread_face(&app, "Body2"); + let unsupported = thread_face(&app, "Body3"); + let planar = crate::dialogs::face_sel(&app.session, Vec3::new(0.0, 0.0, 10.0)).unwrap(); + let mut d = start(&mut app, vec![], "solid.thread"); + for (pick, expected) in [ + (small.clone(), "M8"), + (large.clone(), "M20"), + (large.clone(), "M20"), + (small.clone(), "M8"), + (unsupported, ""), + (small.clone(), "M8"), + (planar, ""), + ] { + d.pick(&app.session, pick); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == expected), "{:?}", d.kind); + } + d.take_box(&app.session, vec![], false); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation.is_empty())); + d.take_box(&app.session, vec![small], false); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8")); + if let Kind::Thread { designation, .. } = &mut d.kind { + *designation = "M8x1".into(); + } + d.pick(&app.session, large); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8x1")); + assert_eq!(apply_commands(&app, &d).unwrap()[0].1["designation"], "M8x1"); +} + +#[test] +fn thread_size_suggestion_reopens_without_changing_saved_thread_parameters() { + for designation in [None, Some("M8"), Some("M8x1")] { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap(); + let Sel::Face { point, .. } = thread_face(&h.app, "Body1") else { panic!("a face") }; + let mut params = json!({"face":point}); + if let Some(size) = designation { + params["designation"] = json!(size); + } + let id = h.app.run("solid.thread", params).unwrap()["feature"].as_u64().unwrap(); + let before = h.app.session.doc.feature(id).unwrap().kind.clone(); + h.app.edit_feature(id); + h.frames(2); + let d = h.app.dialog.as_ref().unwrap(); + assert!(matches!(&d.kind, Kind::Thread { designation: size, .. } if size == designation.unwrap_or("M8")), "{d:?}"); + let (command, params) = apply_commands(&h.app, d).unwrap().remove(0); + h.app.session.execute(&command, ¶ms).unwrap(); + assert_eq!(h.app.session.doc.feature(id).unwrap().kind, before); + } +} + +#[test] +fn thread_size_suggestion_can_be_cleared_and_edited_in_the_dialog() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap(); + let pick = thread_face(&h.app, "Body1"); + h.app.session.selection = vec![pick.clone()]; + h.step(&json!({"start":"solid.thread"})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:clear:Cylindrical face"}})).unwrap(); + assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation.is_empty())); + h.app.dialog.as_mut().unwrap().pick(&h.app.session, pick.clone()); + h.frames(2); + h.step(&json!({"click":{"handle":"dialog:thread_size"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + // Even deliberately typing the same size turns it into an explicit override. + h.step(&json!({"text":"M8"})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(apply_commands(&h.app, d).unwrap()[0].1["designation"], "M8"); + h.step(&json!({"click":{"handle":"dialog:clear:Cylindrical face"}})).unwrap(); + h.app.dialog.as_mut().unwrap().pick(&h.app.session, pick); + h.frames(2); + assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["designation"], "M8"); +} + +#[test] +fn thread_choices_apply_a_fine_pitch_and_partial_modelled_thread() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap(); + h.app.session.selection = vec![thread_face(&h.app, "Body1")]; + h.step(&json!({"start":"solid.thread"})).unwrap(); + let saved = h.app.session.doc.to_json(); + for handle in ["dialog:Pitch", "dialog:Pitch:1 mm", "dialog:Full Length", "dialog:Length"] { + h.step(&json!({"click":{"handle":handle}})).unwrap(); + } + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"4 mm"})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Modeled"}})).unwrap(); + let (command, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0); + assert_eq!(params["designation"], "M8x1"); + assert_eq!(params["length"], "4 mm"); + assert_eq!(params["modeled"], true); + assert_eq!(h.app.session.doc.to_json(), saved, "changing options only previews"); + let before = h.app.session.world_state().body("Body1").unwrap().mesh().measure().volume; + let result = h.app.session.execute(&command, ¶ms).unwrap(); + let state = h.app.session.world_state(); + let after = state.body("Body1").unwrap().mesh().measure().volume; + assert!(before - after > 1.0, "the selected fine pitch must cut the shaft"); + let id = result["feature"].as_u64().unwrap(); + let kind = &h.app.session.doc.feature(id).unwrap().kind; + assert!(matches!(kind, solidcraft_engine::doc::FeatureKind::Thread { designation: Some(d), length: Some(l), modeled: true, .. } + if d == "M8x1" && l == "4 mm")); +} + +#[test] +fn thread_choices_reset_to_the_face_and_can_freeze_the_same_size() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":4,"height":10})).unwrap(); + h.app.session.selection = vec![thread_face(&h.app, "Body1")]; + h.step(&json!({"start":"solid.thread"})).unwrap(); + for handle in ["dialog:Size", "dialog:Size:6 mm", "dialog:Pitch", "dialog:Pitch:0.75 mm"] { + h.step(&json!({"click":{"handle":handle}})).unwrap(); + } + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(apply_commands(&h.app, d).unwrap()[0].1["designation"], "M6x0.75"); + h.step(&json!({"click":{"handle":"dialog:Automatic size"}})).unwrap(); + h.frames(2); + let d = h.app.dialog.as_ref().unwrap(); + assert!(matches!(&d.kind, Kind::Thread { designation, .. } if designation == "M8")); + assert!(apply_commands(&h.app, d).unwrap()[0].1.get("designation").is_none()); + h.step(&json!({"click":{"handle":"dialog:Automatic size"}})).unwrap(); + assert_eq!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["designation"], "M8"); +} + +#[test] +fn thread_choices_preserve_custom_saved_pairs_and_offer_large_fine_pitches() { + for (diameter, designation) in [(7.3, "M7.3x0.6"), (56.0, "M56")] { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.cylinder", json!({"radius":diameter / 2.0,"height":10})).unwrap(); + let Sel::Face { point, .. } = thread_face(&h.app, "Body1") else { panic!("a face") }; + let id = h.app.run("solid.thread", json!({"face":point,"designation":designation})).unwrap()["feature"].as_u64().unwrap(); + h.app.edit_feature(id); + h.frames(2); + assert!(h.widget(&format!("{diameter} mm")).is_some()); + assert!(h.widget(if diameter == 56.0 { "5.5 mm" } else { "0.6 mm" }).is_some()); + let (_, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0); + assert_eq!(params["params"]["designation"], designation); + if diameter == 56.0 { + for handle in ["dialog:Pitch", "dialog:Pitch:2 mm"] { + h.step(&json!({"click":{"handle":handle}})).unwrap(); + } + let (command, params) = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().remove(0); + assert_eq!(params["params"]["designation"], "M56x2"); + h.app.session.execute(&command, ¶ms).unwrap(); + let mut reopened = solidcraft_engine::doc::Document::from_json(&h.app.session.doc.to_json()).unwrap(); + // This test submits the edit command directly; finish the editor's rollback + // before checking the complete saved design. + reopened.marker = None; + let session = Session::new(reopened); + assert_eq!(session.world_state().threads[0].designation, "M56x2"); + } + } +} + +#[test] +fn thread_choices_can_start_without_a_face_and_recover_from_invalid_text() { + let mut h = crate::scenario::Harness::new(); + h.step(&json!({"start":"solid.thread"})).unwrap(); + for handle in ["dialog:Size", "dialog:Size:8 mm", "dialog:thread_size"] { + h.step(&json!({"click":{"handle":handle}})).unwrap(); + } + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"Munknown"})).unwrap(); + h.frames(2); + assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "Munknown")); + for handle in ["dialog:Size", "dialog:Size:8 mm"] { + h.step(&json!({"click":{"handle":handle}})).unwrap(); + } + assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Thread { designation, .. } if designation == "M8x1.25")); + assert!(apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).is_err(), "a face is still required"); +} + #[test] fn newer_options_reach_their_commands() { let mut app = boxed(); @@ -1420,3 +1649,26 @@ fn patch_dialog_rows_are_translated() { assert!(h.widget(through).is_some(), "{lang}: the points input must show {through:?}"); } } + +/// Midplane opens a two-plane dialog. Starting it with nothing selected used to run the +/// command and report that `a` is required. +#[test] +fn midplane_asks_for_two_planes_then_builds_the_one_between_them() { + let mut h = crate::scenario::Harness::new(); + h.app.run("construct.plane.offset", json!({"base":"XY","offset":"20 mm","name":"Shelf"})).unwrap(); + h.app.start("construct.plane.midplane"); + h.frames(2); + let d = h.app.dialog.as_ref().unwrap(); + assert!(matches!(d.kind, Kind::Midplane)); + assert_eq!(d.inputs.len(), 2); + let status = h.app.status.as_ref().map(|s| s.0.clone()).unwrap_or_default(); + assert!(!status.contains("is required"), "{status}"); + h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::Plane { name: "XY".into() }]; + h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Plane { name: "Shelf".into() }]; + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone())); + let (_, _, pl) = + solidcraft_engine::view::construction_planes(&h.app.session).into_iter().find(|(_, name, _)| name != "Shelf").expect("the midplane"); + assert!(pl.normal().cross(Vec3::Z).len() < 1e-6, "{pl:?}"); + assert!((pl.origin.z - 10.0).abs() < 1e-6, "{pl:?}"); +} diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index f234ab8a..41a2ab13 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -26,6 +26,8 @@ use crate::theme::Tokens; pub struct Colors { pub body: [u8; 4], pub added: [u8; 4], + /// A body the preview creates (New Body), so it doesn't look like a join. + pub new_body: [u8; 4], pub removed: [u8; 4], pub edge: [u8; 4], } @@ -33,7 +35,7 @@ pub struct Colors { impl Colors { pub fn from_tokens(t: &Tokens) -> Colors { let c = |c: egui::Color32| c.to_srgba_unmultiplied(); - Colors { body: c(t.body), added: c(t.preview_add), removed: c(t.preview_cut), edge: c(t.body_edge) } + Colors { body: c(t.body), added: c(t.preview_add), new_body: c(t.preview_new), removed: c(t.preview_cut), edge: c(t.body_edge) } } } @@ -207,6 +209,7 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option | "joint.gear" | "joint.rack_pinion" | "joint.screw" + | "joint.cam" ) }) { @@ -500,7 +503,11 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6); for (t, f) in am.triangles.iter().zip(&am.tri_face) { let new = added.get(*f as usize).copied().unwrap_or(true); - let col = if new { c.added } else { c.body }; + let col = match (new, old.is_some()) { + (false, _) => c.body, + (true, true) => c.added, + (true, false) => c.new_body, + }; for k in t { let i = *k as usize; if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) { @@ -716,7 +723,7 @@ mod tests { use serde_json::json; fn colors() -> Colors { - Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], new_body: [4, 4, 4, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } } #[test] @@ -856,6 +863,15 @@ mod tests { }; assert!(motion_key(&gear("0 deg"), 7, colors()).is_some()); assert_eq!(motion_key(&gear("0 deg"), 7, colors()), motion_key(&gear("90 deg"), 7, colors())); + // So does a cam dialog turning its cam. + let cam = |angle: &str| { + vec![ + ("joint.cam".to_string(), json!({"a": 1, "b": 2, "profile": {"sketch": 3}})), + ("joint.drive".to_string(), json!({"joint": 1, "values": [angle]})), + ] + }; + assert!(motion_key(&cam("0 deg"), 7, colors()).is_some()); + assert_eq!(motion_key(&cam("0 deg"), 7, colors()), motion_key(&cam("90 deg"), 7, colors())); } /// Combine previews its result (cut, join or intersect between existing bodies) without an @@ -996,6 +1012,9 @@ mod tests { let b = compute(&s, &[("solid.extrude".into(), json!({"distance": "15 mm"}))], colors()).unwrap(); assert!(b.replaced.is_empty()); assert_eq!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().body), 0); + // #1805: a new body has its own colour, not a join's. + assert!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().new_body) > 0); + assert_eq!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().added), 0); assert!(b.scene.ghost.is_empty()); assert!(compute(&s, &[("solid.extrude".into(), json!({"distance": "1 +"}))], colors()).is_err()); assert!(s.model.state().bodies.is_empty()); diff --git a/crates/ui-egui/src/preview_drag_tests.rs b/crates/ui-egui/src/preview_drag_tests.rs index 24f56f7b..bcaa3afa 100644 --- a/crates/ui-egui/src/preview_drag_tests.rs +++ b/crates/ui-egui/src/preview_drag_tests.rs @@ -3,7 +3,7 @@ use super::*; use serde_json::json; fn colors() -> Colors { - Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], new_body: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } } fn fixture() -> Session { diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index a2fea596..7a3de61a 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -183,7 +183,7 @@ impl Harness { self.app.services.pick_save = Some(Box::new(move |name, _| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned()))))); let next = self.next_file.clone(); - self.app.services.pick_open = Some(Box::new(move || Box::pin(std::future::ready(next.borrow_mut().take())))); + self.app.services.pick_open = Some(Box::new(move |_| Box::pin(std::future::ready(next.borrow_mut().take())))); } /// Autosave into the scenario's recovery folder (as the desktop app does into the user's). diff --git a/crates/ui-egui/src/sketch_continuity_tests.rs b/crates/ui-egui/src/sketch_continuity_tests.rs new file mode 100644 index 00000000..b624c8ab --- /dev/null +++ b/crates/ui-egui/src/sketch_continuity_tests.rs @@ -0,0 +1,75 @@ +//! The Continuity Analysis toolbar tool (#770): the button shades every seam, a click on an edge +//! pins its numbers, the button again turns the analysis off, and a legend names the colours. + +use super::*; + +fn marks() -> Vec { + MARKS.with(|m| m.borrow().iter().map(|(_, label)| label.clone()).collect()) +} + +/// The button measures every edge of the shown bodies and leaves the tool picking edges; a +/// click on an edge pins its class and numbers without dropping the other seams; the button +/// again clears the shading and ends the tool. Without bodies it starts nothing. +#[test] +fn the_button_shades_every_seam_pins_an_edge_and_turns_off() { + let mut h = crate::scenario::Harness::new(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert!(h.app.tool.is_none() && h.app.session.continuity.is_empty(), "nothing to measure"); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert_eq!(h.app.session.continuity.len(), 12, "every edge of the box"); + assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("inspect.continuity")); + h.step(&json!({"click": {"world": [10, 0, 20]}})).unwrap(); + assert_eq!(marks(), ["G0 90.000\u{b0}"], "{:?}", h.app.status); + assert_eq!(h.app.session.continuity.len(), 12, "the clicked edge is measured again, not added twice"); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + assert!(h.app.session.continuity.is_empty() && h.app.tool.is_none()); + h.frame(); + assert!(marks().is_empty(), "the markers go with the tool"); +} + +/// A round's tangent seam reads G1 with its curvature jump (1 / 4 mm against the flat side). +#[test] +fn a_tangent_seam_is_labelled_with_its_curvature_jump() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.app.run("solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 4})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + // The seam where the round meets the front face (y = 0), halfway up. + h.step(&json!({"click": {"world": [16, 0, 10]}})).unwrap(); + let labels = marks(); + assert_eq!(labels.len(), 1, "{labels:?} {:?}", h.app.status); + assert!(labels[0].starts_with("G1 0.000\u{b0} \u{394}k 0.2500/mm"), "{labels:?}"); +} + +/// The legend shows while seams are shaded and goes with the analysis. +#[test] +fn the_legend_shows_while_seams_are_shaded() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + assert!(crate::scenario::rect_of("continuity_legend").is_none()); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.frame(); + let legend = crate::scenario::rect_of("continuity_legend").expect("the legend is drawn"); + assert!(legend.height() > 4.0 * 18.0, "four rows: {legend:?}"); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.frame(); + assert!(crate::scenario::rect_of("continuity_legend").is_none(), "gone with the analysis"); +} + +/// While the tool runs, only what its click takes lights up under the pointer: an edge, never +/// the face around it, whose outline would paint over the seam colours. +#[test] +fn only_edges_light_up_under_the_tool() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + h.settle(); + h.step(&json!({"start": "inspect.continuity"})).unwrap(); + h.step(&json!({"move": {"world": [10, 10, 20]}})).unwrap(); + assert_eq!(h.app.viewport.hover, None, "the top face is not lit"); + h.step(&json!({"move": {"world": [10, 0, 20]}})).unwrap(); + assert!(matches!(h.app.viewport.hover, Some(Hit::Edge { .. })), "{:?}", h.app.viewport.hover); +} diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index c07e49d2..d5359c9b 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -431,3 +431,47 @@ fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() { assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. }))); assert_eq!(ss.profiles.len(), 1); } + +/// #1824: where a third line ends at a corner's point, the point names no single corner; the +/// Fillet window takes the corner's two lines instead and rounds the corner between them. +#[test] +fn fillet_window_rounds_the_corner_between_two_picked_lines() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]],"ids":["left"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[20,0]],"ids":["right"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[18,8]],"ids":["diag"]})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start":"sketch.fillet"})).unwrap(); + // The shared point ends three lines, so it names no single corner: pick the two lines. + h.step(&json!({"click":{"world":[16,0,0]}})).unwrap(); + h.step(&json!({"click":{"world":[13,3,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec!["right".to_string(), "diag".to_string()], "{:?}", h.app.status); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.status); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).count(), 1, "{:?}", h.app.status); +} + +/// #1824 (review): the Chamfer window takes a corner's two lines the same way. +#[test] +fn chamfer_window_cuts_the_corner_between_two_picked_lines() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]],"ids":["left"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[20,0]],"ids":["right"]})).unwrap(); + h.app.run("sketch.line", json!({"points":["left.end",[18,8]],"ids":["diag"]})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + let before = h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(); + h.step(&json!({"start":"sketch.chamfer.equal_distance"})).unwrap(); + h.step(&json!({"click":{"world":[16,0,0]}})).unwrap(); + h.step(&json!({"click":{"world":[13,3,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec!["right".to_string(), "diag".to_string()], "{:?}", h.app.status); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.status); + let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); + assert_eq!(sk.curves.len(), before + 1, "a chamfer line: {:?}", h.app.status); + assert!(sk.curve_index("left").is_some(), "the third line stays"); +} diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 53f5e973..2f6cf12c 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -150,9 +150,10 @@ fn inputs(k: &Mod) -> Vec { // The centre is required: `sketch.pattern.circular` has no default for it. Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)], // A fillet rounds any number of corners to one radius (`points`). Each chamfer command - // cuts one corner, so its input holds one. - Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS, true)], - Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS, false)], + // cuts one corner: its point, or its two lines. Two lines name the corner between them + // where more lines meet at its point (#1824), for both. + Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)], + Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)], } } @@ -548,6 +549,17 @@ fn length(app: &SolveApp, label: &str, text: &str) -> Result { } Ok(v) } +/// The curve ids picked in input `i` (a corner's two lines, #1824). +fn line_ids(inputs: &[SelInput], i: usize) -> Vec { + items(inputs, i) + .iter() + .filter_map(|x| match x { + Sel::SketchCurve { id } => Some(id.clone()), + _ => None, + }) + .collect() +} + /// Corner point ids within the active sketch, including qualified script selections. fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec { items(inputs, i).iter().filter_map(|sel| point_id(&app.session, sel).map(str::to_string)).collect() @@ -734,15 +746,26 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { let corners = corner_ids(app, inputs, 0); - if corners.is_empty() { - return Err("select the corners to round first".into()); - } + let lines = line_ids(inputs, 0); // The radius is checked where it is evaluated, against the document's parameters. - ("sketch.fillet", json!({"points": corners, "radius": size.trim()})) + match (corners.is_empty(), lines.as_slice()) { + (true, []) => return Err("select the corners to round first".into()), + (false, []) => ("sketch.fillet", json!({"points": corners, "radius": size.trim()})), + // Two lines name their corner even where other lines end at its point (#1824). + (true, [a, b]) => ("sketch.fillet", json!({"a": a, "b": b, "radius": size.trim()})), + _ => return Err("select corner points, or the two lines of one corner".into()), + } } Mod::Corner { ctype, size, distance2, angle, flip, .. } => { - let point = corner_ids(app, inputs, 0).into_iter().next().ok_or("select the corner to cut first")?; - let mut p = corner_ref(app, &point, *flip && *ctype > 0)?; + let flipped = *flip && *ctype > 0; + let mut p = match (corner_ids(app, inputs, 0).as_slice(), line_ids(inputs, 0).as_slice()) { + ([point], []) => corner_ref(app, point, flipped)?, + // Flip swaps the lines, so `distance` applies to the other leg. + ([], [a, b]) if flipped => json!({"a": b, "b": a}), + ([], [a, b]) => json!({"a": a, "b": b}), + ([], []) => return Err("select the corner to cut first".into()), + _ => return Err("select one corner point, or the two lines of one corner".into()), + }; p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?); match ctype { 1 => { diff --git a/crates/ui-egui/src/sketch_modify_ui_tests.rs b/crates/ui-egui/src/sketch_modify_ui_tests.rs index c88a575c..6686d596 100644 --- a/crates/ui-egui/src/sketch_modify_ui_tests.rs +++ b/crates/ui-egui/src/sketch_modify_ui_tests.rs @@ -283,7 +283,8 @@ fn moved(x: &str, y: &str, angle: &str) -> Mod { fn the_command_now_has_a_toolbar_placement_and_opens_a_window() { let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered"); assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY")); - assert_eq!(spec.icon, "move"); + assert_eq!(spec.icon, "sketch_move"); + assert_eq!(crate::icon_svg::resolve(spec.icon).master, "move", "reuse the finished Move drawing"); let mut h = rectangle(); let before = curve_ids(&h).len(); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index aa81e55a..cb897822 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -575,7 +575,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.start(id); } if look { - crate::dialogs::look_at_sketch(app); + crate::dialogs::look_at_sketch_again(app); } if finish { app.finish_sketch(); diff --git a/crates/ui-egui/src/sketch_shortcut_tests.rs b/crates/ui-egui/src/sketch_shortcut_tests.rs index 5bd8403a..4a95a847 100644 --- a/crates/ui-egui/src/sketch_shortcut_tests.rs +++ b/crates/ui-egui/src/sketch_shortcut_tests.rs @@ -67,6 +67,29 @@ fn a_sketch_key_with_nothing_selected_asks_for_the_plane_then_starts_the_tool() assert!(h.app.dialog.is_none()); } +/// #1804: Escape puts down the sketch tool. Another Escape does not leave the sketch. +#[test] +fn a_second_escape_stays_in_the_sketch() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + let sketch = h.app.session.active_sketch; + h.app.start("sketch.line"); + h.settle(); + assert_eq!(tool(&h), Some("sketch.line")); + press(&mut h, "Escape"); + assert!(tool(&h).is_none(), "the first Escape puts the tool down"); + assert_eq!(h.app.session.active_sketch, sketch); + press(&mut h, "Escape"); + assert_eq!(h.app.session.active_sketch, sketch, "a second Escape stays in the sketch"); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]]})).unwrap(); + h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into() }]; + press(&mut h, "Escape"); + assert!(h.app.session.selection.is_empty(), "Escape still clears a selection"); + assert_eq!(h.app.session.active_sketch, sketch); + press(&mut h, "Escape"); + assert_eq!(h.app.session.active_sketch, sketch, "Escape after clearing the selection stays in the sketch"); +} + #[test] fn escape_drops_the_tool_waiting_for_a_sketch() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index e03df889..3eb30bdc 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -183,6 +183,13 @@ pub(crate) fn selected_entity(app: &SolveApp) -> Option { selected } +/// The link of the one text the selection belongs to (see [`selected_entity`]). +pub(crate) fn selected_link(app: &SolveApp) -> Option { + let entity = selected_entity(app)?; + let sk = app.session.doc.sketch(app.session.active_sketch?).ok()?; + sk.curves.iter().find(|curve| curve.id == entity)?.link.clone() +} + /// Show every outline affected by the open editor without changing feature selections. pub(crate) fn highlighted(app: &SolveApp) -> Vec { let Some(Kind::Text(tx)) = app.dialog.as_ref().map(|dialog| &dialog.kind) else { return Vec::new() }; @@ -752,6 +759,45 @@ mod tests { assert!(highlighted(&h.app).is_empty()); } + /// Explode Text (#1842): the text's outlines stay where they are as ordinary sketch curves, + /// the text itself goes, and one undo brings it back. + #[test] + fn explode_text_leaves_its_outlines_as_normal_curves() { + let (mut h, link, _) = setup(); + h.app.run("sketch.edit_text", json!({"link":link,"text":"TO"})).unwrap(); + let sid = h.app.session.active_sketch.unwrap(); + let outline = |h: &Harness| { + let sk = h.app.session.doc.sketch(sid).unwrap(); + let mut ids: Vec<(String, Option)> = sk.curves.iter().map(|c| (c.id.clone(), c.link.clone())).collect(); + ids.sort(); + ids + }; + let before = outline(&h); + assert!(before.len() > 4 && before.iter().all(|(_, l)| l.as_deref() == Some(link.as_str()))); + let profiles = h.app.session.world_state().sketch(sid).unwrap().profiles.len(); + h.app.session.selection = vec![Sel::SketchCurve { id: before[0].0.clone() }]; + let undo = h.app.session.undo.len(); + let item = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport) + .into_iter() + .find(|item| item.label == "Explode Text") + .expect("Explode Text is offered for a selected text"); + assert!(item.enabled); + crate::context_menu::run_item(&mut h.app, &item, egui::Pos2::ZERO); + h.frames(2); + let after = outline(&h); + assert_eq!(after.iter().map(|c| &c.0).collect::>(), before.iter().map(|c| &c.0).collect::>(), "every outline stays"); + assert!(after.iter().all(|(_, l)| l.is_none()), "no outline belongs to the text"); + let sk = h.app.session.doc.sketch(sid).unwrap(); + assert!(sk.link(&link).is_none(), "the text is gone"); + assert_eq!(h.app.session.world_state().sketch(sid).unwrap().profiles.len(), profiles, "the letters still make the same profiles"); + assert_eq!(h.app.session.undo.len(), undo + 1); + // No longer text: nothing to edit or explode. + assert!(selected_entity(&h.app).is_none()); + assert!(!crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport).iter().any(|item| item.label == "Explode Text")); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(outline(&h), before, "undo brings the text back"); + } + #[test] fn edit_text_tool_accepts_a_letter_interior_without_preselection() { let (mut h, link, _) = setup(); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 34a14c47..a6e20fe1 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -84,6 +84,44 @@ fn analysis(app: &mut SolveApp, cmd: &str, p: Value) { } } +/// A seam from an `inspect.continuity` reply as a marker label: its class and the measure that +/// decided it (the gap, the angle between the faces' normals, or the curvature jump). +fn continuity_label(app: &SolveApp, seam: &Value) -> String { + let deg = |key: &str| seam[key]["value"].as_f64().map(|x| format!("{x:.3}\u{b0}")).unwrap_or_default(); + let jump = seam["curvature_jump_per_mm"]["value"].as_f64().map(|k| format!("\u{394}k {k:.4}/mm")).unwrap_or_default(); + match seam["verdict"].as_str().unwrap_or_default() { + "gap" => format!("Gap {}", seam["gap_mm"]["value"].as_f64().map(|g| crate::prefs::show_mm(app, g, 1)).unwrap_or_default()), + "sharp" => format!("G0 {}", deg("normal_angle_deg")), + "curvature_jump" => format!("G1 {} {jump}", deg("normal_angle_deg")), + "smooth" => format!("G2 {} {jump}", deg("normal_angle_deg")), + other => other.to_string(), + } +} + +/// The Continuity Analysis legend in the viewport's lower left corner: each class in its +/// colour, while seams are shaded. +fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if app.session.continuity.is_empty() { + return; + } + let tk = crate::theme::Tokens::get(); + let [smooth, jump, sharp, gap] = tk.continuity; + let tr = crate::i18n::tr_label; + let rows = [(tr("G2 curvature"), smooth), (tr("G1 tangent"), jump), (tr("G0 crease"), sharp), (tr("Gap"), gap)]; + let font = egui::FontId::proportional(12.0); + let (row, pad, swatch) = (18.0, 8.0, 22.0); + let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max); + let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad); + let frame = egui::Rect::from_min_size(proj.rect.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size); + painter.rect_filled(frame, 4.0, tk.panel.gamma_multiply(0.9)); + crate::scenario::publish_rect("continuity_legend", frame); + for (i, (label, col)) in rows.iter().enumerate() { + let y = frame.top() + pad + row * (i as f32 + 0.5); + painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(3.0, *col)); + painter.text(egui::pos2(frame.left() + pad + swatch + 8.0, y), egui::Align2::LEFT_CENTER, *label, font.clone(), tk.text); + } +} + /// Isocurve analysis lines. fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") { @@ -104,7 +142,7 @@ fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { /// Analysis markers: a target with its label. fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { - if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass")) { + if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.continuity")) { MARKS.with(|m| m.borrow_mut().clear()); return; } @@ -149,6 +187,25 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool { } return false; } + if id == "inspect.continuity" { + // Every edge of the shown bodies at once; the tool stays for clicking single edges. With + // seams shaded, the button turns the analysis off. + MARKS.with(|m| m.borrow_mut().clear()); + if !app.session.continuity.is_empty() { + if app.run(id, json!({"clear": true})).is_ok() { + app.set_status("Analysis off", false); + } + return false; + } + let Ok(v) = app.run(id, json!({})) else { return false }; + let summary = &v["summary"]; + let counts: Vec = [("smooth", "G2"), ("curvature_jump", "G1"), ("sharp", "G0"), ("gap", "gap")] + .iter() + .filter_map(|(key, class)| summary[*key].as_u64().filter(|n| *n > 0).map(|n| format!("{n} {class}"))) + .collect(); + app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false); + return true; + } if id == "inspect.center_of_mass" { // The whole model at once; the tool stays for picking single bodies. MARKS.with(|m| m.borrow_mut().clear()); @@ -346,11 +403,21 @@ enum Mode { Steps, } +/// Whether a click with tool `id` acts on `hit`: what is under the pointer lights up only when +/// the tool takes it, so the hover shows what a click does and lights nothing over a view the +/// tool draws (Continuity Analysis's seam colours on a face the tool ignores). +pub fn takes(id: &str, hit: &Hit) -> bool { + match id { + "inspect.continuity" => matches!(hit, Hit::Edge { .. }), + _ => true, + } +} + fn mode(id: &str) -> Option { Some(match id { "sketch.project" | "sketch.intersect" | "sketch.include_3d" | "sketch.fit_curves_to_section" | "sketch.isoparametric_curve" => Mode::ModelRef, "inspect.curvature_comb" | "canvas.insert" | "canvas.decal" => Mode::ModelRef, - "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" => Mode::ModelRef, + "inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" | "inspect.continuity" => Mode::ModelRef, "sketch.trim" | "sketch.extend" | "sketch.break" => Mode::CurveAt, "sketch.line.midpoint" => Mode::Points(2), "sketch.arc.tangent" => Mode::Points(2), @@ -398,6 +465,7 @@ pub fn hint(id: &str) -> Option { "inspect.minimum_radius" => "Minimum radius: click sketch curves, edges or faces", "inspect.center_of_mass" => "Center of mass of all bodies; click a body for its own", "inspect.isocurve" => "Isocurves: click faces", + "inspect.continuity" => "Continuity: click an edge for its numbers (the button again turns the colours off)", "canvas.insert" => "Canvas: click a plane or a planar face where the image's centre goes", "canvas.decal" => "Decal: click a planar face where the image's centre goes", "sketch.trim" => "Trim: click the piece of a curve to remove", @@ -596,6 +664,17 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } } + Mode::ModelRef if cmd == "inspect.continuity" => { + // The edge measured again, added to the seams shown, and its numbers pinned there. + let edge = hits.iter().filter(|h| takes(cmd, h)).find_map(|h| if let Hit::Edge { mid, .. } = h { Some(*mid) } else { None }); + if let Some(mid) = edge + && let Ok(v) = app.run(cmd, json!({"edges": [[mid.x, mid.y, mid.z]], "add": true})) + && let Some(seam) = v["bodies"][0]["edges"].get(0) + { + let label = continuity_label(app, seam); + MARKS.with(|m| m.borrow_mut().push((mid, label))); + } + } Mode::ModelRef if matches!(cmd, "inspect.minimum_radius" | "inspect.center_of_mass") => { let params = hits.iter().find_map(|h| match (cmd, h) { ("inspect.minimum_radius", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})), @@ -1134,8 +1213,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } marks(app, painter, proj); iso_lines(app, painter, proj); + continuity_legend(app, painter, proj); } #[cfg(test)] #[path = "sketch_corner_ui_tests.rs"] mod corner_ui_tests; + +#[cfg(test)] +#[path = "sketch_continuity_tests.rs"] +mod continuity_tests; diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index bb9d76e0..4b4ef6fe 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -87,6 +87,16 @@ impl DriverMapping { mapping } + /// The mapping with the user's per-axis choices folded in: `reverse` flips and `speed` scales + /// each physical axis of the cap. A speed that is not a finite number counts as 1. + pub fn with_user_axes(mut self, reverse: [bool; 6], speed: [f32; 6]) -> Self { + for ((rule, reverse), speed) in self.axes.iter_mut().zip(reverse).zip(speed) { + rule.reversed ^= reverse; + rule.scale *= if speed.is_finite() { f64::from(speed).clamp(0.0, 3.0) } else { 1.0 }; + } + self + } + pub fn apply(self, axes: Axes) -> Motion { let raw = [ normalized(axes.translation[0]), @@ -139,7 +149,10 @@ pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMa app.begin_external_navigation(); // A long gap (the first frame after a pause) moves as far as a 50 ms one. let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 }; - app.viewport.device_motion = Some(DeviceMotion { motion: mapping.apply(axes), seconds: step * speed.clamp(0.1, 5.0) }); + app.viewport.device_motion = Some(DeviceMotion { + motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes), + seconds: step * speed.clamp(0.1, 5.0), + }); ctx.request_repaint_after(Duration::from_millis(16)); true } @@ -182,6 +195,22 @@ mod tests { app } + #[test] + fn per_axis_choices_flip_and_scale_one_physical_axis() { + let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]); + let still = Motion::default(); + assert_eq!(mapping.apply(Axes { translation: [0, 350, 0], rotation: [0; 3] }).translation[1], -raw(PUSH).translation[1]); + assert_eq!( + mapping.apply(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0], + raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0] + ); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [350, 0, 0] }).rotation[0], 0.5); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }).rotation[1], 1.0); + assert_eq!(mapping.apply(Axes::default()), still); + let off = DriverMapping::default().with_user_axes([false; 6], [0.0; 6]); + assert_eq!(off.apply(PUSH), still); + } + const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; fn wheel(dy: f32) -> egui::Event { diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index 83ba676e..83f24fc8 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -36,6 +36,12 @@ pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { Ok(()) } +/// Dismiss the offer and keep the file as it opened ("Leave it"); the preference stays on. +pub fn leave(app: &mut SolveApp) { + app.stand_up = None; + app.stand_up_occurrence = None; +} + /// The offer, under the toolbar. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if app.stand_up.is_none() { @@ -52,13 +58,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_label("Stand it up: a quarter turn about X, as one Move feature you can undo.")); ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button(crate::i18n::tr_label("Stand it up")).clicked() { + let stand = ui.button(crate::i18n::tr_label("Stand it up")); + crate::scenario::publish_handle("stand_up:stand", stand.rect.center()); + if stand.clicked() { act = Some("stand"); } - if ui.button(crate::i18n::tr_label("Leave it")).clicked() { + let leave = ui.button(crate::i18n::tr_label("Leave it")); + crate::scenario::publish_handle("stand_up:leave", leave.rect.center()); + if leave.clicked() { act = Some("leave"); } - if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() { + let never = ui + .button(crate::i18n::tr_label("Don't ask again")) + .on_hover_text(crate::i18n::tr_label("Preferences › Design turns the offer back on")); + crate::scenario::publish_handle("stand_up:never", never.rect.center()); + if never.clicked() { act = Some("never"); } }); @@ -69,13 +83,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.set_status(e, true); } } - Some("leave") => { - app.stand_up = None; - app.stand_up_occurrence = None; - } + Some("leave") => leave(app), Some("never") => { - app.stand_up = None; - app.stand_up_occurrence = None; + leave(app); app.preferences.offer_stand_up = false; } _ => {} diff --git a/crates/ui-egui/src/sweep_dialog_tests.rs b/crates/ui-egui/src/sweep_dialog_tests.rs index a2803e2d..60c54b00 100644 --- a/crates/ui-egui/src/sweep_dialog_tests.rs +++ b/crates/ui-egui/src/sweep_dialog_tests.rs @@ -99,3 +99,42 @@ fn editing_sweeps_keeps_their_options() { assert_eq!(after.param_names, before.param_names, "{name}"); } } + +/// A rail picked in the dialog reaches the command with its sketch, the fit with it; editing the +/// sweep brings both back and keeps them. +#[test] +fn a_rail_picked_in_the_dialog_is_kept_through_an_edit() { + let mut h = Harness::new(); + let sketch = setup(&mut h); + h.app.run("sketch.create", json!({"plane":"XZ","name":"R"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[5,0],[10,-40]],"ids":["out"]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let rail_sketch = h.app.session.doc.find_feature("R").unwrap().id; + h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap(); + h.frames(3); + assert!(h.widget("Rail Fit").is_some()); + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }]; + d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }]; + d.inputs[3].items = vec![Sel::SketchCurve { id: "out".into() }]; + let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) }; + opts.rail_fit = 1; + h.frames(2); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + let name = h.app.session.doc.features.last().unwrap().name.clone(); + let rails = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind { + FeatureKind::Sweep { rails, rail_fit, .. } => (rails.clone(), *rail_fit), + k => panic!("{k:?}"), + }; + let (stored, fit) = rails(&h); + assert_eq!(stored, vec![solidcraft_engine::doc::SweepRail { sketch: solidcraft_engine::doc::SketchId(rail_sketch), curves: vec!["out".into()] }]); + assert_eq!(fit, solidcraft_engine::doc::RailFit::Stretch); + h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap(); + h.frames(8); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[3].items, vec![Sel::SketchCurve { id: "out".into() }]); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log); + assert_eq!(rails(&h), (stored, fit)); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ef64fc23..e85fde5c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,7 +3,6 @@ //! uses comes from [`Tokens`]. use std::cell::Cell; -use std::sync::atomic::{AtomicBool, Ordering}; #[path = "../../../assets/styles/ui/palettes.rs"] mod palettes; @@ -11,10 +10,11 @@ mod palettes; use egui::{Color32, Stroke}; use serde::{Deserialize, Serialize}; -static DARK: AtomicBool = AtomicBool::new(true); - thread_local! { - /// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app + /// Theme state belongs to the UI thread drawing the frame. Concurrent app/test renderers + /// must not change one another's palette while a frame or icon texture is being built. + static DARK: Cell = const { Cell::new(true) }; + /// The colour vision mode, per thread. Each app /// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the /// thread that draws it. Tests run many apps side by side on their own threads in one process: /// with one global, a test in a mode changed the colours under another one @@ -26,7 +26,12 @@ thread_local! { /// Is the dark theme on? pub fn is_dark() -> bool { - DARK.load(Ordering::Relaxed) + DARK.get() +} + +/// The common conversion boundary for compiled icon palette data. +pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 { + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } /// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for @@ -203,6 +208,8 @@ pub struct Tokens { pub manipulator: Color32, /// New surface in a feature preview. pub preview_add: Color32, + /// The surface of a body a feature preview creates (New Body), apart from a join (#1805). + pub preview_new: Color32, /// Proposed sketch geometry, distinct from blue selections. pub preview_sketch: Color32, /// Removed material in a feature preview (translucent). @@ -281,6 +288,10 @@ pub struct Tokens { pub bend_down: Color32, /// Interference: the clash fill, before its shading. pub interference: Color32, + /// Continuity Analysis seams, best to worst as `inspect.continuity` judges them: smooth (G2), + /// tangent with a curvature jump (G1), a crease (G0) and a gap. Bluish green, blue, yellow + /// and reddish purple stay apart in every colour vision mode, so no mode changes them. + pub continuity: [Color32; 4], pub analysis: AnalysisColors, pub icons: IconColors, /// The application bar's buttons: ink, fill and accent of their icons. @@ -333,7 +344,7 @@ pub fn material_color(name: &str) -> Option { /// Switch themes (the visuals follow on the next [`apply`]). pub fn set_dark(dark: bool) { - DARK.store(dark, Ordering::Relaxed); + DARK.set(dark); } fn visuals(dark: bool) -> egui::Visuals { @@ -373,6 +384,24 @@ pub fn apply(ctx: &egui::Context) { #[cfg(test)] mod tests { + #[test] + fn concurrent_ui_renderers_keep_their_own_theme_and_vision() { + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + for (dark, vision) in [(false, super::ColorVision::Off), (true, super::ColorVision::Protan)] { + let barrier = &barrier; + scope.spawn(move || { + super::set_dark(dark); + super::set_vision(vision); + barrier.wait(); + assert_eq!(super::is_dark(), dark); + assert_eq!(super::vision(), vision); + assert_eq!(super::Tokens::get().sketch_accent, super::Tokens::themed(dark, vision).sketch_accent); + }); + } + }); + } + /// Title and section bands stand out from a dialog, the title more than its sections. #[test] fn heading_bands_are_darker_than_the_dialog_in_order() { diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 4e7fccd2..3904b026 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -3,7 +3,10 @@ pub(crate) use crate::icons::glyphs::transport_glyph; use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2}; use serde_json::json; -use solidcraft_engine::doc::FeatureKind; +use solidcraft_engine::doc::{ + FeatureKind, PlaneRef, + construct::{AxisDef, PointDef}, +}; use crate::theme::Tokens; use crate::{SolveApp, icons}; @@ -13,14 +16,18 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Sketch { .. } => "sketch", FeatureKind::Extrude { .. } => "extrude", FeatureKind::SurfaceExtrude { .. } => "surface_extrude", - FeatureKind::Unstitch { .. } => "unstitch", - FeatureKind::ReverseNormal { .. } => "reverse_normal", + FeatureKind::Unstitch { .. } => "surface_unstitch", + FeatureKind::ReverseNormal { .. } => "surface_reverse_normal", FeatureKind::SurfaceRevolve { .. } => "surface_revolve", FeatureKind::SurfaceSweep { .. } => "surface_sweep", FeatureKind::SurfaceLoft { .. } => "surface_loft", FeatureKind::SurfaceOffset { .. } => "surface_offset", - FeatureKind::SurfaceRuled { .. } => "ruled", - FeatureKind::SurfaceUntrim { .. } => "untrim", + FeatureKind::SurfaceRuled { .. } => "surface_ruled", + FeatureKind::SurfaceUntrim { .. } => "surface_untrim", + FeatureKind::SurfaceBlend { .. } => "surface_blend", + FeatureKind::SurfaceBoundary { .. } => "surface_boundary", + FeatureKind::SurfaceMatch { .. } => "continuity", + FeatureKind::SurfaceFair { .. } => "surface_fair", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", @@ -30,11 +37,25 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Torus { .. } => "torus", FeatureKind::Combine { .. } => "combine", FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect", + FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Path { .. }, .. } => "pattern_path", FeatureKind::Pattern { .. } => "pattern_circ", FeatureKind::Mirror { .. } => "mirror", - FeatureKind::ConstructionPlane { .. } => "plane", - FeatureKind::ConstructionAxis { .. } => "axis", - FeatureKind::ConstructionPoint { .. } => "point", + FeatureKind::ConstructionPlane { plane } => construction_plane_icon(plane), + FeatureKind::ConstructionAxis { def } => match def { + AxisDef::ThroughFace { .. } => "axis_through_cylinder", + AxisDef::NormalToFace { .. } => "axis_normal_to_face", + AxisDef::TwoPlanes { .. } => "axis_two_planes", + AxisDef::TwoPoints { .. } => "axis_two_points", + AxisDef::Edge { .. } => "axis_edge", + }, + FeatureKind::ConstructionPoint { def } => match def { + PointDef::At { .. } => "point_vertex", + PointDef::TwoEdges { .. } => "point_two_edges", + PointDef::ThreePlanes { .. } => "point_three_planes", + PointDef::Center { .. } => "point_center", + PointDef::EdgeAndPlane { .. } => "point_edge_plane", + PointDef::AlongPath { .. } => "point_along_path", + }, FeatureKind::Hole { .. } => "hole", FeatureKind::Loft { .. } => "loft", FeatureKind::Sweep { .. } => "sweep", @@ -43,10 +64,43 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import", + FeatureKind::BodyTransfer { .. } => "move", + FeatureKind::Thread { .. } => "thread", + FeatureKind::SplitFace { .. } => "split_face", + FeatureKind::Scale { .. } => "scale", + FeatureKind::OffsetFace { .. } => "offset_face", + FeatureKind::DeleteFace { .. } => "delete_face", + FeatureKind::Remove { .. } => "delete", + FeatureKind::BoundingSolid { .. } => "bounding_solid", + FeatureKind::Pipe { .. } => "pipe", + FeatureKind::Emboss { .. } => "emboss", + FeatureKind::ReplaceFace { .. } => "replace_face", + FeatureKind::Align { .. } => "align", + FeatureKind::Coil { .. } => "coil", + FeatureKind::SheetPerforate { .. } => "perforate", + FeatureKind::BoundaryFill { .. } => "boundary_fill", + FeatureKind::Patch { .. } => "surface_patch", + FeatureKind::Stitch { .. } => "surface_stitch", + FeatureKind::Thicken { .. } => "surface_thicken", + FeatureKind::SurfaceExtend { .. } => "surface_extend", + FeatureKind::SurfaceTrim { .. } => "surface_trim", other => crate::workspace::feature_icon(other).unwrap_or("feature"), } } +fn construction_plane_icon(plane: &PlaneRef) -> &'static str { + match plane { + PlaneRef::AtAngle { .. } => "plane_angle", + PlaneRef::Midplane { .. } => "plane_midplane", + PlaneRef::TwoEdges { .. } => "plane_two_edges", + PlaneRef::ThreePoints { .. } => "plane_three_points", + PlaneRef::Tangent { .. } => "plane_tangent", + PlaneRef::AlongPath { .. } => "plane_along_path", + PlaneRef::Perpendicular { .. } => "plane_perpendicular", + _ => "plane", + } +} + #[derive(Clone, Debug, PartialEq)] enum Drag { Marker, @@ -81,7 +135,7 @@ pub(crate) fn delete_features(app: &mut SolveApp, ids: Vec) { } } if with.is_empty() && fail.is_empty() { - let _ = app.run("timeline.delete", json!({ "features": ids })); + let _ = app.run_heavy("timeline.delete", json!({ "features": ids })); } else { app.dialog = Some(crate::dialogs::Dialog::confirm_delete_many(ids, with.into_iter().collect(), fail.into_iter().collect())); } @@ -192,7 +246,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { Some(k) if k < n => json!({"position": k}), _ => json!({}), }; - let _ = app.run("timeline.roll_to", pos); + let _ = app.run_heavy("timeline.roll_to", pos); } x += 24.0; } @@ -341,11 +395,11 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { ui.close(); } if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { - let _ = app.run("timeline.suppress", json!({"feature": id})); + let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); ui.close(); } if ui.button(crate::i18n::tr_label("Roll History Marker Here")).clicked() { - let _ = app.run("timeline.roll_to", json!({ "feature": id })); + let _ = app.run_heavy("timeline.roll_to", json!({ "feature": id })); ui.close(); } if ui.button(crate::i18n::tr_label("Find in Browser")).clicked() { @@ -398,14 +452,14 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let slot = slot_at(&xs, end, pp.x); match dg { Drag::Marker => { - let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); + let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); } Drag::Feature(id) => { let from = app.session.doc.feature_index(id).unwrap_or(0); // Dropping after itself means the slot shifts by one. let to = if slot > from { slot - 1 } else { slot }; if to != from { - let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to })); + let _ = app.run_heavy("timeline.reorder", json!({ "feature": id, "position": to })); } } } @@ -439,6 +493,93 @@ mod tests { use crate::scenario::Harness; + #[test] + fn construction_history_matches_the_tool_that_created_it() { + use solidcraft_engine::doc::{ + FeatureKind, PlaneRef, + construct::{AxisDef, GeoRef, PointDef}, + }; + use solidcraft_engine::geom::Vec3; + let point = || GeoRef::Point { p: Vec3::ZERO }; + let reference = || Box::new(point()); + let mut cases = Vec::new(); + for (id, def) in [ + ("through_cylinder", AxisDef::ThroughFace { face: point() }), + ("normal_to_face", AxisDef::NormalToFace { face: point(), point: point() }), + ("two_planes", AxisDef::TwoPlanes { a: point(), b: point() }), + ("two_points", AxisDef::TwoPoints { a: point(), b: point() }), + ("edge", AxisDef::Edge { edge: point() }), + ] { + cases.push((format!("construct.axis.{id}"), FeatureKind::ConstructionAxis { def })); + } + for (id, def) in [ + ("vertex", PointDef::At { point: point() }), + ("two_edges", PointDef::TwoEdges { a: point(), b: point() }), + ("three_planes", PointDef::ThreePlanes { a: point(), b: point(), c: point() }), + ("center", PointDef::Center { of: point() }), + ("edge_plane", PointDef::EdgeAndPlane { edge: point(), plane: point() }), + ("along_path", PointDef::AlongPath { path: point(), t: "0.5".into() }), + ] { + cases.push((format!("construct.point.{id}"), FeatureKind::ConstructionPoint { def })); + } + for (id, plane) in [ + ("midplane", PlaneRef::Midplane { a: reference(), b: reference() }), + ("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }), + ("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }), + ("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }), + ("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }), + ("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }), + ] { + cases.push((format!("construct.plane.{id}"), FeatureKind::ConstructionPlane { plane })); + } + for (id, feature) in cases { + let command = solidcraft_engine::find_command(&id).unwrap(); + let icon = super::icon_of(&feature); + assert_eq!(icon, command.icon, "{id}: history and toolbar disagree"); + assert!(!crate::icon_svg::resolve(icon).placeholder, "{id}"); + } + } + + #[test] + fn new_surface_and_web_history_icons_match_their_commands() { + let cases = [ + ("surface.ruled", json!({"type":"surface_ruled", "body":"A", "edges":[], "distance":"5"})), + ("surface.untrim", json!({"type":"surface_untrim", "face":{"body":"A", "point":[0,0,0]}})), + ("surface.unstitch", json!({"type":"unstitch", "body":"A"})), + ("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})), + ("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})), + ("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})), + ("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})), + ("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})), + ("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})), + ("solid.web", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2", "web":true})), + ]; + for (id, definition) in cases { + let feature: solidcraft_engine::doc::FeatureKind = serde_json::from_value(definition).unwrap(); + let command = solidcraft_engine::find_command(id).unwrap(); + assert_eq!(super::icon_of(&feature), command.icon, "{id}: history and toolbar disagree"); + } + } + + #[test] + fn a_path_pattern_keeps_its_path_icon_in_history() { + use solidcraft_engine::doc::{FeatureKind, PatternKind}; + let feature = FeatureKind::Pattern { + features: Vec::new(), + bodies: Vec::new(), + pattern: PatternKind::Path { + path_sketch: solidcraft_engine::doc::SketchId(0), + path: Vec::new(), + count: "2".into(), + spacing: "10".into(), + extent: false, + flip: false, + orient: false, + }, + }; + assert_eq!(super::icon_of(&feature), solidcraft_engine::find_command("solid.pattern.path").unwrap().icon); + } + fn two_boxes(left: bool) -> Harness { let mut h = Harness::new(); h.app.preferences.timeline_left = left; diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index 51e1f09a..3f88b81a 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -117,7 +117,7 @@ fn timeline_transport(app: &mut SolveApp, ui: &mut egui::Ui) { Some(position) if position < n => json!({"position": position}), _ => json!({}), }; - let _ = app.run("timeline.roll_to", params); + let _ = app.run_heavy("timeline.roll_to", params); } } }); @@ -794,11 +794,11 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { ui.close(); } if ui.button(if *suppressed { "Unsuppress Feature" } else { "Suppress Feature" }).clicked() { - let _ = app.run("timeline.suppress", json!({"feature": id})); + let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); ui.close(); } if ui.button("Roll Back Here").clicked() { - let _ = app.run("timeline.roll_to", json!({"feature": id})); + let _ = app.run_heavy("timeline.roll_to", json!({"feature": id})); ui.close(); } ui.separator(); @@ -875,7 +875,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) { { match active { Drag::Marker => { - let _ = app.run("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) }); + let _ = app.run_heavy("timeline.roll_to", if slot.position >= n { json!({}) } else { json!({"position": slot.position}) }); } Drag::Items(items) => { let panel_left = ui.min_rect().left(); diff --git a/crates/ui-egui/src/timeline_marks_tests.rs b/crates/ui-egui/src/timeline_marks_tests.rs index 906b586b..7406d782 100644 --- a/crates/ui-egui/src/timeline_marks_tests.rs +++ b/crates/ui-egui/src/timeline_marks_tests.rs @@ -1,5 +1,6 @@ //! A failed feature is marked in the bottom timeline and the Feature History by a shape, not only -//! its colour (#972): the error mark (a disc with a cross) where a warning's mark goes. +//! its colour (#972): the error mark where a warning's mark goes (now an icon image, so these +//! tests look for the 10-point image at the mark's place). use egui::{Pos2, Rect, Shape, vec2}; use serde_json::json; @@ -27,20 +28,23 @@ fn features(app: &SolveApp) -> Vec<(String, bool)> { app.session.doc.features.iter().map(|f| (f.name.clone(), app.session.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect() } -/// The discs filled in the error colour (either theme's) that one frame of `draw` paints. +/// The centres of the 10 to 12-point icon images one frame of `draw` paints: the failed-feature mark +/// (the sample has no warnings, so every such image is an error mark). fn error_discs(app: &mut SolveApp, draw: fn(&mut SolveApp, &mut egui::Ui)) -> Vec { crate::scenario::clear_handles(); let ctx = egui::Context::default(); let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)); - let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui)); - let errors = [crate::theme::Tokens::light().error, crate::theme::Tokens::dark().error]; + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui)); + // Icons upload textures; dropping their deltas unapplied would panic. + out.textures_delta.clear(); let mut found = Vec::new(); for clipped in &out.shapes { each(&clipped.shape, &mut |s| { - if let Shape::Circle(c) = s - && errors.contains(&c.fill) - { - found.push(c.center); + if let Shape::Mesh(m) = s { + let b = m.calc_bounds(); + if matches!(m.texture_id, egui::TextureId::Managed(_)) && (b.width() - 11.0).abs() < 1.5 && (b.height() - 11.0).abs() < 1.5 { + found.push(b.center()); + } } }); } @@ -81,27 +85,3 @@ fn a_failed_feature_is_marked_in_the_feature_history() { let got = marked(&mut app, crate::timeline_left::left_timeline, |item, d| (d.y - item.y).abs() < 1.5 && d.x > item.x); assert_eq!(got, want, "an error mark on each failed feature and on no other"); } - -/// The mark is its own drawing: a disc with a cross in the caller's colours, not the fallback -/// square, and a different shape from the warning's triangle. -#[test] -fn the_error_mark_is_a_disc_with_a_cross() { - let ctx = egui::Context::default(); - let (ink, fill) = (egui::Color32::from_rgb(1, 2, 3), egui::Color32::from_rgb(4, 5, 6)); - let draw = |name: &'static str| { - let out = ctx.run_ui(egui::RawInput::default(), |ui| { - crate::icons::paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)), name, ink, fill, fill); - }); - let mut shapes = Vec::new(); - for c in &out.shapes { - each(&c.shape, &mut |s| shapes.push(s.clone())); - } - shapes - }; - let error = draw("error"); - let discs = error.iter().filter(|s| matches!(s, Shape::Circle(c) if c.fill == fill && c.stroke.color == ink)).count(); - let strokes = error.iter().filter(|s| matches!(s, Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink))).count(); - assert_eq!((discs, strokes), (1, 2), "one disc and the two strokes of a cross: {error:?}"); - assert_ne!(format!("{error:?}"), format!("{:?}", draw("warning"))); - assert_ne!(format!("{error:?}"), format!("{:?}", draw("no such icon")), "drawn, not the fallback square"); -} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 56cf0166..c1bd3ecb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -10,39 +10,10 @@ use crate::{SolveApp, icons}; pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"]; -/// The accent stroke colour for a panel's icons: constraint commands use the same red as the -/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep -/// `sketch_accent`, and every other tab keeps the plain `accent`. -fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 { - if panel == "CONSTRAINTS" { - t.glyph_text - } else if tab == "SKETCH" { - t.sketch_accent - } else { - t.accent - } -} - -/// The accent stroke colour a command's icon keeps on every surface that draws it, where the -/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the -/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the -/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while -/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint` -/// draws both of these icon names in the accent its caller hands it, never in a colour of its own. -/// The caller is the only place the rule can live. Every other command has no fixed colour. It -/// keeps the accent of whichever surface draws it. -fn fixed_accent(t: Tokens, id: &str) -> Option { - match id { - "sketch.construction" | "sketch.centerline" => Some(t.construction_geom), - _ => None, - } -} - -/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own -/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that. -/// Every other command takes its `panel_accent`. -fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 { - fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel)) +/// Legacy paint arguments and non-SVG affordances follow the same semantic palette as the icon. +/// The requested icon id determines its meaning on ribbons, menus and drag previews alike. +fn command_accent(icon: &str) -> Color32 { + crate::icon_svg::primary(icon, crate::theme::is_dark(), crate::theme::vision()) } /// Panels shown on each tab (in order). Commands come from the registry by (tab, panel). @@ -281,8 +252,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if enabled && resp.hovered() { ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30)); } - let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) }; - icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2])); + icons::paint_on(ui.painter(), br.shrink(5.0), icon, Some(true), if enabled { 1.0 } else { 0.35 }); let clicked = resp.on_hover_text(tip).clicked(); enabled && clicked }; @@ -351,7 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }); ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0)); - icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar); + icons::paint_on(ui.painter(), icon, "bug_report", Some(true), 1.0); if report_label { ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text); } @@ -364,7 +334,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if gresp.hovered() { ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30)); } - icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar); + icons::paint_on(ui.painter(), gear.shrink(4.0), "settings", Some(true), 1.0); crate::scenario::publish_handle("titlebar:preferences", gear.center()); let prefs_tip = if cfg!(target_os = "macos") { format!("{} (Cmd+,)", app.tr("ui.preferences")) @@ -597,7 +567,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0)); let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click()); painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent); + let workspace_icon = if crate::render_workspace::active(app) { "ws_render" } else { "ws_design" }; + icons::paint( + &painter, + Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), + workspace_icon, + t.icon, + t.icon_fill, + t.accent, + ); painter.text( pos2(ws.center().x - 5.0, ws.top() + 52.0), Align2::CENTER_CENTER, @@ -719,7 +697,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { painter.rect_filled(br, 4.0, t.hover); } if let Some(c) = shown.first().or(cmds.first()) { - let acc = command_accent(t, &tab, panel, c.id); + let acc = command_accent(c.icon); icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc); } caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim); @@ -769,14 +747,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } else if resp.hovered() && info.enabled { painter.rect_filled(br, 4.0, t.hover); } - let (ink, fill, acc) = if info.enabled { - (t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id)) - } else { - (t.border, t.panel_header, t.border) - }; + let (ink, fill, acc) = + if info.enabled { (t.icon, t.icon_fill, command_accent(selected.icon)) } else { (t.border, t.panel_header, t.border) }; let icon = match selected.id { "sketch.finish" => "finish", - "config.table" => "configuration_table", _ => selected.icon, }; let icon_rect = @@ -1105,11 +1079,9 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine p.rect_filled(rect, 3.0, t.hover); crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center()); } - // Every other row keeps the plain accent it always has. A constraint command's row switches - // to the constraint red, to match its button on the ribbon. A command with a `fixed_accent` - // keeps that colour ahead of either. + // The icon keeps its semantic accent in a drop-down as well as on the ribbon. let (ink, fill, acc, text) = if info.enabled { - let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent }); + let acc = command_accent(c.icon); (t.icon, t.icon_fill, acc, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) @@ -1243,7 +1215,7 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) { let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag"))); let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0)); p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside); - icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent)); + icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, command_accent(c.icon)); if removing { let s = Stroke::new(2.0, t.error); let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0)); @@ -1348,10 +1320,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() { app.tree.picked_joints.clear(); let _ = app.run("select.clear", json!({})); - } else { - // Nothing left to cancel: Esc finishes the sketch. - app.finish_sketch(); } + // An open sketch with nothing left to cancel stays open (#1804). Finish Sketch + // is what leaves it, so a second Escape after putting a tool down is harmless. } Key::S if !mods.shift && !mods.alt => { let at = ctx.input(|i| i.pointer.latest_pos()); @@ -1788,69 +1759,6 @@ mod panel_drop_tests { } } -#[cfg(test)] -mod icon_accent_tests { - use super::*; - - /// `icons.rs` pins one half of the construction icon contract. `construction` and - /// `centerline` take their colour from whatever accent their caller hands over. That half - /// stays green when the caller hands over the wrong colour. That is exactly what the toolbar - /// did. This pins the other half: the colour the toolbar actually passes for those two - /// commands, on every surface the toolbar draws them on. - #[test] - fn the_toolbar_passes_construction_orange_for_both_line_type_commands() { - for t in [Tokens::light(), Tokens::dark()] { - // The regression itself: the SKETCH tab's accent is green. These two icons drew in - // it. Construction orange has to be a colour no panel accent can supply. - assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent"); - assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent"); - assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red"); - for id in ["sketch.construction", "sketch.centerline"] { - assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent"); - // Its ribbon button, a collapsed panel's single button, and a drop-down row. - assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon"); - assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon"); - assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row"); - } - } - } - - /// Those two and no others. Every other command's icon keeps the accent of the panel it sits - /// on, so the constraint red and the sketch green both survive the exception above. - #[test] - fn no_other_command_overrides_the_accent_of_its_own_panel() { - let t = Tokens::light(); - let specs = solidcraft_engine::command_specs(); - let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect(); - fixed.sort_unstable(); - assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent"); - for c in &specs { - if fixed.contains(&c.id) { - continue; - } - let want = panel_accent(t, c.tab, c.panel); - assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id); - } - // The fix did not flatten every icon to one colour. A sketch tool is still green, a - // constraint still red, a solid tool still blue. A missing id fails this as well. - let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id)); - assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent)); - assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text)); - assert_eq!(accent_of("solid.extrude"), Some(t.accent)); - } - - /// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is - /// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws - /// from the caller's accent. - #[test] - fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() { - for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] { - let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon)); - assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel"); - } - } -} - #[cfg(test)] mod dropdown_toggle_tests { use crate::scenario::{Harness, rect_of}; diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 508f72b4..09dd0568 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -29,8 +29,16 @@ pub const REPO: &str = "bherbruck/solidcraft"; pub const LATEST_API: &str = "https://api.github.com/repos/bherbruck/solidcraft/releases/latest"; /// Where Download goes when no package fits this installation. pub const RELEASES_PAGE: &str = "https://github.com/bherbruck/solidcraft/releases/latest"; -/// Release pages and assets must be on GitHub, under this repository. -const RELEASE_PREFIX: &str = "https://github.com/bherbruck/solidcraft/releases/"; +/// Release pages and assets must be on GitHub, under this repository. The repository is moving +/// to the `tangency-labs` organisation, so releases may name either owner (the API request stays +/// on the old name, which GitHub redirects after a transfer). +const RELEASE_PREFIXES: [&str; 2] = ["https://github.com/bherbruck/solidcraft/releases/", "https://github.com/tangency-labs/solidcraft/releases/"]; + +/// A release page or asset URL: github.com, one of the two owners, exactly `solidcraft`, then +/// `/releases/` (the trailing slash rules out look-alike names such as `solidcraft-evil`). +fn is_release_url(url: &str) -> bool { + RELEASE_PREFIXES.iter().any(|p| url.starts_with(p)) +} /// While the app stays open, automatic checks run once a day (every start checks too). pub const CHECK_INTERVAL_S: f64 = 24.0 * 3600.0; @@ -136,7 +144,7 @@ pub enum InstallKind { Rpm, /// The plain `.tar.gz`, unpacked somewhere. LinuxTarball, - /// Installed by the `.msi` (under Program Files). + /// Installed by the `.msi` (under Program Files, or in the folder the installer recorded). WindowsMsi, /// The portable `.zip`, unpacked somewhere. WindowsPortable, @@ -157,6 +165,9 @@ pub struct Probe<'a> { /// The running executable. pub exe: Option, pub exists: &'a dyn Fn(&Path) -> bool, + /// Where the Windows installer put SolidCraft (`HKLM\Software\SolidCraft`, `InstallDir`): a + /// folder chosen in its wizard can be anywhere, not only under Program Files (#1870). + pub msi_dir: Option, } pub fn detect(p: &Probe) -> InstallKind { @@ -194,7 +205,8 @@ pub fn detect(p: &Probe) -> InstallKind { } "windows" => { let under = |k: &str| env(k).is_some_and(|dir| starts_with_ci(&exe, Path::new(&dir))); - if under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { + let installed = p.msi_dir.as_deref().is_some_and(|dir| starts_with_ci(&exe, dir)); + if installed || under("ProgramFiles") || under("ProgramW6432") || under("ProgramFiles(x86)") { return InstallKind::WindowsMsi; } if exe.parent().is_some_and(|d| (p.exists)(&d.join("solidcraft-cli.exe"))) { @@ -232,10 +244,30 @@ pub fn this_install() -> InstallKind { { let env = |k: &str| std::env::var(k).ok(); let exists = |p: &Path| p.exists(); - detect(&Probe { os: std::env::consts::OS, release_build: release_build(), env: &env, exe: std::env::current_exe().ok(), exists: &exists }) + let msi_dir = msi_install_dir(); + detect(&Probe { + os: std::env::consts::OS, + release_build: release_build(), + env: &env, + exe: std::env::current_exe().ok(), + exists: &exists, + msi_dir, + }) } } +/// The folder the Windows installer recorded (solidcraft.wxs writes it for upgrades), if any. +#[cfg(windows)] +fn msi_install_dir() -> Option { + let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE).open_subkey(r"Software\SolidCraft").ok()?; + key.get_value::("InstallDir").ok().map(PathBuf::from) +} + +#[cfg(all(not(windows), not(target_arch = "wasm32")))] +fn msi_install_dir() -> Option { + None +} + impl InstallKind { /// The package manager updates it: Download isn't the way (the window shows the command). pub fn system_managed(&self) -> bool { @@ -358,7 +390,7 @@ impl Release { notes.truncate(cut); notes.push_str("\n\n…"); } - let page = Some(s("html_url")).filter(|u| u.starts_with(RELEASE_PREFIX)).unwrap_or_else(|| RELEASES_PAGE.to_string()); + let page = Some(s("html_url")).filter(|u| is_release_url(u)).unwrap_or_else(|| RELEASES_PAGE.to_string()); let assets = v .get("assets") .and_then(Value::as_array) @@ -368,7 +400,7 @@ impl Release { .filter_map(|a| { let name = a.get("name")?.as_str()?.to_string(); let url = a.get("browser_download_url")?.as_str()?.to_string(); - url.starts_with(RELEASE_PREFIX).then(|| Asset { name, url, size: a.get("size").and_then(Value::as_u64).unwrap_or(0) }) + is_release_url(&url).then(|| Asset { name, url, size: a.get("size").and_then(Value::as_u64).unwrap_or(0) }) }) .collect(); Ok(Release { @@ -697,8 +729,13 @@ fn poll_install(app: &mut SolveApp) { app.updater.job = None; match &result { Ok(install::Outcome::Installer(program, args)) => { - // The installer replaces this program: start it once SolidCraft has closed. - app.updater.launch = Some((program.clone(), args.clone())); + // The installer replaces this program: start it once SolidCraft has closed. On + // Windows the installer is followed by the new version (see install::relaunch_after). + app.updater.launch = Some(match std::env::current_exe() { + #[cfg(windows)] + Ok(exe) => install::relaunch_after(program, args, &exe), + _ => (program.clone(), args.clone()), + }); crate::documents::request_quit(app); } Ok(install::Outcome::Opened(dmg)) => { diff --git a/crates/ui-egui/src/update/install.rs b/crates/ui-egui/src/update/install.rs index 8ec8325c..ead6907e 100644 --- a/crates/ui-egui/src/update/install.rs +++ b/crates/ui-egui/src/update/install.rs @@ -107,6 +107,26 @@ pub fn sha256_file(path: &Path) -> Result { Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// The command that runs an installer, waits for it to finish and then starts `exe` (the +/// installed program), so the app comes back after an update. Windows has no `wait && start` in +/// a plain process spawn, so PowerShell does the waiting; the installer's exit code is ignored on +/// purpose: when the user cancels it, the old version is still there to start. +pub fn relaunch_after(installer: &Path, args: &[String], exe: &Path) -> (PathBuf, Vec) { + // PowerShell single-quoted string: only `'` needs doubling. + let q = |s: &str| format!("'{}'", s.replace('\'', "''")); + // msiexec splits its command line itself, so an argument with spaces needs its own quotes. + let list = args.iter().map(|a| if a.contains(' ') { q(&format!("\"{a}\"")) } else { q(a) }).collect::>().join(","); + let script = format!( + "Start-Process -FilePath {} -ArgumentList {list} -Wait; Start-Process -FilePath {}", + q(&installer.to_string_lossy()), + q(&exe.to_string_lossy()) + ); + ( + PathBuf::from("powershell"), + vec!["-NoProfile".into(), "-NonInteractive".into(), "-WindowStyle".into(), "Hidden".into(), "-Command".into(), script], + ) +} + /// `sums` is signed by `key` (minisign, prehashed) with a trusted comment naming `tag`. pub fn verify_signature(sums: &[u8], signature: &str, key: &str, tag: &str) -> Result<(), String> { let key = minisign_verify::PublicKey::from_base64(key.trim()).map_err(|e| format!("update signing key: {e}"))?; @@ -270,7 +290,10 @@ fn unzip_one(zip: &Path, name: &str, to: &Path) -> Result { let file = std::fs::File::open(zip).map_err(|e| format!("{}: {e}", zip.display()))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", zip.display()))?; let index = (0..archive.len()).find(|&i| { - archive.name_for_index(i).is_some_and(|n| n.rsplit(['/', '\\']).next().is_some_and(|f| f.eq_ignore_ascii_case(name)) && !n.ends_with('/')) + archive + .name_for_index(i) + .and_then(Result::ok) + .is_some_and(|n| n.rsplit(['/', '\\']).next().is_some_and(|f| f.eq_ignore_ascii_case(name)) && !n.ends_with('/')) }); let Some(index) = index else { return Ok(false) }; let mut entry = archive.by_index(index).map_err(|e| format!("{}: {e}", zip.display()))?; diff --git a/crates/ui-egui/src/update/install_tests.rs b/crates/ui-egui/src/update/install_tests.rs index cd1d8bc3..3829203d 100644 --- a/crates/ui-egui/src/update/install_tests.rs +++ b/crates/ui-egui/src/update/install_tests.rs @@ -203,3 +203,18 @@ fn installers_and_disk_images_are_handed_over() { assert_eq!(apply(&Method::Dmg, &pkg).unwrap(), Outcome::Opened(pkg.clone())); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn windows_installer_is_followed_by_the_new_version() { + let args = vec!["/i".to_string(), r"C:\Users\a b\Temp\solidcraft-update\x.msi".to_string(), "/passive".to_string()]; + let (program, out) = relaunch_after(Path::new("msiexec"), &args, Path::new(r"C:\Program Files\SolidCraft\it's.exe")); + assert_eq!(program, PathBuf::from("powershell")); + assert_eq!( + out.last().unwrap(), + &concat!( + r#"Start-Process -FilePath 'msiexec' -ArgumentList '/i','"C:\Users\a b\Temp\solidcraft-update\x.msi"','/passive' -Wait; "#, + r"Start-Process -FilePath 'C:\Program Files\SolidCraft\it''s.exe'" + ) + ); + assert!(out.iter().any(|a| a == "-Command")); +} diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 9bcab45a..8eda32e7 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -32,7 +32,7 @@ fn probe(os: &str, release: bool, env: &[(&str, &str)], exe: &str, files: &[&str let files: Vec = files.iter().map(PathBuf::from).collect(); let get = |k: &str| env.get(k).cloned(); let exists = |p: &Path| files.iter().any(|f| f == p); - detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists }) + detect(&Probe { os, release_build: release, env: &get, exe: Some(PathBuf::from(exe)), exists: &exists, msi_dir: None }) } #[test] @@ -92,7 +92,34 @@ fn portable_windows_layout_is_recognised() { let exe = PathBuf::from("/portable/solidcraft.exe"); let get = |k: &str| (k == "ProgramFiles").then(|| "C:\\Program Files".to_string()); let exists = |p: &Path| p == Path::new("/portable/solidcraft-cli.exe"); - assert_eq!(detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists }), InstallKind::WindowsPortable); + assert_eq!( + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe), exists: &exists, msi_dir: None }), + InstallKind::WindowsPortable + ); +} + +/// #1870: the installer's wizard can put SolidCraft anywhere, and it puts solidcraft-cli.exe +/// beside it as the portable zip does. The folder it recorded is what tells its copy apart. +#[test] +fn an_msi_install_outside_program_files_is_recognised() { + let exe = PathBuf::from(r"D:\Program Files\SolidCraft\solidcraft.exe"); + let get = |k: &str| match k { + "ProgramFiles" | "ProgramW6432" => Some(r"C:\Program Files".to_string()), + "ProgramFiles(x86)" => Some(r"C:\Program Files (x86)".to_string()), + _ => None, + }; + // solidcraft-cli.exe is beside it. + let exists = |_: &Path| true; + let kind = |msi_dir: Option<&str>| { + detect(&Probe { os: "windows", release_build: true, env: &get, exe: Some(exe.clone()), exists: &exists, msi_dir: msi_dir.map(PathBuf::from) }) + }; + // The installer records [INSTALLFOLDER], which ends in a backslash; Windows paths ignore case. + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft\")), InstallKind::WindowsMsi); + assert_eq!(kind(Some(r"d:\program files\solidcraft")), InstallKind::WindowsMsi); + // No installer copy here: the portable zip, as before. + assert_eq!(kind(None), InstallKind::WindowsPortable); + assert_eq!(kind(Some("")), InstallKind::WindowsPortable); + assert_eq!(kind(Some(r"D:\Program Files\SolidCraft2\")), InstallKind::WindowsPortable, "a folder whose name only starts the same"); } /// The assets of v0.10.0, as published. @@ -441,3 +468,32 @@ fn release_notes_read_as_markdown() { } assert_eq!(inline("**[x](y)**"), vec![Span::Strong("x".into())]); } + +#[test] +fn releases_under_either_owner_are_accepted() { + let parse = |owner: &str| { + let base = format!("https://github.com/{owner}/releases"); + Release::parse( + json!({"tag_name": "v1.0.0", "html_url": format!("{base}/tag/v1.0.0"), "assets": [ + {"name": "solidcraft-windows-x64.msi", "browser_download_url": format!("{base}/download/v1.0.0/solidcraft-windows-x64.msi")}, + ]}) + .to_string() + .as_bytes(), + ) + .unwrap() + }; + for owner in ["bherbruck/solidcraft", "tangency-labs/solidcraft"] { + let r = parse(owner); + assert_eq!(r.page, format!("https://github.com/{owner}/releases/tag/v1.0.0")); + assert_eq!(r.assets.len(), 1, "{owner}"); + } + // Any other owner, repository, host or scheme is dropped. + for owner in ["evil/solidcraft", "tangency-labs/solidcraft-evil", "tangency-labs.evil/solidcraft", "bherbruck/other"] { + let r = parse(owner); + assert_eq!(r.page, RELEASES_PAGE, "{owner}"); + assert!(r.assets.is_empty(), "{owner}"); + } + assert!(!is_release_url("http://github.com/tangency-labs/solidcraft/releases/x")); + assert!(!is_release_url("https://github.com.evil.example/tangency-labs/solidcraft/releases/x")); + assert!(!is_release_url("https://github.com/tangency-labs/solidcraft/issues/1")); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 95eb6fda..ddee0f50 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -40,6 +40,9 @@ pub enum NavMode { #[derive(Default)] pub struct ViewportState { + /// A two-point drawing tool's first point was placed by a press that became a drag: the + /// release places the second (#1630). + pub(crate) draw_drag: bool, /// Per-body boxes of one world state, held (not just its address, which a later state can /// reuse once the old one is freed, so stale boxes would come back). pick_bounds: std::sync::Mutex>, @@ -52,6 +55,11 @@ pub struct ViewportState { lod_view: u64, /// Each body mesh's box (the mesh held, so its address stays its own), from the last state. mesh_bounds: std::sync::Mutex, solidcraft_engine::geom::Aabb3)>>, + /// Each body mesh's picking index (made the first time a pick reaches the body), kept while + /// the mesh is part of the world state. + pub(crate) pick_index: crate::pick_index::PickIndexes, + /// The GPU pick buffer's scene, read-backs and requests. + pub(crate) pick_buffer: crate::pick_buffer::State, /// The last click in the view was the middle button's (for the middle double-click). pub(crate) middle_clicked: bool, /// The last pick (its key and hits): reused while nothing it depends on changed. @@ -69,6 +77,8 @@ pub struct ViewportState { pub(crate) display: std::sync::Mutex, hl_slot: SceneSlot, hl_key: u64, + /// Browser/timeline body feedback, independent of the camera and reference widget scale. + hover_block: std::sync::Mutex>, pub nav: Option, /// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken /// when the button went down, so the drag neither selects nor moves sketch geometry. @@ -257,7 +267,7 @@ pub fn scene_radius(app: &SolveApp) -> f64 { pub fn projection(app: &SolveApp, rect: Rect) -> Proj { let mut cam = app.cam; - cam.fov = if app.ui.perspective { 0.6 } else { 0.0 }; + cam.fov = if app.perspective_view() { 0.6 } else { 0.0 }; let aspect = rect.width() as f64 / rect.height().max(1.0) as f64; Proj { cam, vp: cam.view_proj(aspect, scene_radius(app)), rect } } @@ -682,10 +692,34 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + // Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the + // colour of its worst verdict. + for seam in &s.continuity { + let col = c4(continuity_color(&tk, seam.verdict)); + for w in seam.points.windows(2) { + if let (Some(a), Some(b)) = (w.first(), w.get(1)) { + sc.line(a.to_f32(), b.to_f32(), col, 2.6, true); + } + } + } sc } +/// The colour of a measured seam, one per verdict from the theme's `continuity` tokens: within +/// every tolerance (G2), tangent with a curvature jump (G1), a crease (G0), and faces that do +/// not meet. +fn continuity_color(t: &Tokens, verdict: solidcraft_engine::kernel::SeamVerdict) -> Color32 { + use solidcraft_engine::kernel::SeamVerdict as V; + let [smooth, jump, sharp, gap] = t.continuity; + match verdict { + V::Smooth => smooth, + V::CurvatureJump => jump, + V::Sharp => sharp, + V::Gap => gap, + } +} + /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let tk = Tokens::get(); @@ -699,7 +733,8 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame), or // the upright origin plane facing the view when the ground is seen edge-on. let tf = app.cam.to_frame(app.cam.target); - let (u, v) = grid_plane(app.cam.to_frame(app.cam.back())); + // On the ground (XY) unless the Grid follows the view preference is on (#1470). + let (u, v) = if app.preferences.grid_follows_view { grid_plane(app.cam.to_frame(app.cam.back())) } else { (Vec3::X, Vec3::Y) }; let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); let c = snap(u) + snap(v); let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); @@ -860,9 +895,9 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> { if !app.origin_visible() { return Vec::new(); } - // While the Origin is hidden, Create Sketch may bring the planes back (empty design): then - // every plane; otherwise only those not hidden one by one. - let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)); + // While the Origin is hidden, Create Sketch brings the planes back: then every plane; + // otherwise only those not hidden one by one. + let creating = !app.ui.show_origin && app.picking_sketch_plane(); origin_quads(origin_size(app.cam.half_height())) .into_iter() .filter(|(n, _, _)| creating || !app.session.visibility.hidden_origin.iter().any(|h| h == n)) @@ -893,11 +928,16 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> { /// past it either side), at least the origin axes' length around the axis's origin: (name, /// from, to). pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { - let h = origin_size(app.cam.half_height()) * AXIS_LEN; - let bb = view_bounds(app); - solidcraft_engine::view::construction_axes(&app.session) + let axes: Vec<_> = solidcraft_engine::view::construction_axes(&app.session) .into_iter() .filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n)) + .collect(); + if axes.is_empty() { + return Vec::new(); + } + let h = origin_size(app.cam.half_height()) * AXIS_LEN; + let bb = view_bounds(app); + axes.into_iter() .map(|(_, name, o, d)| { let (mut lo, mut hi) = (-h, h); if !bb.is_empty() { @@ -970,6 +1010,7 @@ fn body_bounds( }) .collect::>(), ); + app.viewport.pick_index.retain(|k| now.contains_key(&k)); if let Ok(mut k) = app.viewport.mesh_bounds.lock() { *k = now; } @@ -1063,7 +1104,7 @@ pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec = None; let mut faces = Near::new(all); - for b in &st.bodies { + // Bounds retain the state's body order. Looking each one up by name made both passes + // quadratic for assemblies with many small parts. + for (i, b) in st.bodies.iter().enumerate() { if !app.session.body_visible(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. - if let Some(bb) = bound(&b.name) { + if let Some((_, bb)) = bounds.get(i) { let pad = bb.diagonal() * 1e-6 + 1e-9; - match ray_box(o, d, &bb, pad) { + match ray_box(o, d, bb, pad) { None => continue, Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue, _ => {} } } let m = b.mesh(); - if let Some((t, ti)) = m.raycast(o, d) { + if let Some((t, ti)) = crate::pick_index::raycast(&app.viewport.pick_index.of(&m), &m, o, d) { let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize; let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }; if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) { @@ -1250,40 +1292,55 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, // Wireframe draws no faces, so nothing hides an edge or a vertex. let wireframe = app.ui.visual_style == 2; let visible = |p: Vec3| wireframe || bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack); - // Vertices (edge end points) and edges. + // Vertices (edge end points) and edges, within 7 and 6 points of the cursor. + let reach = crate::pick_index::reach(proj, pos, 8.0); + // The edges seen near the cursor, when the GPU pick buffer has them for this view (Select + // Other wants hidden ones too). + let seen = if all { None } else { crate::pick_buffer::edges_near(app, proj, pos, &st) }; let mut bestv = Near::new(all); let mut beste = Near::new(all); - for b in &st.bodies { + for (i, b) in st.bodies.iter().enumerate() { if !app.session.body_visible(&b.name) { continue; } // Only bodies whose outline on screen is near the cursor can have an edge under it. - if let Some(bb) = bound(&b.name) - && screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos)) + if let Some((_, bb)) = bounds.get(i) + && screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos)) { continue; } let m = b.mesh(); - for (ei, e) in m.edges.iter().enumerate() { + // Only the segments that can come near the cursor: of the edges the pick buffer sees + // there, else from the index. Either way in the order a scan of every edge would meet + // them, so ties go the same way. + let near = match &seen { + Some(seen) => seen.segments(i, &m), + None => crate::pick_index::segments_near(&app.viewport.pick_index.of(&m), &m, o, d, reach), + }; + let mut last_edge = None; + for (ei, si) in near { + let Some(e) = m.edges.get(ei) else { continue }; if !m.edge_pickable(ei) { continue; } - for v in [e.first(), e.last()].into_iter().flatten() { - if let Some(sp) = proj.to_screen(*v) { - let dd = sp.distance(pos); - if dd < 7.0 && visible(*v) && bestv.wants(dd) { - bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v }); + if last_edge != Some(ei) { + last_edge = Some(ei); + for v in [e.first(), e.last()].into_iter().flatten() { + if let Some(sp) = proj.to_screen(*v) { + let dd = sp.distance(pos); + if dd < 7.0 && visible(*v) && bestv.wants(dd) { + bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v }); + } } } } - for w in e.windows(2) { - let (Some(a), Some(c)) = (proj.to_screen(w[0]), proj.to_screen(w[1])) else { continue }; - let dd = seg_dist(pos, a, c); - // Visible where the cursor is: the edge's point under the cursor, not an end or - // the middle, which can lie deeper than the face the cursor is on. - if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, w[0], w[1])) { - beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) }); - } + let (Some(p0), Some(p1)) = (e.get(si), e.get(si + 1)) else { continue }; + let (Some(a), Some(c)) = (proj.to_screen(*p0), proj.to_screen(*p1)) else { continue }; + let dd = seg_dist(pos, a, c); + // Visible where the cursor is: the edge's point under the cursor, not an end or + // the middle, which can lie deeper than the face the cursor is on. + if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) { + beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) }); } } } @@ -1613,6 +1670,12 @@ pub fn hit_sel(h: &Hit) -> Option { }) } +/// What lights up under the pointer while a tool runs: the nearest hit its click takes. +fn tool_hover(app: &SolveApp, hits: &[Hit]) -> Option { + let cmd = app.tool.as_ref()?.cmd; + hits.iter().find(|h| crate::sketch_tools::takes(cmd, h)).cloned() +} + /// What a click would pick: the first hit the open dialog's active input accepts, or with no /// dialog the first hit (sketch entities only belong to the active sketch). pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { @@ -1702,8 +1765,14 @@ fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Opti } fn face_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) { + mesh_tris(sc, m, col, lit, |f| f as usize == face); +} + +/// Emit a set of faces in one mesh pass. Whole-body feedback must not scan every triangle +/// again for each face of a detailed imported part. +fn mesh_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, col: [u8; 4], lit: bool, takes: impl Fn(u32) -> bool) { for (t, f) in m.triangles.iter().zip(&m.tri_face) { - if *f as usize != face { + if !takes(*f) { continue; } for k in t { @@ -1771,22 +1840,35 @@ pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index } /// An origin or construction plane: reference geometry, which a section does not cut (#1229). -fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) { +/// A reference plane's square. `through` draws it over the model (Create Sketch: a plane inside +/// a body stays visible and pickable), else it is depth-tested like the rest of the scene. +fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32, through: bool) { let col = c4(fill); for i in [0, 1, 2, 0, 2, 3] { if let Some(p) = q.get(i) { - sc.guide_tri(p.to_f32(), col); + if through { + sc.xray_tri(p.to_f32(), [0.0; 3], col); + } else { + sc.guide_tri(p.to_f32(), col); + } } } for i in 0..4 { - sc.guide_line(q[i].to_f32(), q[(i + 1) % 4].to_f32(), c4(edge), 1.2); + let (a, b) = (q[i].to_f32(), q[(i + 1) % 4].to_f32()); + if through { + sc.line(a, b, c4(edge), 1.2, true); + } else { + sc.guide_line(a, b, c4(edge), 1.2); + } } } -fn highlight_key(app: &SolveApp) -> u64 { +fn highlight_geometry_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); + let selected = app.highlighted(); app.session.revision.hash(&mut h); - serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); + (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); + serde_json::to_string(&selected).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); app.viewport.hover_bodies.hash(&mut h); @@ -1796,19 +1878,41 @@ fn highlight_key(app: &SolveApp) -> u64 { app.session.active_occurrence_path.hash(&mut h); app.ui.visual_style.hash(&mut h); app.ui.pick_bodies.hash(&mut h); + app.session.active_sketch.hash(&mut h); + (app.dialog.is_some(), app.tool.is_some()).hash(&mut h); crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); app.preview.replaced.hash(&mut h); app.origin_visible().hash(&mut h); + app.picking_sketch_plane().hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); - app.cam.half_height().to_bits().hash(&mut h); - app.cam.back().x.to_bits().hash(&mut h); - crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h); h.finish() } +fn highlight_key(app: &SolveApp) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + highlight_geometry_key(app).hash(&mut h); + let selected = app.highlighted(); + app.cam.half_height().to_bits().hash(&mut h); + // Body triangles and outlines live in world space; the GPU applies the new camera. + // Only sketch feedback needs projected tessellation and the profile's lift toward the eye. + let projected = selected.iter().any(|s| matches!(s, Sel::Profile { .. } | Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. })) + || matches!(app.viewport.hover, Some(Hit::Profile { .. })); + if projected { + crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h); + } + h.finish() +} + +struct HoverBlock { + key: u64, + state: std::sync::Arc, + doc: std::sync::Arc, + block: crate::gpu::SceneBlock, +} + /// The highlight scene: origin widget, construction planes, selection and hover. fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); @@ -1836,6 +1940,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { }; let mut planes: Vec<(String, [Vec3; 4], Color32)> = origin_planes(app).into_iter().map(|(n, _, q)| (n.to_string(), q, t.origin_plane)).collect(); planes.extend(construction_quads(app).into_iter().map(|(n, _, q)| (n, q, t.construction_plane))); + let through = app.picking_sketch_plane(); for (name, q, base) in &planes { let (selected, hovered) = plane_state(name); let fill = if hovered { @@ -1845,7 +1950,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } else { *base }; - quad(&mut sc, q, fill, if selected { t.sel_edge } else { t.origin_plane_edge }); + quad(&mut sc, q, fill, if selected { t.sel_edge } else { t.origin_plane_edge }, through); } // Origin axes: solid near the origin, dashed beyond. for (name, dir) in origin_axes(app) { @@ -1885,14 +1990,30 @@ fn build_highlight(app: &SolveApp) -> GpuScene { sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), c4(t.construction_geom), 2.0, true); } } + // Reference widgets follow zoom; the often much larger world-space feedback does not. + let projected = sel.iter().any(|s| matches!(s, Sel::Profile { .. } | Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. })) + || matches!(hover, Some(Hit::Profile { .. })); + let key = highlight_geometry_key(app); + if !projected + && let Some(block) = app.viewport.hover_block.lock().ok().and_then(|cache| { + cache + .as_ref() + .filter(|c| c.key == key && std::sync::Arc::ptr_eq(&c.state, &st) && std::sync::Arc::ptr_eq(&c.doc, &s.doc)) + .map(|c| c.block.clone()) + }) + { + sc.blocks.push(block); + return sc; + } + let mut feedback = GpuScene::default(); // Selected things. for x in &sel { match x { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { - face_tris(&mut sc, &shown(b), *index, c4(t.sel_face), false); - hidden_face_outline(&mut sc, &drawn_mesh(app, b), *index, t.sel_edge); + face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false); + hidden_face_outline(&mut feedback, &drawn_mesh(app, b), *index, t.sel_edge); } } Sel::Body { name } => { @@ -1900,9 +2021,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene { && app.session.body_visible(name) { let m = shown(b); - for f in 0..b.body.face_count() { - face_tris(&mut sc, &m, f, c4(t.body_selected), true); - } + let count = b.body.face_count(); + mesh_tris(&mut feedback, &m, c4(t.body_selected), true, |f| (f as usize) < count); } } Sel::Edge { body, index, point } => { @@ -1910,10 +2030,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene { && app.session.body_visible(body) && let Some(e) = edge_of(&drawn_mesh(app, b), *index, *point) { - edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); + edge_lines(&mut feedback, e, t.sel_edge, t.sel_edge_rim, 3.0); } } - Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge), + Sel::Profile { sketch, index } => profile_fill(&mut feedback, app, *sketch, *index, t.sel_profile, t.sel_profile_edge), Sel::ExternalSketchCurve { sketch, id } => { if let Some(ss) = st.sketch(*sketch) && let Some(ci) = ss.sketch.curve_index(id) @@ -1921,7 +2041,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts { let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { - sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); + feedback.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -1931,7 +2051,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { for ss in st.sketches.iter() { if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) { for q in w.pts.windows(2) { - sc.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true); + feedback.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -1943,7 +2063,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts { let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { - sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); + feedback.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -1974,16 +2094,28 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let c = body_rgb(app, &b.name); let m = drawn_mesh(app, b); let lit = shown(b); - for &f in &faces { - if !sel.iter().any(|x| { - matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name) - }) { - face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); - } + let selected_body = sel.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name)); + if !selected_body { + let selected_faces: std::collections::HashSet = sel + .iter() + .filter_map(|x| { + if let Sel::Face { body, index, .. } = x + && body == &b.name + { + Some(*index) + } else { + None + } + }) + .collect(); + let hovered_faces: std::collections::HashSet = faces.iter().copied().collect(); + mesh_tris(&mut feedback, &lit, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true, |f| { + hovered_faces.contains(&(f as usize)) && !selected_faces.contains(&(f as usize)) + }); } for (ei, e) in m.edges.iter().enumerate() { if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) { - edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5); + edge_lines(&mut feedback, e, t.hover_edge, t.hover_edge_halo, 1.5); } } } @@ -1995,9 +2127,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let k = t.hover_face_lift; let c = body_rgb(app, body); let m = shown(b); - for f in 0..b.body.face_count() { - face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); - } + let count = b.body.face_count(); + mesh_tris(&mut feedback, &m, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true, |f| { + (f as usize) < count + }); } } Some(Hit::Face { body, index, .. }) @@ -2011,13 +2144,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene { let patch = m.surface_patch(*index); let lit = shown(b); for &f in &patch { - face_tris(&mut sc, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + face_tris(&mut feedback, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) && m.edge_pickable(e) { - edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5); + edge_lines(&mut feedback, p, t.hover_edge, t.hover_edge_halo, 1.5); } } } @@ -2029,7 +2162,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { for i in m.circular_rim(*index) { let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { - edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); + edge_lines(&mut feedback, e, t.hover_edge, t.hover_edge_halo, 2.0); } } } @@ -2037,10 +2170,32 @@ fn build_highlight(app: &SolveApp) -> GpuScene { Some(Hit::Profile { sketch, index }) if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == index)) => { - profile_fill(&mut sc, app, *sketch, *index, t.hover_profile, t.hover_profile_edge); + profile_fill(&mut feedback, app, *sketch, *index, t.hover_profile, t.hover_profile_edge); } _ => {} } + if projected { + sc.tris.append(&mut feedback.tris); + sc.trans.append(&mut feedback.trans); + sc.lines.append(&mut feedback.lines); + sc.hidden.append(&mut feedback.hidden); + sc.overlay.append(&mut feedback.overlay); + } else { + let block = crate::gpu::SceneBlock { + key, + bytes: std::sync::Arc::new(crate::gpu::BlockBytes { + tris: feedback.tris, + lines: feedback.lines, + hidden: feedback.hidden, + ..Default::default() + }), + }; + if let Ok(mut cache) = app.viewport.hover_block.lock() { + *cache = Some(HoverBlock { key, state: st.clone(), doc: s.doc.clone(), block: block.clone() }); + } + sc.blocks.push(block); + } + sc } @@ -2149,6 +2304,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Suppress hover for the whole navigation gesture, including frames between pointer // events. Testing delta made the highlight reappear whenever a held drag paused. // Resolve it before rendering so the GPU scene and screen overlays use the same hit. + // A 3D mouse moving the view is not such a gesture: the pointer hand is free, and hover + // and clicks keep picking while the other hand turns the view. let navigating = scroll != 0.0 || pinch != 1.0 || pan != egui::Vec2::ZERO @@ -2165,7 +2322,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // corner drawn from the origin is the fixed origin, which ends no two curves. let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p)); let cand = if app.tool.is_some() { - hits.first().cloned().map(|h| (h, None)) + tool_hover(app, &hits).map(|h| (h, None)) } else if let Some((hit, sel)) = corner { Some((hit, Some(sel))) } else { @@ -2249,17 +2406,47 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }, }); painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); + crate::pick_buffer::paint(app, ui.ctx(), &painter, &proj); } analysis_legend(app, &painter, rect); crate::frame_rate::paint(app, ui.ctx(), &painter, rect); // ---- interaction ---- + // A two-point shape being dragged out (#1630) follows the pointer and ends where it is let go, + // even over the shape's own dimension box, which rides along beside the pointer. + if app.viewport.draw_drag { + let at = ui.input(|i| i.pointer.latest_pos()).filter(|p| rect.contains(*p)); + if resp.drag_stopped() { + app.viewport.draw_drag = false; + if let Some(p) = at + && app.tool.as_ref().is_some_and(|t| t.pts.len() == 1) + { + crate::tools::on_hover(app, &proj, p); + crate::tools::on_click(app, &proj, p); + } + } else if let Some(p) = at { + crate::tools::on_hover(app, &proj, p); + } + } let add = mods.shift || mods.command || mods.ctrl; crate::sketch_text::set_one_letter(mods.shift); if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); } + // Rectangles, circles and polygons also draw by press, drag and release (#1630): the press + // places the first point as a click there would, the release the second. + let two_point_start = app.tool.as_ref().is_some_and(|t| matches!(t.kind, crate::tools::Kind::Draw(2)) && t.pts.is_empty()); + if two_point_start + && !held + && primary_nav.is_none() + && mode.is_none() + && resp.drag_started_by(egui::PointerButton::Primary) + && let Some(a) = ui.input(|i| i.pointer.press_origin()) + { + crate::tools::on_click(app, &proj, a); + app.viewport.draw_drag = app.tool.as_ref().is_some_and(|t| t.pts.len() == 1); + } if resp.clicked() && !held { if app.tool.is_some() { crate::tools::on_click(app, &proj, p); @@ -2591,6 +2778,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::select_other::show(app, ui.ctx()); axis_view(app, ui, rect); nav_bar(app, ui, rect); + // A command recomputing the history off the window thread (the marker moved, undo): the + // window goes on, and says so above the status chip. + if let Some(since) = app.computing_since() { + let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); + let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); + let pos = pos2(rect.left() + 12.0, rect.bottom() - 76.0); + let galley = painter.layout_no_wrap(text, FontId::proportional(12.5), t.text); + let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0)); + painter.rect_filled(r, 4.0, t.overlay); + egui::Spinner::new().size(12.0).paint_at(ui, Rect::from_center_size(pos2(r.left() + 13.0, r.center().y), vec2(12.0, 12.0))); + painter.galley(pos + vec2(26.0, 4.0), galley, t.text); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } // Status chip. if let Some((msg, at, err)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { @@ -3156,7 +3356,8 @@ pub(crate) fn nearest_quarter_turn(yaw: f64) -> f64 { } /// Navigation bar (bottom centre). -/// Turn the view to face the selected planar face or plane, else the active sketch. +/// Turn the view to face the selected planar face or plane, else the active sketch; asked again +/// while it already faces it, from the other side (#1886). pub fn look_at_selection(app: &mut SolveApp) { let target = app.session.selection.iter().find_map(|s| match s { Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)), @@ -3165,11 +3366,11 @@ pub fn look_at_selection(app: &mut SolveApp) { }); match target { Some((p, n)) => { - let mut to = app.cam.looking_from(n); + let mut to = app.cam.looking_from(crate::dialogs::facing(app, n, true)); to.target = p; app.animate_to(to); } - None => crate::dialogs::look_at_sketch(app), + None => crate::dialogs::look_at_sketch_again(app), } } @@ -3314,6 +3515,127 @@ mod tests { use super::*; use crate::navigation::*; + /// Every tangent seam Continuity Analysis reports keeps its colour while the pointer rests on + /// a face (#1247): a 60 x 40 x 20 box with its four top edges rounded (6 mm) has eight, four + /// against the sides and four against the top face. The tool lit the face under the pointer, + /// though its click takes only edges, and the face's outline covered the colours of its + /// edges. Rendered offscreen with bodies built on the CPU and evaluated on the GPU (skipped + /// without a GPU adapter); with `SOLIDCRAFT_CONTINUITY_TOP_SHOT` set, the pictures are + /// written there (`-cpu.png`, `-gpu.png`). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn continuity_colours_stay_on_the_edges_of_the_face_under_the_pointer() { + use solidcraft_engine::kernel::SeamVerdict; + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 20, "body_name": "B"})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[30, 0, 20], [30, 40, 20], [0, 20, 20], [60, 20, 20]], "radius": 6})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.fit_view(); + // The toolbar button: every seam shaded, the tool left picking edges. + app.start("inspect.continuity"); + let tangent = |app: &SolveApp| app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump).count(); + assert_eq!(tangent(&app), 8); + const SIZE: u32 = 512; // rows of 256-byte multiples (the copy alignment) + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + // The pointer rests in the middle of the top face, away from its edges: the tool takes + // only edges, so nothing there lights up over the analysis. + let proj = projection(&app, rect); + let middle = proj.to_screen(Vec3::new(30.0, 20.0, 20.0)).unwrap(); + let hits = pick_cached(&mut app, &proj, middle); + assert!(matches!(hits.first(), Some(Hit::Face { .. })), "{hits:?}"); + app.viewport.hover = tool_hover(&app, &hits); + assert_eq!(app.viewport.hover, None); + let t = Tokens::get(); + let g1 = t.continuity[1]; + // Each tangent seam lies in one flat face (the top, or a side under its round): that + // face's outward normal, and the seam shows where it faces the eye (the body is convex). + let flat_normal = |e: &[Vec3]| { + let on = |f: fn(&Vec3) -> f64, at: f64| e.iter().all(|p| (f(p) - at).abs() < 1e-6); + [ + (on(|p| p.z, 20.0), Vec3::Z), + (on(|p| p.y, 0.0), -Vec3::Y), + (on(|p| p.y, 40.0), Vec3::Y), + (on(|p| p.x, 0.0), -Vec3::X), + (on(|p| p.x, 60.0), Vec3::X), + ] + .into_iter() + .find_map(|(lies, n)| lies.then_some(n)) + }; + for tess in [false, true] { + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; + let Some(img) = crate::gpu::render_offscreen( + build_scene(&app).merged(), + Some(build_highlight(&app)), + proj.vp.to_f32(), + proj.cam.back().to_f32(), + eye, + SIZE, + tess.then_some(solidcraft_gpu::SHADER), + [0.0; 8], + crate::gpu::analysis_colors(&t.analysis), + ) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + if let Ok(path) = std::env::var("SOLIDCRAFT_CONTINUITY_TOP_SHOT") + && let Some(png) = image::RgbaImage::from_raw(SIZE, SIZE, img.clone()) + { + png.save(path.replace(".png", if tess { "-gpu.png" } else { "-cpu.png" })).unwrap(); + } + let lit = |x: i64, y: i64| { + (0..SIZE as i64).contains(&x) + && (0..SIZE as i64).contains(&y) + && img[((y * SIZE as i64 + x) * 4) as usize..][..3].iter().zip([g1.r(), g1.g(), g1.b()]).all(|(a, b)| a.abs_diff(b) <= 12) + }; + let mut seen = 0; + for seam in app.session.continuity.iter().filter(|seam| seam.verdict == SeamVerdict::CurvatureJump) { + let (a, b) = (seam.points[0], seam.points[seam.points.len() - 1]); + let normal = flat_normal(&seam.points).unwrap_or_else(|| panic!("a tangent seam off the flat faces: {a:?}..{b:?}")); + if normal.dot(proj.cam.eye() - a.lerp(b, 0.5)) <= 0.0 { + continue; + } + seen += 1; + // In its colour a quarter, half and three quarters along. + for f in [0.25, 0.5, 0.75] { + let p = a.lerp(b, f); + let q = proj.to_screen(p).unwrap(); + let (x, y) = (q.x.round() as i64, q.y.round() as i64); + let drawn = (-2..=2).any(|dy| (-2..=2).any(|dx| lit(x + dx, y + dy))); + assert!(drawn, "tess {tess}: tangent seam {a:?}..{b:?} is not drawn in its colour at {p:?} ({x}, {y})"); + } + } + assert_eq!(seen, 6, "the four top seams and the two sides facing the eye"); + } + // On an edge, the edge lights up: what a click measures. + let proj = projection(&app, rect); + let on_edge = proj.to_screen(Vec3::new(30.0, 6.0, 20.0)).unwrap(); + let hits = pick_cached(&mut app, &proj, on_edge); + assert!(matches!(tool_hover(&app, &hits), Some(Hit::Edge { .. })), "{hits:?}"); + } + + /// Continuity Analysis draws each seam in its verdict's `continuity` token (#770): a box + /// with one edge rounded shows the round's two tangent seams (G1) and the creases (G0), and + /// nothing in the smooth or gap colours. + #[test] + fn continuity_seams_take_their_colours_from_the_theme() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap(); + s.execute("solid.fillet", &json!({"edges": [[20, 20, 10]], "radius": 4})).unwrap(); + s.execute("inspect.continuity", &json!({})).unwrap(); + let app = SolveApp::new(s, crate::Services::default()); + let sc = build_scene(&app); + // An overlay line is two points, a colour and a width: 32 bytes. + let colours: Vec<[u8; 4]> = sc.overlay.as_chunks::<32>().0.iter().map(|r| [r[24], r[25], r[26], r[27]]).collect(); + let t = Tokens::get(); + let count = |c: Color32| colours.iter().filter(|x| **x == c4(c)).count(); + let [smooth, jump, sharp, gap] = t.continuity.map(count); + assert!(jump > 0 && sharp > 0, "tangent seams {jump}, creases {sharp} segments"); + assert_eq!((smooth, gap), (0, 0)); + } + /// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies /// built into triangles on the CPU: the sample design rendered both ways through the /// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one). @@ -3489,7 +3811,7 @@ mod tests { let mut render = |tess: bool, hover: Option| { app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); app.viewport.hover = hover.map(|index| Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); - let (scene, highlight) = (build_scene(&app), build_highlight(&app)); + let (scene, highlight) = (build_scene(&app), build_highlight(&app).merged()); assert_eq!(!scene.planned.is_empty(), tess, "the box is drawn from its plan exactly on the GPU path"); let proj = projection(&app, rect); let e = proj.cam.eye().to_f32(); @@ -3534,6 +3856,81 @@ mod tests { assert!(checked >= 3, "{checked} faces seen lit"); } + /// An origin plane lying on a body's face is tinted evenly over it (#1739: a box built at the + /// origin, seen from below in orthographic; the XY plane's square z-fought with the bottom + /// face in stripes). Faces are pushed back by their slope (`FACE_BIAS`), the planes are not. + /// Skipped without a GPU adapter. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn an_origin_plane_on_a_face_is_tinted_evenly() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, 0]})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.cam = Camera { + target: Vec3::new(16.904, 10.024, 3.661), + yaw: -0.32, + pitch: -0.9652, + roll: 0.0, + distance: 98.557, + fov: 0.0, + ..Default::default() + }; + app.ui.perspective = false; + const SIZE: u32 = 256; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + let mut render = |origin: bool| { + app.ui.show_origin = origin; + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + let (scene, highlight) = (build_scene(&app), build_highlight(&app).merged()); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let img = crate::gpu::render_offscreen( + scene, + Some(highlight), + proj.vp.to_f32(), + proj.cam.back().to_f32(), + [e[0], e[1], e[2], 0.0], + SIZE, + None, + [0.0; 8], + crate::gpu::analysis_colors(&Tokens::get().analysis), + ); + (img, proj) + }; + let (Some(with), proj) = render(true) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let (Some(without), _) = render(false) else { return }; + app.ui.show_origin = true; + let xy = origin_planes(&app).into_iter().find(|(n, _, _)| *n == "XY").map(|(_, _, q)| q).unwrap(); + let (w, h, r) = (f64::from(SIZE), f64::from(SIZE), scene_radius(&app)); + let (mut samples, mut plain) = (0, Vec::new()); + // Points inside the square (away from its outline), where it lies on the bottom face. + for i in 1..12 { + for j in 1..12 { + let (u, v) = (f64::from(i) / 12.0, f64::from(j) / 12.0); + let p = xy[0] + (xy[1] - xy[0]) * u + (xy[3] - xy[0]) * v; + if !(p.x > 0.5 && p.x < 39.5 && p.y > 0.5 && p.y < 29.5) { + continue; + } + let Some((x, y, _)) = proj.cam.to_screen(p, w, h, r) else { continue }; + let at = (y as usize * SIZE as usize + x as usize) * 4; + samples += 1; + if with.get(at..at + 4) == without.get(at..at + 4) { + plain.push((x as u32, y as u32)); + } + } + } + assert!(samples > 40, "{samples} points of the square on the face"); + assert!( + plain.is_empty(), + "{} of {samples} points of the XY plane's square do not show over the face: {:?}", + plain.len(), + &plain[..plain.len().min(6)] + ); + } + #[test] fn construction_drawing_and_hover_overlays_preserve_visible_gaps() { fn length(shape: &Shape) -> f32 { @@ -3706,14 +4103,14 @@ mod tests { let p = crate::scenario::handle_at("timeline:Blend").unwrap(); h.step(&json!({"move":[p.x,p.y]})).unwrap(); assert_eq!(h.app.viewport.hover_feature, Some(id)); - assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); - assert!(!build_highlight(&h.app).lines.is_empty()); + assert_eq!(build_highlight(&h.app).merged().tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); + assert!(!build_highlight(&h.app).merged().lines.is_empty()); h.step(&json!({"click":[p.x,p.y]})).unwrap(); assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]); // Moving away removes hover but leaves the selected blend highlighted. h.step(&json!({"move":[5,5]})).unwrap(); assert_eq!(h.app.viewport.hover_feature, None); - assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); + assert_eq!(build_highlight(&h.app).merged().tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); let bounds = selection_bounds(&h.app).unwrap(); assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}"); // Creator features retain whole-body feedback, while hidden bodies stay hidden. @@ -3721,7 +4118,7 @@ mod tests { h.app.session.visibility.hidden_bodies.push(body.clone()); assert!(feature_geometry(&h.app, id).is_empty()); assert!(selection_bounds(&h.app).is_none()); - assert!(build_highlight(&h.app).tris.is_empty()); + assert!(build_highlight(&h.app).merged().tris.is_empty()); } } @@ -3843,6 +4240,40 @@ mod tests { app } + fn space(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::default() } + } + + /// Holding Space is a momentary pan: the drag moves the view rather than the geometry under the + /// pointer, in every preset. The point is over the sketch's own point, so without Space this + /// drag would drag it (and the undo stack would grow). + #[test] + fn holding_space_pans_instead_of_dragging_geometry() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let ctx = egui::Context::default(); + let mut app = hover_app(false); + app.preferences.nav = preset.into(); + app.ui.show_origin = false; + hover_frame(&mut app, &ctx, vec![]); + let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); + let before = app.cam; + let undo = app.session.undo.len(); + let press = |pressed: bool, at: Pos2| egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }; + hover_frame(&mut app, &ctx, vec![space(true)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point), press(true, point)]); + hover_frame(&mut app, &ctx, vec![space(true), egui::Event::PointerMoved(point + vec2(40.0, 25.0))]); + assert_ne!(app.cam, before, "{preset}: holding Space must pan the view"); + assert_eq!(app.session.undo.len(), undo, "{preset}: holding Space must not edit the design"); + assert!(app.viewport.point_drag.is_none(), "{preset}: holding Space must not drag geometry"); + hover_frame(&mut app, &ctx, vec![press(false, point + vec2(40.0, 25.0)), space(false)]); + } + } + #[test] fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() { for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { @@ -4245,6 +4676,50 @@ mod tests { } } + /// Two-handed use: while a 3D mouse turns the view, the pointer still preselects what is + /// under it in the view as it is now, and a click picks it (it neither does nothing nor + /// clears the selection). + #[test] + fn device_navigation_keeps_hover_and_click_picking() { + use crate::space_mouse::{DeviceMotion, Motion}; + for gpu in [false, true] { + let ctx = egui::Context::default(); + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.ui.show_origin = false; + if gpu { + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + } + app.cam.target = Vec3::new(20.0, 15.0, 10.0); + hover_frame(&mut app, &ctx, vec![]); + // The middle of the top face. + let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::new(20.0, 15.0, 20.0)).unwrap(); + hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + assert!(matches!(app.viewport.hover, Some(Hit::Face { .. })), "{:?}", app.viewport.hover); + let turn = || Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 0.1] }, seconds: 0.016 }); + let mut pick = app.viewport.last_pick.as_ref().unwrap().0; + for _ in 0..3 { + let before = app.cam; + app.viewport.device_motion = turn(); + hover_frame(&mut app, &ctx, vec![]); + assert_ne!(app.cam, before, "the device turns the view"); + assert!(matches!(app.viewport.hover, Some(Hit::Face { .. })), "hover stays on while the device moves the view"); + let now = app.viewport.last_pick.as_ref().unwrap().0; + assert_ne!(now, pick, "the pick follows the turned view"); + pick = now; + } + // A click while the device keeps turning the view selects the face under the pointer. + let button = + |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + app.viewport.device_motion = turn(); + hover_frame(&mut app, &ctx, vec![button(true)]); + app.viewport.device_motion = turn(); + hover_frame(&mut app, &ctx, vec![button(false)]); + assert!(matches!(app.session.selection.as_slice(), [Sel::Face { .. }]), "gpu {gpu}: {:?}", app.session.selection); + } + } + #[test] fn hover_stays_off_during_spin_and_animated_views() { let ctx = egui::Context::default(); @@ -4320,7 +4795,7 @@ mod tests { let mut lit_faces = 0; for index in 0..body.body.face_count() { app.viewport.hover = Some(Hit::Face { body: body.name.clone(), index, point: Vec3::ZERO }); - let lit = tris_of(&build_highlight(&app).tris); + let lit = tris_of(&build_highlight(&app).merged().tris); assert!(!lit.is_empty(), "face {index} lights"); assert!(lit.iter().all(|t| drawn.contains(t)), "face {index}: every lit triangle is a drawn one"); lit_faces += 1; @@ -4337,6 +4812,71 @@ mod tests { app } + #[test] + fn body_highlight_buffers_are_reused_when_the_camera_orbits_or_pans() { + let mut app = cylinder_app(); + app.ui.show_origin = true; + app.viewport.hover_bodies = vec!["Body1".into()]; + let key = highlight_key(&app); + let before = build_highlight(&app).merged(); + assert!(!before.tris.is_empty()); + app.cam.orbit(25.0, 10.0); + app.cam.roll_by(0.3); + app.cam.pan(20.0, -15.0, 600.0); + assert_eq!(highlight_key(&app), key, "world-space feedback must stay resident during navigation"); + let after = build_highlight(&app).merged(); + assert_eq!(before.tris, after.tris); + assert_eq!(before.lines, after.lines); + app.cam.zoom_at(1.2, None); + assert_ne!(highlight_key(&app), key, "reference geometry follows the view's scale"); + + let mut app = hover_app(true); + let sketch = app.session.active_sketch.unwrap(); + app.run("sketch.circle.center", json!({"center":[0,0],"radius":5})).unwrap(); + app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + let key = highlight_key(&app); + let before = build_highlight(&app).merged(); + assert!(!before.trans.is_empty()); + app.cam.orbit(25.0, 10.0); + assert_ne!(highlight_key(&app), key, "profile feedback still follows the projected curves and lift toward the eye"); + assert_ne!(before.trans, build_highlight(&app).merged().trans); + } + + #[test] + fn world_highlight_block_survives_zoom_and_refreshes_when_bodies_are_hidden() { + let mut app = cylinder_app(); + app.ui.show_origin = true; + app.viewport.hover_bodies = vec!["Body1".into()]; + let before = build_highlight(&app); + let bytes = before.blocks[0].bytes.clone(); + assert!(!bytes.tris.is_empty()); + app.cam.zoom_at(4.0, None); + let after = build_highlight(&app); + assert!(std::sync::Arc::ptr_eq(&bytes, &after.blocks[0].bytes), "zoom must not regenerate or upload body feedback"); + assert_ne!(before.guide_trans, after.guide_trans, "reference planes still resize with the view"); + app.session.visibility.hidden_bodies.push("Body1".into()); + let hidden = build_highlight(&app); + assert!(!std::sync::Arc::ptr_eq(&bytes, &hidden.blocks[0].bytes)); + assert!(hidden.blocks[0].bytes.tris.is_empty(), "hidden bodies must lose their feedback"); + app.session.visibility.hidden_bodies.clear(); + let shown = build_highlight(&app); + assert_eq!(shown.blocks[0].bytes.tris, bytes.tris); + } + + #[test] + fn whole_body_highlight_preserves_each_triangle_and_selected_face_once() { + let mut app = cylinder_app(); + let body = app.session.world_state().bodies[0].clone(); + let expected = shown_mesh(&app, &body, &None).triangles.len() * 3 * crate::gpu::TRI_SIZE; + app.viewport.hover_bodies = vec![body.name.clone()]; + for selected in + [vec![], vec![Sel::Face { body: body.name.clone(), index: 0, point: Vec3::ZERO }], vec![Sel::Body { name: body.name.clone() }]] + { + app.session.selection = selected; + assert_eq!(build_highlight(&app).merged().tris.len(), expected, "whole-body feedback must neither omit nor duplicate selected faces"); + } + } + #[test] fn smooth_wall_display_preserves_wireframe_and_edge_selection() { let mut s = solidcraft_engine::Session::default(); @@ -4372,7 +4912,7 @@ mod tests { assert!(wire.tris.is_empty()); app.ui.visual_style = 0; app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }]; - assert!(!build_highlight(&app).lines.is_empty()); + assert!(!build_highlight(&app).merged().lines.is_empty()); let mut without_display_mask = (*mesh).clone(); without_display_mask.smooth_edges.clear(); assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize)); @@ -4523,7 +5063,7 @@ mod tests { h.app.ui.show_origin = true; h.frames(2); assert!(section_plane(&h.app).is_some()); - let sc = build_highlight(&h.app); + let sc = build_highlight(&h.app).merged(); let planes = origin_planes(&h.app).len(); assert!(planes > 0); assert_eq!(sc.guide_trans.len(), planes * 6 * crate::gpu::TRI_SIZE); @@ -4545,12 +5085,12 @@ mod tests { for &i in &rim { let mid = polyline_mid(&m.edges[i]); app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid }); - assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); + assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); select(&mut app, Some(item.clone()), false); assert_eq!(app.session.selection.len(), 2); // Selecting both pieces must not draw each highlight twice. - assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); + assert_eq!(build_highlight(&app).merged().lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); // Ctrl/Shift clicking either half deselects the entire rim. select(&mut app, Some(item.clone()), true); assert!(app.session.selection.is_empty()); @@ -4594,11 +5134,11 @@ mod tests { let [a, c, d] = m.tri(t).unwrap(); let point = (a + c + d) / 3.0; app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point }); - assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); + assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); select(&mut app, Some(item.clone()), false); assert_eq!(app.session.selection.len(), 2); - assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); + assert_eq!(build_highlight(&app).merged().tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first"); let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap(); assert_eq!(dialog.items().len(), 2); @@ -5108,10 +5648,10 @@ mod tests { let mut s = solidcraft_engine::Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - assert!(build_highlight(&app).hidden.is_empty()); + assert!(build_highlight(&app).merged().hidden.is_empty()); // The back face: its outline is drawn (dimmed) where the box hides it. app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }]; - assert!(!build_highlight(&app).hidden.is_empty()); + assert!(!build_highlight(&app).merged().hidden.is_empty()); } #[test] @@ -5538,4 +6078,43 @@ mod grid_plane_tests { } } } + + /// A sketch is drawn in a parallel view even when the preference asks for perspective; the + /// preference comes back when the sketch is finished. + #[test] + fn an_open_sketch_is_drawn_orthographic() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.perspective = true; + assert!(projection(&app, rect).cam.fov > 0.0); + app.run("sketch.create", json!({"plane": "XZ"})).unwrap(); + assert!(!app.perspective_view()); + assert_eq!(projection(&app, rect).cam.fov, 0.0); + app.finish_sketch(); + assert!(app.ui.perspective, "the preference is left alone"); + assert!(projection(&app, rect).cam.fov > 0.0); + } + + /// Create Sketch shows the Origin even when the browser hides it, and draws its planes + /// through the model so a plane inside a body can be seen and picked; closing it restores + /// the hidden, depth-tested Origin. + #[test] + fn create_sketch_shows_the_origin_planes_through_the_model() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap(); + app.ui.show_origin = false; + assert!(origin_planes(&app).is_empty()); + assert!(build_highlight(&app).xray.is_empty()); + app.start("sketch.create"); + assert!(app.picking_sketch_plane()); + assert_eq!(origin_planes(&app).len(), 3, "every origin plane is offered"); + let sc = build_highlight(&app); + assert_eq!(sc.xray.len(), 3 * 6 * (6 * 4 + 4), "each plane's two triangles are drawn through the model"); + assert!(sc.guide_trans.is_empty()); + app.dialog = None; + assert!(origin_planes(&app).is_empty()); + app.ui.show_origin = true; + let sc = build_highlight(&app); + assert!(sc.xray.is_empty() && !sc.guide_trans.is_empty(), "outside Create Sketch the planes are depth-tested"); + } } diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 674d1437..bec39cab 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -19,6 +19,7 @@ const ASSEMBLE: &[&str] = &[ "joint.gear", "joint.rack_pinion", "joint.screw", + "joint.cam", "motion.study", "motion.trace", "explode.create", @@ -151,8 +152,9 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> { FeatureKind::SheetConvert { .. } => "convert_sheet", FeatureKind::Boss { .. } => "boss", FeatureKind::Lip { .. } => "lip", - FeatureKind::SnapFit { .. } => "snap", + FeatureKind::SnapFit { .. } => "snap_fit", FeatureKind::Rest { .. } => "rest", + FeatureKind::Rib { web: true, .. } => "web", FeatureKind::Rib { .. } => "rib", _ => return None, }) diff --git a/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json b/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json new file mode 100644 index 00000000..44e11fc8 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/two_point_shapes_drawn_by_dragging.json @@ -0,0 +1,14 @@ +[ + {"note": "#1630: a rectangle or a circle draws by press, drag and release as well as by two clicks: the press places the first point, the release the second."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"start": "sketch.rectangle.two_point"}, + {"drag": [{"sketch": [10, 10]}, {"sketch": [40, 30]}], "steps": 8}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 3, "construction"], "value": false}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4], "value": null}}}, + {"key": "Escape"}, + {"start": "sketch.circle.center"}, + {"drag": [{"sketch": [-30, 10]}, {"sketch": [-20, 10]}], "steps": 8}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "construction"], "value": false}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5], "value": null}}} +] diff --git a/crates/ui-egui/tests/stand_up_offer.rs b/crates/ui-egui/tests/stand_up_offer.rs new file mode 100644 index 00000000..958afa60 --- /dev/null +++ b/crates/ui-egui/tests/stand_up_offer.rs @@ -0,0 +1,70 @@ +//! The "Stand this part up?" offer can be answered by handle (#1905): its buttons are published +//! as `stand_up:stand`, `stand_up:leave` and `stand_up:never`, it stays up through a camera move +//! and a command until answered, and `ui.prefs {"stand_up": false}` leaves the file as it is. +use serde_json::json; +use solidcraft_ui_egui::scenario::Harness; + +/// A harness with a 10 × 40 × 5 mm box (Y up) just opened from STEP: the offer is up. +fn opened() -> Harness { + let mut src = Harness::new(); + let path = src.dir.join("tall_in_y.step").to_string_lossy().into_owned(); + src.app.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap(); + src.app.run("file.export", json!({"path": path})).unwrap(); + let mut h = Harness::new(); + h.app.open_path(&path); + h.frames(3); + assert_eq!(h.app.stand_up.as_ref().map(Vec::len), Some(1), "the file's body is offered"); + h +} + +fn height(h: &Harness) -> f64 { + let b = solidcraft_engine::view::bounds(&h.app.session); + b.max.z - b.min.z +} + +#[test] +fn the_offer_buttons_are_handles() { + let mut h = opened(); + for name in ["stand_up:stand", "stand_up:leave", "stand_up:never"] { + assert!(h.at(&json!({"handle": name})).is_ok(), "{name} is published"); + } + // Unanswered, it stays up through a camera move and a command. + h.run(&json!([{"camera": {"yaw": 0.6, "pitch": 0.4}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}}])) + .unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_some(), "the offer waits for an answer"); + h.run(&json!([{"click": {"handle": "stand_up:stand"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!((height(&h) - 40.0).abs() < 1e-6, "stood up: {}", height(&h)); +} + +#[test] +fn leave_it_keeps_the_file_and_the_preference() { + let mut h = opened(); + h.run(&json!([{"click": {"handle": "stand_up:leave"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!(h.app.preferences.offer_stand_up); + assert!((height(&h) - 5.0).abs() < 1e-6); + assert!(h.at(&json!({"handle": "stand_up:leave"})).is_err(), "the offer is gone"); +} + +#[test] +fn dont_ask_again_turns_the_offer_off() { + let mut h = opened(); + h.run(&json!([{"click": {"handle": "stand_up:never"}}])).unwrap(); + h.frames(3); + assert!(h.app.stand_up.is_none()); + assert!(!h.app.preferences.offer_stand_up); + assert!((height(&h) - 5.0).abs() < 1e-6); +} + +#[test] +fn prefs_stand_up_false_leaves_the_offer() { + let mut h = opened(); + let r = h.call("ui.prefs", json!({"stand_up": false})); + assert_eq!(r["result"]["stand_up_offer"], json!(null), "{r}"); + assert_eq!(r["result"]["offer_stand_up"], json!(true), "{r}"); + assert!((height(&h) - 5.0).abs() < 1e-6); +} diff --git a/deny.toml b/deny.toml index c24dbb4c..c5b57291 100644 --- a/deny.toml +++ b/deny.toml @@ -3,6 +3,14 @@ # The nightly command downgrades only this diagnostic with --warn unmaintained. unmaintained = "all" yanked = "warn" +# zip 8.6 comes only through cadmpeg-codec-freecad 0.6.1 (`zip = "^8"`); all three advisories +# are in ZipArchive::extract / ZipStreamReader::extract, which neither it nor solidcraft-io +# calls. Remove these when cadmpeg moves to zip 9. +ignore = [ + { id = "RUSTSEC-2026-0367", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, + { id = "RUSTSEC-2026-0368", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, + { id = "RUSTSEC-2026-0369", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, +] [bans] multiple-versions = "warn" diff --git a/docs/action-matrix.md b/docs/action-matrix.md index f4ae77f6..b15c3855 100644 --- a/docs/action-matrix.md +++ b/docs/action-matrix.md @@ -257,7 +257,7 @@ resolves; it is recorded as `+edit`. ## Causes -Every failure, grouped by cause (5025 in all). Faces that can't take an attached sketch fail four +Every failure, grouped by cause (5013 in all). Faces that can't take an attached sketch fail four actions each (`face.sketch`, `face.offset_plane`, and `face.press_pull` and `face.extrude`, which make a legacy face sketch there). @@ -283,7 +283,6 @@ make a legacy face sketch there). | 56 | #563 | Draft and tapered Extrude drop the faces' sources | `face.extrude` 14, `face.offset_plane` 14, `face.press_pull` 14, `face.sketch` 14 | 3 | oracle/05-taper-extrude `Frustum/F2:start` | | 48 | #564 | The rim a Shell leaves has no source | `face.extrude` 12, `face.offset_plane` 12, `face.press_pull` 12, `face.sketch` 12 | 8 | demo/pi-case `Body1/F2:top` | | 18 | #567 | Axis or tangent plane on a drill tip: no face at NaN | `curved.axis` 9, `curved.tangent_plane` 9 | 3 | oracle/18-hole-features `Plate/F5:face:15` | -| 12 | #565 | Split Body and Combine Intersect drop the faces' sources | `face.offset_plane` 4, `face.sketch` 4, `face.extrude` 2, `face.press_pull` 2 | 1 | oracle/22-split-body `Cyl (1)/F2:bottom` | | 9 | #568 | Construction geometry on model edges, faces and vertices is lost when a parameter moves it | `vertex.plane_three_points+edit` 5, `edge.normal_plane+edit` 2, `face.midplane+edit` 1, `face.shell+edit` 1 | 4 | demo/enclosure_steps `Enclosure/v[F2:top & F4:inner:F2:side:l4 & F4:inner:F3:blend:F2:side:l` | | 9 | | Axis through a face the kernel sees as a cylinder that the command doesn't (sensor enclosure slot ends; not filed yet) | `curved.axis` 9 | 1 | examples/sensor_enclosure `Lid/F15:blend:F14:side:l2\|F14:side:l3` | | 7 | | Other failures in the edit round: the hole cut and blend sizes above, and an extrude from the M0 box's top face whose profile isn't found again (not filed yet) | `face.hole+edit` 3, `face.extrude+edit` 1, `face.press_pull+edit` 1, `edge.chamfer+edit` 1 | 4 | corpus/m0-box-fillet-hole `Body1/F2:top` | @@ -297,5 +296,6 @@ chamfers, pattern and mirror copies, sides a Join merged, revolves, lofts, sweep shell rims, the plastic features' faces and the faces around them, and pieces of a split face (by the piece's own name where Offset Face moved it off the others' plane). Sheet metal panels, their rims and the flat ends of bends attach by name. What still refuses a sketch is what makes faces -without recording their history (some cuts, Split Body, Intersect): press-pull and -extrude-from-face then fall back to a legacy face sketch found again by a point. +without recording their history (some cuts, the face a Split Body cuts with another body's +face): press-pull and extrude-from-face then fall back to a legacy face sketch found again by a +point. diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 5529a94c..d0bf0fa7 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -1,7 +1,7 @@ # Agent chat Agent chat is a side panel where you talk to a coding agent you already use (Claude Code, -Codex, Gemini CLI, OpenCode, Grok Build and others), or to a model behind an OpenAI-compatible endpoint you +Codex, Google Antigravity, OpenCode, Grok Build and others), or to a model behind an OpenAI-compatible endpoint you choose (Ollama or LM Studio on your computer, OpenAI, any other URL), and have it work on the design that's open. A coding agent runs on your computer under **your own account** with that agent: SolidCraft doesn't sell or proxy a model and needs no key for it. An endpoint's key, if @@ -88,7 +88,7 @@ the keychain, so SolidCraft doesn't guess there. |---|---| | Claude Code | `npm install -g @agentclientprotocol/claude-agent-acp` (the ACP adapter for Claude Code) | | Codex | `npm install -g @agentclientprotocol/codex-acp` | -| Gemini CLI | `npm install -g @google/gemini-cli` (it speaks ACP itself: `gemini --acp`) | +| Google Antigravity | Google's ACP server, a separate download from Google's `agy` command (which has no ACP mode): take the zip for your system from the [ACP registry](https://agentclientprotocol.com/registry), unzip it and put `agy_acp_server.par` (`agy_acp_server.exe` on Windows) on your `PATH`. The zip also holds `localharness_external`: leave the two together. It signs in itself (browser). | | OpenCode | `npm install -g opencode-ai` (`opencode acp`) | | XAI Grok | [Grok Build installer](https://docs.x.ai/build/overview), then `grok login` (`grok agent stdio` speaks ACP) | | GitHub Copilot CLI, Qwen Code, goose | listed with their install command when missing | @@ -149,6 +149,9 @@ The agent reaches the open design only through SolidCraft's own tools (the a single undo step, named after your message. The conversation shows *N changes, one undo step* with **Undo these changes** while nothing else has happened since. - **Tool cards:** each tool call shows as a card with its state; click it to see what it returned. +- **Formatted text:** the agent's answers show as Markdown: headings, lists, quotes, code blocks + (with **Copy**), bold, italic, `code` and links (web links only; each link is a button that opens + it in your browser). What you type, and the agent's thoughts, stay plain text. - **Attach view:** tick it to send a picture of the current view with your message, for agents that take pictures. diff --git a/docs/book/book.toml b/docs/book/book.toml index a306debd..00fd80a7 100644 --- a/docs/book/book.toml +++ b/docs/book/book.toml @@ -10,9 +10,9 @@ build-dir = "../../target/book" create-missing = false [output.html] -git-repository-url = "https://github.com/bherbruck/solidcraft" -edit-url-template = "https://github.com/bherbruck/solidcraft/edit/main/docs/book/{path}" -site-url = "/solidcraft/" +git-repository-url = "https://github.com/tangency-labs/solidcraft" +edit-url-template = "https://github.com/tangency-labs/solidcraft/edit/main/docs/book/{path}" +site-url = "/" no-section-label = false [output.html.search] @@ -23,7 +23,7 @@ enable = true # links; these send them to the right place. "/dev/mcp.html" = "../guide/mcp.html" "/dev/docs/oracle.html" = "../oracle.html" -"/dev/README.html" = "https://github.com/bherbruck/solidcraft/blob/main/README.md" -"/dev/ATTRIBUTION.html" = "https://github.com/bherbruck/solidcraft/blob/main/ATTRIBUTION.md" -"/dev/AGENTS.html" = "https://github.com/bherbruck/solidcraft/blob/main/AGENTS.md" -"/dev/ROADMAP.html" = "https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md" +"/dev/README.html" = "https://github.com/tangency-labs/solidcraft/blob/main/README.md" +"/dev/ATTRIBUTION.html" = "https://github.com/tangency-labs/solidcraft/blob/main/ATTRIBUTION.md" +"/dev/AGENTS.html" = "https://github.com/tangency-labs/solidcraft/blob/main/AGENTS.md" +"/dev/ROADMAP.html" = "https://github.com/tangency-labs/solidcraft/blob/main/ROADMAP.md" diff --git a/docs/book/src/dev/testing.md b/docs/book/src/dev/testing.md index e275dc42..219b7328 100644 --- a/docs/book/src/dev/testing.md +++ b/docs/book/src/dev/testing.md @@ -77,7 +77,7 @@ Pull requests skip a few heavy tests (each takes one to four minutes); they run `main`, nightly, and in `cargo xtask test`: - `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and - `tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part), + the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th), `pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`, `pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`, `cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index f3a1de7f..6672bbf1 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -26,7 +26,25 @@ couples two joints. Links can be chained (one joint drives a second, which drive third), and a value is carried through a chain of up to 64 links in whatever order they were made. Contact sets stop motion where bodies would pass through each other, **Motion Study** keys joint values along a timeline, and **Interference** shows overlapping -volumes in red. +volumes in red, lists the pairs closer than its **Clearance** and each body's nearest neighbour. + +The check behind it, `inspect.clearance`, does all of that in one call for any number of bodies, +which suits scripts and agents checking a whole assembly: + +```json +{"command": "inspect.clearance", "params": {"clearance": 1, "nearest": 2, "skip_within": "component"}} +``` + +It returns `overlaps` (pairs sharing at least `min_volume`, 0.01 mm³ by default, with the shared +volume, area, box and centre), `near` (pairs closer than `clearance`, with the distance and the +two nearest points; a contact sharing less than `min_volume` is listed here at 0 with its +`overlap_mm3`), `nearest` (each body's `nearest` closest bodies) and `unchecked` (pairs whose +overlap could not be worked out, with the reason). `against` limits the check to pairs with one +body in `bodies` and the other in `against`, and `skip_within` leaves out pairs inside one +occurrence (`"occurrence"`) or one placed component, its subcomponents included +(`"component"`): the parts of a bought component that touch by design. Distances are measured on +the exact faces and edges; the meshes only rule out pairs that are too far apart, and the pairs +left are checked in parallel. `inspect.interference` remains for the overlaps alone. **Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose ratio comes from the parts rather than a typed number: @@ -55,6 +73,33 @@ driven one. A joint follows one link at most, and a coupling that would drive it refused. `joint.list` lists each coupling's definition under `links` with an `error` when it can't be worked out (the picked face is gone); the link then keeps its last ratio. +**Cam** (`joint.cam {a, b, profile}`, on the **ASSEMBLE** panel) makes a follower ride a cam. The +cam turns on a revolute joint (a); the follower is a slider (b, a translating follower such as a +valve tappet) or a revolute joint (b, an oscillating follower such as a rocker arm). The profile +is the cam's outline in a plane square to its axis: a closed profile of a sketch in the cam's +component, or the cam's edges picked on its body (one click on an edge takes the closed loop it is +on in that plane). The follower is: + +- a **Knife edge**: a point on the cam; +- a **Roller** of a radius, typed or taken from the roller's round face (picking that face also + says where the roller is); +- a **Flat face** square to a slider's line, such as a bucket tappet. + +A translating follower's line runs along its slider's axis, through its **Tip** when one is picked +(its knife's point or its roller's centre), so a follower offset from the cam's axis rides the +right flank. An oscillating follower needs its tip, or its roller's face, to know its arm. The +follower rides the cam from the side it is on (**Other side** takes the other one). The follower's +travel over a turn of the cam is worked out from the profile and kept with the link, one value a +degree with a smooth curve between them that never overshoots its samples. It is worked out again +whenever the design changes: change the cam's sketch, a parameter or the roller, and the follower +follows. Like the gears, the new link leaves the follower where it is (model it touching the cam), +and the cam is a motion link: it drives in **Drive Joints**, plays in motion studies, works in +closed loops (a tappet can be the piston of a slider-crank) and stops at contact sets. The profile +may also be on the grounded part, with the follower's guide on a turret that the cam joint turns. +`joint.list` gives a cam's link `lift` (the follower's lowest and highest values and its rise) +instead of a ratio. A profile that is not closed, a line or an arm that misses the cam somewhere, +a follower that would jump, and axes that are not square or parallel are refused. + A joint moves one component against another, so its two picks must be on different components. A STEP file with several parts often comes in as bodies of one component, and picks on two of those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer @@ -122,6 +167,56 @@ study, samples?}`) answers the world points without changing the design, and `motion.trace.keep` with the same parameters (and `name?`) keeps them. A trace takes 2 to 500 samples: 100 by default for a joint, one per step for a study. +**Export Animation…** in the **Motion Study** dialog saves the study as an animated GIF, drawn +from the viewport's camera as the study plays (the keys in the dialog count, even before OK). +`motion.export_animation` does the same for scripts, the CLI and agents, without a window: give a +`study`, or a `joint` with `from` and `to` (and `index?`) to animate a drive, and a `path`. A path +ending in `.gif` gets an animated GIF that loops; any other path is a folder that gets the frames +as `frame-0001.png`, `frame-0002.png`, and so on (made if missing; frames of a longer earlier +export there are removed), for a video tool. The web app writes GIFs only. `samples` is +the number of frames (one per step for a study by default, 100 for a joint), `width` and `height` +their size in pixels (800 × 500 by default, at most 4096 a side), and `fps` the GIF's speed (20 +by default); `grid: false` and `axes: false` leave out the ground grid and the origin's axes. +`camera` takes the reply of the control channel's `ui.camera` (or any of its fields; +the rest come from the view `view.apply` chose, or the home view framing the model). The frames +are drawn by the same renderer as `solidcraft-cli snapshot`, on a copy of the design, which is not +changed. A GIF keeps one palette for all its frames, so colours don't flicker, and stores only +what changed from one frame to the next. Frames are capped: at most 1000, and a GIF holds at most +2^26 pixels of frames (about 160 frames of 800 × 500). + +**Motion analysis.** The **Motion Study** dialog's **Study** box opens a saved study to edit, and +by its timeline the dialog charts what the study does. +**Plot** picks a moving joint value (a joint picked in the Browser is picked here too), or +**Picked point** once a point is picked in the dialog's **Point** box, and **Quantity** whether to +show its value, velocity or acceleration; the chart runs over the +study's steps with a cursor at the step shown, and a click or a drag on it moves to that step. +Values are in degrees and millimetres; rates are per step, or per second once **Duration** says +how many seconds the whole study takes. A picked point charts its distance from where it +starts, its speed and the size of its acceleration. **Check collisions** plays the study and +lists the pairs of parts that pass through each other, with the steps where they do (a click on +one shows the first of them); those steps are shaded on the chart. It checks the parts picked in +**Components** (against the ones picked in **Against**, when there are any), else every +component, or the pairs of the design's contact sets (a choice beside the button once it has any). + +`motion.plot {study, joints?, occurrence?, point?, samples?, duration?}` answers the same +numbers: for each joint value its `value`, `velocity` and `acceleration` per sample (with their +units), and for a point its `position`, `velocity` and `acceleration` vectors, `speed` and +`acceleration_norm`. The study plays as `motion.play` places it (closed loops follow, contact sets +stop it), each sample from the one before, on a copy: the design is not changed. Velocities and +accelerations are finite differences between samples, central inside and one-sided at the ends, +so they are as smooth as the samples are dense (up to 1000, one per step by default). Angles are +unwrapped, so a joint that turns on past half a turn keeps counting. +`motion.interference {study, occurrences?, against?, contact_sets?, samples?}` answers each +colliding pair with its `first_step`, `last_step`, the `steps` ranges where they overlap, how +far into each other (`depth_mm`, the most at `deepest_step`) and their shared `volume_mm3` +there (null when it is not cheap to work out). It plays the study as `motion.play` does, but +contact sets don't stop it, and parts are tested at the samples only: a part that passes right +through another between two samples is missed. Parts are told apart by occurrence, as contact +sets tell them apart: an occurrence inside a sub-assembly placed twice is one part, so its two +copies are not checked against each other. A check takes at most 256 bodies; on a large design, +list the `occurrences` to check (an arm, with `against` the parts it must miss). When the work +runs out it says `truncated` and the step it `checked_to`. + **Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and in the **File** menu) adds a component linked to another saved design, so each part can live in its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index e98a158c..b36c8711 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -45,11 +45,16 @@ to the same user installation: ```sh flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo -flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo +flatpak remote-add --user --if-not-exists solidcraft https://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo flatpak install --user solidcraft ai.storyteller.solidcraft flatpak run ai.storyteller.solidcraft ``` +A SolidCraft remote added before the project moved to `docs.solidcraft.io` (from +`bherbruck.github.io`) no longer finds updates. Point it at the new address, then update: +`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft` and +`flatpak update --user`. + Releases up to 0.7 used the application ID `ai.storyteller.solvecraft`. If you installed one of those from the SolidCraft remote, `flatpak update --user` reports it as end-of-life and offers to replace it with `ai.storyteller.solidcraft`; accept, and your settings and autosaves move with @@ -171,6 +176,7 @@ cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket cargo run --release -p solidcraft-cli -- eval examples/bracket.json # volume, area, faces… cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png cargo run --release -p solidcraft-cli -- exec solid.box '{"length": 20, "width": 10, "height": 5}' +cargo run --release -p solidcraft-cli -- run mechanism.json --in arm.solidcraft # with a motion.export_animation step: a GIF ``` A script is a list of commands: @@ -212,6 +218,17 @@ cargo run --release -p solidcraft-cli -- run examples/bracket.json --results --j cargo run --release -p solidcraft-cli -- eval examples/bracket.json --json-out measures.json ``` +To find the slow steps of a script, `--timings` prints how long each command took instead of +the measurement: `{"timings": [{"index", "command", "ms"}], "total_ms"}`, with `index` counted +from 0. `--profile` adds each command's `profile: {"boolean_ms", "tessellate_ms", "other_ms"}`, +its wall time in the kernel's booleans, in tessellation and in everything else. With +`--results` as well, the results go in the same object as `results`; `--results` alone still +prints a plain array. + +```sh +cargo run --release -p solidcraft-cli -- run examples/bracket.json --profile +``` + A design's history turns into a script that rebuilds it, which makes a bug that only shows on one design a one-file reproduction: diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index f9b43dc4..c3edf3ea 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -11,7 +11,9 @@ parameter editor. The separate **Configurations** button opens the configuration Press **Enter** in the new parameter's name, expression or comment field to add it. Valid entries return focus to the name field for the next parameter; invalid entries remain -editable. In expression fields, **Up/Down** selects an autocomplete suggestion, +editable. In expression fields, type a parameter or dimension name for autocomplete, +or press **Ctrl+Space** to open suggestions, including in an empty field. +**Up/Down** selects an autocomplete suggestion, **Enter/Tab** accepts it, and **Escape** dismisses the suggestions. Accepting a suggestion does not also submit the parameter. @@ -19,7 +21,9 @@ Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in `+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod trunc` and unit conversion. A decimal comma works too, outside parentheses: `2,5 mm` is `2.5 mm` (inside a function's parentheses the comma separates its arguments, so write -`max(2.5, 3)` there). A length where an angle is expected (or the other way round) is an +`max(2.5, 3)` there). A mixed number is one value, as imperial sizes are written: `2 1/8"` and +`3 7/16 in` are 2.125 in and 3.4375 in (a whole number, a space, then a proper fraction of whole +numbers). A length where an angle is expected (or the other way round) is an error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a parameter updates every expression, dimension and feature input that uses it, and a parameter in use can't be deleted (the error lists its users). diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 3ca3c4ef..18f82b38 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -66,6 +66,11 @@ keeps the original text. Position and height use the document's units. The four expressions: one that names a parameter (`plate_width / 2`) stays an expression, is shown as typed when the dialog is opened again, and keeps the text following the parameter. +To work on the letters' shapes themselves, right-click the text and choose **Explode Text** +(`sketch.break_link` with the text's `link`). Its outlines stay where they are as ordinary +lines, arcs and curves that can be picked, trimmed, dimensioned and extruded one by one; the +text itself is gone, so it can no longer be edited as text or follow parameters. Undo brings it back. + A text can follow a curve of its sketch (its **Path**). That curve becomes construction: the letters stand on it and their descenders cross it, so as a normal curve it would cut them into pieces, and picking the text would pick those pieces. Taking the text off the curve leaves it diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 45307590..f871c29e 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -88,7 +88,7 @@ and SolidCraft picks the one matching how it was installed: | `.deb` | under `/usr`, dpkg knows `solidcraft` | `solidcraft-linux-.deb` | `sudo apt install ./solidcraft-linux-.deb` | | `.rpm` | under `/usr`, an rpm database exists | `solidcraft-linux-.rpm` | `sudo dnf install ./solidcraft-linux-.rpm` | | `.tar.gz` | anywhere else on Linux | `solidcraft--linux-.tar.gz` | Unpack over the old copy | -| Windows installer | under Program Files | `solidcraft-windows-.msi` | Run the installer | +| Windows installer | under Program Files, or in the folder the installer recorded | `solidcraft-windows-.msi` | Run the installer | | Windows portable | `solidcraft-cli.exe` beside it | `solidcraft-windows--portable.zip` | Unzip over the old copy | | Windows `.exe` | anywhere else on Windows | `SolidCraft--windows-x64.exe` | Replace the old `.exe` | | macOS | inside `SolidCraft.app` | `solidcraft-macos-universal.dmg` | Drag the new app to Applications | diff --git a/docs/book/theme/head.hbs b/docs/book/theme/head.hbs index 87857aa9..05ffee23 100644 --- a/docs/book/theme/head.hbs +++ b/docs/book/theme/head.hbs @@ -3,13 +3,13 @@ - - + + - + diff --git a/docs/browser-api.md b/docs/browser-api.md new file mode 100644 index 00000000..33ded368 --- /dev/null +++ b/docs/browser-api.md @@ -0,0 +1,50 @@ +# Browser command API + +After the browser app starts, `window.solidcraft` controls the design open in that tab. +Calls use the same command ids, parameters, control handlers and undo history as the desktop +control channel. No separate engine or server is started. + +| Call | Result | +|---|---| +| `solidcraft.request(method, params?)` | Any [control method](control-protocol.md), such as `ui.inspect`, `document.inspect` or `engine.batch` | +| `solidcraft.execute(command, params?)` | Run an engine command without opening its interactive dialog | +| `solidcraft.commands()` | Discover commands, their parameters and current enablement | +| `solidcraft.inspect(params?)` | Inspect the active document, optionally with `{measure: true}` | + +Every call returns a promise. It resolves to the control reply's `result`, or rejects with an +`Error`. Omitted or `null` parameters become `{}`. Parameters must be JSON serializable and +at most 16 MiB of JSON. At most 256 requests may be pending; excess requests reject without +running. Requests run on the UI thread between frames. A hidden or suspended tab can delay +completion; keep it active while driving the app. A reload ends the session and its pending work. + +In the browser's developer console, on an empty design: + +```js +await solidcraft.commands(); +await solidcraft.request("ui.home", {open: false}); +await solidcraft.execute("solid.box", { + length: 30, width: 20, height: 10, body_name: "Browser API box" +}); +await solidcraft.inspect({measure: true}); +await solidcraft.execute("edit.undo"); +await solidcraft.execute("edit.redo"); +await solidcraft.request("ui.inspect"); +``` + +An agent with JavaScript evaluation access to this tab can make the same calls. The API does +not register WebMCP tools or expose a network listener. Scripts executing in the page can use +the API with the page's browser permissions. File paths refer to the browser's virtual file +system, not arbitrary files on the user's computer. Native-only operations remain subject to +the browser host's limitations; this API does not grant desktop filesystem or window access. + +## Verify a browser build + +Build the app with `trunk build` in `apps/solidcraft-web`, then run from the repository root: + +```sh +cargo xtask web-api-test --site dist/web +``` + +The test starts a local server and headless Chrome, discovers commands, creates a box in the +live document, checks undo and redo, and verifies error handling and the pending-request limit. +Set `CHROME` to a Chrome or Chromium executable if it is not installed in a usual location. diff --git a/docs/compatibility.md b/docs/compatibility.md index 1c4f97cb..9ecc3a6f 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -158,8 +158,11 @@ replace any Dock shortcut with the old name (open `SolidCraft.app`, then ## Flatpak remotes added from the former website address -`flatpak remote-modify --user --url=https://bherbruck.github.io/solidcraft/flatpak/repo/ solvecraft` -(use your remote's name), then `flatpak update --user`. +The feed has moved twice: from the SolveCraft address, then from `bherbruck.github.io/solidcraft` +when the project moved to `docs.solidcraft.io` (#1920). A remote added from either address no +longer finds updates. Point it at the current one: +`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft` +(use your remote's name: `solvecraft` for one added before 0.4), then `flatpak update --user`. ## Draft design containers diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebddf4c3..b5ae2391 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -74,7 +74,7 @@ MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Witho |---|---|---| | `engine.execute` | `command`: command id, `params`: JSON object | the command's result | | `engine.script` | `commands`: `[{command, params}, …]` | results, stops at the first error | -| `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back). Refused before anything runs, with an error starting `engine.batch unavailable`, while the agent cursor is shown or when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`). Send such steps one by one. Answered when done, for up to 3600 s | +| `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true), `timings?`, `profile?`: bool (default false) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails, and with `timings` `timings: [{index, command, ms}]` (the failing step included) and `total_ms`; `profile` adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back). Refused before anything runs, with an error starting `engine.batch unavailable`, while the agent cursor is shown or when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`). Send such steps one by one. Answered when done, for up to 3600 s | | `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut, params doc, enabled | | `document.inspect` | `measure?`: bool | parameters, timeline (errors, timings), bodies, sketches, selection | @@ -125,7 +125,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing | -| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions). A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | +| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | @@ -139,12 +139,12 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | -| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), panel labels `panel::`, browser rows `row:

', 1)[0] + self.assertIn('stroke="var(--icon-palette-roles-active-3-', card('bounding_solid')) + self.assertIn('stroke="var(--icon-palette-accents-sketch-active-3-', card('extend')) + self.assertIn('stroke="var(--icon-palette-roles-guide-', card('draft')) + + def test_live_palette_tokens_save_only_display_colours_and_reject_stale_or_invalid_changes(self): + with review_fixture() as (root, design, get, post): + palette_path = design / 'palette.toml' + original = palette_path.read_text() + palette = tomllib.loads(original) + state = json.loads(get('/api/palette')) + tokens = {token['id']: token for token in state['tokens']} + self.assertIn('roles.ink', tokens) + self.assertIn('accents.surface.active-2', tokens) + self.assertIn('variants.red-green.sketch.active-3', tokens) + self.assertNotIn('accents.construction.guide', tokens, 'guides have one shared colour') + self.assertNotIn('roles.ink.source', tokens) + self.assertTrue(tokens['roles.gap']['knockout']) + accepted = (design / 'review.json').read_bytes() + artwork = (design / 'svg/model/box.svg').read_bytes() + def save(revision, changes): + return post({'revision': revision, 'changes': changes}, endpoint='/api/palette') + replacement = '#123456' if tokens['roles.ink']['dark'] != '#123456' else '#654321' + paints = {'light': tokens['roles.ink']['light'], 'dark': replacement} + changes = {'roles.ink': paints, + 'accents.surface.active-2': {'light': tokens['accents.surface.active-2']['light'], 'dark': '#bc789a'}, + 'variants.red-green.sketch.active-3': {'light': tokens['variants.red-green.sketch.active-3']['light'], 'dark': '#778899'}} + saved = save(state['revision'], changes) + self.assertTrue(saved['ok']) + expected = tomllib.loads(original) + for key, colours in changes.items(): + target = expected + for part in key.split('.'): target = target[part] + target.update(colours) + self.assertEqual(tomllib.loads(palette_path.read_text()), expected) + self.assertIn('# Icon colour roles.', palette_path.read_text(), 'comments remain intact') + self.assertEqual(tomllib.loads(palette_path.read_text())['roles']['ink']['source'], palette['roles']['ink']['source']) + for revision, changes in [ + (state['revision'], {'roles.ink': paints}), + (saved['revision'], {'roles.ink.source': paints}), + (saved['revision'], {'accents.construction.guide': paints}), + (saved['revision'], {'roles.gap': paints}), + (saved['revision'], {'roles.ink': {'light': 'url(evil)', 'dark': replacement}}), + ]: + before = palette_path.read_bytes() + with self.assertRaises(urllib.error.HTTPError): save(revision, changes) + self.assertEqual(palette_path.read_bytes(), before) + page = get('/') + self.assertIn('var(--icon-palette-roles-line-light,', page) + self.assertIn('--icon-palette-roles-ink-dark:' + replacement, page) + self.assertIn('id="palette-tokens"', page) + self.assertIn('input.oninput', page) + self.assertIn('body.palette-preview .card.final .art{opacity:1}', page) + self.assertEqual((design / 'review.json').read_bytes(), accepted) + self.assertEqual((design / 'svg/model/box.svg').read_bytes(), artwork) + self.assertFalse(list(design.glob('*.tmp'))) + + def test_knockouts_mask_only_preceding_layers_and_preserve_later_foreground(self): + with review_fixture() as (root, design, get, post): + source = '''''' + path = design / 'svg/model/cutout.svg' + path.write_text(source) + accepted = (design / 'review.json').read_bytes() + page = get('/') + fragments = [svg for svg in re.findall(r'', page, re.S) if '-base"' in svg] + self.assertGreater(len(fragments), 3) + ns = '{http://www.w3.org/2000/svg}' + for svg in fragments: + tree = ET.fromstring(svg) + mask = tree.find(ns + 'defs/' + ns + 'mask') + self.assertIsNotNone(mask) + self.assertEqual(mask.get('maskUnits'), 'userSpaceOnUse') + self.assertEqual(mask[0].get('fill'), 'white') + self.assertIn('-left)', mask[1].get('clip-path')) + self.assertEqual(mask[1][0].get('fill'), 'black') + preceding, foreground = list(tree)[-2:] + self.assertEqual(preceding.get('mask'), 'url(#' + mask.get('id') + ')') + self.assertTrue(preceding[0].get('id').endswith('-base')) + self.assertTrue(foreground.get('id').endswith('-foreground')) + self.assertIsNone(foreground.get('mask')) + self.assertNotIn('roles-gap-', svg) + self.assertEqual(path.read_text(), source) + self.assertEqual((design / 'review.json').read_bytes(), accepted) + + def test_semantic_colours_reload_from_one_palette_without_changing_acceptance(self): + with review_fixture() as (root, design, get, post): + palette = design / 'palette.toml' + colours = tomllib.loads(palette.read_text()) + source = colours['roles']['active-3'].get('source', colours['roles']['active-3']['light']) + art = SVG.replace('currentColor', source) + for name in ('line', 'c_fix'): + (design / f'svg/model/{name}.svg').write_text(art) + with (design / 'aliases.toml').open('a') as aliases: + aliases.write('ground="c_fix"\nsurface_line={master="line",accent="surface"}\n') + state, accepted = get('/api/review'), (design / 'review.json').read_bytes() + def card(page, icon): + return page.split('data-id="' + icon + '"', 1)[1].split('
', 1)[0] + page = get('/') + self.assertIn(colours['accents']['sketch']['active-3']['light'], card(page, 'line')) + self.assertIn(colours['variants']['red-green']['sketch']['active-3']['dark'], card(page, 'line'), + 'red/green vision shares the application ramp') + self.assertIn(colours['accents']['constraint']['active-3']['light'], card(page, 'c_fix')) + self.assertIn(colours['roles']['active-3']['light'], card(page, 'ground'), + 'a shared master does not transfer its meaning to an alias') + self.assertIn(colours['accents']['surface']['active-3']['light'], card(page, 'surface_line'), + 'explicit alias accent wins over the Sketch master') + def set_light(section, colour): + lines, current = palette.read_text().splitlines(), '' + for index, line in enumerate(lines): + if line.strip().startswith('['): + current = line.strip().split(']', 1)[0] + ']' + elif current == '[' + section + ']' and line.split('=', 1)[0].strip() == 'light': + lines[index] = 'light = ' + json.dumps(colour) + palette.write_text('\n'.join(lines) + '\n') + return + self.fail('missing palette section ' + section) + replacement = '#123456' if colours['accents']['sketch']['active-3']['light'] != '#123456' else '#654321' + set_light('accents.sketch.active-3', replacement) + self.assertNotEqual(state['rev'], get('/api/review')['rev']) + self.assertIn(replacement, card(get('/'), 'line'), 'a running server reads the updated palette') + base = '#345678' if colours['roles']['active-3']['light'] != '#345678' else '#876543' + set_light('roles.active-3', base) + self.assertIn(base, card(get('/'), 'ground'), 'source identifiers survive changing the base display palette too') + self.assertEqual((design / 'review.json').read_bytes(), accepted) + self.assertEqual((design / 'svg/model/line.svg').read_text(), art) + + def test_new_unused_masters_and_alias_targets_are_never_hidden(self): + with review_fixture() as (root, design, get, post): + original = get('/api/review') + page = get('/') + self.assertIn('data-id="box"', page, 'unused alias target must still have a card') + self.assertIn('data-id="box_alias"', page) + (design / 'svg/model/brand_new.svg').write_text(SVG) + current = get('/api/review') + self.assertNotEqual(original['rev'], current['rev']) + self.assertNotIn('brand_new', current['final']) + page = get('/') + self.assertIn('data-id="brand_new"', page) + self.assertIn("let filter = 'open', tab = ''", page) + self.assertNotIn("const VK='icon-view'", page) + self.assertIn('cards.length - finals', page, 'notes remain a subset of Open') + post(current, final=current['final'] + ['brand_new']) + self.assertIn('brand_new', get('/api/review')['final']) + # A new opening does not inherit a previous session's hidden tab/query. + self.assertIn('data-f="open" class="on"', get('/')) + + def test_changed_art_reopens_and_requires_deliberate_current_acceptance(self): + with review_fixture() as (root, design, get, post): + initial = get('/api/review') + self.assertIn('box', initial['final']) + old_hash = json.loads((design / 'review.json').read_text())['final']['box'] + (design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="18"')) + changed = get('/api/review') + self.assertNotIn('box', changed['final']) + self.assertIn('changed since acceptance', get('/')) + self.assertEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box']) + (design / 'svg/model/box.svg').write_text(SVG.replace('r="20"', 'r="17"')) + with self.assertRaises(urllib.error.HTTPError) as error: + post(changed, final=['box']) + self.assertEqual(error.exception.code, 400) + post(get('/api/review'), final=['box']) + self.assertIn('box', get('/api/review')['final']) + self.assertNotEqual(old_hash, json.loads((design / 'review.json').read_text())['final']['box']) + + def test_missing_art_is_visible_but_cannot_be_accepted(self): + with review_fixture() as (root, design, get, post): + commands = root / 'crates/engine/src/cmd' + commands.mkdir(parents=True) + (commands / 'example.rs').write_text('CommandSpec::new("example.action", "Example", run).icon("missing");') + page = get('/') + self.assertIn('data-id="missing"', page) + self.assertIn('aria-label="Accept missing as final" disabled', page) + with self.assertRaises(urllib.error.HTTPError): + post(get('/api/review'), final=['box', 'missing']) + + def test_final_alias_protects_shared_art_and_newer_notes_survive_stale_save(self): + with review_fixture() as (root, design, get, post): + state = get('/api/review') + post(state, final=['box', 'box_alias']) + state = get('/api/review') + post(state, final=['box_alias'], notes={'box': 'Unreviewed master note'}) + state = get('/api/review') + self.assertEqual(post(state, endpoint='/api/export')['sent'], 0) + # A stale page changes another note; it must not resurrect replaced feedback. + saved = json.loads((design / 'review.json').read_text()) + saved['notes']['box'] = 'Newer feedback' + (design / 'review.json').write_text(json.dumps(saved)) + post(state, notes={**state['notes'], 'box_alias': 'Another note'}) + self.assertEqual(get('/api/review')['notes']['box'], 'Newer feedback') + self.assertFalse(list(design.glob('*.tmp'))) + + def test_export_resolution_preserves_notes_added_since_the_snapshot(self): + with review_fixture() as (root, design, get, post): + (design / 'svg/model/fresh.svg').write_text(SVG) + state = get('/api/review') + post(state, notes={'fresh': 'Newer feedback'}) + script = """ +import sys +sys.path.insert(0,sys.argv[1]) +import review_state +request={'notes':{'fresh':'Exported feedback'}} +assert review_state.resolve_export(request, {'fresh'}) == [] +assert review_state.load()['notes']['fresh'] == 'Newer feedback' +request={'notes':{'fresh':'Newer feedback'}} +assert review_state.resolve_export(request, {'fresh'}) == ['fresh'] +assert review_state.load()['notes'] == {} +""" + subprocess.run([sys.executable, '-B', '-c', script, str(design / 'tools')], check=True) + + def test_style_reference_page_uses_repository_bases(self): + with review_fixture() as (root, design, get, post): + page = get('/guide') + self.assertIn('Canonical grey cube', page) + self.assertIn('Canonical grey slab', page) + self.assertIn('Standard projection', page) + self.assertIn('STYLE_GUIDE.md', page) + + def test_scaffold_refuses_overwrite_and_starts_unaccepted(self): + with review_fixture() as (root, design, get, post): + # The fixture's palette is intentionally minimal; the scaffold uses + # the real helper constants and emits an editable starter SVG. + (root / 'ATTRIBUTION.md').write_text('# Attribution\n') + command = [sys.executable, '-B', str(design / 'tools/author/new_icon.py')] + first = subprocess.run(command + ['fresh_shape', '--base', 'cube'], capture_output=True, text=True) + self.assertEqual(first.returncode, 0, first.stderr) + artwork = design / 'svg/model/fresh_shape.svg' + self.assertTrue(artwork.exists()) + self.assertNotIn('fresh_shape', get('/api/review')['final']) + self.assertIn('fresh_shape.svg', (root / 'ATTRIBUTION.md').read_text()) + before = artwork.read_bytes() + again = subprocess.run(command + ['fresh_shape'], capture_output=True, text=True) + self.assertNotEqual(again.returncode, 0) + self.assertEqual(artwork.read_bytes(), before) + alias = subprocess.run(command + ['box_alias'], capture_output=True, text=True) + self.assertNotEqual(alias.returncode, 0) + + +if __name__ == '__main__': unittest.main() diff --git a/vendor/README.md b/vendor/README.md index 3d4a592f..f1770570 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -53,6 +53,12 @@ Changes: farther from the leader's point than the curve's tolerance (the projection settled on another part of a surface, such as the far side of a small hole) is dropped for the search from nothing, as before. `crates/kernel/src/issue_1038_tests.rs` holds it to that search. +- `decorators/revolved_curve.rs`: a point on a revolved surface's seam (angle 0, the same place + as 2π) takes the end of the period nearer the hinted angle. The angle was decided by the sign of + a cross product that is rounding noise there, so a boundary followed point by point could come + back to the seam at the other end of the period and fold its parameter polygon over itself (no + intersection loop then lay inside the face). `crates/kernel/src/seam_faces_tests.rs` holds it + to a cone read from STEP. ## truck-stepio 0.3.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck) @@ -149,6 +155,31 @@ Changes: compares the two. - `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are dropped (their sources are not vendored). +- `tessellation/triangulation.rs`: adaptive division also splits a cell that bows off its + corners' plane by more than the chord tolerance (a quarter of its twist `p00 - p01 - p10 + + p11`, across that plane, at the middle). A bilinear patch equals the blend of its corners the + division compared against, so a twisted ruled face (a loft between polylines) went out as two + triangles per face (#1666); a flat cell such as a cone's trapezoid between rulings is not split. +- `tessellation/triangulation.rs`: a shell's face meshes share their edges' points exactly. A + face's mesh puts each point at its parameters on the face's own surface, so a point of an edge + that lies off a surface within tolerance (an intersection-curve sample whose projection did not + converge, an imported edge) came out in a different place on each of the edge's faces, and the + exported mesh had a crack there. In display and export meshes (`Deflection::rulings`), a + vertex at a point of a face's boundary loops that is farther than `TOLERANCE` from the nearest + point of the face's edges now goes to that point, when it is within the chord tolerance + (`onto_edges`). Points within `TOLERANCE` stay on the surface, so exact geometry meshes as + before. The meshes Boolean operations intersect keep truck's points. + +- `tessellation/triangulation.rs`: in display and export meshes (`Deflection::rulings`), a + direction left undivided along the rulings of a face ruled one way also gets a grid line + through each point of the face's boundary that lies inside the face along it + (`with_boundary_lines`), when there are at most 16 such points: corners standing into the + face, not a boundary curve running through it, which the triangulation already follows. A hole in a cone or cylinder smaller than one cell (a notch into a + chamfer) or a trim crossing the rulings of a narrow band (an oblique plane through a bore's + chamfer) had no grid point beside it, and the triangulation fanned from it to the far ends of + the face, many degrees round: those triangles cut through the surface and folded over each + other (a cone face with a small hole meshed several mm² too large, and the boolean that made + it was refused for its volume). ## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath) @@ -190,3 +221,5 @@ SolidCraft changes: clamped to it (`clamp_surface_extent`, aspect ratio kept) and egui is drawn at the matching smaller scale, instead of a `Surface::configure` validation panic (#869). The resize path and an unsupported surface no longer unwrap. +- `src/winit.rs`: acquire a drawable before preparing paint callbacks, so a skipped + surface frame cannot discard initialization commands for cached viewport meshes. diff --git a/vendor/egui-wgpu/src/winit.rs b/vendor/egui-wgpu/src/winit.rs index a2c00fe4..832f64f2 100644 --- a/vendor/egui-wgpu/src/winit.rs +++ b/vendor/egui-wgpu/src/winit.rs @@ -588,43 +588,6 @@ impl Painter { return vsync_sec; }; - let mut encoder = - render_state - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("encoder"), - }); - - // Upload all resources for the GPU. - // SolidCraft: a clamped surface draws the whole window at a smaller scale. - let screen_descriptor = renderer::ScreenDescriptor { - size_in_pixels: [surface_state.width, surface_state.height], - pixels_per_point: pixels_per_point * surface_state.scale, - }; - - let user_cmd_bufs = { - let mut renderer = render_state.renderer.write(); - #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here - for (id, image_deltas) in textures_delta.set.drain() { - for image_delta in image_deltas { - renderer.update_texture( - &render_state.device, - &render_state.queue, - id, - &image_delta, - ); - } - } - - renderer.update_buffers( - &render_state.device, - &render_state.queue, - &mut encoder, - clipped_primitives, - &screen_descriptor, - ) - }; - if surface_state.needs_reconfigure { Self::configure_surface(surface_state, render_state, &self.config.surface); surface_state.needs_reconfigure = false; @@ -670,6 +633,47 @@ impl Painter { } }; + // SolidCraft: acquire the drawable before preparing callbacks. Preparation can + // cache GPU resources whose initialization is recorded into `encoder` (such as + // the viewport's tessellated vertices). If acquisition skips this frame, dropping + // that encoder leaves the cached resources uninitialized on every later frame. + let mut encoder = + render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("encoder"), + }); + + // Upload all resources for the GPU. + // SolidCraft: a clamped surface draws the whole window at a smaller scale. + let screen_descriptor = renderer::ScreenDescriptor { + size_in_pixels: [surface_state.width, surface_state.height], + pixels_per_point: pixels_per_point * surface_state.scale, + }; + + let user_cmd_bufs = { + let mut renderer = render_state.renderer.write(); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + renderer.update_texture( + &render_state.device, + &render_state.queue, + id, + &image_delta, + ); + } + } + + renderer.update_buffers( + &render_state.device, + &render_state.queue, + &mut encoder, + clipped_primitives, + &screen_descriptor, + ) + }; + let mut capture_buffer = None; { let renderer = render_state.renderer.read(); diff --git a/vendor/truck-geometry/src/decorators/revolved_curve.rs b/vendor/truck-geometry/src/decorators/revolved_curve.rs index ee4bd7c6..5bee821b 100644 --- a/vendor/truck-geometry/src/decorators/revolved_curve.rs +++ b/vendor/truck-geometry/src/decorators/revolved_curve.rs @@ -82,6 +82,20 @@ impl Revolution { } } +/// SolidCraft: the angle of a point on the seam, where 0 and 2π are the same place, taken at the end +/// of the period nearer the hint's angle. [`Revolution::proj_angle`] decides it by the sign of a cross +/// product that is rounding noise there, so a boundary followed point by point could come back to +/// the seam at the other end of the period from where it left, and its parameter polygon folded +/// over itself (a whole turn of a cylinder had no area, and no intersection loop lay inside it). +fn seam_side(angle: f64, hint: Option) -> f64 { + const SEAM: f64 = 1.0e-7; + match hint { + Some(h) if angle < SEAM && h > PI => angle + 2.0 * PI, + Some(h) if angle > 2.0 * PI - SEAM && h < PI => angle - 2.0 * PI, + _ => angle, + } +} + impl ParametricSurface for RevolutedCurve { type Point = Point3; type Vector = Vector3; @@ -330,14 +344,19 @@ impl SearchParameter for RevolutedCurve revolution: self.revolution, }; let p = self.revolution.proj_point(point); - let hint0 = match hint.into() { + let hint = hint.into(); + let hint_angle = match hint { + SPHint2D::Parameter(_, y) => Some(y), + _ => None, + }; + let hint0 = match hint { SPHint2D::Parameter(x, _) => SPHint1D::Parameter(x), SPHint2D::Range((x0, _), (x1, _)) => SPHint1D::Range(x0, x1), SPHint2D::None => SPHint1D::None, }; let t = proj_curve.search_parameter(p, hint0, trials)?; let p = self.curve.subs(t); - let ang = self.revolution.proj_angle(p, point); + let ang = seam_side(self.revolution.proj_angle(p, point), hint_angle); match self.subs(t, ang).near(&point) { true => Some((t, ang)), false => None, @@ -377,14 +396,19 @@ impl SearchNearestParameter for Revolut revolution: self.revolution, }; let p = self.revolution.proj_point(point); - let hint0 = match hint.into() { + let hint = hint.into(); + let hint_angle = match hint { + SPHint2D::Parameter(_, y) => Some(y), + _ => None, + }; + let hint0 = match hint { SPHint2D::Parameter(x, _) => SPHint1D::Parameter(x), SPHint2D::Range((x0, _), (x1, _)) => SPHint1D::Range(x0, x1), SPHint2D::None => SPHint1D::None, }; let t = proj_curve.search_nearest_parameter(p, hint0, trials)?; let p = self.curve.subs(t); - Some((t, self.revolution.proj_angle(p, point))) + Some((t, seam_side(self.revolution.proj_angle(p, point), hint_angle))) } } } diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index b1b9b5a6..61b62402 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -389,7 +389,14 @@ fn shell_create_polygon( .collect::>>(); let polygon: Option = (|| { let boundary = PolyBoundary::new(preboundary?, &surface, tol); - Some(trimming_tessellation(surface, &boundary, tol, cap)) + let mut mesh = trimming_tessellation(surface, &boundary, tol, cap); + // Display and export meshes only, as the ruling grid lines: the meshes Boolean + // operations intersect keep truck's points. + if tol.rulings { + let points: Vec = wires.iter().flat_map(Wire::edge_iter).flat_map(|e| e.curve().0).collect(); + onto_edges(&mut mesh, &boundary, &points, tol.chord); + } + Some(mesh) })(); // SolidCraft: faces with seam edges (a loop running along an edge both ways, as STEP // writes cylinders) are valid here; `debug_new` asserts simple loops in debug builds. @@ -400,6 +407,42 @@ fn shell_create_polygon( new_face } +/// SolidCraft: a face's mesh puts every point where its own surface passes the point's +/// parameters, and a boundary point's parameters are those its edge point projects to. An edge +/// that lies on a surface only within tolerance (a sample of an intersection curve whose +/// projection did not converge, an imported edge) then puts the point it shares with the next +/// face in a different place on each face, and the shell's mesh cracks there. Each vertex at a +/// point of the boundary loops (the same parameters, as the triangulation got them) that is +/// farther than `TOLERANCE` from the nearest point of the face's edges goes to that point, when +/// it is within `tol`, so the faces of an edge have the very same points along it. Within +/// `TOLERANCE` the points are already the same, and a face keeps them on its surface: grid +/// points can lie on a boundary between its points (the ruling lines of a plane), and moving +/// only the boundary points would bend the boundary there. +fn onto_edges(mesh: &mut PolygonMesh, boundary: &PolyBoundary, points: &[Point3], tol: f64) { + let key = |x: f64, y: f64| [spade_round(x).to_bits(), spade_round(y).to_bits()]; + let loops: rustc_hash::FxHashSet<[u64; 2]> = boundary.0.iter().flatten().map(|p| key(p.x, p.y)).collect(); + let on_loops: Vec = mesh.uv_coords().iter().map(|uv| loops.contains(&key(uv.x, uv.y))).collect(); + let cell = |p: Point3| [p.x, p.y, p.z].map(|x| (x / tol).floor() as i64); + let mut grid: HashMap<[i64; 3], Vec> = HashMap::default(); + points.iter().for_each(|p| grid.entry(cell(*p)).or_default().push(*p)); + for (p, _) in mesh.positions_mut().iter_mut().zip(on_loops).filter(|(_, on)| *on) { + let c = cell(*p); + let mut best = (tol * tol, None); + for d in (0..27).map(|k| [k % 3 - 1, k / 3 % 3 - 1, k / 9 - 1]) { + let near = [c[0].saturating_add(d[0]), c[1].saturating_add(d[1]), c[2].saturating_add(d[2])]; + for q in grid.get(&near).into_iter().flatten() { + let d2 = q.distance2(*p); + if d2 <= best.0 { + best = (d2, Some(*q)); + } + } + } + if let Some(q) = best.1.filter(|_| best.0 > TOLERANCE2) { + *p = q; + } + } +} + #[derive(Debug, Default, Clone)] struct PolyBoundaryPiece(Vec); @@ -860,6 +903,8 @@ fn insert_surface( let (u, v) = balanced_grid(&surface, u, v, cap); let v = with_ruling_vertices(v, polyline, 1, tol.rulings && v_ruled, u.len(), cap); let u = with_ruling_vertices(u, polyline, 0, tol.rulings && u_ruled, v.len(), cap); + let v = with_boundary_lines(v, polyline, 1, tol.rulings && v_ruled && !u_ruled, u.len(), cap); + let u = with_boundary_lines(u, polyline, 0, tol.rulings && u_ruled && !v_ruled, v.len(), cap); (u, v) }; let insert_res: Vec>> = udiv @@ -971,7 +1016,14 @@ fn bounded_surface_division(surface: &S, (ur, vr): ((f64, let pt10 = surface.subs(u[1], v[0]).to_vec(); let pt11 = surface.subs(u[1], v[1]).to_vec(); let pt = Point3::from_vec(pt00 * (1.0 - p) * (1.0 - q) + pt01 * (1.0 - p) * q + pt10 * p * (1.0 - q) + pt11 * p * q); - let far = p0.distance2(pt) > tol * tol; + // SolidCraft (#1666): a cell is drawn as two triangles, not as the bilinear blend + // of its corners. A twisted cell (a bilinear patch is the blend exactly, so `pt` + // never tells) bows off its corners' plane by a quarter of its twist at the middle, + // across that plane: a cone's trapezoid between two rulings is flat and needs none. + let normal = (pt10 - pt00).cross(pt01 - pt00); + let bow = (pt00 - pt01 - pt10 + pt11).dot(normal) * 0.25; + let twisted = normal.magnitude2() > 0.0 && bow * bow > tol * tol * normal.magnitude2(); + let far = p0.distance2(pt) > tol * tol || twisted; // Which direction bends: refine only that one (a cylinder needs no division // along its rulings). let (su, sv) = ([surface.subs(u[0], v0), surface.subs(u[1], v0)], [surface.subs(u0, v[0]), surface.subs(u0, v[1])]); @@ -1133,6 +1185,46 @@ fn with_ruling_vertices(div: Vec, polyline: &PolyBoundary, axis: usize, rul out } +/// SolidCraft: `div` (the grid along coordinate `axis`, 0 for u and 1 for v) with a line through +/// every point of the face's boundary that lies inside the face along that axis: the points of a +/// hole cut into a cylinder or a cone, or of a trim that crosses its rulings (an oblique plane +/// through a cone's narrow chamfer). A ruled direction gets few lines (one cell along a cone's +/// short side), and a boundary point between them has no grid point beside it: the triangulation +/// joins it to the far ends of the face, many degrees round, and those triangles cut through the +/// surface (a cone face with a small hole meshed several mm² too large, and the boolean that made +/// it was refused for its volume). Only a direction that needs no division of its own (`ruled`) +/// while the other does gets them (a plane, ruled both ways, maps to its uv space without +/// stretching and needs none), only for a few such points (corners standing into the face, at +/// most [`MAX_BOUNDARY_LINES`]), and while the grid stays within `cap`. +/// SolidCraft: most boundary points inside a ruled face that get grid lines through them. +const MAX_BOUNDARY_LINES: usize = 16; + +fn with_boundary_lines(div: Vec, polyline: &PolyBoundary, axis: usize, ruled: bool, other: usize, cap: usize) -> Vec { + let (Some(&first), Some(&last)) = (div.first(), div.last()) else { return div }; + let span = last - first; + if !ruled || !(span.is_finite() && span > 0.0) { + return div; + } + // Lines closer than this to another add nothing but slivers. + let apart = span * 1e-3; + let mut at: Vec = polyline.0.iter().flatten().map(|p| p[axis]).filter(|t| t.is_finite() && *t > first + apart && *t < last - apart).collect(); + at.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + at.dedup_by(|a, b| (*a - *b).abs() <= apart); + // Many: a boundary curve running through the face, which the triangulation already follows + // with short triangles (a cylinder cut by another); lines along it only crowd it. + if at.len() > MAX_BOUNDARY_LINES { + return div; + } + let extra: Vec = at.into_iter().filter(|t| div.iter().all(|d| (d - t).abs() > apart)).collect(); + if extra.is_empty() || (div.len() + extra.len()).saturating_mul(other) > cap { + return div; + } + let mut out = div; + out.extend(extra); + out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + out +} + /// Converts triangulation into `PolygonMesh`. fn triangulation_into_polymesh<'a>( vertices: VertexIterator<'a, SPoint2, (), CdtEdge<()>, ()>, diff --git a/xtask/src/icons.rs b/xtask/src/icons.rs new file mode 100644 index 00000000..e3f61858 --- /dev/null +++ b/xtask/src/icons.rs @@ -0,0 +1,16 @@ +//! Original UI icon standards and review-tool regression checks. +use std::path::Path; +use std::process::Command; + +pub fn run(root: &Path) -> Result<(), String> { + for args in [ + vec!["-B", "assets/icons/ui/design/tools/lint_icons.py"], + vec!["-B", "assets/icons/ui/design/tools/icon_status.py", "--check"], + vec!["-B", "-m", "unittest", "discover", "-s", "tests/tools", "-p", "test_icon*.py"], + ] { + let mut command = Command::new("python3"); + command.args(args).current_dir(root); + super::run(command, "UI icon standards (Python 3.11+)")?; + } + Ok(()) +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 2118c8e0..7bc55bf9 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -36,6 +36,8 @@ impl Class { /// prefix. pub const TABLE: &[(&str, Class)] = &[ ("geom", Class::Layer(0)), + // 3D mouse descriptors and reports (no workspace dependencies; the apps read the device) + ("space-mouse", Class::Layer(0)), // the derive macros (proc-macro, no workspace dependencies) ("derive", Class::Standalone), ("sketch", Class::Layer(1)), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8d009c63..96faebd5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,13 +1,14 @@ //! Workspace tooling: `cargo xtask `. //! -//! Pure Rust (std, serde_json and sha2). External tools (`cargo`) are invoked through -//! `std::process::Command`. +//! Rust workspace tooling. External tools (`cargo`, and Python 3.11+ for the +//! original icon collection) are invoked through `std::process::Command`. mod app_icons; mod assets; mod book; mod features; mod i18n; +mod icons; mod layers; mod licences; mod lists; @@ -25,6 +26,7 @@ commands: layers enforce the crate dependency layering (plan/architecture.md §3) assets check that every non-code asset file is attributed in ATTRIBUTION.md app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows + icons validate UI icon standards, discovery and review-tool behavior (Python 3.11+) features list SolidCraft's commands by area in docs/features.md (from the registry) i18n [--check | --add-missing] report how complete the message catalogs in assets/locales are: @@ -58,10 +60,11 @@ commands: web-offline [DIR] write the web build's service worker (sw.js: offline use, updates); run by Trunk after release builds of apps/solidcraft-web, or on a built site in DIR + web-api-test [--site DIR] Test the command API in a real browser build (default: dist/web) web-offline-test [--site DIR] check in headless Chrome that the web app works offline and updates to a new build (a stand-in app, or the web build in DIR such as dist/web) - ci fmt --check, assets, licences --check, lists, layers, clippy -D warnings, then the tests + ci fmt --check, assets, icons, licences --check, lists, layers, clippy -D warnings, then the tests with the wasm check alongside, then web-offline-test when Chrome is installed (stops at the first failing step) "; @@ -72,6 +75,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("icons") => icons::run(&root()), Some("app-icons") => app_icons::run(&root()), Some("features") => features::run(&root()), Some("i18n") => i18n::run(&root(), &rest), @@ -94,6 +98,10 @@ fn main() -> ExitCode { let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied(); web_offline_test::run(&root(), site, true) } + Some("web-api-test") => { + let site = rest.iter().position(|a| *a == "--site").and_then(|i| rest.get(i + 1)).copied().unwrap_or("dist/web"); + web_offline_test::run_api(site) + } Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -362,6 +370,7 @@ fn cmd_ci() -> Result<(), String> { c.args(["fmt", "--all", "--", "--check"]); run(c, "cargo fmt --check")?; assets::run(&root())?; + icons::run(&root())?; licences::run(&root(), true)?; lists::run(&root(), false)?; cmd_layers()?; diff --git a/xtask/src/web_offline_test.rs b/xtask/src/web_offline_test.rs index 817d9753..e006b229 100644 --- a/xtask/src/web_offline_test.rs +++ b/xtask/src/web_offline_test.rs @@ -66,6 +66,67 @@ const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3); /// How long a file takes in Mode::SlowFiles. const SLOW_FILE: Duration = Duration::from_secs(6); +/// Exercise the real browser build's command bridge, using the same Chrome harness as the offline test. +pub fn run_api(site: &str) -> Result<(), String> { + let site = std::fs::canonicalize(site).map_err(|e| format!("{site}: {e}; build apps/solidcraft-web with trunk first"))?; + if !site.join("index.html").is_file() { + return Err(format!("{}: index.html missing; build apps/solidcraft-web with trunk first", site.display())); + } + let chrome = find_chrome().ok_or("web-api-test: Chrome not found (set CHROME to its executable)")?; + let work = TempDir::new()?; + let server = Server::start(site.clone())?; + server.set(Mode::Serve(site)); + let browser = Chrome::launch(&chrome, &work.0.join("profile"))?; + let mut cdp = browser.connect()?; + cdp.call("Emulation.setDeviceMetricsOverride", json!({"width": 1280, "height": 900, "deviceScaleFactor": 1, "mobile": false}))?; + cdp.call("Page.navigate", json!({"url": format!("http://127.0.0.1:{}{PREFIX}?webgl", server.port)}))?; + wait_for(&mut cdp, "!!window.solidcraft && !document.getElementById('solidcraft_loading')", "the browser API is ready", |v| v == true)?; + let result = cdp.eval(API_TEST)?; + println!("web-api-test: {result}"); + Ok(()) +} + +const API_TEST: &str = r#"(async () => { + const app = window.solidcraft; + const check = (condition, message) => { if (!condition) throw new Error(message); }; + const rejects = async (call, message) => { + try { await call(); } catch (error) { + check(error instanceof Error, 'rejections must be Error objects'); + check(error.message.includes(message), error.message); + return; + } + throw new Error('expected rejection: ' + message); + }; + const commands = await app.commands(); + check(JSON.stringify(commands).includes('solid.box'), 'command discovery'); + await app.request('ui.home', {open: false}); + const before = await app.inspect(); + check(before.bodies.length === 0, 'test starts with an empty design'); + await app.execute('solid.box', {length: 30, width: 20, height: 10, body_name: 'Browser API box'}); + const after = await app.inspect({measure: true}); + check(after.bodies.length === 1, 'box appears in the active document'); + check(Math.abs(after.bodies[0].volume_mm3 - 6000) < 0.001, 'box volume is 6000 mm³'); + await app.execute('edit.undo'); + check((await app.inspect()).bodies.length === 0, 'undo removes the box'); + await app.execute('edit.redo', null); + check((await app.inspect()).bodies.length === 1, 'redo restores the box'); + await rejects(() => app.request('missing.browser.method'), 'unknown'); + await rejects(() => app.execute('missing.browser.command'), 'unknown'); + const circular = {}; circular.self = circular; + await rejects(() => app.request('ui.inspect', circular), 'JSON serializable'); + await rejects(() => app.request('ui.inspect', {value: 1n}), 'JSON serializable'); + await rejects(() => app.request('ui.inspect', {text: 'x'.repeat(16 * 1024 * 1024)}), '16 MiB'); + const queued = Array.from({length: 257}, () => app.request('ui.inspect')); + const settled = await Promise.allSettled(queued); + check(settled.filter(r => r.status === 'fulfilled').length === 256, 'queued requests drain across frames'); + check(settled.filter(r => r.status === 'rejected' && r.reason instanceof Error && r.reason.message.includes('pending')).length === 1, + 'queue limit rejects excess work'); + check((await app.inspect()).bodies.length === 1, 'API still works after rejected requests'); + const ui = await app.request('ui.inspect'); + check(ui.home === false && ui.viewport[2] > 600, 'model viewport is visible'); + return {commands: 'discovered', bodies: [0, 1, 0, 1], errors: 'rejected', queue: '256 completed, 1 rejected'}; +})()"#; + /// Run the test; `required: false` (from `cargo xtask ci`) skips it when Chrome is missing. pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String> { let Some(chrome) = find_chrome() else { @@ -555,6 +616,7 @@ fn answer(mut stream: TcpStream, shared: &Mutex, slow: &AtomicUsize, elsew let kind = match file.and_then(|f| f.rsplit('.').next()) { Some("html") => "text/html; charset=utf-8", Some("js") => "text/javascript", + Some("css") => "text/css", Some("json") => "application/json", Some("wasm") => "application/wasm", _ => "application/octet-stream",