Compare commits

..

1 Commits

1185 changed files with 36148 additions and 113312 deletions
+5 -17
View File
@@ -1,27 +1,15 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
@@ -189,5 +177,5 @@ reviews:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
+1 -2
View File
@@ -4,5 +4,4 @@
# The vendored SolidCore kernel, owned as in SolidCore's own CODEOWNERS
# (solidcore/.github/CODEOWNERS, which GitHub reads only at a repository's root).
# The team (jyviko and bherbruck) assigns one of them per pull request, so either one approves.
/solidcore/ @tangency-labs/solidcore-owners
/solidcore/ @jyviko @bherbruck
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+5 -62
View File
@@ -402,7 +402,7 @@ jobs:
name: Format and repository checks
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 25
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -430,11 +430,8 @@ jobs:
- name: Sorted lists
run: cargo xtask lists
# New UI text must reach every complete catalog; report it here, before review (#8542).
# Catalog coverage only: the command labels need the app's command table, which this job
# never builds (a release build of the CLI took 9-15 minutes). The `tests` job checks them
# against the CLI it builds anyway.
- name: Translations in the complete catalogs
run: cargo xtask i18n --check --no-registry
run: cargo xtask i18n --check
- name: Model decisions use exact geometry, not meshes
run: |
cargo xtask mesh-decisions
@@ -451,8 +448,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and PR-check bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -580,9 +577,6 @@ jobs:
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
ACTION_MATRIX_JOBS: "12"
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -660,20 +654,6 @@ jobs:
--ignore-default-filter -E "($base) & not (package(=solidcraft-gpu) & test(/^path::/))" || status=$?
if [ "$status" -ne 0 ] && [ "$status" -ne 100 ]; then exit "$status"; fi
cargo xtask known-failing --junit "$junit"
# Every command label needs an English catalog entry (#8542). The label table is the CLI's
# command registry, so this builds the CLI in the profile the tests just used (the shared
# crates are cached) and reads it. Skipped when no selected package can change the table.
- name: Command labels in the translation catalogs
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
case "$FULL $PACKAGES " in
true*|*" solidcraft-cli "*|*" solidcraft-ui-egui "*|*" xtask "*) ;;
*) echo "No package that feeds the command table changed."; exit 0 ;;
esac
cargo build -p solidcraft-cli --profile ci --locked
cargo xtask i18n --check --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
- name: Compiler cache statistics
if: always()
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
@@ -706,9 +686,6 @@ jobs:
# As in Native Linux tests: lavapipe draws, and a test without an adapter fails.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
# lavapipe's own shader cache (below).
MESA_SHADER_CACHE_DIR: /home/runner/.cache/mesa-gpu
CARGO_INCREMENTAL: "0"
@@ -1015,9 +992,6 @@ jobs:
# WARP is a software (Cpu) adapter.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# The same for the shared viewport crate's own GPU tests, which read no SolidCraft variable.
VIEWPORT_TEST_GPU: software
VIEWPORT_TEST_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -1066,7 +1040,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore, solidcore-standalone]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1126,7 +1100,6 @@ jobs:
WASM: ${{ needs.wasm.result }}
SOLIDCORE_RUN: ${{ needs.changes.outputs.solidcore }}
SOLIDCORE: ${{ needs.solidcore.result }}
SOLIDCORE_STANDALONE: ${{ needs.solidcore-standalone.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1167,7 +1140,6 @@ jobs:
# solidcore/** changed on an approved pull request, in a merge group or on main.
if [ "$SOLIDCORE_RUN" = true ]; then
test "$SOLIDCORE" = success
test "$SOLIDCORE_STANDALONE" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
@@ -1203,35 +1175,6 @@ jobs:
# solidcore/** (Select checks' `solidcore`), where `ci` requires it; skipped otherwise. Its build,
# kernel-rig and goldens jobs are one entry here, so the rig and goldens binaries are built once
# without an artifact between jobs.
# solidcore/ exported alone (as the mirror to tangency-labs/solidcore carries it) must still check: the
# `layers` rule reads the manifests, this builds them without the rest of the repository around.
solidcore-standalone:
name: SolidCore builds on its own
needs: changes
if: needs.changes.outputs.solidcore == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
env:
CARGO_INCREMENTAL: 0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Export solidcore/ alone
run: |
mkdir -p "$RUNNER_TEMP/sc"
git archive HEAD solidcore | tar -x -C "$RUNNER_TEMP/sc"
- name: Toolchain (solidcore/rust-toolchain.toml)
working-directory: ${{ runner.temp }}/sc/solidcore
run: rustup show active-toolchain || rustup toolchain install
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "${{ runner.temp }}/sc/solidcore -> bin"
key: solidcore-standalone
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Check the exported tree
working-directory: ${{ runner.temp }}/sc/solidcore
run: cargo check --workspace --all-targets --locked
solidcore:
name: SolidCore checks (${{ matrix.check }})
needs: changes
+21 -5
View File
@@ -1,4 +1,4 @@
# Files generated from the code are rewritten here, on main, once a night, so pull requests
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
# never edit them (two pull requests that each regenerated one would always conflict):
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
# Pending files (assets/locales/pending, attribution/pending) are folded into the catalogs and
@@ -6,10 +6,26 @@
name: Generated files
on:
# Nightly, outside merge hours. A push straight to main resets every merge-queue batch, so the
# regeneration no longer runs on each push.
schedule:
- cron: "17 8 * * *"
push:
branches: [main]
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
# read through solidcraft-cli. Other pushes regenerate nothing.
paths:
- "Cargo.lock"
- "**/Cargo.toml"
- "vendor/**"
# solidcore's licence files are embedded in THIRD-PARTY-LICENSES.txt.
- "solidcore/LICENSE"
- "solidcore/NOTICE"
- "solidcore/ATTRIBUTION.md"
- "assets/fonts/**"
- "crates/engine/**"
- "apps/solidcraft-cli/**"
- "xtask/**"
- "assets/locales/pending/*.json"
- "attribution/pending/*.md"
- ".github/workflows/generated.yml"
workflow_dispatch:
permissions:
+43
View File
@@ -0,0 +1,43 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+8 -8
View File
@@ -17,8 +17,8 @@
# The trigger paths are whole folders that hold every area's PATHS (a self-test,
# scripts/ci/test_kernel_rig_plan.py, checks they do), so a pull request that touches none of
# them (the UI, docs, packaging) starts no runner at all, and a new area needs no edit here: the
# plan finds the areas from the folders. Merge groups run the areas their batch's diff reaches, like a
# pull request. Pushes to main and develop on the same paths, the release gate (release-gate.yml, workflow_call) and a run by hand run
# plan finds the areas from the folders. Pushes to main and develop on the same paths, merge groups
# (once the queue is on), the release gate (release-gate.yml, workflow_call) and a run by hand run
# every area; those pushes also commit the compiler cache (a Blacksmith sticky disk) that every
# other run mounts read-only.
name: Kernel rig
@@ -96,19 +96,19 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A pull request's diff needs its base's history.
fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && '0' || '1' }}
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
- name: Selection self-test
run: python3 -I scripts/ci/test_kernel_rig_plan.py
- id: plan
name: Map the changed files to areas
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Pull requests and merge groups run the areas their diff reaches (a batch that touches no
# kernel path runs none); pushes to main, the release gate and a run by hand run every area.
if [ "$EVENT_NAME" = pull_request ] || [ "$EVENT_NAME" = merge_group ]; then
# Pull requests run the areas their diff reaches; pushes, merge groups, the release
# gate and a run by hand run every area.
if [ "$EVENT_NAME" = pull_request ]; then
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/rig-paths"
python3 -I scripts/ci/kernel_rig_plan.py --paths "$RUNNER_TEMP/rig-paths" \
--github-output "$GITHUB_OUTPUT"
+2 -2
View File
@@ -12,7 +12,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `docs/layers.md`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `solidcore/docs/layers.md`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
@@ -20,7 +20,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
+1 -2
View File
@@ -200,7 +200,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| crates/viewport-gpu/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR 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/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
@@ -843,7 +843,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
| docs/layers/layers.svg | SolidCraft contributors | original diagram of SolidCraft's crate layers for docs/layers.md, written as SVG in solidcore's docs/layers diagram style | MIT OR Apache-2.0 |
## Code adapted from sibling projects
+9 -27
View File
@@ -17,29 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work goes in solidcore/
## Kernel work is paused
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel pull request targets `main` like any other; a change that touches the kernel and the app is one
pull request. A big feature in several steps goes as stacked pull requests into `main`, each one landing in order
on top of the one before.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
contributors without write access can't push to them. To update one:
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
`git switch moved/solidcore-N`.
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
3. Open a PR against `main` that says "Replaces #M" (M is the moved PR's number). The moved one
gets closed.
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or the vendored kernel in `solidcore/`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
## Get it running (about 10 minutes on a fast machine)
@@ -443,13 +429,9 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
## Labels and triage
Generated
+12 -316
View File
@@ -125,21 +125,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -154,21 +143,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -511,15 +485,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -551,16 +516,6 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -748,16 +703,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
"cipher",
]
[[package]]
@@ -822,17 +768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
"inout",
]
[[package]]
@@ -951,12 +887,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1066,9 +996,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
@@ -1165,15 +1095,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1704,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -2147,20 +2068,10 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2405,43 +2316,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2634,15 +2508,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -2908,16 +2773,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -3456,7 +3311,6 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3501,12 +3355,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3882,8 +3730,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
@@ -4208,14 +4056,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-convert"
version = "0.1.0"
dependencies = [
"solidcore",
"solidcore-record",
]
[[package]]
name = "solidcore-record"
version = "0.1.0"
@@ -4225,19 +4065,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-tess"
version = "0.1.0"
dependencies = [
"rayon",
"serde",
"solidcore",
"solidcore-convert",
"solidcore-record",
"spade",
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft"
version = "0.40.0-rc.3"
@@ -4270,8 +4097,6 @@ dependencies = [
"solidcraft-theme",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"solidcraft-viewport",
"solidcraft-viewport-gpu",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4296,8 +4121,6 @@ dependencies = [
"solidcraft-gpu",
"solidcraft-kernel",
"solidcraft-mcp",
"solidcraft-viewport",
"solidcraft-viewport-gpu",
]
[[package]]
@@ -4341,7 +4164,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
@@ -4349,7 +4171,6 @@ dependencies = [
"solidcraft-profile",
"solidcraft-render",
"solidcraft-sketch",
"solidcraft-viewport",
"thiserror 2.0.21",
"web-time",
]
@@ -4385,7 +4206,6 @@ dependencies = [
"solidcraft-profile",
"solidcraft-render",
"solidcraft-sketch",
"solidcraft-viewport",
"thiserror 2.0.21",
"toml_edit",
"web-time",
@@ -4397,9 +4217,7 @@ version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
"solidcore",
"solidcore-record",
"solidcore-tess",
"thiserror 2.0.21",
]
@@ -4415,15 +4233,12 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
"solidcraft-kernel",
"solidcraft-render",
"solidcraft-viewport",
"solidcraft-viewport-gpu",
"thiserror 2.0.21",
"wgpu",
]
@@ -4466,7 +4281,6 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcore-tess",
"solidcraft-geom",
"solidcraft-profile",
"solidcraft-smlib",
@@ -4486,8 +4300,6 @@ dependencies = [
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp-server",
"solidcraft-viewport",
"solidcraft-viewport-gpu",
]
[[package]]
@@ -4513,19 +4325,15 @@ dependencies = [
"image",
"moxcms",
"png",
"rayon",
"robust",
"serde",
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
"solidcraft-geom",
"solidcraft-viewport",
"sysinfo",
"web-time",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -4594,7 +4402,6 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"muda",
"nix 0.31.3",
@@ -4605,7 +4412,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
"solidcraft-control",
"solidcraft-control-egui",
@@ -4615,9 +4421,6 @@ dependencies = [
"solidcraft-theme",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"solidcraft-viewport",
"solidcraft-viewport-egui",
"solidcraft-viewport-gpu",
"syn 2.0.119",
"tiny-skia",
"toml",
@@ -4644,45 +4447,6 @@ dependencies = [
"unicode-bidi",
]
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde",
"serde_json",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
]
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"egui-wgpu",
"log",
"solidcraft-viewport-gpu",
"wgpu",
]
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.3"
dependencies = [
"log",
"naga",
"pollster 0.4.0",
"serde",
"solidcore-record",
"solidcore-tess",
"solidcraft-color",
"solidcraft-viewport",
"thiserror 2.0.21",
"wgpu",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
@@ -4742,17 +4506,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4804,20 +4557,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "sysinfo"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4890,7 +4629,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl 0.1.12",
"weezl",
"zune-jpeg",
]
@@ -4929,12 +4668,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -5112,15 +4845,6 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5502,12 +5226,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -5691,22 +5409,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -5716,12 +5418,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
-9
View File
@@ -33,9 +33,6 @@ solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-viewport = { path = "crates/viewport" }
solidcraft-viewport-egui = { path = "crates/viewport-egui" }
solidcraft-viewport-gpu = { path = "crates/viewport-gpu" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-mcp-server = { path = "crates/mcp-server" }
@@ -50,10 +47,6 @@ solidcore = { path = "solidcore/crates/solidcore" }
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
# and the native body file. Vendored with solidcore, in the same subtree.
solidcore-record = { path = "solidcore/crates/solidcore-record" }
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
solidcore-tess = { path = "solidcore/crates/solidcore-tess" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
@@ -76,8 +69,6 @@ semver = "1"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
# packages (3MF)
zip = { version = "9", default-features = false, features = ["deflate"] }
+113 -658
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -28,8 +28,6 @@ raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-viewport-gpu = { workspace = true }
solidcraft-viewport = { workspace = true }
solidcore-record = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
@@ -85,9 +85,9 @@ fn run() -> Result<(), String> {
// Vulkan device, parse GPU policy or initialize a GPU host.
let (adapter_report, gpu_host_seconds) = if needs_gpu(&session, &params)? {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::env::policy().map_err(|e| e.to_string())?;
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_viewport_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+6 -6
View File
@@ -76,7 +76,7 @@ const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27] [--quality coarse|normal|fine|ultra]\n solidcraft-cli snapshot --in <design.sc|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON;\n --quality: the display quality the bodies are meshed at, as the app's setting (default normal))\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted] [--legacy-memory-admission]\n --legacy-memory-admission skips available-RAM checks; fixed shared/host budgets and allocation limits still apply.\n";
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
@@ -653,9 +653,9 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
/// `gpu`: the headless adapter survey and the policy's pick.
fn cmd_gpu(args: &[String]) -> Result<(), String> {
use solidcraft_viewport_gpu::select::{Policy, survey};
use solidcraft_gpu::select::{Policy, survey};
let policy = match args {
[] => solidcraft_gpu::env::policy()?,
[] => Policy::from_env()?,
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
};
@@ -675,7 +675,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
}))
);
match s.lands_on {
Some(solidcraft_viewport_gpu::select::Landing::Cpu) => {
Some(solidcraft_gpu::select::Landing::Cpu) => {
solidcraft_engine::note!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
@@ -691,7 +691,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
/// error that names the policy.
fn install_renderer() {
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
let (rgba, renderer) = solidcraft_gpu::env::headless_rgba(scene, cam, w, h)?;
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
}
@@ -778,7 +778,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
solidcraft_viewport::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
+4 -14
View File
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Session,
doc::render_settings::{RenderJob, RenderSetup},
render::job::{CpuHost, JobId, NativeMemoryPolicy, Phase, ToneMap},
render::job::{CpuHost, JobId, Phase, ToneMap},
};
/// Only explicit runtime CLI flags are stored here. A typed setup owns these
@@ -74,7 +74,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut height = None;
let mut exposure = None;
let mut runtime = RuntimeOptions::default();
let mut memory_policy = NativeMemoryPolicy::AvailableRam;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
@@ -83,7 +82,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--progressive" => runtime.progressive = true,
"--transparent" => runtime.transparent = true,
"--overwrite" => overwrite = true,
"--legacy-memory-admission" => memory_policy = NativeMemoryPolicy::SoftwarePoolOnly,
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
@@ -136,15 +134,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
// Resolve saved intent before opening a GPU or explicitly settling geometry.
// Width/height/exposure only change this validated inline settings clone.
let (mut params, needs_gpu) = runtime.request(&session, settings)?;
// The machine admission policy may accompany any saved setup; it does not
// replace the setup's image-quality options or alter the saved document.
let cpu_host = CpuHost::for_platform().map_err(|e| e.to_string())?;
cpu_host.set_native_memory_policy(memory_policy);
session.set_render_job_host(Some(cpu_host)).map_err(|e| e.to_string())?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
if needs_gpu {
let gpu_host = solidcraft_gpu::path::host().map_err(|e| e.to_string())?;
gpu_host.set_native_memory_policy(memory_policy);
session.register_render_job_host(gpu_host).map_err(|e| e.to_string())?;
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
if setup.is_none() {
@@ -152,9 +144,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
camera
} else {
let mut camera = RenderSetup::default().camera;
camera.fit(&solidcraft_engine::render::view_bounds(
&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds,
));
camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
camera
};
params["settings"]["camera"] = json!(camera);
@@ -1,7 +1,6 @@
//! The bracket example must cut two through holes, rather than successfully cutting empty
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
use solidcraft_engine::geom::MeshGeom;
use std::f64::consts::PI;
use serde_json::Value;
+1 -11
View File
@@ -152,17 +152,7 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in [
"--progressive",
"--samples",
"--max-depth",
"--seed",
"--exposure",
"--lighting",
"--environment",
"--tone-map",
"--legacy-memory-admission",
] {
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
+4 -12
View File
@@ -507,7 +507,7 @@ fn snapshot_runs_its_script_on_the_in_design() {
std::fs::read(&png).unwrap()
};
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
match solidcraft_viewport_gpu::capture::shared(solidcraft_gpu::env::policy().unwrap_or_else(|e| panic!("{e}"))) {
match solidcraft_gpu::capture::shared() {
Ok(_) => {
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
@@ -515,7 +515,7 @@ fn snapshot_runs_its_script_on_the_in_design() {
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
}
Err(e @ solidcraft_viewport_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("pictures left out: {e}")
}
Err(e) => panic!("{e}"),
@@ -673,7 +673,6 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.unwrap(),
);
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "available_ram");
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["request"]["integrator"], "raytrace");
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
@@ -690,12 +689,11 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.arg(&saved)
.args(["--setup", "Portable setup", "--out"])
.arg(&overridden)
.args(["--width", "12", "--height", "8", "--exposure", "1.25", "--legacy-memory-admission"])
.args(["--width", "12", "--height", "8", "--exposure", "1.25"])
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
.output()
.unwrap(),
);
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "software_pool_only");
assert_eq!(result["preflight"]["request"]["width"], 12);
assert_eq!(result["preflight"]["request"]["height"], 8);
assert_eq!(result["preflight"]["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -775,13 +773,7 @@ fn typed_gpu_render_selects_its_host_without_a_progressive_runtime_flag() {
let script = render_setup_script(&dir, job.clone());
let out = dir.0.join("gpu.png");
let mut command = cli();
command
.arg("render")
.arg(&script)
.args(["--setup", "Portable setup", "--out"])
.arg(&out)
.arg("--legacy-memory-admission")
.env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
command.arg("render").arg(&script).args(["--setup", "Portable setup", "--out"]).arg(&out).env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
if job.is_null() {
command.arg("--progressive");
}
+5 -5
View File
@@ -17,14 +17,14 @@ static NEXT: AtomicUsize = AtomicUsize::new(0);
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
fn gpu() -> bool {
match solidcraft_viewport_gpu::capture::shared(solidcraft_gpu::env::policy().unwrap_or_else(|e| panic!("{e}"))) {
match solidcraft_gpu::capture::shared() {
Ok(c) => {
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
}));
true
}
Err(e @ solidcraft_viewport_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("skipping: {e}");
false
}
@@ -172,7 +172,7 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s).unwrap();
cam.set_view(solidcraft_viewport::StandardView::Top);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
@@ -336,9 +336,9 @@ fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s).unwrap();
cam.set_view(solidcraft_viewport::StandardView::Top);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
let cpu = solidcraft_viewport::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
}
+8 -30
View File
@@ -191,9 +191,8 @@ mod web {
const CANVAS_ID: &str = "solidcraft_canvas";
const LOADING_ID: &str = "solidcraft_loading";
/// The viewport scene's depth buffer, a 32-bit float one (no stencil): WebGPU and WebGL2 both
/// have it, and the viewport draws it with reversed depth, whose precision follows the
/// distance from the eye (#1434). egui's own pass has none: the scene is drawn offscreen.
/// A 32-bit float depth buffer (no stencil): WebGPU and WebGL2 both have it, and the viewport
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
const DEPTH_BITS: u8 = 32;
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
@@ -361,7 +360,7 @@ mod web {
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
@@ -1060,7 +1059,7 @@ mod web {
}
};
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
@@ -1094,7 +1093,7 @@ mod web {
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
}
}
@@ -1211,25 +1210,6 @@ mod web {
storage.and_then(|s| s.get_item(&crate::channel::key(WEBGL_KEY)).ok().flatten()).is_some()
}
/// Install the GPU viewport the desktop's way: the scene is drawn offscreen with its own depth
/// buffer and the saved anti-aliasing, then copied into egui's pass, which has neither. Drawn
/// straight into egui's pass, the view was 1x whatever Graphics said (#11622).
///
/// The sample count follows the desktop's rule (`gpu::scene_samples`): the saved choice where
/// the browser's device draws it, else the supported default. WebGL draws 1x, as desktop
/// OpenGL does. eframe's web painter has no stencil buffer.
fn install_viewport(app: &mut SolveApp, rs: &eframe::egui_wgpu::RenderState) {
let backend = rs.adapter.get_info().backend;
let depth = eframe::egui_wgpu::depth_format_from_bits(DEPTH_BITS, 0);
let supported = eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth);
let requested = solidcraft_ui_egui::gpu::scene_samples(backend, app.preferences.msaa, &supported);
let drawn = app.set_wgpu_split(rs, DEPTH_BITS, 0, requested);
// After the install, which sets egui's own counts: those leave out the scene's depth
// buffer, and they include counts WebGL would draw at 1x.
app.msaa_supported = solidcraft_ui_egui::gpu::scene_sample_choices(backend, supported);
log::info!("SolidCraft graphics: {backend:?}, MSAA {drawn}x (saved {}x)", app.preferences.msaa);
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
@@ -1246,7 +1226,6 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
solidcraft_ui_egui::icon_svg::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
@@ -1260,9 +1239,7 @@ mod web {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// The window's pass (egui's) has no depth buffer: the scene has its own
// (`install_viewport`).
let mut options = eframe::WebOptions { depth_buffer: 0, ..Default::default() };
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
if webgl()
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
@@ -1336,7 +1313,8 @@ mod web {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
install_viewport(&mut app, rs);
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if launch.open.is_none() && crate::query_flag(&query(), "sample") {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
-2
View File
@@ -30,8 +30,6 @@ type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-viewport-gpu = { workspace = true }
solidcraft-viewport = { workspace = true }
solidcraft-engine = { workspace = true }
solidcraft-control = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
+11 -10
View File
@@ -261,11 +261,7 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// The frame's input before egui reads it: the release of a press handed to a window move
/// or resize, the macOS menu and full-screen fixes, the 3D mouse, then the app's own hook.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// First: the release of a press the last frame handed to a window move or resize (#13334).
solidcraft_ui_egui::window_chrome::release_press_taken_by_os(ctx, raw);
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.raw_input_hook(&mut self.solve, ctx, raw);
@@ -848,10 +844,16 @@ fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// The viewport scene's sample count, by the rule the web app shares
/// (`solidcraft_ui_egui::gpu::scene_samples`).
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
/// cannot render with falls back to the supported default.
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
solidcraft_ui_egui::gpu::scene_samples(backend, samples, supported)
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
requested
} else {
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
}
}
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
@@ -934,7 +936,6 @@ fn main() -> eframe::Result {
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
solidcraft_ui_egui::icon_svg::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
@@ -1228,7 +1229,7 @@ fn main() -> eframe::Result {
app.session.undo.clear();
// Built straight into the session, so nothing asked for a fit: frame it at the
// home view as any opened design is (#9195). The fit waits for the first layout.
app.cam.set_view(solidcraft_viewport::StandardView::Iso);
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
app.fit_view();
}
// Lock files next to open designs: a design open on another computer says so (#5467),
@@ -1359,7 +1360,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let indirect = std::env::var(startup::INDIRECT_VALIDATION_ENV).ok();
setup.instance_descriptor.flags = startup::window_instance_flags(setup.instance_descriptor.flags, intel_mac, indirect.as_deref());
// Never a `dxcompiler.dll` another program put on PATH (#6756).
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_viewport_gpu::dx12_compiler();
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let environment = std::env::var("WGPU_ADAPTER_NAME").ok();
let adapter = graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string));
startup::configure_adapter(setup, cpu, environment, adapter);
+3 -3
View File
@@ -260,7 +260,7 @@ pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::
/// asks for a float one only when whichever it picks has it.
pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false };
let mut desc = solidcraft_viewport_gpu::instance_descriptor_from_env();
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
desc.backends = setup.instance_descriptor.backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends));
@@ -277,7 +277,7 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
desc.backend_options.dx12.shader_compiler = solidcraft_viewport_gpu::dx12_compiler();
desc.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
@@ -698,7 +698,7 @@ mod tests {
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
assert_eq!(backends, eframe::wgpu::Backends::DX12);
}
let mut desc = solidcraft_viewport_gpu::instance_descriptor_from_env();
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
desc.backends = backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
+1
View File
@@ -21,6 +21,7 @@ assets/
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
shaders/viewport.wgsl # original viewport shader
fonts/biz-udp-gothic/ # unmodified font and OFL licence
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
+8 -5
View File
@@ -150,13 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
}
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
pub(crate) const AXIS_RADIUS: f32 = 15.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
}
/// A compass axis end at a uniform scale, including its lettering and strokes.
/// The same compass artwork at a uniform scale, including its lettering and strokes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d[0] != 0.0 {
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d.x != 0.0 {
t.axis_x
} else if d[1] != 0.0 {
} else if d.y != 0.0 {
t.axis_y
} else {
t.axis_z
@@ -193,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+1 -59
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.ui.palette": "البحث في الأوامر",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "العرض",
"parts.family.bearing": "محمل كريات أخدودي عميق (الغلاف)",
"parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي",
"parts.family.countersunk_socket_screw": "برغي برأس غاطس بتجويف سداسي",
"parts.family.dowel_pin": "مسمار توجيه أسطواني",
"parts.family.hex_bolt": "برغي سداسي الرأس",
"parts.family.hex_cap_screw_inch": "برغي سداسي الرأس (بوصة)",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "معطّل افتراضيًا. عند تفعيله، بعد نجاح الأمر، يُعاد استخدام قيمه وخياراته الآمنة في المرة التالية التي تفتح فيها الأمر نفسه، حتى إغلاق SolidCraft.",
"prefs.render_legacy_image_defaults": "استخدام الإعدادات الافتراضية السابقة لصور التصيير",
"prefs.render_legacy_image_defaults_help": "الاحتفاظ بسير العمل اليدوي السابق: إعدادات جديدة بالتصيير النقطي على CPU، وأخذ العينات الثابت لتتبع الأشعة على CPU، وفتح النتائج يدويًا. إعادة ملاءمة العرض عند دخول التصيير، وتبديل اللقطات دون استعادة كاميراتها، واستخدام التأطير والتنقل السابقين. تعطيل التحديث التلقائي للصورة المباشرة. لا تتغير الإعدادات المحفوظة.",
"prefs.render_legacy_memory_admission": "استخدام فحوصات ذاكرة التصيير السابقة",
"prefs.render_legacy_memory_admission_help": "السماح بالتصيير المحلي ضمن حدود الذاكرة المشتركة وحدود المضيف الثابتة حتى عندما يتجاوز التقدير الذاكرة المتاحة. تظل حدود الجهاز وفحوصات تخصيص الذاكرة سارية.",
"prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير",
"prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.",
"prefs.reports_saved": "{n} محفوظة",
@@ -3224,10 +3220,8 @@
"render.category_paint": "طلاء",
"render.category_plastic": "بلاستيك",
"render.category_wood": "خشب",
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
"render.caustics_depth": "عمق مسار تجمّع الضوء",
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
"render.caustics_photons": "عدد الفوتونات",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
"render.cpu_path_depth": "عمق المسار",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
"render.cpu_threads": "خيوط المعالجة",
"render.cpu_threads_auto": "تلقائي",
"render.cpu_threads_custom": "مخصص",
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
"render.cpu_threads_single": "واحد",
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
"render.cpu_transparent_help": "يزيل PNG الشفاف الخلفية ويحافظ على تغطية الحواف. يجب أن تبقى عتامة الأجسام والأوجه عند 100%.",
"render.cpu_volume_enable_path": "تفعيل الإضاءة غير المباشرة",
"render.cpu_volume_enable_path_plan": "تتبع أشعة CPU · عمق المسار {depth} · {spp} عينة/بكسل · بذرة {seed} · ظلال متتبعة. يحتفظ بالغلاف الجوي وميزانية الصورة المحددين.",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
"render.cpu_warm_cool": "دافئ وبارد",
"render.denoise_class_background": "متجاوز: خلفية",
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
"render.denoise_class_lens": "متجاوز: عمق المجال",
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
"render.denoise_class_nested": "متجاوز: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
"render.denoise_class_surface": "مؤهل: سطح",
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
"render.denoise_format_mask": "قناع PNG",
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
"render.denoise_passes": "مرات الترشيح",
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة الضوضاء",
"render.denoise_view_image": "الصورة المصيّرة",
"render.denoise_view_mask": "قناع إزالة الضوضاء",
"render.diag_bodies": "الأجسام المرئية",
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
"render.diag_last_check": "آخر فحص مسبق",
@@ -4046,8 +4000,6 @@
"render.material_transient": "تجاوزات بصرية مؤقتة لعلامة تبويب المستند هذه. لا تُحفظ في التصميم ولا في إعداد التصيير ولا في مكتبة المواد.",
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
"render.material_weight": "وزن الطبقة (0–1)",
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
"render.method_cpu": "تتبع الأشعة على CPU",
"render.method_gpu": "تتبع المسارات على GPU",
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "تقليل التشويش: مفعّل",
"render.quality_draft": "مسودة",
"render.quality_high": "عالية",
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
"render.quality_projection_help": "تغيّر هذه المراجعة الدقة وميزانية العينات وعمق المسار والبذرة وتقليل التشويش فقط. تُحفظ الإعدادات الأخرى؛ ولا تثبت الميزانية تقارب الصورة.",
"render.quality_review": "مراجعة ميزانية العيّنات",
"render.quality_summary": "{width} × {height} · {samples} عينة · العمق {depth} · البذرة {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "إظهار شريط اللقطات",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "مخصص…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "مسار عشوائي للتشتت الداخلي",
"render.subsurface_help": "للعوازل المغلقة فقط. التشتت وامتصاص RGB معاملان منفصلان لكل mm. تتشتت المسارات داخل الهندسة الفعلية، ثم تنكسر أو تنعكس عند الحد. عطّل نفاذ الظل المستقيم وإزالة تشويش السطح والحجم الخارجي؛ واستخدم طبقة فرينل واحدة. ليس مظلل انتشار ولا شبكة كثافة ولا مادة مقيسة. الأوساط الكثيفة قد تحتاج مزيدًا من العيّنات وعمق المسار.",
"render.subsurface_rate": "معدل اصطدامات التشتت",
"render.supersampling": "أخذ عينات فائقة",
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
"render.supersampling_off": "إيقاف",
"render.supersampling_x2": "2×",
"render.surface_import.author": "المؤلف",
"render.surface_import.busy": "جارٍ استيراد الصورة…",
"render.surface_import.complete": "اختر صورة وأدخل مؤلفها ومصدرها وترخيصها.",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "احفظ الخرائط لاستخدامها في التصيير على CPU.",
"render.surface_import.source": "المصدر",
"render.surface_import.title": "استيراد صورة",
"render.texture_filtering": "ترشيح تفاصيل الخامات",
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
"render.tone_aces_fitted": "ACES مُلاءَم",
"render.tone_reinhard": "راينهارد",
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
+1 -59
View File
@@ -1319,7 +1319,6 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.ui.palette": "Befehle suchen",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
@@ -2222,7 +2221,6 @@
"parts.dimension.width": "Breite",
"parts.family.bearing": "Rillenkugellager (Hüllform)",
"parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant",
"parts.family.countersunk_socket_screw": "Senkschraube mit Innensechskant",
"parts.family.dowel_pin": "Zylinderstift",
"parts.family.hex_bolt": "Sechskantschraube",
"parts.family.hex_cap_screw_inch": "Sechskantschraube (Zoll)",
@@ -2593,8 +2591,6 @@
"prefs.remember_dialog_values.help": "Standardmäßig aus. Wenn aktiviert, werden nach erfolgreicher Ausführung eines Befehls seine sicheren Werte und Optionen beim nächsten Öffnen desselben Befehls wiederverwendet, bis SolidCraft geschlossen wird.",
"prefs.render_legacy_image_defaults": "Bisherige Render-Bildvorgaben verwenden",
"prefs.render_legacy_image_defaults_help": "Den bisherigen manuellen Ablauf beibehalten: neue Setups mit CPU-Raster, feste Abtastung beim CPU-Raytracing und manuell geöffnete Ergebnisse. Beim Wechsel zu Render die Ansicht neu einpassen, Aufnahmen ohne Wiederherstellung ihrer Kamera wechseln und die bisherige Bildausrichtung und Navigation verwenden. Automatische Live-Bildrückmeldungen deaktivieren. Gespeicherte Setups bleiben unverändert.",
"prefs.render_legacy_memory_admission": "Bisherige Render-Speicherprüfung verwenden",
"prefs.render_legacy_memory_admission_help": "Native Renderaufträge innerhalb der festen gemeinsamen und hostspezifischen Speichergrenzen zulassen, auch wenn der geschätzte Bedarf den verfügbaren RAM übersteigt. Gerätegrenzen und Prüfungen der Speicherzuweisung gelten weiterhin.",
"prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden",
"prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.",
"prefs.reports_saved": "{n} gespeichert",
@@ -2978,10 +2974,8 @@
"render.category_paint": "Lack",
"render.category_plastic": "Kunststoff",
"render.category_wood": "Holz",
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
"render.caustics_depth": "Kaustik-Pfadtiefe",
"render.caustics_enable": "Glaskaustiken durch Punkt- und Richtungslichter",
"render.caustics_environment": "Kaustiken aus der Umgebung",
"render.caustics_help": "Die Photonenzahl bestimmt Rauschen und Aufwand. Der Sammelradius tauscht scharfe Details gegen eine glattere Schätzung. CPU benötigt indirekte Beleuchtung.",
"render.caustics_path_required": "CPU-Kaustiken benötigen indirekte Beleuchtung.",
"render.caustics_photons": "Photonen",
@@ -3158,7 +3152,7 @@
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
"render.cpu_path_depth": "Pfadtiefe",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_lighting": "Indirekte Beleuchtung",
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_point": "Punktlicht",
@@ -3198,12 +3192,6 @@
"render.cpu_subsurface_denoise_reason": "Streuung im Glas auf der CPU benötigt ausgeschaltete Rauschreduzierung. Die Einstellungen bleiben erhalten.",
"render.cpu_subsurface_help": "Streuung lenkt Licht im Inneren geschlossenen Glases um. Positive Raten benötigen indirekte Beleuchtung ohne Rauschreduzierung. Die Rate zählt Kollisionen pro mm; 0 fügt keine Streuung hinzu. Positive Richtungswerte bevorzugen vorwärts, negative rückwärts.",
"render.cpu_summary": "CPU-Raytracing · Reflexionen · HDR · Richtungs- und Punktlichter",
"render.cpu_threads": "CPU-Threads",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Eigene",
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
"render.cpu_threads_single": "Einzeln",
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
"render.cpu_transparent_help": "Transparentes PNG entfernt den Hintergrund und erhält die Kantenabdeckung. Körper- und Flächendeckkraft müssen 100 % bleiben.",
"render.cpu_volume_enable_path": "Indirekte Beleuchtung einschalten",
"render.cpu_volume_enable_path_plan": "CPU-Raytracing · Pfadtiefe {depth} · {spp} Samples/Pixel · Seed {seed} · Raytracing-Schatten. Atmosphäre und Bildbudget bleiben erhalten.",
@@ -3212,40 +3200,6 @@
"render.cpu_volume_method_incompatible": "Diese Licht-, Sampling- oder Atmosphäreinstellungen lassen sich nicht auf diese Methode übertragen. Die Einstellungen bleiben erhalten.",
"render.cpu_volume_path_required": "Indirekte Beleuchtung einschalten, um CPU-Atmosphäre zu nutzen.",
"render.cpu_warm_cool": "Warm und kühl",
"render.denoise_class_background": "Übersprungen: Hintergrund",
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung",
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
"render.denoise_class_surface": "Geeignet: Oberfläche",
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
"render.denoise_export_mask": "Maske als PNG exportieren",
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
"render.denoise_format_mask": "Maske PNG",
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
"render.denoise_optical_filter": "Spiegel und Glas filtern",
"render.denoise_optical_filter_help": "Geeignetes reflektiertes und durchgelassenes Licht filtern. Ausschalten, um nur undurchsichtige Oberflächen zu entrauschen.",
"render.denoise_passes": "Filterdurchläufe",
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
"render.denoise_strength": "Entrauschungsstärke",
"render.denoise_view_image": "Gerendertes Bild",
"render.denoise_view_mask": "Entrauschungsmaske",
"render.diag_bodies": "Sichtbare Körper",
"render.diag_job_details": "Erfasste Auftragsdetails",
"render.diag_last_check": "Letzte Vorabprüfung",
@@ -3800,8 +3754,6 @@
"render.material_transient": "Vorübergehende optische Überschreibungen für diesen Dokument-Tab. Sie werden weder im Design noch im Render-Setup oder in der Materialbibliothek gespeichert.",
"render.material_v_up": "Textur-V zeigt im Bild nach oben",
"render.material_weight": "Klarlack-Gewicht (0–1)",
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
"render.method_cpu": "CPU-Raytracing",
"render.method_gpu": "GPU-Pathtracing",
"render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.",
@@ -3930,8 +3882,6 @@
"render.quality_denoise_on": "Rauschreduzierung: Ein",
"render.quality_draft": "Entwurf",
"render.quality_high": "Hoch",
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
"render.quality_projection_help": "Diese Prüfung ändert nur Auflösung, Abtastbudget, Pfadtiefe, Startwert und Rauschreduzierung. Andere Einstellungen bleiben erhalten; das Budget garantiert keine Konvergenz.",
"render.quality_review": "Sampling-Budget prüfen",
"render.quality_summary": "{width} × {height} · {samples} Samples · Tiefe {depth} · Startwert {seed} · {denoise}",
@@ -4079,7 +4029,6 @@
"render.show_shots_label": "Aufnahmenleiste einblenden",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Benutzerdefiniert…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4242,11 +4191,6 @@
"render.subsurface_enable": "Random Walk für innere Streuung",
"render.subsurface_help": "Nur für geschlossene Dielektrika. Streuung und RGB-Absorption sind getrennte Koeffizienten pro mm. Pfade streuen innerhalb der tatsächlichen Geometrie und werden dann an der Grenzfläche gebrochen oder reflektiert. Deaktivieren Sie die Transmission gerader Schattenstrahlen, das Entrauschen von Oberflächen und das äußere Volumen; verwenden Sie eine Fresnel-Schicht. Kein Diffusions-Shader, kein Dichtegitter und kein gemessenes Material. Dichte Medien können mehr Samples und Pfadtiefe benötigen.",
"render.subsurface_rate": "Streukollisionsrate",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
"render.supersampling_off": "Aus",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Bild wird importiert…",
"render.surface_import.complete": "Wählen Sie ein Bild und geben Sie Autor, Quelle und Lizenz an.",
@@ -4260,8 +4204,6 @@
"render.surface_import.saved_cpu": "Speichern Sie Texturen für das CPU-Rendering.",
"render.surface_import.source": "Quelle",
"render.surface_import.title": "Bild importieren",
"render.texture_filtering": "Texturdetails filtern",
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung.",
"render.tone_aces_fitted": "ACES angepasst",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU-Glas unterstützt zertifizierte getrennte Medien. Unbeschränkt verschachtelte Medien und ein rauschfreier Transmissionsschätzer werden nicht bereitgestellt.",
+4 -64
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.ui.palette": "Search Commands",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
@@ -1890,13 +1889,13 @@
"help.sketch.construction_line": "Create an infinite construction line through two points. Use it to lay out a sketch without splitting profiles.",
"help.sketch.construction_line.angle": "Create an infinite construction line at an angle from sketch X. Pick the point and type the angle, or pick its direction.",
"help.sketch.construction_line.bisector": "Pick two intersecting straight sketch lines, then choose either angle bisector. The result follows its source lines.",
"help.sketch.construction_line.horizontal": "Create an infinite horizontal construction line through a point, or type its distance from the sketch X axis. A typed distance becomes a driving dimension. Its horizontal constraint follows later edits.",
"help.sketch.construction_line.parallel": "Pick a straight sketch line, then place a parallel infinite construction line or type its offset. A typed offset becomes a driving distance. Right-click to choose another reference.",
"help.sketch.construction_line.horizontal": "Create an infinite horizontal construction line through a point. Its horizontal constraint follows later edits.",
"help.sketch.construction_line.parallel": "Pick a straight sketch line, then place a parallel infinite construction line. Right-click to choose another reference.",
"help.sketch.construction_line.perpendicular": "Pick a straight sketch line, then place a perpendicular infinite construction line. Right-click to choose another reference.",
"help.sketch.construction_line.tangent_at": "Create a tangent at a point on a circle or circular arc, or an endpoint of an open spline or conic. Pick the curve, then its contact point.",
"help.sketch.construction_line.tangent_external": "Pick a circle or circular arc, then an external point and a tangent solution. The previews show only contacts on the drawn arc.",
"help.sketch.construction_line.tangent_two": "Pick two circles or circular arcs, then choose a common tangent in the preview. Contacts must lie on the drawn curves.",
"help.sketch.construction_line.vertical": "Create an infinite vertical construction line through a point, or type its distance from the sketch Y axis. A typed distance becomes a driving dimension. Its vertical constraint follows later edits.",
"help.sketch.construction_line.vertical": "Create an infinite vertical construction line through a point. Its vertical constraint follows later edits.",
"help.sketch.copy": "Copies the selected sketch curves and points, together with the constraints and dimensions among them. Paste them into this sketch or another one.",
"help.sketch.create": "Starts a new 2D sketch on a plane or on a flat face of the model. Every extrude, revolve and sweep begins with a sketch.",
"help.sketch.cut": "Copies the selected sketch geometry and deletes it from the sketch, in one undo step. Paste it elsewhere in this sketch or into another one.",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "Width",
"parts.family.bearing": "Deep groove ball bearing (envelope)",
"parts.family.button_head_cap_screw": "Button head cap screw",
"parts.family.countersunk_socket_screw": "Countersunk socket screw",
"parts.family.dowel_pin": "Parallel pin (dowel)",
"parts.family.hex_bolt": "Hex head bolt / screw",
"parts.family.hex_cap_screw_inch": "Hex cap screw (inch)",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "Off by default. When on, after a command succeeds, its safe values and options are reused the next time you open the same command, until SolidCraft closes.",
"prefs.render_legacy_image_defaults": "Use previous Render image defaults",
"prefs.render_legacy_image_defaults_help": "Keep the previous manual workflow: new setups use CPU raster, CPU ray tracing keeps fixed sampling, and you open rendered results manually. Refit on Render entry, switch shots without restoring their cameras, and use the previous framing and navigation. Disable automatic live image feedback. Saved setups are unchanged.",
"prefs.render_legacy_memory_admission": "Use previous render memory checks",
"prefs.render_legacy_memory_admission_help": "Allow native renders within the fixed shared and host memory budgets even when their estimate exceeds available RAM. Device limits and allocation checks still apply.",
"prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget",
"prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.",
"prefs.reports_saved": "{n} saved",
@@ -3224,10 +3220,8 @@
"render.category_paint": "Paint",
"render.category_plastic": "Plastic",
"render.category_wood": "Wood",
"render.caustics_area_lights": "Area light caustics",
"render.caustics_depth": "Caustic path depth",
"render.caustics_enable": "Glass caustics from point and directional lights",
"render.caustics_environment": "Environment caustics",
"render.caustics_help": "Photon count controls noise and work. Gather radius trades sharp detail for a smoother estimate. CPU requires Indirect lighting.",
"render.caustics_path_required": "CPU caustics require Indirect lighting.",
"render.caustics_photons": "Photons",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
"render.cpu_path_depth": "Path depth",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_lighting": "Indirect lighting",
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_point": "Point light",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "CPU subsurface scattering needs Denoise off. Your settings are kept.",
"render.cpu_subsurface_help": "Scattering redirects light inside closed Glass. Positive rates need Indirect lighting with Denoise off. The rate is collisions per mm; 0 adds no scattering. Positive phase bias favors forward directions; negative favors backward.",
"render.cpu_summary": "CPU ray tracing · reflections · HDR · directional and point lights",
"render.cpu_threads": "CPU workers",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Custom",
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
"render.cpu_threads_single": "Single",
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
"render.cpu_transparent_help": "Transparent PNG removes the background and keeps edge coverage. Body and face opacity must stay at 100%.",
"render.cpu_volume_enable_path": "Enable Indirect lighting",
"render.cpu_volume_enable_path_plan": "CPU ray tracing · path depth {depth} · {spp} samples/pixel · seed {seed} · traced shadows. Keeps the authored atmosphere and image budget.",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "These light, sampling or atmosphere settings cannot be transferred to this method. Your settings are kept.",
"render.cpu_volume_path_required": "Enable Indirect lighting to use CPU Atmosphere.",
"render.cpu_warm_cool": "Warm and cool",
"render.denoise_class_background": "Skipped: background",
"render.denoise_class_coverage": "Skipped: partial coverage",
"render.denoise_class_degenerate": "Skipped: degenerate path",
"render.denoise_class_depth": "Skipped: path depth limit",
"render.denoise_class_emitter": "Skipped: light emitter",
"render.denoise_class_exit": "Skipped: incompatible exit",
"render.denoise_class_identity": "Skipped: guide identity limit",
"render.denoise_class_lens": "Skipped: depth of field",
"render.denoise_class_material": "Skipped: unsupported material",
"render.denoise_class_nested": "Skipped: nested medium",
"render.denoise_class_optical": "Eligible: refracted surface",
"render.denoise_class_reflection": "Skipped: reflective endpoint",
"render.denoise_class_surface": "Eligible: surface",
"render.denoise_class_tir": "Skipped: total internal reflection",
"render.denoise_class_transmission": "Skipped: further transmission",
"render.denoise_class_unavailable": "Skipped: no guide",
"render.denoise_class_volume": "Skipped: volume scattering",
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
"render.denoise_diagnostics_title": "Denoising diagnostics",
"render.denoise_export_mask": "Export mask PNG",
"render.denoise_firefly": "Suppress isolated bright pixels",
"render.denoise_format_mask": "Mask PNG",
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
"render.denoise_optical_filter": "Filter mirror and glass",
"render.denoise_optical_filter_help": "Filter eligible reflected and transmitted light. Turn off to denoise opaque surfaces only.",
"render.denoise_passes": "Filter passes",
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
"render.denoise_strength": "Denoising strength",
"render.denoise_view_image": "Rendered image",
"render.denoise_view_mask": "Denoising mask",
"render.diag_bodies": "Visible bodies",
"render.diag_job_details": "Captured job details",
"render.diag_last_check": "Last preflight",
@@ -4046,8 +4000,6 @@
"render.material_transient": "Temporary optical overrides for this document tab. They are not saved in the design, render setup or material library.",
"render.material_v_up": "Texture V points upward in the image",
"render.material_weight": "Coat weight (0–1)",
"render.metal_energy_compensation": "Recover rough metal reflectance",
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
"render.method_cpu": "CPU ray tracing",
"render.method_gpu": "GPU path tracing",
"render.method_limit_raster": "Raster uses the fixed opaque studio preview. Select an explicit raytracing method for supported optical controls.",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "Noise reduction: On",
"render.quality_draft": "Draft",
"render.quality_high": "High",
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
"render.quality_projection_help": "This review changes only resolution, sample budget, path depth, seed and noise reduction. Other settings are kept; the budget does not certify convergence.",
"render.quality_review": "Review sampling budget",
"render.quality_summary": "{width} × {height} · {samples} samples · depth {depth} · seed {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "Show Shots strip",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Custom…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "Interior scattering random walk",
"render.subsurface_help": "Closed dielectric only. Scattering and RGB absorption are separate per-mm coefficients. Paths scatter inside actual geometry, then refract or reflect at the boundary. Disable straight shadow transmission, surface denoising and exterior volume; use one Fresnel stratum. Not a diffusion shader, density grid or measured material. Dense media may need more samples and path depth.",
"render.subsurface_rate": "Scattering collision rate",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
"render.supersampling_off": "Off",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Author",
"render.surface_import.busy": "Importing image…",
"render.surface_import.complete": "Choose an image and enter its author, source and license.",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "Save maps to use them with CPU rendering.",
"render.surface_import.source": "Source",
"render.surface_import.title": "Import image",
"render.texture_filtering": "Filter texture detail",
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering.",
"render.tone_aces_fitted": "ACES fitted",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU glass supports certified disjoint media. Unrestricted nested media and a noise-free transmission estimator are not provided.",
@@ -5300,14 +5242,12 @@
"ui.constraints_conflict": "Constraints conflict",
"ui.construction": "Construction",
"ui.construction.angle_hint": "Construction line: click a point, then type an angle or pick a direction",
"ui.construction.axis_hint": "Construction line: click a point, or type its distance from the sketch axis",
"ui.construction.bisector_hint": "Bisector: pick two intersecting straight lines, then the desired solution",
"ui.construction.common_hint": "Common tangent: pick two circles/arcs, then the desired solution",
"ui.construction.contact_hint": "Tangent: pick a circle/arc or open spline/conic, then the contact point (free-form curves: endpoints)",
"ui.construction.external_hint": "Tangent: pick a circle or arc, an external point, then the desired solution",
"ui.construction.free_hint": "Construction line: click two points; right-click to restart",
"ui.construction.normal_needs_length": "An infinite line is always construction: type a length to draw a normal line",
"ui.construction.parallel_hint": "Construction line: pick a straight line, then click or type the offset; right-click to change the reference",
"ui.construction.point_hint": "Construction line: click the point to pass through",
"ui.construction.reference_hint": "Construction line: pick a straight line, then a point; right-click to change the reference",
"ui.construction.ribbon_label": "X-Lines",
+1 -127
View File
@@ -265,7 +265,6 @@
"Edge": "Arista",
"Edges": "Aristas",
"Edit": "Editar",
"Edit Holes": "Editar agujeros",
"Edit Profile Sketch": "Editar boceto del perfil",
"Edit shortcuts": "Editar atajos",
"Enable rule": "Activar regla",
@@ -977,7 +976,6 @@
"browser.calibrate_click_a": "Haz clic en la imagen para el Punto A, o escribe sus coordenadas.",
"browser.calibrate_click_b": "Haz clic en la imagen para el Punto B, o escribe sus coordenadas.",
"browser.calibrate_title": "Calibrar: {name}",
"browser.canvas_locked": "El lienzo está bloqueado. Desbloquéalo primero.",
"browser.capture_many": "Se han movido {count} componentes, pero sus posiciones no se han capturado.",
"browser.capture_note": "Capturar posición conserva la nueva posición; Revertir posición restaura la anterior.",
"browser.capture_one": "Se ha movido un componente, pero su posición no se ha capturado.",
@@ -1207,7 +1205,6 @@
"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_body_features": "Listar operaciones del cuerpo",
"cmd.model_edges": "Listar aristas",
"cmd.model_faces": "Listar caras",
"cmd.model_threads": "Listar roscas",
@@ -1236,11 +1233,8 @@
"cmd.panel.dismiss": "Descartar vista previa de panel",
"cmd.panel.float": "Panel flotante",
"cmd.panel.focus": "Enfocar panel",
"cmd.panel.focus.next": "Enfocar el panel siguiente",
"cmd.panel.focus.previous": "Enfocar el panel anterior",
"cmd.panel.list": "Listar paneles",
"cmd.panel.move": "Mover panel",
"cmd.panel.move.next_zone": "Mover el panel a la zona siguiente",
"cmd.panel.native_close": "Volver a acoplar sección de panel nativo",
"cmd.panel.open": "Abrir panel",
"cmd.panel.peek": "Vista previa de panel colapsado",
@@ -1248,12 +1242,6 @@
"cmd.panel.reset_pair": "Restablecer divisor de panel",
"cmd.panel.resize": "Redimensionar sección de panel",
"cmd.panel.resize_pair": "Redimensionar divisor de panel",
"cmd.panel.sash.down": "Redimensionar el divisor del panel hacia abajo",
"cmd.panel.sash.left": "Redimensionar el divisor del panel hacia la izquierda",
"cmd.panel.sash.reset": "Restablecer el divisor del panel enfocado",
"cmd.panel.sash.right": "Redimensionar el divisor del panel hacia la derecha",
"cmd.panel.sash.up": "Redimensionar el divisor del panel hacia arriba",
"cmd.panel.toggle": "Alternar el panel enfocado",
"cmd.parameters_add": "Añadir parámetro de usuario",
"cmd.parameters_change": "Cambiar parámetros",
"cmd.parameters_comment": "Comentar parámetro",
@@ -1377,7 +1365,6 @@
"cmd.sketch.construction_line.tangent_external": "Tangente por punto externo",
"cmd.sketch.construction_line.tangent_two": "Tangente común",
"cmd.sketch.construction_line.vertical": "Línea de construcción vertical",
"cmd.sketch.replace": "Reemplazar boceto",
"cmd.sketch_arc3d": "Arco 3D",
"cmd.sketch_arc_center_point": "Arco de centro y punto",
"cmd.sketch_arc_tangent": "Arco tangente",
@@ -1509,7 +1496,6 @@
"cmd.timeline_stop": "Detener historial",
"cmd.timeline_suppress": "Suprimir operación",
"cmd.ui.assistant": "Alternar Asistente",
"cmd.ui.palette": "Buscar comandos",
"cmd.view.navigator": "Navegador de vistas personalizado",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Mover acople de render",
@@ -2029,7 +2015,6 @@
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
"history.sketch_under_constrained": "Bajo restricción ({count} grados de libertad)",
"history.sketch_under_constrained_one": "Bajo restricción ({count} grado de libertad)",
"hole_folder.applies_to": "Los cambios se aplican a los {count} agujeros de {folder}; cada uno conserva su posición.",
"hole_presets.confirm_delete": "Confirmar eliminación",
"hole_presets.delete": "Eliminar",
"hole_presets.delete_prompt": "Eliminar esta preconfiguración personal? Los agujeros existentes no cambian.",
@@ -2432,7 +2417,6 @@
"parts.dimension.width": "Anchura",
"parts.family.bearing": "Rodamiento rígido de bolas (envolvente)",
"parts.family.button_head_cap_screw": "Tornillo cabeza botón",
"parts.family.countersunk_socket_screw": "Tornillo avellanado con hexágono interior",
"parts.family.dowel_pin": "Pasador cilíndrico",
"parts.family.hex_bolt": "Tornillo de cabeza hexagonal",
"parts.family.hex_cap_screw_inch": "Tornillo de cabeza hexagonal (pulgadas)",
@@ -2531,8 +2515,6 @@
"prefs.bottom_centre": "Centro abajo",
"prefs.bottom_right": "Derecha abajo",
"prefs.browser": "Explorador",
"prefs.browser_body_features": "Operaciones bajo los cuerpos",
"prefs.browser_body_features_note": "Cada cuerpo del explorador se despliega sobre las operaciones de la línea de tiempo que lo construyeron, en el orden de la línea de tiempo.",
"prefs.browser_components_open": "Componentes abiertos al inicio",
"prefs.browser_components_open_note": "Cada componente del explorador empieza abierto, como antes, en lugar de cerrado hasta que lo abras.",
"prefs.browser_eye_left": "Ojo junto a la flecha de plegado",
@@ -2573,11 +2555,7 @@
"prefs.design_tools.degrees": "Grados",
"prefs.design_tools.double_click_help": "O para editar uno vinculado en su lugar.",
"prefs.design_tools.ghost": "Mostrar la posición anterior de las piezas movidas como un fantasma",
"prefs.design_tools.hole_group": "Agrupar agujeros creados juntos",
"prefs.design_tools.hole_group_help": "Los agujeros creados de una vez comparten una carpeta en el historial, cerrada al principio; ábrela para editar un agujero por separado.",
"prefs.design_tools.hole_moves_help": "Volver a seleccionar la cara mueve el agujero en lugar de eliminarlo.",
"prefs.design_tools.hole_several": "Varios agujeros en una cara",
"prefs.design_tools.hole_several_help": "Hacer clic en otro punto de una cara añade otro agujero; hacer clic en un agujero lo quita.",
"prefs.design_tools.hole_snaps_help": "El centro de una cara y los centros de sus aristas redondeadas.",
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
@@ -2659,8 +2637,6 @@
"prefs.history": "Historial",
"prefs.history_bar_groups": "Plegar la barra Historial en grupos",
"prefs.history_bar_groups_note": "En un historial largo, una serie de operaciones de un mismo componente se pliega en una ficha, y las carpetas de operaciones empiezan plegadas. Un ensamblaje STEP insertado va a una carpeta Grupo. Desactivado, se muestra cada operación, las carpetas empiezan abiertas y no se crea ninguna carpeta.",
"prefs.history_folder_kind_icon": "Icono de carpeta según sus operaciones",
"prefs.history_folder_kind_icon_note": "Una carpeta del historial cuyas operaciones son todas del mismo tipo muestra su icono.",
"prefs.history_group_joints": "Agrupar uniones en el Historial",
"prefs.history_group_joints_note": "Dos o más uniones se muestran como un botón con su número, y al hacer clic se muestra cada una. Desactivado, se muestra cada unión.",
"prefs.history_origin_bodies": "Mostrar origen y cuerpos al principio",
@@ -2766,7 +2742,6 @@
"prefs.origin_plane_names": "Nombres de los planos de origen",
"prefs.origin_plane_names_note": "Nombres que se muestran en el explorador, diálogos y listas. {top} es XY, {front} es XZ y {right} es YZ",
"prefs.overall_speed": "Velocidad general",
"prefs.panel_layout.recovery": "No se han podido restaurar las disposiciones de paneles guardadas. Los datos guardados se conservan; Clásico sigue disponible.",
"prefs.parameter_list_rows": "Filas de la lista de parámetros",
"prefs.parameter_list_rows_tip": "Número de filas visibles en la lista de parámetros",
"prefs.persistent_ribbon_pressed": "Resaltar comandos activos de la cinta",
@@ -2828,8 +2803,6 @@
"prefs.remember_dialog_values.help": "Desactivado de forma predeterminada. Si se activa, cuando un comando termina correctamente, sus valores y opciones seguros se reutilizan la próxima vez que abras el mismo comando, hasta que se cierre SolidCraft.",
"prefs.render_legacy_image_defaults": "Usar los valores de imagen anteriores de Render",
"prefs.render_legacy_image_defaults_help": "Mantiene el flujo manual anterior: las configuraciones nuevas usan rasterizado por CPU, el ray tracing por CPU conserva el muestreo fijo y los resultados renderizados se abren a mano. Al entrar en Render se reencuadra, se cambia de toma sin restaurar sus cámaras y se usa el encuadre y la navegación anteriores. Se desactiva la vista previa automática de la imagen en vivo. Las configuraciones guardadas no cambian.",
"prefs.render_legacy_memory_admission": "Usar las comprobaciones de memoria de renderizado anteriores",
"prefs.render_legacy_memory_admission_help": "Permite los renderizados nativos dentro de los presupuestos fijos de memoria compartida y del host aunque su estimación supere la RAM disponible. Los límites del dispositivo y las comprobaciones de asignación se siguen aplicando.",
"prefs.render_previous_geometry_budget": "Usar presupuesto de geometría previo de Render",
"prefs.render_previous_geometry_budget_help": "Restaurar el límite fijo previo (512 MiB en escritorio de 64 bits, 128 MiB en otros) en lugar del presupuesto basado en dispositivo.",
"prefs.reports_saved": "{n} guardados",
@@ -2856,8 +2829,6 @@
"prefs.select_other_hidden": "Seleccionar geometría oculta con Seleccionar otro",
"prefs.select_other_hidden_help": "Activado: incluir cuerpos, bocetos y aristas ocultas u ocluidas cerca del puntero. Desactivado: us",
"prefs.selecting": "Selección",
"prefs.shared_panel_layout": "Usar la disposición de paneles compartida",
"prefs.shared_panel_layout_help": "Muestra el explorador, el historial y el asistente existentes en paneles movibles. El cambio se aplica al aplicar las preferencias.",
"prefs.shift": "Mayús",
"prefs.shift_add_ctrl_toggle": "Mayús solo añade; Ctrl/Cmd alterna",
"prefs.shift_add_note": "Activado: Mayús añade y Ctrl/Cmd alterna. Desactivado (predeterminado): ambos alternan.",
@@ -2880,8 +2851,6 @@
"prefs.sketch.drawing": "Dibujo",
"prefs.sketch.ellipse_axes": "Dibujar elipses con sus ejes",
"prefs.sketch.ellipse_axes_note": "Los ejes mayor y menor como líneas de construcción, para poder acotar los semiejes y los ejes. Desactivado: solo la elipse, y al pulsar D sobre ella se da su radio menor allí donde se arrastre el valor, dibujado y etiquetado como R, como antes.",
"prefs.sketch.escape_twice_finishes": "Doble Esc finaliza el boceto",
"prefs.sketch.escape_twice_finishes_note": "Dos pulsaciones rápidas de Esc finalizan el boceto. Desactivado: Esc solo cancela la herramienta o la selección, y el boceto solo se finaliza con Finalizar boceto.",
"prefs.sketch.exit_keep": "Mantener",
"prefs.sketch.exit_previous": "Volver a la vista anterior",
"prefs.sketch.exit_unless_rotated": "Salvo que se haya girado",
@@ -3224,10 +3193,8 @@
"render.category_paint": "Pintura",
"render.category_plastic": "Plástico",
"render.category_wood": "Madera",
"render.caustics_area_lights": "Cáusticas de luces de área",
"render.caustics_depth": "Profundidad de trayectoria de cáusticas",
"render.caustics_enable": "Cáusticas de vidrio con luces puntuales y direccionales",
"render.caustics_environment": "Cáusticas del entorno",
"render.caustics_help": "El número de fotones controla el ruido y el trabajo. El radio de captación cambia detalle nítido por una estimación más suave. La CPU requiere iluminación indirecta.",
"render.caustics_path_required": "Las cáusticas por CPU requieren iluminación indirecta.",
"render.caustics_photons": "Fotones",
@@ -3404,7 +3371,7 @@
"render.cpu_opaque_required": "El trazado CPU requiere un fondo opaco. Desactiva Fondo transparente para continuar.",
"render.cpu_options_pending": "Este panel CPU todavía no puede editar emisión, efectos de superficie, vidrio ni anisotropía. Borra esas opciones de la API antes de usarlo.",
"render.cpu_path_depth": "Profundidad de trayectoria",
"render.cpu_path_help": "Añade rebotes de luz entre superficies opacas y a través de formas de vidrio admitidas. Requiere renderizado progresivo y sombras trazadas. La profundidad de trayectoria cuenta superficies sombreadas, límites de vidrio y eventos de dispersión, empezando por el primer impacto (1–64). La profundidad 1 da iluminación directa sobre superficies opacas; los valores mayores permiten más rebotes o cruces de vidrio y pueden tardar más. Desactiva la iluminación indirecta para usar reflexiones personalizadas o anisotropía.",
"render.cpu_path_help": "Añade rebotes de luz entre superficies opacas y a través de formas de vidrio admitidas. Requiere renderizado progresivo y sombras trazadas. La profundidad de trayectoria cuenta superficies sombreadas, límites de vidrio y eventos de dispersión, empezando por el primer impacto (1–16). La profundidad 1 da iluminación directa sobre superficies opacas; los valores mayores permiten más rebotes o cruces de vidrio y pueden tardar más. Desactiva la iluminación indirecta para usar reflexiones personalizadas o anisotropía.",
"render.cpu_path_lighting": "Iluminación indirecta",
"render.cpu_path_optics_reason": "Se conservan tus ajustes. Desactiva la iluminación indirecta para usar reflexiones personalizadas o anisotropía.",
"render.cpu_point": "Luz puntual",
@@ -3444,12 +3411,6 @@
"render.cpu_subsurface_denoise_reason": "La dispersión subsuperficial por CPU necesita la reducción de ruido desactivada. Se conservan tus ajustes.",
"render.cpu_subsurface_help": "La dispersión redirige la luz dentro del vidrio cerrado. Las tasas positivas necesitan iluminación indirecta con la reducción de ruido desactivada. La tasa es de colisiones por mm; 0 no añade dispersión. Un sesgo de fase positivo favorece las direcciones hacia delante; uno negativo, las hacia atrás.",
"render.cpu_summary": "Trazado CPU · reflejos · HDR · luces direccionales y puntuales",
"render.cpu_threads": "Procesos de CPU",
"render.cpu_threads_auto": "Automático",
"render.cpu_threads_custom": "Personalizado",
"render.cpu_threads_help": "Automático usa los procesos de CPU disponibles. Un proceso se ejecuta en serie. Más procesos pueden necesitar más memoria.",
"render.cpu_threads_single": "Uno",
"render.cpu_threads_web": "El renderizado en el navegador usa un proceso cooperativo. Se conserva la elección de procesos guardada para el escritorio.",
"render.cpu_transparent_help": "El PNG transparente quita el fondo y conserva la cobertura de los bordes. La opacidad del cuerpo y de la cara debe mantenerse al 100 %.",
"render.cpu_volume_enable_path": "Activar iluminación indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing en CPU · profundidad {depth} · {spp} muestras/píxel · semilla {seed} · sombras trazadas. Conserva la atmósfera definida y el presupuesto de imagen.",
@@ -3458,40 +3419,6 @@
"render.cpu_volume_method_incompatible": "Estos ajustes de luz, muestreo o atmósfera no se pueden transferir a este método. Se conservan tus ajustes.",
"render.cpu_volume_path_required": "Activa la iluminación indirecta para usar la atmósfera por CPU.",
"render.cpu_warm_cool": "Cálido y frío",
"render.denoise_class_background": "Omitido: fondo",
"render.denoise_class_coverage": "Omitido: cobertura parcial",
"render.denoise_class_degenerate": "Omitido: trayectoria degenerada",
"render.denoise_class_depth": "Omitido: límite de profundidad de trayectoria",
"render.denoise_class_emitter": "Omitido: emisor de luz",
"render.denoise_class_exit": "Omitido: salida incompatible",
"render.denoise_class_identity": "Omitido: límite de identidad de guía",
"render.denoise_class_lens": "Omitido: profundidad de campo",
"render.denoise_class_material": "Omitido: material no admitido",
"render.denoise_class_nested": "Omitido: medio anidado",
"render.denoise_class_optical": "Elegible: superficie refractada",
"render.denoise_class_reflection": "Omitido: punto final reflectante",
"render.denoise_class_surface": "Elegible: superficie",
"render.denoise_class_tir": "Omitido: reflexión total interna",
"render.denoise_class_transmission": "Omitido: transmisión adicional",
"render.denoise_class_unavailable": "Omitido: sin guía",
"render.denoise_class_volume": "Omitido: dispersión de volumen",
"render.denoise_diagnostics_counts": "Elegibles: {eligible} · Omitidos: {skipped}",
"render.denoise_diagnostics_enable": "Recopilar diagnóstico de reducción de ruido",
"render.denoise_diagnostics_grid": "Recuentos de píxeles con el tamaño interno {width} × {height}.",
"render.denoise_diagnostics_help": "Recopila los recuentos de píxeles elegibles y omitidos incluso con la reducción de ruido desactivada. Los recuentos usan el tamaño interno de renderizado; la máscara usa el tamaño de salida.",
"render.denoise_diagnostics_title": "Diagnóstico de reducción de ruido",
"render.denoise_export_mask": "Exportar máscara PNG",
"render.denoise_firefly": "Suprimir los píxeles brillantes aislados",
"render.denoise_format_mask": "Máscara PNG",
"render.denoise_mask_copy_help": "Exporta la máscara de reducción de ruido como PNG o vuelve a la imagen renderizada para copiarla.",
"render.denoise_mask_unavailable": "Activa el diagnóstico de reducción de ruido y renderiza una imagen para inspeccionar su máscara.",
"render.denoise_optical_filter": "Filtrar espejos y vidrio",
"render.denoise_optical_filter_help": "Filtra la luz reflejada y transmitida elegible. Desactívalo para reducir el ruido solo en superficies opacas.",
"render.denoise_passes": "Pasadas del filtro",
"render.denoise_settings_help": "Más pasadas suavizan un área mayor. La intensidad controla cuánto filtra cada pasada. Las trayectorias ópticas no admitidas no se filtran.",
"render.denoise_strength": "Intensidad de la reducción de ruido",
"render.denoise_view_image": "Imagen renderizada",
"render.denoise_view_mask": "Máscara de reducción de ruido",
"render.diag_bodies": "Cuerpos visibles",
"render.diag_job_details": "Detalles del trabajo capturado",
"render.diag_last_check": "Última comprobación",
@@ -4046,8 +3973,6 @@
"render.material_transient": "Ajustes ópticos temporales de esta pestaña. No se guardan en el diseño, la configuración de render ni la biblioteca de materiales.",
"render.material_v_up": "La coordenada V apunta hacia arriba en la imagen",
"render.material_weight": "Peso del recubrimiento (0–1)",
"render.metal_energy_compensation": "Recuperar la reflectancia del metal rugoso",
"render.metal_energy_compensation_help": "Compensa la luz perdida entre las microfacetas del metal rugoso. El renderizado por CPU necesita iluminación indirecta.",
"render.method_cpu": "Trazado CPU",
"render.method_gpu": "Trazado GPU",
"render.method_limit_raster": "Raster utiliza la vista opaca de estudio fija. Selecciona un método de trazado de rayos para los controles ópticos compatibles.",
@@ -4176,8 +4101,6 @@
"render.quality_denoise_on": "Reducción de ruido: Activada",
"render.quality_draft": "Boceto",
"render.quality_high": "Alto",
"render.quality_legacy_samples": "Valor guardado: {samples} spp. Las selecciones nuevas se limitan a {maximum} spp.",
"render.quality_preset_limit": "Este preajuste supera el límite de selección de 16384 spp. Elige un número de muestras de hasta 16384.",
"render.quality_projection_help": "Esta revisión solo cambia la resolución, el presupuesto de muestras, la profundidad de trayectoria, la semilla y la reducción de ruido. Los demás ajustes no cambian; el presupuesto no certifica la convergencia.",
"render.quality_review": "Revisar presupuesto de muestras",
"render.quality_summary": "{width} × {height} · {samples} muestras · profundidad {depth} · semilla {seed} · {denoise}",
@@ -4325,7 +4248,6 @@
"render.show_shots_label": "Mostrar tira de tomas",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Personalizado…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4488,11 +4410,6 @@
"render.subsurface_enable": "Recorrido aleatorio de dispersión interna",
"render.subsurface_help": "Solo dieléctricos cerrados. Dispersión y absorción RGB son coeficientes separados por mm. Las trayectorias se dispersan dentro de la geometría y se refractan o reflejan en la frontera. Desactiva sombras rectas, reducción de ruido de superficies y volumen exterior; usa un estrato Fresnel. No es un filtro de difusión, rejilla de densidad ni material medido. Los medios densos pueden requerir más muestras y profundidad.",
"render.subsurface_rate": "Tasa de colisiones de dispersión",
"render.supersampling": "Supermuestreo",
"render.supersampling_dimensions": "Renderiza {width} × {height} y reduce a {output_width} × {output_height}. Usa el cuádruple de píxeles.",
"render.supersampling_help": "Renderiza al doble de ancho y alto y luego reduce en luz lineal. El tamaño de salida no cambia.",
"render.supersampling_off": "Desactivado",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Importando imagen…",
"render.surface_import.complete": "Elige una imagen e indica su autor, fuente y licencia.",
@@ -4506,8 +4423,6 @@
"render.surface_import.saved_cpu": "Guarda los mapas para usarlos con renderizado por CPU.",
"render.surface_import.source": "Fuente",
"render.surface_import.title": "Importar imagen",
"render.texture_filtering": "Filtrar el detalle de las texturas",
"render.texture_filtering_help": "Usa niveles mip y filtrado anisotrópico para reducir el aliasing de las texturas al reducirlas. Consume más memoria; desactivado mantiene el filtrado anterior.",
"render.tone_aces_fitted": "ACES ajustado",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "El vidrio GPU admite medios separados certificados. No se admiten medios anidados sin restricciones ni un estimador de transmisión sin ruido.",
@@ -5602,7 +5517,6 @@
"ui.heading_plastic": "PLÁSTICO",
"ui.heading_position": "POSICIÓN",
"ui.heading_prepare": "PREPARAR",
"ui.heading_press_pull": "PULSAR/TIRAR",
"ui.heading_rectangular_pattern": "PATRÓN RECTANGULAR",
"ui.heading_revolve": "REVOLUCIÓN",
"ui.heading_right": "DERECHA",
@@ -5845,42 +5759,6 @@
"ui.panel_host.surface": "Panel",
"ui.panel_host.tab": "Pestaña del panel",
"ui.panel_host.tabs": "Todas las pestañas",
"ui.panel_layout.applied": "Se ha aplicado {name} a {workspace}",
"ui.panel_layout.cancel": "Cancelar",
"ui.panel_layout.confirm_replace": "¿Reemplazar «{name}»?",
"ui.panel_layout.copy": "Copiar",
"ui.panel_layout.current": "Disposición actual",
"ui.panel_layout.custom": "Personalizada",
"ui.panel_layout.custom_hint": "Se han movido paneles desde {name}.",
"ui.panel_layout.details": "Detalles",
"ui.panel_layout.layout_workspace": "Espacio de trabajo de disposición",
"ui.panel_layout.page_defaults_hint": "Restablece los ajustes de esta página. La organización de los paneles y las disposiciones guardadas se conservan.",
"ui.panel_layout.panel_layout": "Disposición de paneles",
"ui.panel_layout.panel_style": "Estilo de paneles",
"ui.panel_layout.replace": "Reemplazar disposición",
"ui.panel_layout.replace_current": "Reemplazar con la actual",
"ui.panel_layout.replace_short": "Reemplazar",
"ui.panel_layout.reset": "Restablecer espacio de trabajo",
"ui.panel_layout.reset_hint": "Restablece {workspace} a su disposición predeterminada. Las disposiciones guardadas se conservan.",
"ui.panel_layout.reset_short": "Restablecer",
"ui.panel_layout.restore_failed": "No se ha podido restaurar tu disposición",
"ui.panel_layout.save": "Guardar disposición",
"ui.panel_layout.save_as": "Guardar como",
"ui.panel_layout.save_as_tile": "Guardar como…",
"ui.panel_layout.selected": "seleccionada",
"ui.panel_layout.suggested_name": "Disposición {n}",
"ui.panel_layout.try_again": "Reintentar",
"ui.panel_layout.validation.builtin": "Las disposiciones integradas no se pueden cambiar",
"ui.panel_layout.validation.duplicate": "Ya tienes este nombre",
"ui.panel_layout.validation.empty": "Introduce un nombre",
"ui.panel_layout.validation.full": "64 de 64 guardadas. Elige una para reemplazarla.",
"ui.panel_layout.validation.invalid": "Usa de 1 a 64 caracteres, sin caracteres de control",
"ui.panel_layout.workspace.classic": "Clásico",
"ui.panel_layout.workspace.modelling": "Modelado",
"ui.panel_layout.workspace.render": "Renderizado",
"ui.panel_layout.workspace.review": "Revisión",
"ui.panel_layout.workspace.sketch": "Boceto",
"ui.panel_layout.your_layout": "tu disposición",
"ui.parallel": "Paralelo",
"ui.parameters": "Parámetros",
"ui.part_size_needs_a_part": "Seleccione una pieza normalizada insertada (un tornillo, una tuerca, una arandela o un pasador) para cambiar su tamaño. Para agrandar o reducir un cuerpo, use Escala.",
@@ -5946,8 +5824,6 @@
"ui.profiles": "Perfiles",
"ui.project": "Proyectar",
"ui.project_face_edges_sketch": "Proyectar aristas de caras en bocetos nuevos",
"ui.project_references_construction": "Proyectar referencias como geometría de construcción",
"ui.project_references_construction_note": "Proyectar, Intersecar, Incluir geometría 3D y las aristas de cara de un boceto nuevo sobre una cara crean geometría de construcción: referencias sobre las que dibujar, excluidas de los perfiles. Siguen vinculadas al modelo.",
"ui.properties": "Propiedades",
"ui.properties.copy_matrix": "Copiar matriz",
"ui.properties.first_moments": "Primeros momentos",
@@ -5991,8 +5867,6 @@
"ui.renderer": "Motor de renderizado",
"ui.repeat": "Repetir",
"ui.repeat_nothing": "Nada que repetir aún: inicia una herramienta o un diálogo primero.",
"ui.replace_sketch.from_file": "Desde un archivo de diseño…",
"ui.replace_sketch.no_sketches": "Ese diseño no tiene ningún boceto que reutilizar",
"ui.replace_with_link_done": "Las copias ya son colocaciones del diseño vinculado.",
"ui.report_a_bug": "Notificar un error",
"ui.report_characters_left_one": "Queda {count} carácter",
+1 -59
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.ui.palette": "חיפוש פקודות",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "רוחב",
"parts.family.bearing": "מסב כדורי חריץ עמוק (מעטפת)",
"parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה",
"parts.family.countersunk_socket_screw": "בורג ראש שקוע עם שקע משושה",
"parts.family.dowel_pin": "פין גלילי",
"parts.family.hex_bolt": "בורג ראש משושה",
"parts.family.hex_cap_screw_inch": "בורג ראש משושה (אינץ')",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "כבוי כברירת מחדל. כשהוא פועל, לאחר הצלחת פקודה, הערכים והאפשרויות הבטוחים שלה ישמשו שוב בפעם הבאה שתפתח את אותה פקודה, עד לסגירת SolidCraft.",
"prefs.render_legacy_image_defaults": "שימוש בברירות המחדל הקודמות לתמונות הדמיה",
"prefs.render_legacy_image_defaults_help": "שמירה על תהליך העבודה הידני הקודם: הגדרות חדשות ברסטר CPU, דגימה קבועה בעקיבת קרניים ב־CPU ופתיחת התוצאות ידנית. התאמת התצוגה מחדש בכניסה להדמיה, מעבר בין צילומים בלי לשחזר את המצלמות שלהם ושימוש במסגור ובניווט הקודמים. עדכון אוטומטי של התמונה החיה מושבת. הגדרות שמורות אינן משתנות.",
"prefs.render_legacy_memory_admission": "שימוש בבדיקות הזיכרון הקודמות לרינדור",
"prefs.render_legacy_memory_admission_help": "מאפשר רינדור מקומי במסגרת מגבלות הזיכרון הקבועות המשותפות ושל המארח, גם כשההערכה חורגת מהזיכרון הזמין. מגבלות המכשיר ובדיקות הקצאת הזיכרון עדיין חלות.",
"prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור",
"prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.",
"prefs.reports_saved": "{n} שמורים",
@@ -3224,10 +3220,8 @@
"render.category_paint": "צבע",
"render.category_plastic": "פלסטיק",
"render.category_wood": "עץ",
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
"render.caustics_depth": "עומק מסלול ריכוז האור",
"render.caustics_enable": "ריכוז אור דרך זכוכית ממקורות נקודתיים וכיווניים",
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
"render.caustics_help": "מספר הפוטונים שולט ברעש ובעומס החישוב. רדיוס האיסוף מאזן בין פרטים חדים לאומדן חלק יותר. CPU דורש תאורה עקיפה.",
"render.caustics_path_required": "ריכוז אור ב-CPU דורש תאורה עקיפה.",
"render.caustics_photons": "מספר פוטונים",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "מעקב קרניים ב־CPU דורש רקע אטום. יש לבטל את „רקע שקוף” כדי להמשיך.",
"render.cpu_options_pending": "לוח CPU זה עדיין אינו יכול לערוך פליטה, אפקטי משטח, זכוכית או אנאיזוטרופיה. יש לנקות את אפשרויות ה־API האלה לפני השימוש בו.",
"render.cpu_path_depth": "עומק מסלול",
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_path_lighting": "תאורה עקיפה",
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_point": "אור נקודתי",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "פיזור בתוך זכוכית ב־CPU דורש הסרת רעש כבויה. ההגדרות נשמרות.",
"render.cpu_subsurface_help": "פיזור משנה את כיוון האור בתוך זכוכית סגורה. קצב חיובי דורש תאורה עקיפה והסרת רעש כבויה. הקצב הוא מספר התנגשויות למ״מ; 0 אינו מוסיף פיזור. הטיית כיוון חיובית מעדיפה קדימה, ושלילית אחורה.",
"render.cpu_summary": "מעקב קרניים ב־CPU · החזרות · HDR · אורות כיווניים ונקודתיים",
"render.cpu_threads": "תהליכוני CPU",
"render.cpu_threads_auto": "אוטומטי",
"render.cpu_threads_custom": "מותאם",
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
"render.cpu_threads_single": "יחיד",
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
"render.cpu_transparent_help": "PNG שקוף מסיר את הרקע ושומר על כיסוי הקצוות. אטימות הגופים והפאות חייבת להישאר 100%.",
"render.cpu_volume_enable_path": "הפעלת תאורה עקיפה",
"render.cpu_volume_enable_path_plan": "מעקב קרניים ב־CPU · עומק מסלול {depth} · {spp} דגימות לפיקסל · זרע {seed} · צללים במעקב קרניים. האטמוספרה ותקציב התמונה שנקבעו נשמרים.",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "לא ניתן להעביר הגדרות תאורה, דגימה או אטמוספרה אלה לשיטה זו. ההגדרות שלך נשמרות.",
"render.cpu_volume_path_required": "יש להפעיל תאורה עקיפה כדי להשתמש באטמוספרה ב־CPU.",
"render.cpu_warm_cool": "חם וקר",
"render.denoise_class_background": "דולג: רקע",
"render.denoise_class_coverage": "דולג: כיסוי חלקי",
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
"render.denoise_class_emitter": "דולג: מקור אור",
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
"render.denoise_class_lens": "דולג: עומק שדה",
"render.denoise_class_material": "דולג: חומר לא נתמך",
"render.denoise_class_nested": "דולג: תווך מקונן",
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
"render.denoise_class_surface": "מתאים: משטח",
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
"render.denoise_class_transmission": "דולג: העברה נוספת",
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
"render.denoise_class_volume": "דולג: פיזור נפחי",
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
"render.denoise_format_mask": "מסכה PNG",
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
"render.denoise_optical_filter": "סינון מראות וזכוכית",
"render.denoise_optical_filter_help": "סינון אור מוחזר ומועבר שמתאים לסינון. כיבוי האפשרות מגביל את הסרת הרעש למשטחים אטומים.",
"render.denoise_passes": "מעברי סינון",
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
"render.denoise_strength": "עוצמת הפחתת רעש",
"render.denoise_view_image": "תמונה מרונדרת",
"render.denoise_view_mask": "מסכת הפחתת רעש",
"render.diag_bodies": "גופים גלויים",
"render.diag_job_details": "פרטי המשימה שנלכדו",
"render.diag_last_check": "בדיקה מקדימה אחרונה",
@@ -4046,8 +4000,6 @@
"render.material_transient": "דריסות אופטיות זמניות ללשונית המסמך הזאת. הן אינן נשמרות בתכן, בהגדרת ההדמיה או בספריית החומרים.",
"render.material_v_up": "כיוון V של הטקסטורה מצביע מעלה בתמונה",
"render.material_weight": "משקל הציפוי (0–1)",
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
"render.method_cpu": "מעקב קרניים ב־CPU",
"render.method_gpu": "מעקב מסלולים ב־GPU",
"render.method_limit_raster": "רסטר משתמש בתצוגה המקדימה הקבועה של הסטודיו האטום. לפקדים האופטיים הנתמכים יש לבחור שיטת מעקב קרניים מפורשת.",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "הפחתת רעש: פעילה",
"render.quality_draft": "טיוטה",
"render.quality_high": "גבוהה",
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
"render.quality_projection_help": "בדיקה זו משנה רק רזולוציה, תקציב דגימות, עומק נתיב, זרע והפחתת רעש. ההגדרות האחרות נשמרות; התקציב אינו מעיד על התכנסות התמונה.",
"render.quality_review": "סקירת תקציב הדגימה",
"render.quality_summary": "{width} × {height} · {samples} דגימות · עומק {depth} · זרע {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "הצג את סרגל הצילומים",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "מותאם…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "הילוך אקראי לפיזור פנימי",
"render.subsurface_help": "לדיאלקטרי סגור בלבד. פיזור ובליעת RGB הם מקדמים נפרדים לכל mm. המסלולים מתפזרים בתוך הגאומטריה בפועל, ואז נשברים או מוחזרים בגבול. יש לבטל העברת צל ישרה, הסרת רעש משטחית ונפח חיצוני; ולהשתמש בשכבת פרנל אחת. אינו שיידר דיפוזיה, אינו רשת צפיפות ואינו חומר מדוד. תווך צפוף עשוי לדרוש יותר דגימות ועומק מסלול.",
"render.subsurface_rate": "קצב התנגשויות הפיזור",
"render.supersampling": "דגימת יתר",
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
"render.supersampling_off": "כבוי",
"render.supersampling_x2": "2×",
"render.surface_import.author": "יוצר",
"render.surface_import.busy": "מתבצע ייבוא תמונה…",
"render.surface_import.complete": "בחר תמונה והזן את היוצר, המקור והרישיון שלה.",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "יש לשמור את המפות לשימוש בהדמיה ב־CPU.",
"render.surface_import.source": "מקור",
"render.surface_import.title": "ייבוא תמונה",
"render.texture_filtering": "סינון פרטי מרקם",
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם.",
"render.tone_aces_fitted": "ACES מותאם",
"render.tone_reinhard": "ריינהרד",
"render.transmission_scope": "זכוכית GPU תומכת בתווכים נפרדים מאושרים. תווכים מקוננים ללא הגבלה ומעריך העברה ללא רעש אינם מסופקים.",
File diff suppressed because it is too large Load Diff
+1 -59
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.ui.palette": "コマンドを検索",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "幅",
"parts.family.bearing": "深溝玉軸受(外形)",
"parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト",
"parts.family.countersunk_socket_screw": "六角穴付き皿ボルト",
"parts.family.dowel_pin": "平行ピン",
"parts.family.hex_bolt": "六角ボルト",
"parts.family.hex_cap_screw_inch": "六角ボルト(インチ)",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "既定ではオフです。オンにすると、コマンドが成功した後、SolidCraft を閉じるまで、同じコマンドを次に開いたときに安全な値とオプションを再利用します。",
"prefs.render_legacy_image_defaults": "以前のレンダー画像の既定値を使用",
"prefs.render_legacy_image_defaults_help": "以前の手動の画像ワークフローを使用します。新しいセットアップはCPUラスタ、CPUレイトレーシングは固定サンプリングとし、結果は手動で開きます。レンダーに入るたびにビューをフィットし、ショットを切り替えても保存カメラを復元せず、以前のフレーミングとナビゲーションを使用します。自動のライブ画像更新は無効にします。保存済みのセットアップは変更しません。",
"prefs.render_legacy_memory_admission": "以前のレンダリング用メモリチェックを使用",
"prefs.render_legacy_memory_admission_help": "推定使用量が使用可能なRAMを超えていても、共有およびホストの固定メモリ上限内でネイティブレンダリングを許可します。デバイスの制限とメモリ割り当てのチェックは引き続き適用されます。",
"prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用",
"prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。",
"prefs.reports_saved": "{n} 件保存済み",
@@ -3224,10 +3220,8 @@
"render.category_paint": "塗装",
"render.category_plastic": "プラスチック",
"render.category_wood": "木材",
"render.caustics_area_lights": "面光源のコースティクス",
"render.caustics_depth": "集光経路の深さ",
"render.caustics_enable": "点光源・平行光源によるガラスの集光",
"render.caustics_environment": "環境光のコースティクス",
"render.caustics_help": "光子数はノイズと計算量を調整します。収集半径を広げると細部より滑らかさを優先します。CPUでは間接照明が必要です。",
"render.caustics_path_required": "CPUの集光には間接照明が必要です。",
"render.caustics_photons": "光子数",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "CPU レイトレーシングには不透明な背景が必要です。透明な背景を解除してください。",
"render.cpu_options_pending": "このCPUパネルでは、発光、表面効果、ガラス、異方性はまだ編集できません。使用前にそれらのAPIオプションを解除してください。",
"render.cpu_path_depth": "パス深度",
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_path_lighting": "間接照明",
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_point": "点光源",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "CPUの内部散乱ではノイズ除去をオフにしてください。設定値は保持されます。",
"render.cpu_subsurface_help": "散乱は閉じたガラスの内部で光の方向を変えます。正の値には間接照明を使い、ノイズ除去をオフにします。率は1mmあたりの衝突回数です。0では散乱を加えません。方向の偏りが正なら前方、負なら後方を優先します。",
"render.cpu_summary": "CPUレイトレーシング・反射・HDR・平行光と点光源",
"render.cpu_threads": "CPU ワーカー",
"render.cpu_threads_auto": "自動",
"render.cpu_threads_custom": "カスタム",
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
"render.cpu_threads_single": "1 ワーカー",
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
"render.cpu_transparent_help": "透明PNGは背景を除き、輪郭の被覆率を保持します。ボディと面の不透明度は100%にしてください。",
"render.cpu_volume_enable_path": "間接照明を有効にする",
"render.cpu_volume_enable_path_plan": "CPUレイトレーシング · パス深度{depth} · {spp}サンプル/ピクセル · シード{seed} · レイトレースシャドウ。大気と画像予算の設定を保持します。",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "この照明、サンプリング、大気の設定はこの方式に引き継げません。設定は保持されます。",
"render.cpu_volume_path_required": "CPUの大気を使うには間接照明を有効にしてください。",
"render.cpu_warm_cool": "暖色と寒色",
"render.denoise_class_background": "対象外: 背景",
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ",
"render.denoise_class_degenerate": "対象外: 退化した経路",
"render.denoise_class_depth": "対象外: 経路深度の上限",
"render.denoise_class_emitter": "対象外: 発光体",
"render.denoise_class_exit": "対象外: 不整合な出射",
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
"render.denoise_class_lens": "対象外: 被写界深度",
"render.denoise_class_material": "対象外: 未対応のマテリアル",
"render.denoise_class_nested": "対象外: 入れ子の媒質",
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
"render.denoise_class_reflection": "対象外: 反射する終点",
"render.denoise_class_surface": "対象: サーフェス",
"render.denoise_class_tir": "対象外: 全反射",
"render.denoise_class_transmission": "対象外: 追加の透過",
"render.denoise_class_unavailable": "対象外: ガイドなし",
"render.denoise_class_volume": "対象外: ボリューム散乱",
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
"render.denoise_diagnostics_title": "ノイズ除去の診断",
"render.denoise_export_mask": "マスク PNG を書き出す",
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
"render.denoise_format_mask": "マスク PNG",
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
"render.denoise_optical_filter": "鏡とガラスをフィルター処理",
"render.denoise_optical_filter_help": "対象となる反射光と透過光にフィルターを適用します。オフにすると、不透明な表面のみのノイズを除去します。",
"render.denoise_passes": "フィルターパス数",
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
"render.denoise_strength": "ノイズ除去の強度",
"render.denoise_view_image": "レンダリング画像",
"render.denoise_view_mask": "ノイズ除去マスク",
"render.diag_bodies": "表示ボディ数",
"render.diag_job_details": "記録されたジョブの詳細",
"render.diag_last_check": "前回の事前確認",
@@ -4046,8 +4000,6 @@
"render.material_transient": "この文書タブの一時的な光学設定です。設計、レンダー設定、マテリアルライブラリには保存されません。",
"render.material_v_up": "テクスチャV軸は画像の上向き",
"render.material_weight": "コートの重み(0~1)",
"render.metal_energy_compensation": "粗い金属の反射を補正",
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
"render.method_cpu": "CPU レイトレーシング",
"render.method_gpu": "GPU パストレーシング",
"render.method_limit_raster": "ラスターは固定の不透明スタジオプレビューを使用します。対応する光学設定には明示的なレイトレーシング方式を選択してください。",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "ノイズ低減:オン",
"render.quality_draft": "ドラフト",
"render.quality_high": "高品質",
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
"render.quality_projection_help": "この確認で変更するのは、解像度、サンプル予算、パス深度、シード、ノイズ低減だけです。他の設定は維持され、収束は保証されません。",
"render.quality_review": "サンプリング予算を確認",
"render.quality_summary": "{width} × {height} · {samples} サンプル · 深度 {depth} · シード {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "ショットストリップを表示",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "カスタム…",
"render.size_full_hd": "フルHD",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "内部散乱ランダムウォーク",
"render.subsurface_help": "閉じた誘電体専用。散乱とRGB吸収は別々の毎mm係数です。実際の形状内で散乱し、境界で屈折・反射します。直線透過影、表面ノイズ除去、外部ボリュームを無効にし、フレネル層数を1にします。拡散シェーダー、密度グリッド、実測物性ではありません。高密度ではサンプル数と深度が必要です。",
"render.subsurface_rate": "散乱衝突率",
"render.supersampling": "スーパーサンプリング",
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
"render.supersampling_off": "オフ",
"render.supersampling_x2": "2×",
"render.surface_import.author": "作者",
"render.surface_import.busy": "画像をインポート中…",
"render.surface_import.complete": "画像を選び、作者、出典、ライセンスを入力してください。",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "CPUレンダリングで使うにはマップを保存してください。",
"render.surface_import.source": "出典",
"render.surface_import.title": "画像をインポート",
"render.texture_filtering": "テクスチャの細部をフィルタリング",
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。",
"render.tone_aces_fitted": "ACES近似",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPUガラスは検証された非交差媒体に対応します。無制限の入れ子媒体やノイズのない透過推定器は提供しません。",
+1 -58
View File
@@ -1252,7 +1252,6 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.ui.palette": "명령 검색",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
@@ -2130,8 +2129,6 @@
"prefs.remember_dialog_values.help": "기본값은 꺼짐입니다. 켜면 명령이 성공한 후 SolidCraft를 닫을 때까지 같은 명령을 다음에 열 때 안전한 값과 옵션을 다시 사용합니다.",
"prefs.render_legacy_image_defaults": "이전 렌더 이미지 기본값 사용",
"prefs.render_legacy_image_defaults_help": "이전의 수동 이미지 작업 방식을 유지합니다. 새 설정은 CPU 래스터를 사용하고 CPU 광선 추적은 고정 샘플링을 사용하며 결과는 직접 엽니다. 렌더에 들어갈 때 뷰를 다시 맞추고, 샷을 전환해도 저장된 카메라를 복원하지 않으며, 이전의 프레이밍과 탐색 방식을 사용합니다. 자동 실시간 이미지 피드백을 끕니다. 저장된 설정은 변경하지 않습니다.",
"prefs.render_legacy_memory_admission": "이전 렌더링 메모리 검사 사용",
"prefs.render_legacy_memory_admission_help": "예상 사용량이 사용 가능한 RAM을 초과해도 고정된 공유 및 호스트 메모리 한도 내에서 네이티브 렌더링을 허용합니다. 장치 제한과 메모리 할당 검사는 계속 적용됩니다.",
"prefs.reports_saved": "{n}개 저장됨",
"prefs.restore_page_defaults": "페이지 기본값 복원",
"prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료",
@@ -2494,10 +2491,8 @@
"render.category_paint": "페인트",
"render.category_plastic": "플라스틱",
"render.category_wood": "목재",
"render.caustics_area_lights": "면광원 집광 효과",
"render.caustics_depth": "집광 경로 깊이",
"render.caustics_enable": "점광원 및 방향광의 유리 집광",
"render.caustics_environment": "환경광 집광 효과",
"render.caustics_help": "광자 수는 노이즈와 계산량을 조절합니다. 수집 반경은 세밀한 표현과 부드러운 추정 사이를 조절합니다. CPU에는 간접 조명이 필요합니다.",
"render.caustics_path_required": "CPU 집광에는 간접 조명이 필요합니다.",
"render.caustics_photons": "광자 수",
@@ -2674,7 +2669,7 @@
"render.cpu_opaque_required": "CPU 레이 트레이싱에는 불투명 배경이 필요합니다. 계속하려면 투명 배경을 해제하세요.",
"render.cpu_options_pending": "이 CPU 패널은 아직 발광, 표면 효과, 유리, 이방성을 편집할 수 없습니다. 사용하기 전에 해당 API 옵션을 해제하세요.",
"render.cpu_path_depth": "경로 깊이",
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_path_lighting": "간접 조명",
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_point": "점 조명",
@@ -2714,12 +2709,6 @@
"render.cpu_subsurface_denoise_reason": "CPU 내부 산란을 사용하려면 노이즈 제거를 끄세요. 설정은 유지됩니다.",
"render.cpu_subsurface_help": "산란은 닫힌 유리 내부에서 빛의 방향을 바꿉니다. 양수 비율에는 간접광을 사용하고 노이즈 제거를 끄세요. 비율은 mm당 충돌 횟수이며, 0은 산란을 추가하지 않습니다. 방향 편향이 양수이면 전방, 음수이면 후방을 선호합니다.",
"render.cpu_summary": "CPU 레이 트레이싱 · 반사 · HDR · 방향성 및 점 조명",
"render.cpu_threads": "CPU 작업 스레드",
"render.cpu_threads_auto": "자동",
"render.cpu_threads_custom": "사용자 지정",
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
"render.cpu_threads_single": "단일",
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
"render.cpu_transparent_help": "투명 PNG는 배경을 제거하고 가장자리 커버리지를 유지합니다. 바디와 면의 불투명도는 100%여야 합니다.",
"render.cpu_volume_enable_path": "간접 조명 켜기",
"render.cpu_volume_enable_path_plan": "CPU 레이 트레이싱 · 경로 깊이 {depth} · 픽셀당 {spp}샘플 · 시드 {seed} · 레이 트레이싱 그림자. 작성한 대기와 이미지 예산을 유지합니다.",
@@ -2728,40 +2717,6 @@
"render.cpu_volume_method_incompatible": "이 조명, 샘플링 또는 대기 설정을 이 방식으로 옮길 수 없습니다. 설정은 유지됩니다.",
"render.cpu_volume_path_required": "CPU 대기를 사용하려면 간접 조명을 켜세요.",
"render.cpu_warm_cool": "따뜻함과 차가움",
"render.denoise_class_background": "건너뜀: 배경",
"render.denoise_class_coverage": "건너뜀: 부분 커버리지",
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
"render.denoise_class_emitter": "건너뜀: 발광체",
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
"render.denoise_class_lens": "건너뜀: 피사계 심도",
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
"render.denoise_class_reflection": "건너뜀: 반사 종점",
"render.denoise_class_surface": "적용 가능: 표면",
"render.denoise_class_tir": "건너뜀: 전반사",
"render.denoise_class_transmission": "건너뜀: 추가 투과",
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
"render.denoise_diagnostics_title": "노이즈 제거 진단",
"render.denoise_export_mask": "마스크 PNG 내보내기",
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
"render.denoise_format_mask": "마스크 PNG",
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
"render.denoise_optical_filter": "거울과 유리 필터링",
"render.denoise_optical_filter_help": "필터링 가능한 반사광과 투과광을 처리합니다. 끄면 불투명한 표면의 노이즈만 제거합니다.",
"render.denoise_passes": "필터 반복 횟수",
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
"render.denoise_strength": "노이즈 제거 강도",
"render.denoise_view_image": "렌더링 이미지",
"render.denoise_view_mask": "노이즈 제거 마스크",
"render.diag_bodies": "표시된 바디",
"render.diag_job_details": "캡처된 작업 세부 정보",
"render.diag_last_check": "마지막 사전 점검",
@@ -3314,8 +3269,6 @@
"render.material_transient": "이 문서 탭의 임시 광학 재정의입니다. 디자인, 렌더 설정, 재질 라이브러리에는 저장되지 않습니다.",
"render.material_v_up": "텍스처 V축은 이미지 위쪽",
"render.material_weight": "코트 가중치 (0~1)",
"render.metal_energy_compensation": "거친 금속의 반사 복원",
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
"render.method_cpu": "CPU 레이 트레이싱",
"render.method_gpu": "GPU 패스 트레이싱",
"render.method_limit_raster": "래스터는 고정된 불투명 스튜디오 미리보기를 사용합니다. 지원되는 광학 컨트롤을 쓰려면 명시적인 레이 트레이싱 방식을 선택하세요.",
@@ -3444,8 +3397,6 @@
"render.quality_denoise_on": "노이즈 제거: 켜짐",
"render.quality_draft": "초안",
"render.quality_high": "높음",
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
"render.quality_projection_help": "이 검토에서는 해상도, 샘플 예산, 경로 깊이, 시드 및 노이즈 제거만 변경합니다. 다른 설정은 유지되며 수렴은 보장되지 않습니다.",
"render.quality_review": "샘플링 예산 검토",
"render.quality_summary": "{width} × {height} · 샘플 {samples}개 · 깊이 {depth} · 시드 {seed} · {denoise}",
@@ -3592,7 +3543,6 @@
"render.show_shots_label": "샷 스트립 표시",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "사용자 지정…",
"render.size_full_hd": "풀 HD",
"render.size_hd": "HD",
@@ -3755,11 +3705,6 @@
"render.subsurface_enable": "내부 산란 랜덤 워크",
"render.subsurface_help": "닫힌 유전체 전용입니다. 산란과 RGB 흡수는 mm당 별도 계수입니다. 경로는 실제 형상 내부에서 산란한 뒤 경계에서 굴절하거나 반사합니다. 직선 그림자 투과, 표면 노이즈 제거, 외부 볼륨을 끄고 프레넬 층 하나를 사용하세요. 확산 셰이더, 밀도 그리드, 측정된 재질이 아닙니다. 밀도가 높은 매질에는 더 많은 샘플과 경로 깊이가 필요할 수 있습니다.",
"render.subsurface_rate": "산란 충돌률",
"render.supersampling": "슈퍼샘플링",
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
"render.supersampling_off": "끄기",
"render.supersampling_x2": "2×",
"render.surface_import.author": "작성자",
"render.surface_import.busy": "이미지 가져오는 중…",
"render.surface_import.complete": "이미지를 선택하고 작성자, 출처, 라이선스를 입력하세요.",
@@ -3773,8 +3718,6 @@
"render.surface_import.saved_cpu": "CPU 렌더링에서 사용하려면 맵을 저장하세요.",
"render.surface_import.source": "출처",
"render.surface_import.title": "이미지 가져오기",
"render.texture_filtering": "텍스처 세부 필터링",
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다.",
"render.tone_aces_fitted": "ACES 근사",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU 유리는 보증된 분리 매질을 지원합니다. 제한 없는 중첩 매질과 노이즈 없는 투과 추정기는 제공되지 않습니다.",
File diff suppressed because it is too large Load Diff
@@ -1,21 +0,0 @@
{
"en": { "ui.agent_chat_snap_none_found": "No coding agent found. The Snap package is confined, so it cannot see or start the coding agents installed on this computer. To use them, install SolidCraft from the AppImage, .deb or .rpm package instead. An agent that uses an OpenAI-compatible API works in the Snap package too: set one up with Add agent." },
"ar": { "ui.agent_chat_snap_none_found": "لم يُعثر على وكيل برمجي. تعمل حزمة Snap في بيئة معزولة، لذا لا يمكنها رؤية الوكلاء البرمجيين المثبّتين على هذا الحاسوب ولا تشغيلهم. لاستخدامهم، ثبّت SolidCraft من حزمة AppImage أو .deb أو .rpm بدلًا من ذلك. يعمل الوكيل الذي يستخدم واجهة برمجية متوافقة مع OpenAI في حزمة Snap أيضًا: أعدّه من «إضافة وكيل»." },
"cs": { "ui.agent_chat_snap_none_found": "Nebyl nalezen žádný kódovací agent. Balíček Snap běží v izolovaném prostředí, takže nevidí kódovací agenty nainstalované v tomto počítači a nemůže je spustit. Chcete-li je používat, nainstalujte SolidCraft z balíčku AppImage, .deb nebo .rpm. Agent, který používá API kompatibilní s OpenAI, v balíčku Snap funguje: nastavte ho přes Přidat agenta." },
"de": { "ui.agent_chat_snap_none_found": "Kein Coding-Agent gefunden. Das Snap-Paket ist abgeschottet und kann die auf diesem Computer installierten Coding-Agenten weder sehen noch starten. Um sie zu nutzen, installieren Sie SolidCraft stattdessen aus dem AppImage-, .deb- oder .rpm-Paket. Ein Agent, der eine OpenAI-kompatible API nutzt, funktioniert auch im Snap-Paket: Richten Sie ihn mit Agent hinzufügen ein." },
"es": { "ui.agent_chat_snap_none_found": "No se ha encontrado ningún agente de programación. El paquete Snap está confinado, así que no puede ver ni iniciar los agentes de programación instalados en este equipo. Para usarlos, instala SolidCraft desde el paquete AppImage, .deb o .rpm. Un agente que use una API compatible con OpenAI también funciona en el paquete Snap: configúralo con Añadir agente." },
"fr": { "ui.agent_chat_snap_none_found": "Aucun agent de programmation trouvé. Le paquet Snap est confiné, il ne peut donc ni voir ni lancer les agents de programmation installés sur cet ordinateur. Pour les utiliser, installer plutôt SolidCraft depuis le paquet AppImage, .deb ou .rpm. Un agent qui utilise une API compatible OpenAI fonctionne aussi dans le paquet Snap. Le configurer avec Ajouter un agent." },
"he": { "ui.agent_chat_snap_none_found": "לא נמצא סוכן קוד. חבילת ה־Snap מבודדת, ולכן היא לא יכולה לראות או להפעיל את סוכני הקוד שמותקנים במחשב הזה. כדי להשתמש בהם, יש להתקין את SolidCraft מחבילת AppImage, .deb או .rpm במקום זאת. סוכן שמשתמש ב־API תואם OpenAI עובד גם בחבילת ה־Snap, ואפשר להגדיר אותו בעזרת הוספת סוכן." },
"it": { "ui.agent_chat_snap_none_found": "Nessun agente di programmazione trovato. Il pacchetto Snap è confinato, quindi non può vedere né avviare gli agenti di programmazione installati su questo computer. Per usarli, installa SolidCraft dal pacchetto AppImage, .deb o .rpm. Un agente che usa un’API compatibile con OpenAI funziona anche nel pacchetto Snap: configuralo con Aggiungi agente." },
"ja": { "ui.agent_chat_snap_none_found": "コーディングエージェントが見つかりません。Snap パッケージは制限された環境で動作するため、このコンピューターにインストールされたコーディングエージェントを認識することも起動することもできません。それらを使うには、代わりに AppImage、.deb または .rpm パッケージから SolidCraft をインストールしてください。OpenAI 互換 API を使うエージェントは Snap パッケージでも動作します。「エージェントを追加」から設定してください。" },
"ko": { "ui.agent_chat_snap_none_found": "코딩 에이전트를 찾지 못했습니다. Snap 패키지는 격리된 환경에서 실행되므로 이 컴퓨터에 설치된 코딩 에이전트를 보거나 시작할 수 없습니다. 이 에이전트를 사용하려면 대신 AppImage, .deb 또는 .rpm 패키지로 SolidCraft를 설치하세요. OpenAI 호환 API를 사용하는 에이전트는 Snap 패키지에서도 작동합니다. 에이전트 추가에서 설정하세요." },
"nl": { "ui.agent_chat_snap_none_found": "Geen codeeragent gevonden. Het Snap-pakket is afgeschermd en kan de codeeragents die op deze computer zijn geïnstalleerd niet zien of starten. Installeer SolidCraft in plaats daarvan via het AppImage-, .deb- of .rpm-pakket om ze te gebruiken. Een agent die een OpenAI-compatibele API gebruikt, werkt ook in het Snap-pakket: stel er een in met Agent toevoegen." },
"pl": { "ui.agent_chat_snap_none_found": "Nie znaleziono agenta programistycznego. Pakiet Snap działa w izolacji, więc nie widzi agentów programistycznych zainstalowanych na tym komputerze i nie może ich uruchomić. Aby z nich korzystać, zainstaluj SolidCraft z pakietu AppImage, .deb lub .rpm. Agent korzystający z API zgodnego z OpenAI działa także w pakiecie Snap: skonfiguruj go przyciskiem Dodaj agenta." },
"pt-BR": { "ui.agent_chat_snap_none_found": "Nenhum agente de programação encontrado. O pacote Snap é confinado, então não consegue ver nem iniciar os agentes de programação instalados neste computador. Para usá-los, instale o SolidCraft pelo pacote AppImage, .deb ou .rpm. Um agente que usa uma API compatível com OpenAI também funciona no pacote Snap: configure-o em Adicionar agente." },
"ru": { "ui.agent_chat_snap_none_found": "Кодирующий агент не найден. Пакет Snap работает в изолированной среде, поэтому не видит и не может запускать кодирующих агентов, установленных на этом компьютере. Чтобы пользоваться ими, установите SolidCraft из пакета AppImage, .deb или .rpm. Агент, использующий API, совместимый с OpenAI, работает и в пакете Snap: настройте его через «Добавить агент»." },
"tr": { "ui.agent_chat_snap_none_found": "Kodlama ajanı bulunamadı. Snap paketi yalıtılmış çalıştığı için bu bilgisayarda yüklü kodlama ajanlarını göremez ve başlatamaz. Bunları kullanmak için SolidCraft'ı bunun yerine AppImage, .deb veya .rpm paketinden yükleyin. OpenAI uyumlu API kullanan bir ajan Snap paketinde de çalışır: Ajan ekle ile ayarlayın." },
"uk": { "ui.agent_chat_snap_none_found": "Агента для програмування не знайдено. Пакет Snap працює в ізольованому середовищі, тому не бачить і не може запускати агентів для програмування, установлених на цьому комп’ютері. Щоб користуватися ними, установіть SolidCraft з пакета AppImage, .deb або .rpm. Агент, що використовує API, сумісний з OpenAI, працює і в пакеті Snap: налаштуйте його через «Додати агента»." },
"vi": { "ui.agent_chat_snap_none_found": "Không tìm thấy tác tử lập trình nào. Gói Snap chạy trong môi trường bị giới hạn nên không thể thấy hay khởi động các tác tử lập trình được cài trên máy tính này. Để dùng chúng, hãy cài SolidCraft từ gói AppImage, .deb hoặc .rpm thay vào đó. Tác tử dùng API tương thích OpenAI vẫn hoạt động trong gói Snap: hãy thiết lập bằng Thêm AI Agent." },
"zh": { "ui.agent_chat_snap_none_found": "未找到编程代理。Snap 软件包运行在受限环境中,无法看到或启动此计算机上安装的编程代理。要使用它们,请改用 AppImage、.deb 或 .rpm 软件包安装 SolidCraft。使用 OpenAI 兼容 API 的代理在 Snap 软件包中同样可用:请通过“添加智能体”进行设置。" },
"zh-TW": { "ui.agent_chat_snap_none_found": "未找到程式設計代理。Snap 套件在受限環境中執行,無法看到或啟動這台電腦上安裝的程式設計代理。若要使用它們,請改用 AppImage、.deb 或 .rpm 套件安裝 SolidCraft。使用 OpenAI 相容 API 的代理在 Snap 套件中同樣可用:請透過「新增代理」進行設定。" }
}
@@ -1,26 +0,0 @@
{
"en": {
"component.error.ambiguous_name": "Several components are named `{name}`. Use a numeric component ID: {ids}."
},
"ar": {
"component.error.ambiguous_name": "توجد عدة مكونات بالاسم `{name}`. استخدم معرّف مكوّن رقميًا: {ids}."
},
"de": {
"component.error.ambiguous_name": "Mehrere Komponenten heißen `{name}`. Verwende eine numerische Komponenten-ID: {ids}."
},
"he": {
"component.error.ambiguous_name": "לכמה רכיבים יש השם `{name}`. יש להשתמש במזהה רכיב מספרי: {ids}."
},
"ja": {
"component.error.ambiguous_name": "複数のコンポーネントに `{name}` という名前が付いています。数値のコンポーネント ID を使用してください: {ids}。"
},
"ko": {
"component.error.ambiguous_name": "이름이 `{name}`인 구성요소가 여러 개입니다. 숫자 구성요소 ID를 사용하세요: {ids}."
},
"vi": {
"component.error.ambiguous_name": "Nhiều thành phần có tên `{name}`. Hãy dùng ID thành phần dạng số: {ids}."
},
"zh": {
"component.error.ambiguous_name": "多个组件名为 `{name}`。请使用数字组件 ID:{ids}。"
}
}
@@ -1,18 +0,0 @@
{
"en": { "ui.browser_collapse_below": "Collapse Below", "ui.browser_expand_below": "Expand Below" },
"ar": { "ui.browser_collapse_below": "طي ما بالأسفل", "ui.browser_expand_below": "توسيع ما بالأسفل" },
"de": { "ui.browser_collapse_below": "Darunter einklappen", "ui.browser_expand_below": "Darunter ausklappen" },
"es": { "ui.browser_collapse_below": "Contraer debajo", "ui.browser_expand_below": "Expandir debajo" },
"fr": { "ui.browser_collapse_below": "Réduire en dessous", "ui.browser_expand_below": "Développer en dessous" },
"he": { "ui.browser_collapse_below": "כיווץ מה שמתחת", "ui.browser_expand_below": "הרחבת מה שמתחת" },
"it": { "ui.browser_collapse_below": "Comprimi sotto", "ui.browser_expand_below": "Espandi sotto" },
"ja": { "ui.browser_collapse_below": "この下を折りたたむ", "ui.browser_expand_below": "この下を展開" },
"ko": { "ui.browser_collapse_below": "하위 항목 접기", "ui.browser_expand_below": "하위 항목 펼치기" },
"nl": { "ui.browser_collapse_below": "Onderliggende samenvouwen", "ui.browser_expand_below": "Onderliggende uitvouwen" },
"pl": { "ui.browser_collapse_below": "Zwiń poniżej", "ui.browser_expand_below": "Rozwiń poniżej" },
"ru": { "ui.browser_collapse_below": "Свернуть вложенное", "ui.browser_expand_below": "Развернуть вложенное" },
"uk": { "ui.browser_collapse_below": "Згорнути вкладене", "ui.browser_expand_below": "Розгорнути вкладене" },
"vi": { "ui.browser_collapse_below": "Thu gọn bên dưới", "ui.browser_expand_below": "Mở rộng bên dưới" },
"zh": { "ui.browser_collapse_below": "折叠下级", "ui.browser_expand_below": "展开下级" },
"zh-TW": { "ui.browser_collapse_below": "折疊下層", "ui.browser_expand_below": "展開下層" }
}
@@ -1,22 +0,0 @@
{
"ar": {
"ui.construction.axis_hint": "خط إنشائي: انقر نقطة، أو أدخل بعدها عن محور الرسم",
"ui.construction.parallel_hint": "خط إنشائي: اختر خطًا مستقيمًا ثم انقر أو أدخل الإزاحة؛ انقر بزر الفأرة الأيمن لتغيير المرجع"
},
"he": {
"ui.construction.axis_hint": "ישר עזר: לחיצה על נקודה, או הקלדת המרחק מציר הסקיצה",
"ui.construction.parallel_hint": "ישר עזר: בחירת ישר, ואז לחיצה או הקלדת ההיסט; לחיצה ימנית להחלפת הייחוס"
},
"ja": {
"ui.construction.axis_hint": "補助直線: 点をクリックするか、スケッチ軸からの距離を入力",
"ui.construction.parallel_hint": "補助直線: 直線を選択し、クリックするかオフセットを入力。右クリックで参照を変更"
},
"vi": {
"ui.construction.axis_hint": "Đường dựng hình: nhấp một điểm, hoặc nhập khoảng cách tới trục phác thảo",
"ui.construction.parallel_hint": "Đường dựng hình: chọn đường thẳng, rồi nhấp hoặc nhập khoảng dời; nhấp chuột phải để đổi tham chiếu"
},
"zh": {
"ui.construction.axis_hint": "构造直线:单击一点,或输入它到草图轴的距离",
"ui.construction.parallel_hint": "构造直线:先选直线,再单击或输入偏移;右键单击更换参照"
}
}
@@ -1,34 +0,0 @@
{
"ar": {
"prefs.history_steps_component": "خطوات السجل حسب المكوّن",
"prefs.history_steps_component_note": "عندما يكون مكوّن نشطًا أو محددًا، تتنقل عناصر تحكم السجل بين ميزات ذلك المكوّن كما يعرضها السجل في الأسفل. عند إيقافه تتنقل بين كل ميزات التصميم. السجل على اليسار أو تحت المستعرض يتنقل دائمًا بين كل الميزات."
},
"de": {
"prefs.history_steps_component": "Verlaufsschritte je Komponente",
"prefs.history_steps_component_note": "Ist eine Komponente aktiv oder ausgewählt, gehen die Verlaufssteuerelemente durch deren Features, so wie der Verlauf unten sie zeigt. Aus: Sie gehen durch alle Features des Designs. Der Verlauf links oder unter dem Browser geht immer durch alle Features."
},
"en": {
"prefs.history_steps_component": "History steps by component",
"prefs.history_steps_component_note": "With a component active or picked, the History controls step through that component's features, as the History at the bottom shows them. Off: they step through every feature of the design. The History on the left or under the browser always steps through every feature."
},
"he": {
"prefs.history_steps_component": "צעדי ציר הזמן לפי רכיב",
"prefs.history_steps_component_note": "כשרכיב פעיל או נבחר, פקדי ציר הזמן עוברים בין הפיצ'רים של אותו רכיב, כפי שציר הזמן למטה מציג אותם. כבוי: הם עוברים בין כל הפיצ'רים של התכנון. ציר הזמן משמאל או מתחת לדפדפן עובר תמיד בין כל הפיצ'רים."
},
"ja": {
"prefs.history_steps_component": "コンポーネント単位で履歴を移動",
"prefs.history_steps_component_note": "コンポーネントがアクティブまたは選択されているとき、履歴コントロールは下部の履歴に表示されるとおり、そのコンポーネントのフィーチャだけを移動します。オフにすると、デザインのすべてのフィーチャを移動します。左側またはブラウザー下の履歴は常にすべてのフィーチャを移動します。"
},
"ko": {
"prefs.history_steps_component": "구성 요소별 히스토리 이동",
"prefs.history_steps_component_note": "구성 요소가 활성화되었거나 선택되어 있으면 기록 컨트롤이 아래쪽 히스토리에 보이는 대로 그 구성 요소의 피처만 이동합니다. 끄면 디자인의 모든 피처를 이동합니다. 왼쪽 또는 브라우저 아래의 히스토리는 항상 모든 피처를 이동합니다."
},
"vi": {
"prefs.history_steps_component": "Bước Lịch sử theo cụm chi tiết",
"prefs.history_steps_component_note": "Khi một cụm chi tiết đang kích hoạt hoặc được chọn, các điều khiển Lịch sử đi qua các tính năng của cụm đó, như dòng Lịch sử ở cạnh dưới hiển thị. Tắt: chúng đi qua mọi tính năng của thiết kế. Cây Lịch sử ở bên trái hoặc dưới cây thư mục luôn đi qua mọi tính năng."
},
"zh": {
"prefs.history_steps_component": "历史按组件逐步移动",
"prefs.history_steps_component_note": "当某个组件处于活动或选中状态时,历史控件按底部历史的显示,只在该组件的特征之间移动。关闭后,它们在设计的所有特征之间移动。左侧或浏览器下方的历史始终在所有特征之间移动。"
}
}
@@ -1,47 +0,0 @@
{
"en": {
"measure.perimeter": "Perimeter",
"measure.perimeter_inner": "Inner perimeter",
"measure.perimeter_outer": "Outer perimeter"
},
"ar": {
"measure.perimeter": "المحيط",
"measure.perimeter_inner": "المحيط الداخلي",
"measure.perimeter_outer": "المحيط الخارجي"
},
"de": {
"measure.perimeter": "Umfang",
"measure.perimeter_inner": "Innenumfang",
"measure.perimeter_outer": "Außenumfang"
},
"fr": {
"measure.perimeter": "Périmètre",
"measure.perimeter_inner": "Périmètre intérieur",
"measure.perimeter_outer": "Périmètre extérieur"
},
"he": {
"measure.perimeter": "היקף",
"measure.perimeter_inner": "היקף פנימי",
"measure.perimeter_outer": "היקף חיצוני"
},
"ja": {
"measure.perimeter": "周長",
"measure.perimeter_inner": "内周長",
"measure.perimeter_outer": "外周長"
},
"ko": {
"measure.perimeter": "둘레",
"measure.perimeter_inner": "내부 둘레",
"measure.perimeter_outer": "외부 둘레"
},
"vi": {
"measure.perimeter": "Chu vi",
"measure.perimeter_inner": "Chu vi trong",
"measure.perimeter_outer": "Chu vi ngoài"
},
"zh": {
"measure.perimeter": "周长",
"measure.perimeter_inner": "内周长",
"measure.perimeter_outer": "外周长"
}
}
-82
View File
@@ -1,82 +0,0 @@
{
"ar": {
"document.discard_row": "تجاهل",
"document.finish_save_prompt": "أكمل الرد على نافذة الحفظ قبل فتح تصميم آخر.",
"document.not_saved_yet": "لم يُحفظ بعد",
"document.save_many": "حفظ التغييرات في {n} من التصاميم؟",
"document.save_row": "حفظ",
"document.save_selected": "حفظ المحدد",
"prefs.save_prompt_one_at_a_time": "السؤال عن كل تصميم غير محفوظ على حدة عند إغلاق عدة تصاميم",
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلاً من قائمة مجمّعة."
},
"de": {
"document.discard_row": "Verwerfen",
"document.finish_save_prompt": "Beantworten Sie die Speicherabfrage, bevor Sie einen weiteren Entwurf öffnen.",
"document.not_saved_yet": "Noch nicht gespeichert",
"document.save_many": "Änderungen an {n} Entwürfen speichern?",
"document.save_row": "Speichern",
"document.save_selected": "Auswahl speichern",
"prefs.save_prompt_one_at_a_time": "Beim Schließen mehrerer Entwürfe für jeden ungespeicherten Entwurf einzeln fragen",
"prefs.save_prompt_one_at_a_time_help": "Für jeden ungespeicherten Entwurf eine eigene Speicherabfrage statt einer gemeinsamen Liste anzeigen."
},
"en": {
"document.discard_row": "Discard",
"document.finish_save_prompt": "Finish answering the save prompt before opening another design.",
"document.not_saved_yet": "Not saved yet",
"document.save_many": "Save changes to {n} designs?",
"document.save_row": "Save",
"document.save_selected": "Save Selected",
"prefs.save_prompt_one_at_a_time": "Ask about each unsaved design separately when closing several",
"prefs.save_prompt_one_at_a_time_help": "Show one save prompt per unsaved design instead of a combined list."
},
"he": {
"document.discard_row": "ביטול שינויים",
"document.finish_save_prompt": "יש לסיים לענות על חלון השמירה לפני פתיחת תכן אחר.",
"document.not_saved_yet": "עדיין לא נשמר",
"document.save_many": "לשמור שינויים ב־{n} תכנים?",
"document.save_row": "שמירה",
"document.save_selected": "שמירת הנבחרים",
"prefs.save_prompt_one_at_a_time": "לשאול על כל תכן שלא נשמר בנפרד בעת סגירת כמה",
"prefs.save_prompt_one_at_a_time_help": "להציג חלון שמירה נפרד לכל תכן שלא נשמר במקום רשימה משותפת."
},
"ja": {
"document.discard_row": "破棄",
"document.finish_save_prompt": "別のデザインを開く前に、保存の確認に回答してください。",
"document.not_saved_yet": "未保存",
"document.save_many": "{n} 件のデザインの変更を保存しますか?",
"document.save_row": "保存",
"document.save_selected": "選択項目を保存",
"prefs.save_prompt_one_at_a_time": "複数を閉じるとき、未保存のデザインごとに確認する",
"prefs.save_prompt_one_at_a_time_help": "一覧でまとめて確認する代わりに、未保存のデザインごとに保存を確認します。"
},
"ko": {
"document.discard_row": "버리기",
"document.finish_save_prompt": "다른 디자인을 열기 전에 저장 여부를 선택하세요.",
"document.not_saved_yet": "아직 저장하지 않음",
"document.save_many": "디자인 {n}개의 변경 사항을 저장하시겠습니까?",
"document.save_row": "저장",
"document.save_selected": "선택 항목 저장",
"prefs.save_prompt_one_at_a_time": "여러 디자인을 닫을 때 저장하지 않은 디자인마다 따로 묻기",
"prefs.save_prompt_one_at_a_time_help": "통합 목록 대신 저장하지 않은 디자인마다 저장 여부를 묻습니다."
},
"vi": {
"document.discard_row": "Bỏ thay đổi",
"document.finish_save_prompt": "Hãy trả lời xong hộp thoại lưu trước khi mở thiết kế khác.",
"document.not_saved_yet": "Chưa lưu",
"document.save_many": "Lưu thay đổi cho {n} thiết kế?",
"document.save_row": "Lưu",
"document.save_selected": "Lưu mục đã chọn",
"prefs.save_prompt_one_at_a_time": "Hỏi riêng từng thiết kế chưa lưu khi đóng nhiều thiết kế",
"prefs.save_prompt_one_at_a_time_help": "Hiển thị một hộp thoại lưu cho mỗi thiết kế chưa lưu thay vì một danh sách chung."
},
"zh": {
"document.discard_row": "放弃",
"document.finish_save_prompt": "请先回答保存提示,再打开其他设计。",
"document.not_saved_yet": "尚未保存",
"document.save_many": "保存 {n} 个设计的更改?",
"document.save_row": "保存",
"document.save_selected": "保存所选项",
"prefs.save_prompt_one_at_a_time": "关闭多个设计时,逐个询问未保存的设计",
"prefs.save_prompt_one_at_a_time_help": "对每个未保存的设计分别显示保存提示,而不是合并列表。"
}
}
-104
View File
@@ -1,104 +0,0 @@
{
"uk": {
"Edit Holes": "Редагувати отвори",
"Select the curves to divide first": "Спершу виберіть криві для поділу",
"browser.canvas_locked": "Полотно заблоковано. Спершу розблокуйте його.",
"cmd.model_body_features": "Перелічити операції тіла",
"cmd.panel.focus.next": "Сфокусувати наступну панель",
"cmd.panel.focus.previous": "Сфокусувати попередню панель",
"cmd.panel.move.next_zone": "Перемістити панель до наступної зони",
"cmd.panel.sash.down": "Змінити розмір роздільника панелей униз",
"cmd.panel.sash.left": "Змінити розмір роздільника панелей ліворуч",
"cmd.panel.sash.reset": "Скинути роздільник сфокусованої панелі",
"cmd.panel.sash.right": "Змінити розмір роздільника панелей праворуч",
"cmd.panel.sash.up": "Змінити розмір роздільника панелей угору",
"cmd.panel.toggle": "Перемкнути сфокусовану панель",
"cmd.pointcloud_reload": "Перезавантажити хмару точок",
"cmd.sketch.replace": "Замінити ескіз",
"cmd.sketch_point_divide": "Поділити криву",
"cmd.ui.palette": "Пошук команд",
"file_load.reloading_cloud": "Перезавантаження хмари точок",
"help.sketch.point.divide": "Розміщує точки, що поділяють вибрані криві на рівні за довжиною частини. Точки залишаються на кожній кривій і зберігають свій крок, коли крива змінюється.",
"hole_folder.applies_to": "Зміни застосовуються до {count} отворів у теці {folder}; кожен зберігає своє місце.",
"nav.orbit_cursor_wrap.help": "Перетягування для обертання, що сягає краю виду, переносить курсор на протилежний край і продовжує обертання. Вимкнено: перетягування зупиняється на краю екрана. Не діє у Wayland і в браузері.",
"nav.orbit_cursor_wrap.label": "Переносити курсор під час обертання",
"parts.family.countersunk_socket_screw": "Гвинт із потайною головкою та внутрішнім шестигранником",
"prefs.browser_body_features": "Операції під тілами",
"prefs.browser_body_features_note": "Кожне тіло в браузері розгортається, показуючи операції часового ланцюжка, що його побудували, у порядку ланцюжка.",
"prefs.design_tools.hole_group": "Групувати отвори, створені разом",
"prefs.design_tools.hole_group_help": "Отвори, створені за один раз, опиняються в одній теці в історії, спершу закритій; відкрийте її, щоб редагувати отвір окремо.",
"prefs.design_tools.hole_several": "Кілька отворів на одній грані",
"prefs.design_tools.hole_several_help": "Вибір іншого місця на грані додає ще один отвір; вибір отвору видаляє його.",
"prefs.history_folder_kind_icon": "Значок теки за її операціями",
"prefs.history_folder_kind_icon_note": "Тека історії, усі операції якої одного типу, показує їхній значок.",
"prefs.panel_layout.recovery": "Не вдалося відновити збережені компонування панелей. Збережені дані не втрачено; «Класичне» залишається доступним.",
"prefs.shared_panel_layout": "Використовувати спільне компонування панелей",
"prefs.shared_panel_layout_help": "Показувати наявні браузер, історію та асистента в переміщуваних панелях. Зміна набуде чинності після застосування налаштувань.",
"prefs.sketch.escape_twice_finishes": "Подвійний Esc завершує ескіз",
"prefs.sketch.escape_twice_finishes_note": "Два швидкі натискання Esc завершують ескіз. Вимкнено: Esc лише скасовує інструмент чи вибір, а ескіз завершує «Завершити ескіз».",
"render.caustics_depth": "Глибина шляху каустики",
"render.caustics_enable": "Каустика скла від точкових і спрямованих джерел світла",
"render.caustics_help": "Кількість фотонів керує шумом і обсягом роботи. Радіус збирання обмінює чіткі деталі на плавнішу оцінку. CPU потребує непрямого освітлення.",
"render.caustics_path_required": "Каустика на CPU потребує непрямого освітлення.",
"render.caustics_photons": "Фотони",
"render.caustics_radius": "Радіус збирання",
"render.glass_abbe": "Число Аббе",
"render.glass_dispersion": "Хроматична дисперсія",
"render.glass_film": "Інтерференція тонкої плівки",
"render.glass_film_ior": "IOR плівки",
"render.glass_film_thickness": "Товщина плівки",
"render.glass_model_enable": "Розширена модель скла",
"render.glass_model_help": "Шорсткість скла розсіює промені, що проходять крізь нього. Дисперсія й колір плівки використовують три смуги довжин хвиль. Для шорстких листів потрібно вимкнути пряме пропускання тіней.",
"render.glass_roughness": "Шорсткість скла",
"render.gpu_job_shader_wait": "Підготовка шейдерів може тривати хвилини",
"sketch.divide_hint": "Точки поділяють кожну криву на рівні за довжиною частини й зберігають їх, коли крива змінюється.",
"ui.first_dimension_scales": "Перший розмір масштабує креслення",
"ui.first_dimension_scales_tip": "Увімкнено: перший лінійний розмір ескізу масштабує креслення, яке він вимірює, тож форма зберігається. Вимкнено: розмір задає лише те, що вимірює.",
"ui.heading_divide_curve": "ПОДІЛИТИ КРИВУ",
"ui.heading_press_pull": "НАТИСНУТИ/ВИТЯГНУТИ",
"ui.mesh_import_origin": "Перемістити до початку координат",
"ui.mesh_import_origin_tip": "Центрувати сітку по X і Y, розмістивши її основу на Z = 0. Коли вимкнено, сітка зберігає координати, записані у файлі.",
"ui.panel_layout.applied": "Застосовано «{name}» у робочій області «{workspace}»",
"ui.panel_layout.cancel": "Скасувати",
"ui.panel_layout.confirm_replace": "Замінити «{name}»?",
"ui.panel_layout.copy": "Копіювати",
"ui.panel_layout.current": "Поточне компонування",
"ui.panel_layout.custom": "Власне",
"ui.panel_layout.custom_hint": "Панелі переміщено після застосування: {name}.",
"ui.panel_layout.details": "Подробиці",
"ui.panel_layout.layout_workspace": "Робоча область компонування",
"ui.panel_layout.page_defaults_hint": "Скидає налаштування цієї сторінки. Розташування панелей і збережені компонування залишаються без змін.",
"ui.panel_layout.panel_layout": "Компонування панелей",
"ui.panel_layout.panel_style": "Стиль панелей",
"ui.panel_layout.replace": "Замінити компонування",
"ui.panel_layout.replace_current": "Замінити поточним",
"ui.panel_layout.replace_short": "Замінити",
"ui.panel_layout.reset": "Скинути робочу область",
"ui.panel_layout.reset_hint": "Повернути робочу область «{workspace}» до типового розташування. Збережені компонування залишаються без змін.",
"ui.panel_layout.reset_short": "Скинути",
"ui.panel_layout.restore_failed": "Не вдалося відновити ваше компонування",
"ui.panel_layout.save": "Зберегти компонування",
"ui.panel_layout.save_as": "Зберегти як",
"ui.panel_layout.save_as_tile": "Зберегти як…",
"ui.panel_layout.selected": "вибрано",
"ui.panel_layout.suggested_name": "Компонування {n}",
"ui.panel_layout.try_again": "Спробувати знову",
"ui.panel_layout.validation.builtin": "Вбудовані компонування не можна змінювати",
"ui.panel_layout.validation.duplicate": "Ця назва вже використовується",
"ui.panel_layout.validation.empty": "Введіть назву",
"ui.panel_layout.validation.full": "Збережено 64 з 64. Виберіть одне для заміни.",
"ui.panel_layout.validation.invalid": "Використовуйте від 1 до 64 символів без керівних символів",
"ui.panel_layout.workspace.classic": "Класичне",
"ui.panel_layout.workspace.modelling": "Моделювання",
"ui.panel_layout.workspace.render": "Рендеринг",
"ui.panel_layout.workspace.review": "Огляд",
"ui.panel_layout.workspace.sketch": "Ескіз",
"ui.panel_layout.your_layout": "ваше компонування",
"ui.point_cloud_reload": "Перезавантажити з файлу",
"ui.project_references_construction": "Проєктувати посилання як допоміжну геометрію",
"ui.project_references_construction_note": "«Проєктувати», «Перетин», «Включити 3D-геометрію» і ребра грані, які додає новий ескіз на грані, створюють допоміжну геометрію: посилання для креслення, не включені до профілів. Вони залишаються пов'язаними з моделлю.",
"ui.replace_sketch.from_file": "З файлу проєкту…",
"ui.replace_sketch.no_sketches": "У цьому проєкті немає ескізу, який можна взяти",
"ui.segments": "Сегменти"
}
}
+1 -59
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.ui.palette": "Tìm kiếm lệnh",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "Bề rộng",
"parts.family.bearing": "Ổ bi cầu rãnh sâu (bao ngoài)",
"parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm",
"parts.family.countersunk_socket_screw": "Vít đầu chìm lục giác chìm",
"parts.family.dowel_pin": "Chốt trụ",
"parts.family.hex_bolt": "Bu lông đầu lục giác",
"parts.family.hex_cap_screw_inch": "Bu lông đầu lục giác (inch)",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "Tắt theo mặc định. Khi bật, sau khi lệnh thành công, các giá trị và tùy chọn an toàn của lệnh sẽ được dùng lại vào lần tiếp theo bạn mở cùng lệnh đó, cho đến khi SolidCraft đóng.",
"prefs.render_legacy_image_defaults": "Dùng mặc định ảnh Kết xuất trước đây",
"prefs.render_legacy_image_defaults_help": "Giữ quy trình hình ảnh thủ công trước đây: thiết lập mới dùng raster CPU, dò tia CPU dùng lấy mẫu cố định và mở kết quả thủ công. Căn vừa lại khung nhìn khi vào Kết xuất, chuyển góc chụp mà không khôi phục camera đã lưu, đồng thời dùng cách lấy khung hình và điều hướng trước đây. Tắt phản hồi ảnh trực tiếp tự động. Các thiết lập đã lưu không thay đổi.",
"prefs.render_legacy_memory_admission": "Dùng cách kiểm tra bộ nhớ kết xuất trước đây",
"prefs.render_legacy_memory_admission_help": "Cho phép kết xuất gốc trong giới hạn bộ nhớ dùng chung và của máy chủ cố định, ngay cả khi mức ước tính vượt quá RAM khả dụng. Giới hạn thiết bị và kiểm tra cấp phát bộ nhớ vẫn được áp dụng.",
"prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất",
"prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.",
"prefs.reports_saved": "Đã lưu {n}",
@@ -3224,10 +3220,8 @@
"render.category_paint": "Sơn",
"render.category_plastic": "Nhựa",
"render.category_wood": "Gỗ",
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
"render.caustics_depth": "Độ sâu đường tụ quang",
"render.caustics_enable": "Tụ quang qua kính từ nguồn điểm và nguồn định hướng",
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
"render.caustics_help": "Số photon điều chỉnh nhiễu và khối lượng tính toán. Bán kính thu thập đánh đổi chi tiết sắc nét để có ước lượng mượt hơn. CPU cần chiếu sáng gián tiếp.",
"render.caustics_path_required": "Tụ quang trên CPU cần chiếu sáng gián tiếp.",
"render.caustics_photons": "Số photon",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
"render.cpu_path_depth": "Độ sâu đường truyền",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_point": "Nguồn sáng điểm",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "Tán xạ bên trong kính trên CPU cần tắt Khử nhiễu. Các cài đặt được giữ nguyên.",
"render.cpu_subsurface_help": "Tán xạ đổi hướng ánh sáng bên trong kính kín. Tốc độ dương cần Chiếu sáng gián tiếp và tắt Khử nhiễu. Tốc độ là số va chạm mỗi mm; 0 không thêm tán xạ. Độ lệch hướng dương ưu tiên phía trước, âm ưu tiên phía sau.",
"render.cpu_summary": "Dò tia CPU · phản xạ · HDR · nguồn sáng định hướng và điểm",
"render.cpu_threads": "Luồng CPU",
"render.cpu_threads_auto": "Tự động",
"render.cpu_threads_custom": "Tùy chỉnh",
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
"render.cpu_threads_single": "Một luồng",
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
"render.cpu_transparent_help": "PNG trong suốt loại bỏ nền và giữ độ phủ ở biên. Độ đục của thân và mặt phải giữ ở 100%.",
"render.cpu_volume_enable_path": "Bật chiếu sáng gián tiếp",
"render.cpu_volume_enable_path_plan": "Dò tia CPU · độ sâu đường đi {depth} · {spp} mẫu/pixel · hạt giống {seed} · bóng dò tia. Giữ nguyên khí quyển và ngân sách ảnh đã đặt.",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "Không thể chuyển các thiết lập đèn, lấy mẫu hoặc khí quyển này sang phương pháp này. Thiết lập của bạn được giữ nguyên.",
"render.cpu_volume_path_required": "Bật chiếu sáng gián tiếp để dùng Khí quyển trên CPU.",
"render.cpu_warm_cool": "Ấm và lạnh",
"render.denoise_class_background": "Bỏ qua: nền",
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần",
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
"render.denoise_export_mask": "Xuất mặt nạ PNG",
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
"render.denoise_format_mask": "PNG mặt nạ",
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
"render.denoise_optical_filter": "Lọc gương và kính",
"render.denoise_optical_filter_help": "Lọc ánh sáng phản xạ và truyền qua đủ điều kiện. Tắt để chỉ khử nhiễu các bề mặt không trong suốt.",
"render.denoise_passes": "Số lượt lọc",
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
"render.denoise_strength": "Cường độ khử nhiễu",
"render.denoise_view_image": "Ảnh đã kết xuất",
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
"render.diag_bodies": "Số khối hiển thị",
"render.diag_job_details": "Chi tiết tác vụ đã ghi nhận",
"render.diag_last_check": "Lần kiểm tra sơ bộ gần nhất",
@@ -4046,8 +4000,6 @@
"render.material_transient": "Ghi đè quang học tạm thời cho tab tài liệu này. Chúng không được lưu trong thiết kế, thiết lập kết xuất hoặc thư viện vật liệu.",
"render.material_v_up": "Trục V kết cấu hướng lên trên trong ảnh",
"render.material_weight": "Trọng số lớp phủ bóng (0–1)",
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
"render.method_cpu": "Dò tia trên CPU",
"render.method_gpu": "Dò đường truyền tia trên GPU (Path Tracing)",
"render.method_limit_raster": "Quét mảng (Raster) dùng bản xem trước studio đục cố định. Chọn phương thức dò tia rõ ràng để dùng các điều khiển quang học được hỗ trợ.",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "Giảm nhiễu: Bật",
"render.quality_draft": "Bản nháp",
"render.quality_high": "Cao",
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
"render.quality_projection_help": "Bản xem xét này chỉ thay đổi độ phân giải, ngân sách mẫu, độ sâu đường đi, hạt giống và giảm nhiễu. Các thiết lập khác được giữ nguyên; ngân sách không chứng nhận ảnh đã hội tụ.",
"render.quality_review": "Xem xét định mức lấy mẫu",
"render.quality_summary": "{width} × {height} · {samples} mẫu · độ sâu {depth} · hạt giống {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "Hiện dải cảnh chụp",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Tùy chỉnh…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "Bước đi ngẫu nhiên tán xạ bên trong",
"render.subsurface_help": "Chỉ dành cho chất điện môi kín. Hệ số tán xạ và hấp thụ RGB là các hệ số riêng biệt trên mỗi mm. Tia sáng tán xạ bên trong hình học thực tế, sau đó khúc xạ hoặc phản xạ tại ranh giới. Hãy tắt bóng đổ thẳng, khử nhiễu bề mặt và thể tích bên ngoài; dùng một phân tầng Fresnel. Đây không phải bộ đổ bóng khuếch tán, lưới mật độ hay vật liệu đo đạc. Môi trường đậm đặc có thể cần nhiều mẫu và độ sâu tia lớn hơn.",
"render.subsurface_rate": "Tần suất va chạm tán xạ",
"render.supersampling": "Lấy mẫu siêu phân giải",
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
"render.supersampling_off": "Tắt",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Tác giả",
"render.surface_import.busy": "Đang nhập ảnh…",
"render.surface_import.complete": "Chọn ảnh rồi nhập tác giả, nguồn và giấy phép.",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "Lưu bản đồ để dùng khi kết xuất bằng CPU.",
"render.surface_import.source": "Nguồn",
"render.surface_import.title": "Nhập ảnh",
"render.texture_filtering": "Lọc chi tiết kết cấu",
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây.",
"render.tone_aces_fitted": "ACES khớp",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "Thủy tinh GPU hỗ trợ các môi trường rời rạc đã xác thực. Không cung cấp môi trường lồng nhau không giới hạn hay ước tính truyền dẫn không nhiễu.",
+1 -59
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.ui.palette": "搜索命令",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "宽度",
"parts.family.bearing": "深沟球轴承(外形)",
"parts.family.button_head_cap_screw": "圆头内六角螺钉",
"parts.family.countersunk_socket_screw": "沉头内六角螺钉",
"parts.family.dowel_pin": "圆柱销",
"parts.family.hex_bolt": "六角头螺栓",
"parts.family.hex_cap_screw_inch": "六角头螺栓(英制)",
@@ -2828,8 +2826,6 @@
"prefs.remember_dialog_values.help": "默认关闭。开启后,命令成功执行后,在关闭 SolidCraft 前,下次打开同一命令时会重用其安全的值和选项。",
"prefs.render_legacy_image_defaults": "使用以前的渲染图像默认值",
"prefs.render_legacy_image_defaults_help": "保留以前的手动图像流程:新设置使用 CPU 光栅渲染,CPU 光线追踪使用固定采样,并手动打开结果。进入渲染时重新适配视图,切换镜头时不恢复其保存的相机,并使用以前的取景和导航方式。禁用自动实时图像反馈。已保存的设置保持不变。",
"prefs.render_legacy_memory_admission": "使用以前的渲染内存检查",
"prefs.render_legacy_memory_admission_help": "即使估算用量超过可用RAM,也允许在固定的共享内存和主机内存限额内进行原生渲染。设备限制和内存分配检查仍然适用。",
"prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算",
"prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。",
"prefs.reports_saved": "已保存 {n} 份",
@@ -3224,10 +3220,8 @@
"render.category_paint": "涂漆",
"render.category_plastic": "塑料",
"render.category_wood": "木材",
"render.caustics_area_lights": "面光源焦散",
"render.caustics_depth": "焦散路径深度",
"render.caustics_enable": "点光源和方向光的玻璃焦散",
"render.caustics_environment": "环境光焦散",
"render.caustics_help": "光子数控制噪点和计算量。增大收集半径可得到更平滑的估计,但会损失细节。CPU需要间接照明。",
"render.caustics_path_required": "CPU焦散需要间接照明。",
"render.caustics_photons": "光子数",
@@ -3404,7 +3398,7 @@
"render.cpu_opaque_required": "CPU 光线追踪需要不透明背景。请关闭透明背景。",
"render.cpu_options_pending": "此CPU面板暂不支持编辑自发光、表面效果、玻璃或各向异性。使用前请清除这些API选项。",
"render.cpu_path_depth": "路径深度",
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_path_lighting": "间接照明",
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_point": "点光源",
@@ -3444,12 +3438,6 @@
"render.cpu_subsurface_denoise_reason": "CPU 内部散射需要关闭降噪。设置会保留。",
"render.cpu_subsurface_help": "散射会改变闭合玻璃内部的光线方向。正散射率需要开启间接光照并关闭降噪。散射率表示每毫米的碰撞次数;0 不增加散射。方向偏置为正时偏向前方,为负时偏向后方。",
"render.cpu_summary": "CPU光线追踪 · 反射 · HDR · 平行光与点光源",
"render.cpu_threads": "CPU 工作线程",
"render.cpu_threads_auto": "自动",
"render.cpu_threads_custom": "自定义",
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
"render.cpu_threads_single": "单线程",
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
"render.cpu_transparent_help": "透明PNG移除背景并保留边缘覆盖率。实体和面的不透明度必须保持100%。",
"render.cpu_volume_enable_path": "启用间接照明",
"render.cpu_volume_enable_path_plan": "CPU 光线追踪 · 路径深度 {depth} · 每像素 {spp} 个采样 · 种子 {seed} · 追踪阴影。保留已编写的大气和图像预算。",
@@ -3458,40 +3446,6 @@
"render.cpu_volume_method_incompatible": "这些灯光、采样或大气设置无法转移到此方式。您的设置会保留。",
"render.cpu_volume_path_required": "启用间接照明以使用 CPU 大气。",
"render.cpu_warm_cool": "暖色与冷色",
"render.denoise_class_background": "已跳过:背景",
"render.denoise_class_coverage": "已跳过:部分覆盖",
"render.denoise_class_degenerate": "已跳过:退化路径",
"render.denoise_class_depth": "已跳过:路径深度上限",
"render.denoise_class_emitter": "已跳过:发光体",
"render.denoise_class_exit": "已跳过:不兼容的出射",
"render.denoise_class_identity": "已跳过:引导标识上限",
"render.denoise_class_lens": "已跳过:景深",
"render.denoise_class_material": "已跳过:不支持的材质",
"render.denoise_class_nested": "已跳过:嵌套介质",
"render.denoise_class_optical": "可滤波:折射后的表面",
"render.denoise_class_reflection": "已跳过:反射终点",
"render.denoise_class_surface": "可滤波:表面",
"render.denoise_class_tir": "已跳过:全内反射",
"render.denoise_class_transmission": "已跳过:进一步透射",
"render.denoise_class_unavailable": "已跳过:无引导数据",
"render.denoise_class_volume": "已跳过:体积散射",
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
"render.denoise_diagnostics_title": "降噪诊断",
"render.denoise_export_mask": "导出蒙版 PNG",
"render.denoise_firefly": "抑制孤立亮点",
"render.denoise_format_mask": "蒙版 PNG",
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
"render.denoise_optical_filter": "过滤镜面和玻璃",
"render.denoise_optical_filter_help": "对符合条件的反射光和透射光进行过滤。关闭后,仅对不透明表面降噪。",
"render.denoise_passes": "滤波轮数",
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
"render.denoise_strength": "降噪强度",
"render.denoise_view_image": "渲染图像",
"render.denoise_view_mask": "降噪蒙版",
"render.diag_bodies": "可见实体数",
"render.diag_job_details": "已捕获任务详情",
"render.diag_last_check": "上次预检",
@@ -4046,8 +4000,6 @@
"render.material_transient": "此文档标签页的临时光学设置,不保存到设计、渲染设置或材质库中。",
"render.material_v_up": "纹理 V 轴指向图像上方",
"render.material_weight": "涂层权重(0–1)",
"render.metal_energy_compensation": "恢复粗糙金属的反射",
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
"render.method_cpu": "CPU 光线追踪",
"render.method_gpu": "GPU 路径追踪",
"render.method_limit_raster": "光栅化使用固定的不透明工作室预览;要使用受支持的光学控件,请明确选择光线追踪方式。",
@@ -4176,8 +4128,6 @@
"render.quality_denoise_on": "降噪:开启",
"render.quality_draft": "草稿",
"render.quality_high": "高",
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
"render.quality_projection_help": "此检查仅更改分辨率、采样预算、路径深度、种子和降噪。其他设置保持不变,预算不保证收敛。",
"render.quality_review": "审阅采样预算",
"render.quality_summary": "{width} × {height} · {samples} 次采样 · 深度 {depth} · 种子 {seed} · {denoise}",
@@ -4325,7 +4275,6 @@
"render.show_shots_label": "显示镜头条",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K 超高清",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "自定义…",
"render.size_full_hd": "全高清",
"render.size_hd": "HD",
@@ -4488,11 +4437,6 @@
"render.subsurface_enable": "内部散射随机游走",
"render.subsurface_help": "仅适用于封闭介质。散射和RGB吸收使用独立的每毫米系数。路径在实际几何内部散射,并在边界折射或反射。关闭直线透射阴影、表面降噪和外部体积;使用一个菲涅耳分层。这不是扩散着色器、密度网格或实测材料。高密度可能需要更多样本和路径深度。",
"render.subsurface_rate": "散射碰撞率",
"render.supersampling": "超采样",
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
"render.supersampling_off": "关闭",
"render.supersampling_x2": "2×",
"render.surface_import.author": "作者",
"render.surface_import.busy": "正在导入图像…",
"render.surface_import.complete": "请选择图像并填写作者、来源和许可。",
@@ -4506,8 +4450,6 @@
"render.surface_import.saved_cpu": "保存贴图以用于 CPU 渲染。",
"render.surface_import.source": "来源",
"render.surface_import.title": "导入图像",
"render.texture_filtering": "过滤纹理细节",
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。",
"render.tone_aces_fitted": "ACES 拟合",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "GPU 玻璃支持经过验证且互不相交的介质;不支持任意嵌套介质,也不提供无噪声透射估计器。",
@@ -21,7 +21,7 @@ struct U {
model: mat4x4<f32>,
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
// the section hatch colours by part (`crate::hatch`), the first the cap's own
// the section hatch colours by part (`solidcraft_render::hatch`), the first the cap's own
hatch: array<vec4<f32>, 3>,
product_lights_meta: vec4<f32>, // explicit preview flag and bounded count
product_lights: array<vec4<f32>,84>, // position/kind, radiance, axis/cone, attenuation
@@ -1 +0,0 @@
| assets/locales/lv.json | SolidCraft contributors | Original Latvian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
-2
View File
@@ -9,9 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
solidcraft-viewport = { workspace = true }
solidcore-record = { workspace = true }
solidcore-tess = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
+1 -1
View File
@@ -51,7 +51,7 @@ impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_viewport::surface::OpaqueSurface>> {
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_render::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into()));
}
+1 -1
View File
@@ -189,7 +189,7 @@ pub(crate) fn remap_link(
remap_link(a, ids, names, bodies, depth + 1);
remap_link(b, ids, names, bodies, depth + 1);
}
LinkSource::Plane { name } | LinkSource::ConstructionPoint { name } => mapped_name(name, names),
LinkSource::Plane { name } => mapped_name(name, names),
// A construction axis, by name (#9324); the origin axes are no feature's.
LinkSource::Axis { name } if !matches!(name.as_str(), "X" | "Y" | "Z") => mapped_name(name, names),
_ => {}
-64
View File
@@ -1,64 +0,0 @@
//! CSV text fields, as every SolidCraft export writes them and the parameter import reads them.
//!
//! A spreadsheet reads a field that starts with `=`, `+`, `-` or `@` as a formula, so a part
//! numbered `=1+1` would show `2`. A field like that is written with a `'` first, which
//! spreadsheets show as text (OWASP's CSV Injection page). [`unguard`] takes the `'` off again
//! when SolidCraft reads its own file back.
use std::borrow::Cow;
/// The first characters that make a spreadsheet read a field as a formula, from OWASP's list.
/// The full-width forms act as formulas in some locales, such as Japanese, so they are here too.
const FORMULA_STARTS: [char; 11] = ['=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@'];
/// What makes a field need quotes: a separator (`;` as well as `,`, since a spreadsheet set to
/// `;` splits there and the new cell could start a formula), a quote, a tab or a line break.
const NEEDS_QUOTES: [char; 6] = [',', ';', '"', '\t', '\r', '\n'];
/// A text field, quoted only when it needs quotes.
pub fn field(text: &str) -> String {
let text = guard(text);
if text.contains(NEEDS_QUOTES) { quote(&text) } else { text.into_owned() }
}
/// A text field, always in quotes.
pub fn quoted(text: &str) -> String {
quote(&guard(text))
}
/// A field as SolidCraft wrote it, with the `'` that [`field`] or [`quoted`] put first taken off.
/// A field without one is returned as it is.
pub fn unguard(text: &str) -> &str {
match text.strip_prefix('\'') {
Some(rest) if starts_a_formula(rest) => rest,
_ => text,
}
}
/// `text` with a `'` first when a spreadsheet would read it as a formula. A field that already
/// starts with `'` and then a formula gets one more, so [`unguard`] gives back exactly what was
/// written. A whole plain number (`-5`, `+1e-9`) is read as that number, not as a formula, so it
/// is left as it is.
fn guard(text: &str) -> Cow<'_, str> {
if starts_a_formula(text) && !plain_number(text) { Cow::Owned(format!("'{text}")) } else { Cow::Borrowed(text) }
}
/// Whether `text`, after any `'` and spaces it starts with, starts as a formula does. Spaces count
/// because a spreadsheet may take them off as it reads (LibreOffice's "Trim spaces").
fn starts_a_formula(text: &str) -> bool {
text.trim_start_matches(['\'', ' ']).starts_with(FORMULA_STARTS)
}
/// A finite number and nothing else, spaces included. Rust's `f64` parsing also takes `inf`,
/// `infinity` and `NaN` in any case, and none of them is finite.
fn plain_number(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
#[cfg(test)]
#[path = "csv_tests.rs"]
mod tests;
-89
View File
@@ -1,89 +0,0 @@
use super::{FORMULA_STARTS, NEEDS_QUOTES, field, quoted, unguard};
#[test]
fn a_field_a_spreadsheet_would_run_gets_an_apostrophe() {
for (text, written) in [
("=1+1", "'=1+1"),
("+2 for the fit", "'+2 for the fit"),
("-5 mm shim", "'-5 mm shim"),
("@SUM(1,1)", "\"'@SUM(1,1)\""),
("=1+1", "'=1+1"),
("+2", "'+2"),
("-5", "'-5"),
("@x", "'@x"),
("\tx", "\"'\tx\""),
("\rx", "\"'\rx\""),
("\nx", "\"'\nx\""),
// Already an apostrophe, then a formula: one more, so the import gives back the original.
("'=x", "''=x"),
("''-x", "'''-x"),
// Spaces before a formula count: a spreadsheet may take them off.
(" =x", "' =x"),
(" -5", "' -5"),
("' @x", "'' @x"),
] {
assert_eq!(field(text), written, "{text:?}");
assert_eq!(unguard(written.trim_matches('"')), text, "{written:?}");
}
}
#[test]
fn a_whole_plain_number_stays_a_number() {
for number in ["-5", "+5", "-0", "0", "1e-9", "-1.5E+3", ".5", "-.5", "5.", "+1e5", "-12.000000000"] {
assert_eq!(field(number), number, "{number:?}");
}
// Not plain numbers: Rust's parse takes the words, spaces aren't a number's, and one too large isn't finite.
for text in ["-inf", "+inf", "-infinity", "-NaN", "+nan", "-", "+", "--5", "-5 mm", "-5 ", "-1e400", "-0x10", "-5,5"] {
assert!(field(text).trim_start_matches('"').starts_with('\''), "{text:?}: {}", field(text));
}
}
#[test]
fn other_fields_are_left_as_they_are() {
for text in ["", "Plate", "PL-100", "a=b", "x+1", "'quoted' word", "'", "''", "5 mm", " 5", "é=", "μ-5"] {
assert_eq!(field(text), text, "{text:?}");
assert_eq!(unguard(text), text, "{text:?}");
}
}
#[test]
fn a_field_with_a_separator_a_quote_or_a_break_is_quoted() {
assert_eq!(field("Plate, 3 mm"), "\"Plate, 3 mm\"");
assert_eq!(field("one; two"), "\"one; two\"");
assert_eq!(field("Pin \"short\""), "\"Pin \"\"short\"\"\"");
assert_eq!(field("a\tb"), "\"a\tb\"");
assert_eq!(field("line one\r\nline two"), "\"line one\r\nline two\"");
assert_eq!(quoted("Plate"), "\"Plate\"");
assert_eq!(quoted("=A1"), "\"'=A1\"");
assert_eq!(quoted("-5"), "\"-5\"");
}
/// Random fields from an alphabet heavy in what matters: every formula start, apostrophes, quotes,
/// separators, spaces, digits and the letters of `inf` and `NaN`. Each one reads back exactly, never starts
/// a formula once written unless it is a whole number, and is quoted whenever it holds a separator.
#[test]
fn fuzz_every_field_reads_back_and_never_starts_a_formula() {
let mut alphabet: Vec<char> = "'''\"\";,.eE0123456789 infaNxé".chars().collect();
alphabet.extend(FORMULA_STARTS);
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for _ in 0..50_000 {
let n = (rnd() % 12) as usize;
let text: String = (0..n).filter_map(|_| alphabet.get((rnd() % alphabet.len() as u64) as usize)).collect();
let written = field(&text);
let inner = match written.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(inner) => inner.replace("\"\"", "\""),
None => written.clone(),
};
assert_eq!(unguard(&inner), text, "{text:?} written as {written:?}");
let bare = inner.trim_start_matches(' ');
assert!(!bare.starts_with(FORMULA_STARTS) || text.parse::<f64>().is_ok_and(f64::is_finite), "{text:?} written as {written:?}");
assert!(!text.contains(NEEDS_QUOTES) || written.starts_with('"'), "{text:?} written as {written:?}");
assert_eq!(quoted(&text), format!("\"{}\"", inner.replace('"', "\"\"")), "{text:?}");
}
}
+1 -2
View File
@@ -7,7 +7,6 @@
use serde::{Deserialize, Serialize};
pub use crate::tolerance::{DimensionStyle, DimensionText, DimensionValue, format_dimension};
use crate::{DocError, Result, expr};
/// Units per millimetre and the unit's name. Unknown names use millimetres.
@@ -276,7 +275,7 @@ fn round_decimal_step(x: f64, step: f64, decimals: u8, resolution: usize, ties:
round_prepared(&magnitude, x.is_sign_negative(), decimals, ties)
}
pub(crate) fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
for (i, digit) in digits.chars().enumerate() {
if groups && digits.len() >= 5 && i > 0 && if integer { (digits.len() - i).is_multiple_of(3) } else { i.is_multiple_of(3) } {
text.push('\u{202f}');
+4 -74
View File
@@ -1951,8 +1951,7 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skip: Vec<u64>,
},
/// Rebuild an upstream mesh as a solid: analytic planar/cylindrical faces for a
/// constant-section part, planar facets for any other closed mesh.
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
MeshConvert {
body: BodyName,
#[walk(length = "Tolerance")]
@@ -2295,10 +2294,6 @@ pub struct MeshData {
pub positions: std::sync::Arc<[f64]>,
/// Three vertex indices per triangle.
pub triangles: std::sync::Arc<[u32]>,
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
/// Independent of the welded positions and the geometric boundary certificate.
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
pub corner_normals: std::sync::Arc<[f64]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
@@ -2314,36 +2309,6 @@ impl PartialEq for MeshData {
&& self.color == other.color
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
}
}
impl MeshData {
/// Explicitly generate crease-aware shading for an existing imported source.
/// Coordinates, triangle indices, topology and body identity remain unchanged.
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
if !angle_degrees.is_finite()
|| !(0.0..=180.0).contains(&angle_degrees)
|| !self.positions.len().is_multiple_of(3)
|| !self.triangles.len().is_multiple_of(3)
{
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
}
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
let mut positions = Vec::new();
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
let mut triangles = Vec::new();
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
let mut values = Vec::new();
values.try_reserve_exact(count).map_err(allocation)?;
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
self.corner_normals = values.into();
Ok(())
}
}
@@ -3963,35 +3928,12 @@ impl Document {
}
/// A component by id or name (0 / "root" is the design itself). A component that carries the
/// design's own name is found by that name: the design is still named by 0 / "root". An
/// ambiguous name finds nothing; commands use [`Self::resolve_component`] to report why.
/// design's own name is found by that name: the design is still named by 0 / "root".
pub fn find_component(&self, key: &str) -> Option<u64> {
self.resolve_component(key).ok().flatten()
}
/// Resolve a component reference without choosing between components with the same name.
/// Canonical numeric ids (including ids sent as strings by the UI) and the root aliases
/// take priority over names. Other numeric spellings, such as `01`, remain names.
pub fn resolve_component(&self, key: &str) -> Result<Option<u64>> {
if key == "0" || key.eq_ignore_ascii_case("root") {
return Ok(Some(0));
return Some(0);
}
if let Ok(id) = key.parse::<u64>()
&& id.to_string() == key
&& self.components.iter().any(|component| component.id == id)
{
return Ok(Some(id));
}
let mut matches = self.components.iter().filter(|component| component.name == key);
let Some(first) = matches.next() else { return Ok((key == self.name).then_some(0)) };
if let Some(second) = matches.next() {
let mut ids = vec![first.id, second.id];
ids.extend(matches.map(|component| component.id));
ids.sort_unstable();
let ids = ids.iter().map(u64::to_string).collect::<Vec<_>>().join(", ");
return Err(DocError::Invalid(format!("ambiguous component `{key}`; matching IDs: {ids}; use a numeric component ID")));
}
Ok(Some(first.id))
self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id).or_else(|| (key == self.name).then_some(0))
}
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
@@ -4450,18 +4392,6 @@ impl Document {
name
}
/// Create a model parameter with a name the caller chose (checked: valid and free).
pub fn new_named_model_param(&mut self, name: &str, expr_s: &str, unit: &str) -> Result<String> {
if let Some(why) = Self::param_name_problem(name) {
return Err(DocError::Invalid(why));
}
if self.name_taken(name) {
return Err(DocError::Invalid(format!("there is already a parameter `{name}`")));
}
self.params.push(Param { name: name.into(), expr: expr_s.into(), unit: unit.into(), comment: String::new(), model: true });
Ok(name.into())
}
/// Evaluate an expression against the document's parameters.
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
// The parameters are evaluated only when the expression names one: most expressions an
+49 -443
View File
@@ -55,9 +55,6 @@ pub struct ModelBody {
/// The display plan (none when the body has no plan: a mesh body, or one the planner
/// refuses), shared like the mesh. A GPU viewport draws it.
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
/// Each display plan in the form the GPU evaluates ([`crate::gpu_plan`]), made once from it
/// and shared like it, so the viewport sees the same allocation every frame.
gpu_plan: Arc<[OnceLock<Option<Arc<solidcore_tess::TessPlan>>>; 4]>,
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
/// GPU viewport draws, so a highlight over a face lies exactly on it.
plan_mesh: Arc<[OnceLock<Option<Arc<Mesh>>>; 4]>,
@@ -107,7 +104,6 @@ impl ModelBody {
mirrored_mesh: None,
opposite_hand: Arc::new(OnceLock::new()),
plan: Arc::new(Default::default()),
gpu_plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
key: 0,
names: Default::default(),
@@ -232,13 +228,6 @@ impl ModelBody {
}
}
/// The display plan at a given display quality in the form the GPU evaluates
/// ([`crate::gpu_plan`]): made once per level from [`ModelBody::display_plan_at`], the same
/// allocation on every call.
pub fn gpu_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<solidcore_tess::TessPlan>> {
self.gpu_plan.get(q.index())?.get_or_init(|| self.display_plan_at(q).map(|plan| Arc::new(crate::gpu_plan::gpu_plan(&plan)))).clone()
}
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
/// viewport evaluates and draws for this body. Highlights over a face drawn from the plan use
/// it, since the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and
@@ -365,14 +354,13 @@ impl ModelBody {
self.plan_mesh_at(q)
}
/// The display plan in the form the GPU evaluates ([`ModelBody::gpu_plan_at`]) and its mesh
/// `k` steps finer than `q` (view-dependent refinement, #1320;
/// The display plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320;
/// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer
/// steps are made on each call and kept by the viewport, which evicts them when it runs over
/// its budget. `None` when the body has no plan at that step.
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<solidcore_tess::TessPlan>, Arc<Mesh>)> {
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<TessPlan>, Arc<Mesh>)> {
if k == 0 {
return Some((self.gpu_plan_at(q)?, self.plan_mesh_at(q)?));
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
}
let plan = match self
.body
@@ -385,7 +373,7 @@ impl ModelBody {
}
};
let mesh = self.body.mesh_of(&plan).ok()?;
Some((Arc::new(crate::gpu_plan::gpu_plan(&plan)), Arc::new(mesh)))
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the definition's size, at least
@@ -1175,9 +1163,6 @@ pub struct Model {
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
/// The [`placement_digest`] each [`pose_only`] feature was last evaluated at, by feature id.
/// Their fingerprints leave it out, so a pose change doesn't reach the features after them.
poses: std::collections::HashMap<u64, u64>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
@@ -1239,15 +1224,12 @@ struct ImportDigests {
struct MeshDigest {
positions: Arc<[f64]>,
triangles: Arc<[u32]>,
corner_normals: Arc<[f64]>,
digest: u64,
}
impl MeshDigest {
fn matches(&self, mesh: &crate::MeshData) -> bool {
Arc::ptr_eq(&self.positions, &mesh.positions)
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
}
}
@@ -1266,12 +1248,7 @@ impl ImportDigests {
.enumerate()
.map(|(index, mesh)| {
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
MeshDigest {
positions: mesh.positions.clone(),
triangles: mesh.triangles.clone(),
corner_normals: mesh.corner_normals.clone(),
digest,
}
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
})
.collect();
out.meshes.insert(f.id, digests);
@@ -1300,9 +1277,6 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
coordinate.to_bits().hash(&mut h);
}
mesh.triangles.hash(&mut h);
for n in mesh.corner_normals.iter() {
n.to_bits().hash(&mut h);
}
h.finish()
}
@@ -1394,7 +1368,6 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
mesh.positions = Arc::from([]);
mesh.triangles = Arc::from([]);
mesh.corner_normals = Arc::from([]);
}
}
// Keep the complete definition (including mesh order, names and colors) without
@@ -1506,10 +1479,7 @@ fn chain_fingerprint(
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
// A pose-only feature's placement is checked on its own ([`Model::poses`]), so moving the
// parts it reads doesn't change the fingerprint of every feature after it (#12139).
let placed = if pose_only(f) { 0 } else { placement_digest(doc, f, st) };
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placed;
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
// 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();
@@ -1761,14 +1731,6 @@ impl PlacementReads {
self.link(doc, st, b);
}
// In the sketch's own frame: the component's origin, axes and planes, its own axes, text.
// A construction point is kept in its own component's frame: the sketch reads where
// that component is placed (#12075).
L::ConstructionPoint { name } => match doc.find_feature(name) {
Some(point) => {
self.components.insert(point.component);
}
None => self.all = true,
},
L::Origin | L::Axis { .. } | L::SketchAxis { .. } | L::Plane { .. } | L::Text { .. } => {}
}
}
@@ -1831,70 +1793,6 @@ fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>]
h.finish()
}
/// Whether only the bodies `f` makes follow where parts are placed: a mirror of placed bodies
/// into new bodies. A feature after it reads its copies only by naming them, so a pose change
/// need not evaluate features that don't (#12139).
fn pose_only(f: &Feature) -> bool {
matches!(&f.kind, FeatureKind::Mirror { features, bodies, placements, combine: false, .. }
if features.is_empty() && !bodies.is_empty() && !placements.is_empty())
}
/// The new bodies of pose-only `f`, evaluated again (to `state`) for its pose alone: the result
/// `old` was on the same definitions and made bodies of the same names, and nothing besides.
fn pose_moved(old: Option<&FeatureResult>, f: &Feature, fp: u64, state: &ModelState) -> Option<Vec<ModelBody>> {
let old = old.filter(|o| o.id == f.id && o.fingerprint == fp && !o.stale && !o.skipped && !o.unkept && o.error.is_none())?;
let made = |st: &ModelState| st.bodies.iter().filter(|b| b.feature == f.id).cloned().collect::<Vec<_>>();
let (before, after) = (made(&old.state), made(state));
let names = |bodies: &[ModelBody]| bodies.iter().map(|b| b.name.clone()).collect::<std::collections::BTreeSet<_>>();
let same = names(&before) == names(&after)
&& old.state.bodies.len() == state.bodies.len()
&& old.state.threads.len() == state.threads.len()
&& old.state.sheets.len() == state.sheets.len()
&& old.state.construct.len() == state.construct.len();
same.then_some(after)
}
/// `old` (a result after the moved pose-only features) with their new bodies in place of theirs.
fn with_moved(old: &Arc<ModelState>, moved: &[(u64, Vec<ModelBody>)]) -> Arc<ModelState> {
let mut st = (**old).clone();
for b in &mut st.bodies {
if let Some(new) = moved.iter().filter(|(id, _)| *id == b.feature).flat_map(|(_, bodies)| bodies).find(|n| n.name == b.name) {
*b = new.clone();
}
}
Arc::new(st)
}
/// Whether `f` (starting from `st`) reads none of the bodies of `moved`: it reads no bodies, or
/// only bodies it names or would cut or join, none of them those. Anything else (a picked face,
/// a projection, To Object, ...) is taken as reading them.
fn reads_none_of(f: &Feature, st: &ModelState, moved: &[(u64, Vec<ModelBody>)], components: &std::collections::HashMap<u64, u64>) -> bool {
if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() {
return false;
}
let reads: Vec<String> = match &f.kind {
_ if placement_reuse::local_geometry(f, components) => Vec::new(),
FeatureKind::Sketch { plane, sketch } if sketch.links.is_empty() && sketch.wires.is_empty() && placement_reuse::local_plane(plane, 0) => {
Vec::new()
}
FeatureKind::Mirror { features, bodies, plane, .. } if features.is_empty() && placement_reuse::local_plane(plane, 0) => {
bodies.iter().map(|b| b.0.clone()).collect()
}
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => return false,
FeatureKind::Revolve { to: Some(_), .. } | FeatureKind::Revolve { axis: AxisRef::Model { .. }, .. } => return false,
kind if drawn_tools_change_bodies(kind) => {
let mut reads = PlacementReads::default();
reads.drawn_targets(Some(st), f);
if reads.all {
return false;
}
reads.bodies.into_iter().collect()
}
_ => return false,
};
!moved.iter().flat_map(|(_, bodies)| bodies).any(|b| reads.contains(&b.name))
}
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
// No shortcut for a design without placements: its first component (placed where the
// design is) must not change what its features read, or creating it evaluates them all.
@@ -2295,11 +2193,10 @@ impl Model {
let mut state = self.empty.clone();
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
let posed = !pose_only(f) || self.poses.get(&f.id) == Some(&placement_digest(doc, f, Some(&state)));
if !r.skipped {
state = r.state.clone();
}
r.id == f.id && r.fingerprint == prev && posed
r.id == f.id && r.fingerprint == prev
})
}
@@ -2367,10 +2264,6 @@ impl Model {
feature_component.entry(f.id).or_insert(f.component);
}
let watched = crate::progress::begin(doc.features.len());
let mut poses = std::collections::HashMap::new();
// Pose-only features evaluated again for a pose change alone: their new bodies, which
// replace the old ones in the results reused after them (#12139).
let mut moved: Vec<(u64, Vec<ModelBody>)> = Vec::new();
// The features that failed so far in this pass, by id: their names and errors (#9075).
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
for (i, f) in doc.features.iter().enumerate() {
@@ -2399,12 +2292,7 @@ impl Model {
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let pose = pose_only(f).then(|| placement_digest(doc, f, Some(&state)));
if let Some(pose) = pose {
poses.insert(f.id, pose);
}
let posed = pose.is_none_or(|pose| self.poses.get(&f.id) == Some(&pose));
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale && posed;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
// results after it that the model did keep still stand (#3220).
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
@@ -2415,28 +2303,14 @@ impl Model {
// has a result only while those picks are what they were.
if reuse
&& !unkept
&& let Some(old) = self
.results
.get(i)
.filter(current)
.filter(|old| moved.is_empty() || old.skipped || reads_none_of(f, &state, &moved, &feature_component))
.filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
{
if moved.is_empty() {
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
} else {
if !old.skipped {
state = with_moved(&old.state, &moved);
}
out.push(FeatureResult { state: state.clone(), ..old.clone() });
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
continue;
}
// Reuse goes on past a pose-only feature evaluated again for its pose alone.
let reusing = reuse && !unkept && pose.is_some();
reuse = unkept && !cancelled;
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
@@ -2512,14 +2386,6 @@ impl Model {
}
// A kept result after a failed one would not be this evaluation's.
reuse &= c.error.is_none();
if reusing
&& c.error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2541,9 +2407,8 @@ impl Model {
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled
.as_ref()
.filter(|_| moved.is_empty())
.filter(current)
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
{
restored += 1;
@@ -2684,14 +2549,6 @@ impl Model {
}
}
reuse &= error.is_none();
if reusing
&& error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2709,7 +2566,6 @@ impl Model {
});
}
self.placement_local = self.placement_local && out.iter().all(|r| !r.skipped && !r.stale && !r.unkept && r.error.is_none());
self.poses = poses;
self.results = out;
self.last_recomputed = recomputed;
self.recomputed += recomputed;
@@ -3294,53 +3150,10 @@ fn apply_op(doc: &Document, state: &mut ModelState, f: &Feature, tools: Vec<Body
apply_traced(doc, state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
}
/// The most cut tools [`clear_tools_as_one`] joins into one tool. The kernel's boolean takes
/// far longer for a tool of many pieces than for several of fewer: one cut of 20 apart pieces
/// into a ring took 0.34 s, of 40 1.75 s, of 60 6.4 s, of about 105 (a pattern of a pattern,
/// #8765) 23 s. So more tools are joined in groups of at most this many, each cut in turn. This
/// works around the boolean's cost; it does not change what is cut.
const MAX_JOINED_TOOLS: usize = 32;
#[cfg(test)]
thread_local! {
/// [`MAX_JOINED_TOOLS`] for this thread's tests: a test cuts the same tools in one go.
static JOINED_TOOLS_LIMIT: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_JOINED_TOOLS) };
}
#[cfg(test)]
fn joined_tools_limit() -> usize {
JOINED_TOOLS_LIMIT.with(std::cell::Cell::get)
}
#[cfg(not(test))]
fn joined_tools_limit() -> usize {
MAX_JOINED_TOOLS
}
/// Many cut tools clear of each other (a pattern's copies, the circles of a vent field in one
/// sketch) as one tool of many pieces, so the target is cut once instead of rebuilding an ever
/// more complex body for every tool (#261, #368). Tools that may meet are left as they are.
/// More than [`MAX_JOINED_TOOLS`] tools are joined in even groups, one tool for each (#8765).
fn clear_tools_as_one(tools: Vec<Traced>) -> Result<Vec<Traced>> {
let limit = joined_tools_limit().max(1);
if tools.len() <= limit {
return clear_group_as_one(tools);
}
let groups = tools.len().div_ceil(limit);
let size = tools.len().div_ceil(groups);
let mut rest = tools.into_iter();
let mut out = Vec::new();
loop {
let group: Vec<Traced> = rest.by_ref().take(size).collect();
if group.is_empty() {
return Ok(out);
}
out.extend(clear_group_as_one(group)?);
}
}
/// [`clear_tools_as_one`] for one group of tools.
fn clear_group_as_one(tools: Vec<Traced>) -> Result<Vec<Traced>> {
if tools.len() < 8 {
return Ok(tools);
}
@@ -4711,33 +4524,6 @@ fn line_in_sketch_plane(ss: &SolvedSketch, origin: Vec3, dir: Vec3) -> Result<(V
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
/// A construction point `name` of the timeline so far, as evaluated, in the frame `frame` places
/// in the world: the frame a sketch is evaluated in, its component's placement or the placement
/// of the face it is attached to (#12075). The point is kept in its own component's frame.
pub(crate) fn construction_point_in(doc: &Document, st: &ModelState, name: &str, author: u64, frame: &Mat) -> Result<Vec3> {
let c = st
.construct
.iter()
.find(|c| c.name == name)
.ok_or_else(|| DocError::Unknown(format!("construction point `{name}` (it must come earlier in the timeline)")))?;
let crate::construct::ConstructGeom::Point(p) = &c.geom else {
return Err(DocError::Invalid(format!("`{name}` is not a construction point")));
};
if doc.occurrences.is_empty() {
return Ok(*p);
}
let owner = doc.feature(c.feature).map_or(0, |f| f.component);
// The same definition is authored in this occurrence, even when it is not the first.
// Its point is already in the author coordinates; using the first occurrence here
// would introduce the difference between the two placements.
if owner == author {
return Ok(*p);
}
let inverse = crate::mat_inverse(frame).ok_or_else(|| DocError::Invalid("the sketch's frame cannot be inverted".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
Ok(crate::apply_point(&relative, *p))
}
/// A construction axis `name` of the timeline so far, as evaluated, in the frame of component
/// `comp`: a point on it and its direction. Without a document (`doc` None) it is taken in its
/// own component's frame.
@@ -4968,13 +4754,9 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
let tol = (radius * 1e-6).max(1e-6);
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
let mut best: Option<(f64, Vec3)> = None;
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -5001,15 +4783,12 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64,
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
}
}
}
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
best.map(|(_, wall)| wall)
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -7162,21 +6941,6 @@ fn replay(
}
continue;
}
// An Offset Face repeats at the copied faces of the body they lie on.
if let FeatureKind::OffsetFace { faces, distance, body } = &src.kind {
let d = val(vals, distance, Kind::Length)?;
let picked = crate::plain(faces);
for m in mats {
let moved: Vec<Vec3> = picked.iter().map(|p| crate::apply_point(m, *p)).collect();
let i = body_at(st, body, &moved)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = mb.traced().then(kernel::offset_faces(&mb.body, &moved, d)?);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
}
continue;
}
// A moved copy is copied again as a body: each instance is the copy the Move made,
// placed by the pattern (#5931).
if let FeatureKind::Move { copy: true, .. } = &src.kind {
@@ -7195,30 +6959,6 @@ fn replay(
}
continue;
}
// A thread is cut again on the copy of its face. A copy keeps the thread's hand: the
// mirror of a right-hand M8 hole is a right-hand M8 hole, not a left-hand one.
if matches!(src.kind, FeatureKind::Thread { .. }) {
for m in mats {
let mut copy = (*src).clone();
copy.id = f.id;
copy.face_names.clear();
if let FeatureKind::Thread { face, from, up_to, .. } = &mut copy.kind {
face.0 = crate::apply_point(m, face.0);
if let Some(q) = from {
q.0 = crate::apply_point(m, q.0);
}
match up_to {
Some(crate::ThreadLimit::Face(q)) => q.0 = crate::apply_point(m, q.0),
Some(crate::ThreadLimit::Plane(_)) => {
return Err(DocError::Invalid(format!("{} ends at a plane, so it cannot be patterned or mirrored yet", src.name)));
}
None => {}
}
}
thread::eval(doc, vals, &copy, st, warning)?;
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
@@ -7755,28 +7495,12 @@ fn eval_feature(
let mut constructor = None;
let mut to_body = None;
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body, warning)?;
// #9969: a cut whose tool lies clear of the body (a profile drawn beside it, a
// distance that stops short, the wrong direction) reported success and changed
// nothing. Say so, as a hole does (#10701): the bodies are compared before and after,
// whether the cut went through sheet metal's flat pattern or the generic path.
let before: Option<Vec<Body>> = (*operation == Operation::Cut).then(|| st.bodies.iter().map(|b| b.body.clone()).collect());
let unchanged = |st: &ModelState, warning: &mut Option<String>| {
if let Some(before) = &before
&& !before.is_empty()
&& st.bodies.len() == before.len()
&& st.bodies.iter().zip(before).all(|(b, old)| old.same(&b.body))
{
append_warning(warning, "the cut removes no material; check where its profile lies, the direction and the distance".into());
}
};
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, &crate::plain(targets), constructor.as_ref(), warning)?;
if !handled.is_empty() {
apply_beside_sheet_cut(doc, st, f, tools, *operation, &crate::plain(targets), &handled)?;
unchanged(st, warning);
return Ok(());
return apply_beside_sheet_cut(doc, st, f, tools, *operation, &crate::plain(targets), &handled);
}
}
// A join one sheet thick in the plane of a panel becomes part of that panel. A Join to an
@@ -7790,11 +7514,9 @@ fn eval_feature(
// A Join extrude to an object joins that object too (#6424): the tool and the body
// it runs to are one body, not only the body the profile was sketched on.
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)?;
unchanged(st, warning);
Ok(())
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7809,19 +7531,6 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7857,77 +7566,42 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
continue;
}
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
let available = t.end - t.start;
if depth > available {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
}
}
st.threads.extend(threads);
st.threads.push(t);
}
}
Ok(())
@@ -8024,12 +7698,7 @@ fn eval_feature(
// 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,
&*construction_planes_in_frame(doc, st, f.component, plane)?,
0,
Some(&*construction_state(doc, st, f.component)?),
)?,
None => doc.resolve_plane_in(vals, plane, 0, Some(&*construction_state(doc, st, f.component)?))?,
};
st.construct.push(crate::construct::Construct {
feature: f.id,
@@ -8674,19 +8343,17 @@ fn eval_sketch(
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, &*construction_planes_in_frame(doc, st, f.component, other)?, 0, Some(&fst))?, None),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
// The frame the sketch is evaluated in, where a construction point it projects is
// brought from its own component's (#12075).
let link_frame = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
Some(Ok(frame)) => frame,
_ => doc.component_transform(f.component),
let mut warns = match (anchor, lost_names) {
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
};
let mut warns = crate::project::refresh_links_with(doc, vals, &fst, &plane, &mut sk, anchor, lost_names, Some(&(f.component, link_frame)))?;
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
@@ -8731,17 +8398,7 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
for m in meshes {
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
}
let normals: Vec<[Vec3; 3]> = m
.corner_normals
.as_chunks::<9>()
.0
.iter()
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
.collect();
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
out.push((m.name.clone(), b.with_color(m.color)));
}
Ok(out)
@@ -9207,7 +8864,6 @@ impl PlacedBodies {
fresh.joint_faces = placed.joint_faces.clone();
fresh.mesh_placement = placed.mesh_placement.clone();
fresh.plan = placed.plan.clone();
fresh.gpu_plan = placed.gpu_plan.clone();
fresh.plan_mesh = placed.plan_mesh.clone();
fresh
})
@@ -9277,7 +8933,6 @@ fn in_frame(b: &ModelBody, m: &crate::Mat, placed: &ModelBody) -> ModelBody {
let mut moved = ModelBody::new(b.name.clone(), placed.body.clone(), b.feature);
moved.mesh = placed.mesh.clone();
moved.plan = placed.plan.clone();
moved.gpu_plan = placed.gpu_plan.clone();
moved.plan_mesh = placed.plan_mesh.clone();
moved.component = b.component;
moved.transferred = b.transferred;
@@ -9444,55 +9099,6 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
Ok(out)
}
/// `plane` for a feature of component `comp`, with each construction plane of another component
/// (under any offset or flip of it) given as the plane where that component puts it, in `comp`'s
/// frame (#11734). A construction plane is kept in its own component's frame, as the axes are
/// (`construction_axis_in`), so a plane read as it is would fall in the wrong place for a feature
/// of a component placed differently.
fn construction_planes_in_frame<'a>(doc: &Document, st: &ModelState, comp: u64, plane: &'a PlaneRef) -> Result<std::borrow::Cow<'a, PlaneRef>> {
use std::borrow::Cow;
// The component of a feature, read from the timeline without `Document::feature`'s search counter.
fn owner_of(doc: &Document, id: u64) -> Option<u64> {
doc.features.iter().find(|f| f.id == id).map(|f| f.component)
}
fn foreign(doc: &Document, comp: u64, plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Construction { feature } => owner_of(doc, feature.0).is_some_and(|owner| owner != comp),
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::ThroughPoint { base, .. } => {
foreign(doc, comp, base)
}
_ => false,
}
}
fn map(doc: &Document, st: &ModelState, comp: u64, plane: &mut PlaneRef) -> Result<()> {
match plane {
PlaneRef::Construction { feature } => {
let owner = owner_of(doc, feature.0).unwrap_or(comp);
if owner == comp {
return Ok(());
}
if let Some(crate::construct::ConstructGeom::Plane(pl)) = st.construct.iter().find(|c| c.feature == feature.0).map(|c| &c.geom) {
let inverse = crate::mat_inverse(&doc.component_transform(comp))
.ok_or_else(|| DocError::Invalid("the feature's component frame is invalid".into()))?;
let m = crate::mat_mul(&inverse, &doc.component_transform(owner));
*plane = PlaneRef::Custom { plane: placed_plane(&m, pl)? };
}
}
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::ThroughPoint { base, .. } => {
map(doc, st, comp, base)?
}
_ => {}
}
Ok(())
}
if doc.occurrences.is_empty() || !foreign(doc, comp, plane) {
return Ok(Cow::Borrowed(plane));
}
let mut mapped = plane.clone();
map(doc, st, comp, &mut mapped)?;
Ok(Cow::Owned(mapped))
}
/// `plane` moved by the placement `m`; an error when `m` does not keep it a plane (a placement
/// that is not a rigid motion, which [`Document`] does not take).
fn placed_plane(m: &crate::Mat, plane: &Plane) -> Result<Plane> {
+1 -44
View File
@@ -911,7 +911,6 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
}],
},
None,
@@ -925,7 +924,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
assert!(!model.evaluate_shared(&doc));
assert!(Arc::ptr_eq(&original, &model.state()));
for edit in 0..6 {
for edit in 0..5 {
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
let mesh = &mut meshes[0];
match edit {
@@ -933,7 +932,6 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
2 => mesh.color = Some([0.2, 0.4, 0.6]),
3 => mesh.name = "Renamed".into(),
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
}
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
@@ -1046,7 +1044,6 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
};
let copy = mesh.clone();
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
@@ -1707,35 +1704,6 @@ fn copies_of_a_hole_that_come_close_are_cut_one_by_one() {
assert!((v - (disc - 16.0 * hole)).abs() < 1e-3 * hole, "{v}");
}
#[test]
fn many_copies_cut_in_groups_leave_what_one_cut_leaves() {
// 48 holes on a 40 mm circle, 5.2 mm apart: the 47 copies are more than one joined tool
// takes, so they are cut as two (#8765). The disc comes out as cutting all of them in one go
// leaves it: the same volume, the same faces, named the same.
let disc = |limit: usize| {
super::JOINED_TOOLS_LIMIT.with(|l| l.set(limit));
let mut doc = Document::new("perforated");
disc_with_hole_pattern(&mut doc, 40.0, 48);
let mut model = Model::new();
let work = counted(|| model.evaluate(&doc));
super::JOINED_TOOLS_LIMIT.with(|l| l.set(super::MAX_JOINED_TOOLS));
assert!(model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", model.results);
let st = model.state();
assert_eq!(st.bodies.len(), 1);
let mut names = crate::naming::face_names(&st.bodies[0]).to_vec();
names.sort();
(disc_volume(&model), st.bodies[0].body.face_count(), names, work.cut_tools)
};
let (volume, faces, names, tools) = disc(super::MAX_JOINED_TOOLS);
let (one_volume, one_faces, one_names, one_tools) = disc(usize::MAX);
assert_eq!((tools, one_tools), (2, 1));
let hole = std::f64::consts::PI * 1.75 * 1.75 * 1.5;
assert!((volume - one_volume).abs() < 1e-6 * hole, "{volume} vs {one_volume}");
assert!((one_volume - (std::f64::consts::PI * 2500.0 * 1.5 - 48.0 * hole)).abs() < 1e-3 * hole, "{one_volume}");
assert_eq!(faces, one_faces);
assert_eq!(names, one_names);
}
/// A ring (25 mm outside, 16 mm bore, 55 mm high) with a blind groove, 8 mm wide and 30 mm deep,
/// cut into its bore from below, in a circular pattern of ten about Z: their boxes, grown as the
/// kernel grows them, meet, their exact shapes are 1.9 mm apart. The ring's faces counted as its
@@ -1803,7 +1771,6 @@ fn imported_meshes() -> Document {
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
color: Some([0.2, 0.4, 0.6]),
corner_normals: Default::default(),
})
.collect();
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
@@ -1835,16 +1802,6 @@ fn same_as_fresh(doc: &Document, model: &Model) {
#[test]
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
let mut doc = imported_meshes();
for mesh in meshes_mut(&mut doc) {
mesh.generate_crease_normals(10.0).unwrap();
assert!(!mesh.corner_normals.is_empty());
}
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
for mesh in compact[0]["meshes"].as_array().unwrap() {
assert_eq!(mesh["positions"], serde_json::json!([]));
assert_eq!(mesh["triangles"], serde_json::json!([]));
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
}
let mut model = Model::new();
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
for k in 1..5 {
+2 -57
View File
@@ -130,57 +130,6 @@ impl Document {
self.resolve_attached_plane_noted(vals, plane, author, state, framed).map(|(plane, _)| plane)
}
/// A sketch attached by 0.30.1 to the cap of an extrude (an `extrude_cap` certificate), whose
/// cap the SMLib kernel no longer marks as that extrude's after a join (#13323): the attachment
/// also stores the cap's history name, a point on it and the plane the sketch was in. The sketch
/// keeps that plane when exactly that face is still there: the name finds the face, made by
/// the extrude the certificate names, on a body whose certified cap is nowhere else, a saved
/// point lies on it, and it still lies in the saved plane. A moved or edited face stays lost.
fn old_cap_plane(
&self,
anchor: &FaceAnchor,
name: Option<&str>,
at: solidcore_record::Vec3,
saved: &Plane,
state: &ModelState,
) -> Option<(Plane, Option<String>)> {
let target = &anchor.target;
let Some(crate::config::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }) = &target.certificate else { return None };
if !anchor.path.is_empty() || !anchor.author_path.is_empty() || anchor.placement.is_some() {
return None;
}
let name = name.filter(|n| crate::persistent_name::made_by(crate::naming::strip_piece(n)) == Some(target.origin_feature))?;
let body = state.body(&target.body)?;
let solid = self.feature(target.origin_feature).is_some_and(|f| matches!(f.kind, FeatureKind::Extrude { thin: None, .. }));
if !solid || body.body.is_mesh() || body.definition_name() != target.body || !at.is_finite() {
return None;
}
// The checks below compare the body as built, before its offset: an offset body stays lost.
if self.model_body_offset(body).is_some_and(|offset| *offset != crate::IDENTITY) {
return None;
}
// The certified cap must be gone: while some body still has it, the attachment is exact.
let certified = |source: &Option<solidcraft_kernel::FaceSource>| {
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch: from, high: end, outer: around, holes: inside, .. })
if *producer == target.origin_feature && from == sketch && end == high && around == outer && inside == holes)
};
if state.bodies.iter().any(|other| other.body.face_sources().is_some_and(|sources| sources.iter().any(certified))) {
return None;
}
let indices = self.lineage_face_indices(body, name).ok()?;
let tolerance = (body.body.size() * 1e-6).max(1e-6);
let same_plane = |index: usize| {
body.body.planar_face(index).is_some_and(|face| {
face.normal().dot(saved.normal()) > 1. - 1e-8 && (face.origin - saved.origin).dot(saved.normal()).abs() <= tolerance
})
};
if indices.is_empty() || !indices.iter().all(|index| same_plane(*index)) || !crate::name_lookup::locate_on(body, name, at)?.on_point {
return None;
}
let note = format!("the face this sketch sits on was found again by its name `{name}`: its extrude-cap certificate no longer marks it");
Some((*saved, Some(note)))
}
/// [`Document::resolve_attached_plane`] with the note of an attachment found by fallback
/// ([`Document::anchored_face_indices`]).
pub fn resolve_attached_plane_noted(
@@ -196,12 +145,8 @@ impl Document {
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
}
match plane {
PlaneRef::Face { anchor: Some(anchor), name, at, plane: saved } => {
let found = match doc.resolve_face_anchor_noted(anchor, author, state) {
Ok(found) => found,
Err(error) => doc.old_cap_plane(anchor, name.as_deref(), *at, saved, state).ok_or(error)?,
};
let (resolved, note) = found;
PlaneRef::Face { anchor: Some(anchor), .. } => {
let (resolved, note) = doc.resolve_face_anchor_noted(anchor, author, state)?;
*plane = PlaneRef::Custom { plane: resolved };
Ok(note)
}
@@ -1,33 +0,0 @@
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
//! triangle, independent of positions, topology and optical boundary certification.
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
//! preserve authored corner normals and smoothing groups. Explicit crease generation
//! stores only shading metadata and preserves original coordinates and triangle indices.
//! Older released readers warn about this format and retain fields they cannot read.
//!
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
//! texture sampling. Supported consumers use the captured texture's mip chain and
//! ray footprint without changing source image identity or source color semantics.
//!
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
//!
//! Caustics options may save `area_lights: true` and `environment: true` for
//! both path tracers. Omission preserves the hard-source photon map.
//!
//! Caustics may also save `guided_emission: true` to concentrate photon emission
//! around captured optical bounds with probability-corrected power. Omission or
//! false preserves the original emission proposal. Older released readers warn
//! about the newer format and retain fields they cannot interpret when saving.
//!
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
//! pass. Omission or false retains one map per render job.
//!
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
//! using estimated source power with a uniform support component. Each source
//! retains its matching probability correction. Omission or false preserves
//! uniform source selection and the original random sequence.
//!
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
//! HDR radiance distribution for lighting and photon emission. Omission or false
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
@@ -1,6 +0,0 @@
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
//! Older designs retain native-size output, three full-strength denoising passes and no firefly suppression.
//! DenoiseSettings.optical_filter defaults to true for supported mirror/glass filtering; an explicit false
//! restores legacy opaque-only denoising and is retained in CPU and GPU setups. No rewrite is needed.
//! Omitted CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
//! The last release warns about this newer design format and preserves fields it cannot read.
@@ -1,6 +0,0 @@
//! A sketch link can save a named construction point as its source
//! (`{"type": "construction_point", "name": "Point1"}`): Project makes a linked point where the
//! construction point projects onto the sketch plane, and the point follows it (#12075). An older
//! design has no such link and opens as before. The last release opens a design that has one with
//! a warning about the newer format, keeps the sketch it can't read in its place on the timeline,
//! and saves it back as it came (`crates/doc/src/newer_file.rs`).
-32
View File
@@ -1,32 +0,0 @@
//! A body's display plan in the form the viewport evaluates on the GPU. The kernel plans with the
//! one mesher in solidcore-tess (RFC 0003, item 1.1), so that form is the plan itself; the model
//! still caches it apart from the display plan, so the viewport's cache, keyed by each plan's
//! allocation, keeps its uploads from frame to frame.
use solidcraft_geom::tess::TessPlan;
/// `plan` in the form the viewport evaluates on the GPU: solidcore-tess's plan, which the kernel's
/// already is.
pub fn gpu_plan(plan: &TessPlan) -> solidcore_tess::TessPlan {
plan.clone()
}
#[cfg(test)]
mod tests {
use solidcore_record::Vec3;
/// A body's GPU plan is made once per display quality and then read as the same allocation,
/// by the body and its clones (the viewport keys its uploads by it), equal to its display plan
/// converted.
#[test]
fn a_bodys_gpu_plan_is_one_allocation_across_reads() {
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 8.0, 4.0)).unwrap();
let b = crate::ModelBody::new("Body1".into(), body, 0);
let q = crate::display_quality::DisplayQuality::default();
let first = b.gpu_plan_at(q).unwrap();
assert!(std::sync::Arc::ptr_eq(&first, &b.gpu_plan_at(q).unwrap()));
assert!(std::sync::Arc::ptr_eq(&first, &b.clone().gpu_plan_at(q).unwrap()));
assert!(std::sync::Arc::ptr_eq(&first, &b.refined_display(q, 0).unwrap().0));
assert_eq!(*first, super::gpu_plan(&b.display_plan_at(q).unwrap()));
}
}
+9 -176
View File
@@ -5,14 +5,13 @@
use solidcraft_geom::prelude::*;
use std::cell::Cell;
use std::collections::BTreeSet;
use std::f64::consts::{PI, TAU};
use solidcore_record::Vec3;
use solidcraft_sketch::linalg;
use super::{
Assembly, Held, JointKind, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, jacobian,
joint_values, largest, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, jacobian, joint_values,
largest, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
};
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
@@ -126,27 +125,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
return Err(format!("its chain and loops have more than {MAX_LOOP_VALUES} values"));
}
let scale = loop_scale(tree, &around, &vals);
let raw_bounds = limit_bounds(tree, &vars);
let mut bounds = raw_bounds.clone();
let start: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
// A ball joint moves by a turn from where it starts, not by its Euler angles (`BallTurn`).
let balls = ball_turns(tree, &vars, &start, &raw_bounds);
let mut x0 = start.clone();
for k in balls.iter().flat_map(|b| b.at..b.at + 3) {
if let (Some(x), Some(b)) = (x0.get_mut(k), bounds.get_mut(k)) {
(*x, *b) = (0.0, (f64::NEG_INFINITY, f64::INFINITY));
}
}
// The joints' values for the solve's `x`.
let values = |x: &[f64]| -> Vec<f64> {
let mut v = x.to_vec();
for b in &balls {
if let (Some(q), Some(slot)) = (b.values(x), v.get_mut(b.at..b.at + 3)) {
slot.copy_from_slice(&q);
}
}
v
};
let bounds = limit_bounds(tree, &vars);
let x0: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
// A distance steps by a share of the chain's size, of the way to the target or of the point's
// reach from its joints, whichever is longest (one slider on its own has no size, and a part
// turning about a joint far from its point moves that far).
@@ -167,9 +147,7 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
// a value a link sets can turn far more than the one that drives it, and a loop must stay
// closed on the way, as joint.drive steps its loops.
let mut paced: Vec<((u64, usize), f64)> = Vec::new();
// A ball joint is paced by its turn (`cap`) instead: near pitch 0 a small turn changes its yaw
// and roll a lot.
for j in around.iter().filter_map(|id| tree.joints.get(id)).filter(|j| !balls.iter().any(|b| b.id == j.id)) {
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
for i in 0..j.kind.dofs().len() {
paced.push(((j.id, i), if j.kind.is_angle(i) { STEP_ANGLE.to_radians() } else { STEP_SHARE * span }));
}
@@ -179,11 +157,11 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
let spent = Cell::new(0usize);
let mut work = vals.clone();
let mut workspace = super::PlacementWorkspace::new(tree);
let mut f = |x: &[f64]| misses(&mut workspace, asm, r, scale, &vars, &closers, &values(x), &mut work, &spent);
let mut f = |x: &[f64]| misses(&mut workspace, asm, r, scale, &vars, &closers, x, &mut work, &spent);
let mut x = x0.clone();
let miss = |res: &[f64]| res.iter().take(mt).map(|v| v * v).sum::<f64>().sqrt();
let before = f(&x0).map_or(f64::INFINITY, |r| miss(&r));
let res = approach(&mut f, &|a, b| stride(asm, &vals, &vars, &paced, &values(a), &values(b)), mt, &mut x, &bounds, &cap, tol, loose_gap(scale))
let res = approach(&mut f, &|a, b| stride(asm, &vals, &vars, &paced, a, b), mt, &mut x, &bounds, &cap, tol, loose_gap(scale))
.ok_or("a closed loop on its chain is open and can't close (joint.list shows its conflict)")?;
let mut out = Reached::default();
let point_miss = res.iter().take(3).map(|v| v * v).sum::<f64>().sqrt();
@@ -206,17 +184,6 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
}
}
}
// A ball joint's value held at its limit (its turn isn't bounded; its values are).
for b in &balls {
for (i, (v, (lo, hi))) in b.values(&x).unwrap_or(b.start).iter().zip(b.bounds).enumerate() {
let at = |l: f64| l.is_finite() && (v - l).abs() <= 1e-9 * l.abs().max(1.0);
if at(hi) {
out.limits.push((b.id, i, true));
} else if at(lo) {
out.limits.push((b.id, i, false));
}
}
}
// A value a motion link sets, held at its own limit (the link clamps it there): the
// target's pull on it as if it were free.
if f(&x).is_some() {
@@ -253,8 +220,7 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
let mut alone = vals.clone();
let mut alone_workspace = super::PlacementWorkspace::new(tree);
let mut g = |x: &[f64]| misses(&mut alone_workspace, asm, r, scale, &drive, &[], x, &mut alone, &spent);
let (mut xo, bo, co) =
(start.get(..k).unwrap_or_default().to_vec(), raw_bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
let (mut xo, bo, co) = (x0.get(..k).unwrap_or_default().to_vec(), bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
// The loops left open, nothing to pace.
if let Some(ro) = approach(&mut g, &|_, _| 0.0, mt, &mut xo, bo, co, tol, loose_gap(scale))
&& miss(&ro) < miss(&res) - REACH_TOL.max(1e-9 * miss(&res))
@@ -266,7 +232,7 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
// The stored values that changed, when that got it closer (by more than the solver's noise:
// a held part doesn't creep).
let gained = before - miss(&res) > 1e-7;
for ((id, i), v) in vars.iter().zip(&values(&x)).filter(|_| gained) {
for ((id, i), v) in vars.iter().zip(&x).filter(|_| gained) {
let Some(j) = tree.joints.get(id) else { continue };
if j.values.get(*i) == Some(v) {
continue;
@@ -283,112 +249,6 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
Ok(out)
}
/// A unit quaternion (w, x, y, z): a turn that composes without the lock of Euler angles.
#[derive(Clone, Copy, Debug)]
struct Quat([f64; 4]);
impl Quat {
fn mul(self, o: Quat) -> Quat {
let ([a1, b1, c1, d1], [a2, b2, c2, d2]) = (self.0, o.0);
Quat([
a1 * a2 - b1 * b2 - c1 * c2 - d1 * d2,
a1 * b2 + b1 * a2 + c1 * d2 - d1 * c2,
a1 * c2 - b1 * d2 + c1 * a2 + d1 * b2,
a1 * d2 + b1 * c2 - c1 * b2 + d1 * a2,
])
}
/// The turn by the rotation vector `w`: |w| radians about its direction.
fn exp([x, y, z]: [f64; 3]) -> Quat {
let t = (x * x + y * y + z * z).sqrt();
let (s, c) = (t / 2.0).sin_cos();
let k = if t < 1e-12 { 0.5 } else { s / t };
Quat([c, x * k, y * k, z * k])
}
/// A ball joint's turn for its (yaw, pitch, roll): about z, then y, then z (`motion`).
fn zyz([a, b, c]: [f64; 3]) -> Quat {
let (sa, ca) = (a / 2.0).sin_cos();
let (sb, cb) = (b / 2.0).sin_cos();
let (sc, cc) = (c / 2.0).sin_cos();
Quat([ca, 0.0, 0.0, sa]).mul(Quat([cb, 0.0, sb, 0.0])).mul(Quat([cc, 0.0, 0.0, sc]))
}
/// Its (yaw, pitch, roll): of the two sets (at pitch 0 or 180°, the many), the one nearest
/// `near`, each angle a whole number of turns from where it would wrap.
fn to_zyz(self, near: [f64; 3]) -> [f64; 3] {
let [a0, b0, c0] = near;
let n = self.0.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-300);
let [w, x, y, z] = self.0.map(|v| v / n);
let r22 = 1.0 - 2.0 * (x * x + y * y);
let (r02, r12) = (2.0 * (x * z + w * y), 2.0 * (y * z - w * x));
let (r20, r21) = (2.0 * (x * z - w * y), 2.0 * (y * z + w * x));
let (r00, r10) = (1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + w * z));
// Not acos(r22): that rounds a turn under about 1e-8 to none, and a finite difference
// there sees nothing tilt.
let sin_b = r02.hypot(r12);
let b = sin_b.atan2(r22);
if sin_b > 1e-12 {
let (a, c) = (r12.atan2(r02), r21.atan2(-r20));
let one = [wrap(a, a0), wrap(b, b0), wrap(c, c0)];
let two = [wrap(a + PI, a0), wrap(-b, b0), wrap(c + PI, c0)];
let off = |v: &[f64; 3]| (v[0] - a0).abs() + (v[1] - b0).abs() + (v[2] - c0).abs();
if off(&two) < off(&one) { two } else { one }
} else if r22 > 0.0 {
// Pitch 0: only yaw + roll is known; yaw stays.
[a0, wrap(0.0, b0), wrap(r10.atan2(r00) - a0, c0)]
} else {
// Pitch 180°: only roll − yaw is known; yaw stays.
[a0, wrap(PI, b0), wrap(a0 + r10.atan2(-r00), c0)]
}
}
}
/// `t` moved by whole turns to lie nearest `near`.
fn wrap(t: f64, near: f64) -> f64 {
t + TAU * ((near - t) / TAU).round()
}
/// A ball joint whose three values all move in a reach. It is solved over a small turn from where
/// it starts (a rotation vector in its own frame, radians) instead of its Euler angles, which lock
/// at pitch 0: there yaw and roll turn about the same axis, and nothing tilts it the other way
/// (#12873).
struct BallTurn {
id: u64,
/// Where its yaw is in the solve's values; pitch and roll follow.
at: usize,
from: Quat,
start: [f64; 3],
bounds: [(f64, f64); 3],
}
impl BallTurn {
/// Its (yaw, pitch, roll) for the turn in `x`, each held within its limits (pitch is the
/// angle between the joint's z axes, so its limits hold it in a cone, its yaw still free).
fn values(&self, x: &[f64]) -> Option<[f64; 3]> {
let w = x.get(self.at..self.at + 3)?;
let v = self.from.mul(Quat::exp([*w.first()?, *w.get(1)?, *w.get(2)?])).to_zyz(self.start);
let mut out = [0.0; 3];
for ((o, v), (lo, hi)) in out.iter_mut().zip(v).zip(self.bounds) {
*o = v.max(lo).min(hi);
}
Some(out)
}
}
/// The ball joints among `vars` with all three values there, in order.
fn ball_turns(tree: &Tree, vars: &[(u64, usize)], start: &[f64], bounds: &[(f64, f64)]) -> Vec<BallTurn> {
vars.windows(3)
.enumerate()
.filter_map(|(at, w)| {
let [(id, 0), (i1, 1), (i2, 2)] = w else { return None };
(id == i1 && id == i2 && tree.joints.get(id).is_some_and(|j| j.kind == JointKind::Ball)).then_some(())?;
let s = [*start.get(at)?, *start.get(at + 1)?, *start.get(at + 2)?];
Some(BallTurn { id: *id, at, from: Quat::zyz(s), start: s, bounds: [*bounds.get(at)?, *bounds.get(at + 1)?, *bounds.get(at + 2)?] })
})
.collect()
}
/// The target's misses with the values `vars` at `x`: the point's (mm), then the axis's (as mm
/// across the chain's `scale`), then the gaps of the loops `closers`, weighted.
fn misses(
@@ -608,33 +468,6 @@ mod tests {
use super::super::tests::{PLACEMENT_INSERTS, PLACEMENT_STEPS, chain, placement_tree};
use super::*;
/// A ball joint's quaternion is the turn `motion` places it by, and its angles come back
/// from it: the nearer of the two sets, and at pitch 0 the yaw it had.
#[test]
fn a_ball_turn_matches_the_joint_and_comes_back() {
let rot = |q: Quat, v: Vec3| {
let [w, x, y, z] = q.0;
let u = Vec3::new(x, y, z);
v + u.cross(v) * (2.0 * w) + u.cross(u.cross(v)) * 2.0
};
for zyz in [[0.3, 0.4, -1.2], [-2.0, -0.5, 0.7], [1.0, 3.0, 0.2], [0.5, 0.0, 0.25]] {
let (q, m) = (Quat::zyz(zyz), super::super::motion(JointKind::Ball, &zyz));
for v in [Vec3::X, Vec3::Y, Vec3::Z] {
assert!(rot(q, v).dist(apply_vector(&m, v)) < 1e-12, "{zyz:?} {v:?}");
}
let back = q.to_zyz(zyz);
assert!(back.iter().zip(zyz).all(|(a, b)| (a - b).abs() < 1e-9), "{zyz:?} -> {back:?}");
}
// Pitch negative, asked near it: the same turn as pitch positive, yaw and roll a half
// turn round, but given as it was.
let zyz = [0.2, -0.4, 0.1];
let back = Quat::zyz(zyz).to_zyz(zyz);
assert!(back.iter().zip(zyz).all(|(a, b)| (a - b).abs() < 1e-9), "{back:?}");
// A small turn off pitch 0 tilts it whichever way it points.
let tilted = Quat::zyz([0.0; 3]).mul(Quat::exp([0.05, 0.0, 0.0])).to_zyz([0.0; 3]);
assert!((tilted[1].abs() - 0.05).abs() < 1e-9, "{tilted:?}");
}
/// Exercise the IK callback itself: removing sharing at this call site must fail even if
/// the reusable placement helper still passes its own tests.
#[test]
-7
View File
@@ -22,7 +22,6 @@ mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
pub mod construct;
pub mod csv;
pub mod display_quality;
pub mod display_units;
mod document;
@@ -36,7 +35,6 @@ pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
pub mod frame;
pub mod gpu_plan;
mod history_names;
pub mod hole_reference;
pub mod ids;
@@ -87,7 +85,6 @@ pub mod sheet;
pub mod sketch_equation;
pub mod text_params;
pub mod thread_forms;
pub mod tolerance;
pub use assembly::{
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, is_placement,
@@ -168,11 +165,7 @@ mod feature_walk_tests;
#[cfg(test)]
mod named_projection_tests;
#[cfg(test)]
mod old_cap_anchor_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tolerance_tests;
mod anchor_frame;
pub use anchor_frame::state_in_face_anchor_frame;
+1 -10
View File
@@ -917,10 +917,7 @@ impl Model {
}
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be from
// another pose than `doc`'s: evaluated again, with what reads none of it kept (#12139).
let posed = doc.features.iter().zip(&model.results).any(|(f, r)| !r.skipped && pose_only(f));
if unkept > 0 || posed {
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
// before it: nothing else in the model is missing then.
model.evaluate(doc);
@@ -936,12 +933,6 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be
// from another pose than `doc`'s: it is no result for `doc`'s key (#12139).
if pose_only(f) {
before = r.state.clone();
continue;
}
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
+5 -78
View File
@@ -50,11 +50,8 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, the named edge split into several, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -84,58 +81,12 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
keep_best(&mut best, level, i);
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
match cands.as_slice() {
[i] if numbered && !edge_near(b, &m, *i, near) => {
Err(format!("edge `{name}` was one of several between its faces, and the one left is elsewhere"))
}
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
}
/// Find a saved Sketch Project edge in its body. Older designs can name the edge between
/// numbered pieces of faces that now evaluate whole (`F4:side:l6#2|F4:side:l7#2`, now
/// `F4:side:l6|F4:side:l7`). Recover it only when the faces it separates are whole, the saved
/// point lies on it, and its name is exact. Other edge references still require the stored
/// name as picked.
pub(crate) fn locate_projected_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
locate_edge(b, name, near).or_else(|miss| recover_numbered_projected_edge(b, name, near).ok_or(miss))
}
fn recover_numbered_projected_edge(b: &ModelBody, name: &str, near: Vec3) -> Option<EdgeHit> {
if b.body.is_mesh() || !near.is_finite() {
return None;
}
// Each face of the name without its piece number (`F4:side:l6#2` is `F4:side:l6`).
let mut numbered = false;
let whole: Vec<&str> = name
.split('|')
.map(|part| match part.rsplit_once('#') {
Some((stem, number))
if !stem.is_empty()
&& strip_piece(stem) == stem
&& !number.is_empty()
&& !number.starts_with('0')
&& number.bytes().all(|digit| digit.is_ascii_digit()) =>
{
numbered = true;
stem
}
_ => part,
})
.collect();
if !numbered {
return None;
}
let hit = locate_edge(b, &whole.join("|"), near).ok().filter(|hit| hit.verified && hit.on_point)?;
// Each face it separates is whole: no other piece of it is left to be the one picked. The
// edge is verified by that name, so the faces it separates are the `whole` stems.
whole.iter().all(|face| faces_with_stem(b, face).len() == 1).then_some(hit)
}
/// Whether a face found at `level` is exactly the face a name named: the very name (or another
/// name of that very face: a face its feature made from several roles has each), or the named
/// piece told apart by the faces around it. A face the named one was merged into, or a face of
@@ -374,8 +325,7 @@ impl EdgeHit {
}
/// The face a stored face name finds, on any body: the face with that name, or the named piece
/// told apart by the faces around it, or a numbered piece of a face that is whole now (see
/// [`recover_numbered_face_in`]). `None` when no face does, or several do.
/// told apart by the faces around it. `None` when no face does, or several do.
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
if crate::sheet::split_sheet_face_name(name).is_some() {
return sheet_locate(st, name, p).ok();
@@ -386,30 +336,7 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
// Every body's names are read below: work out the ones not known yet on all cores first
// (each body's names are its own, whichever thread works them out).
naming::warm_names(st.bodies.iter().filter(|b| !b.body.is_mesh()));
match locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p) {
Ok(hit) => Some(hit),
Err(Miss::Gone) => recover_numbered_face_in(st, name, p),
Err(Miss::Several) => None,
}
}
/// A numbered piece of a round face (`F2:side:c1#2`, from the kernel before SMLib) on whichever
/// one body recovers it as its whole face (#13252): see [`recover_numbered_projected_face`] for
/// when that is safe. `None` when no body, or more than one, does.
fn recover_numbered_face_in(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
if !name.contains('#') {
return None;
}
let mut found = None;
for (body, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
if let Some((face, point)) = recover_numbered_projected_face(b, name, p) {
if found.is_some() {
return None;
}
found = Some(FaceHit { body, face, point, verified: true, on_point: true });
}
}
found
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p).ok()
}
/// The one body of `st` with a face the stored face name finds, as [`locate_face`] looks for it.
@@ -534,7 +461,7 @@ pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit
/// Find a saved manual Project face in its body. Older designs can name a numbered piece of
/// a face that now evaluates whole. Recover it only when the original feature directly made
/// the exact whole face and the face contains the
/// old pick. [`locate_face`] does the same for the other stored face references (#13252).
/// old pick. Other face references still require the stored name as picked.
pub(crate) fn locate_projected_face(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3)> {
if body.body.is_mesh() {
return None;
-123
View File
@@ -244,58 +244,6 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
}
/// A tangent plane on a cylinder saved by 0.30.1, whose kernel split the round side into numbered
/// pieces, names it `F2:side:c1#2`; the side is one face now (#13252). The plane, and the sketch
/// on it, are found again when the cylinder is still the body its feature made; a piece that
/// another face of the surface could be (a split side) or a pick off the face is not.
#[test]
fn a_tangent_plane_on_a_numbered_piece_of_a_round_face_is_found_again() {
let doc = |name: &str, at: [f64; 3]| Document {
features: serde_json::from_value(serde_json::json!([
{
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
"sketch": {
"points": [{"fixed": true, "id": "origin", "pos": [0., 0.]}, {"fixed": false, "id": "c1.center", "pos": [0., 0.]}],
"curves": [{"c": 1, "construction": false, "id": "c1", "r": 10., "type": "circle"}],
"constraints": [{"id": "k1", "p": 0, "q": 1, "type": "coincident"}],
"counters": {"k": 1, "l": 0, "p": 1}
}
},
{
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
"extent": {"convention": "total_distance", "direction": "positive", "distance": "20"},
"operation": "new_body", "profiles": {"loops": [["c1"]], "type": "curves"}, "sketch": 1
},
{
"id": 3, "name": "Plane1", "type": "construction_plane", "suppressed": false,
"plane": {"type": "tangent", "at": at, "face": {"type": "face", "at": at, "name": name}}
},
{
"id": 4, "name": "Sketch2", "type": "sketch", "plane": {"feature": 3, "type": "construction"},
"sketch": {"points": [{"fixed": true, "id": "origin", "pos": [0., 0.]}], "curves": [], "constraints": [], "counters": {}}
}
]))
.unwrap(),
..Document::default()
};
let run = |doc: Document| {
let doc = crate::format::read(&doc.to_json()).unwrap();
let mut model = crate::Model::new();
model.evaluate(&doc);
model
};
let whole = run(doc("F2:side:c1", [10., 0., 10.]));
assert!(whole.result(3).unwrap().error.is_none(), "{:?}", whole.result(3));
for piece in ["F2:side:c1#1", "F2:side:c1#2"] {
let model = run(doc(piece, [10., 0., 10.]));
assert!(model.result(3).unwrap().error.is_none(), "{piece}: {:?}", model.result(3));
assert!(model.result(4).unwrap().error.is_none(), "{piece}: {:?}", model.result(4));
}
// A pick that is not on the face is still lost.
let off = run(doc("F2:side:c1#2", [10., 0., 30.]));
assert!(off.result(3).unwrap().error.is_some());
}
/// An edge picked when its side face was split in two keeps the piece in its name
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
#[test]
@@ -349,74 +297,3 @@ fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
/// A ring turned from a rectangle on YZ about Z: each side of the rectangle makes one round face.
fn ring() -> Document {
let doc = Document {
features: serde_json::from_value(serde_json::json!([
{
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "YZ", "type": "origin"},
"sketch": {
"points": [
{"fixed": true, "id": "origin", "pos": [0., 0.]},
{"fixed": false, "id": "p1", "pos": [10., 0.]},
{"fixed": false, "id": "p2", "pos": [20., 0.]},
{"fixed": false, "id": "p3", "pos": [20., 5.]},
{"fixed": false, "id": "p4", "pos": [10., 5.]}
],
"curves": [
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
],
"constraints": [],
"counters": {"l": 4, "p": 4}
}
},
{
"id": 2, "name": "Revolve1", "type": "revolve", "body_names": ["Ring"], "angle": "360 deg",
"axis": {"axis": "Z", "type": "world"}, "convention": "sketch_normal",
"operation": "new_body", "profiles": {"type": "all"}, "sketch": 1
}
]))
.unwrap(),
..Document::default()
};
crate::format::read(&doc.to_json()).unwrap()
}
#[test]
fn numbered_edge_projection_recovers_the_edge_between_whole_faces() {
// 0.30.1 split each round face of a revolve into numbered pieces; the edge between two of
// them is now the edge between the two whole faces, at the same place.
let doc = ring();
let mut model = crate::Model::new();
model.evaluate(&doc);
let state = model.state();
let at = Vec3::new(0., -20., 5.);
let source = LinkSource::Edge { body: "Ring".into(), at };
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &state, &Plane::XY, LinkKind::Project, &source).unwrap();
let mut sketch = Sketch::default();
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
let numbered = "F2:side:l2#2|F2:side:l3#2";
sketch.links[0].edge_name = Some(numbered.into());
let curves = sketch.link_curves(&link).len();
let saved = sketch.clone();
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &state, &Plane::XY, &mut sketch).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!sketch.links[0].lost);
assert_eq!(sketch.links[0].edge_name.as_deref(), Some(numbered));
assert_eq!(sketch.link_curves(&link).len(), curves);
// Edges picked by fillets, holes and joints keep the name as picked.
let body = state.body("Ring").unwrap();
assert!(crate::naming::locate_edge(body, numbered, at).is_err(), "ordinary lookup stays strict");
// The saved point on another edge (the inner rim): not this edge, so it asks to reselect.
let mut elsewhere = saved;
elsewhere.links[0].source = LinkSource::Edge { body: "Ring".into(), at: Vec3::new(0., -10., 5.) };
let error = crate::project::refresh_links(&doc, &BTreeMap::new(), &state, &Plane::XY, &mut elsewhere).unwrap_err();
assert!(matches!(&error, crate::DocError::Reselect { reference, .. } if reference == numbered), "{error}");
}
File diff suppressed because one or more lines are too long
-70
View File
@@ -1,70 +0,0 @@
use crate::Document;
use solidcraft_geom::prelude::*;
/// Sketch1 (a stadium) and Extrude1, Sketch2 on Extrude1's bottom cap and Extrude2 joined from it,
/// then Sketch3 on the same cap, as 0.30.1 saved them (#13323): the attachments carry an
/// `extrude_cap` certificate, which the SMLib kernel's join no longer marks the cap with.
fn old_doc(edit: impl FnOnce(&mut serde_json::Value)) -> Document {
let mut fixture: serde_json::Value = serde_json::from_str(include_str!("old_cap_anchor_fixture.json")).unwrap();
edit(&mut fixture["features"]);
let doc = Document {
features: serde_json::from_value(fixture["features"].take()).unwrap(),
params: serde_json::from_value(fixture["params"].take()).unwrap(),
..Document::default()
};
crate::format::read(&doc.to_json()).unwrap()
}
fn evaluate(doc: &Document) -> crate::Model {
let mut model = crate::Model::new();
model.evaluate(doc);
model
}
fn old_design(edit: impl FnOnce(&mut serde_json::Value)) -> crate::Model {
evaluate(&old_doc(edit))
}
#[test]
fn the_name_does_not_rescue_a_cap_on_an_offset_body() {
// Moved 5 mm along the cap's own plane: the plane and the saved point still match the body as
// built, but the sketch would stay behind at the old place. It stays lost instead.
let mut doc = old_doc(|_| {});
let mut offset = crate::IDENTITY;
offset[3][0] = 5.;
doc.body_offsets.insert("Body1".into(), offset);
let model = evaluate(&doc);
assert!(model.result(5).unwrap().error.is_some(), "{:?}", model.result(5));
}
fn sketch3(features: &mut serde_json::Value) -> &mut serde_json::Value {
features.as_array_mut().unwrap().iter_mut().find(|f| f["name"] == "Sketch3").unwrap()
}
#[test]
fn a_sketch_on_a_cap_that_a_join_left_uncertified_is_found_by_its_name() {
let model = old_design(|_| {});
for id in [1, 2, 3, 4, 5] {
assert!(model.result(id).unwrap().error.is_none(), "feature {id}: {:?}", model.result(id).map(|r| (&r.name, &r.error)));
}
let state = model.state();
let sketch = &state.sketch(5).expect("Sketch3 should be built").plane;
// The plane the file saved: origin (0, 0, 0), x direction (-1, 0, 0), normal +Y.
assert!(sketch.origin.len() < 1e-9, "{sketch:?}");
assert!((sketch.x - solidcore_record::Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-9, "{sketch:?}");
assert!((sketch.normal() - solidcore_record::Vec3::new(0.0, 1.0, 0.0)).len() < 1e-9, "{sketch:?}");
}
#[test]
fn the_name_does_not_rescue_a_cap_that_is_not_where_the_sketch_was() {
// The plane the sketch was attached in is 1 mm off the face the name finds now.
let model = old_design(|features| {
sketch3(features)["plane"]["plane"]["origin"] = serde_json::json!([0.0, 1.0, 0.0]);
});
assert!(model.result(5).unwrap().error.is_some(), "{:?}", model.result(5));
// A name that is no longer a face of the body is not guessed either.
let model = old_design(|features| {
sketch3(features)["plane"]["name"] = serde_json::json!("F2:end");
});
assert!(model.result(5).unwrap().error.is_some(), "{:?}", model.result(5));
}
+4 -6
View File
@@ -483,10 +483,9 @@ impl Document {
}
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`. A text
/// field a spreadsheet would run as a formula is written as text ([`crate::csv`]).
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
pub fn params_csv(&self) -> String {
let q = crate::csv::field;
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
out += &format!(
@@ -1138,14 +1137,13 @@ fn split_csv(text: &str, separator: char) -> impl Iterator<Item = &str> {
fn csv_fields(record: &str) -> Result<Vec<String>> {
split_csv(record, ',')
.map(|raw| {
// `unguard` takes off the `'` an export put before a value a spreadsheet would run.
let field = raw.trim();
if !field.contains('"') {
return Ok(crate::csv::unguard(field).to_string());
return Ok(field.to_string());
}
let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match inner {
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")),
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
}
})
+1 -1
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_viewport::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
/// Explicitly selected opaque response. Existing scalar looks have no such record.
/// Construction and deserialization share the renderer's finite admission bounds.
+2 -4
View File
@@ -162,7 +162,6 @@ impl ModelBody {
if same_triangles(&self.body, &old.body) {
self.mesh = old.mesh.clone();
self.plan = old.plan.clone();
self.gpu_plan = old.gpu_plan.clone();
self.plan_mesh = old.plan_mesh.clone();
self.opposite_hand = old.opposite_hand.clone();
}
@@ -294,7 +293,6 @@ fn proper_rigid(m: &crate::Mat) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::tess::EvaluateMesh as _;
#[test]
fn mesh_outlines_keep_boundaries_and_creases_without_smooth_facet_lines() {
@@ -435,10 +433,10 @@ mod tests {
let plans: Vec<_> = placed.iter().map(|p| p.display_plan_at(q).unwrap()).collect();
assert!(source.plan.get(q.index()).unwrap().get().is_some(), "placing plans the definition, in its own cell");
let own = source.display_plan_at(q).unwrap();
let local = own.evaluate_mesh();
let local = own.evaluate();
for (m, plan) in [a, b].iter().zip(&plans) {
assert!(!Arc::ptr_eq(plan, &own));
let moved = plan.evaluate_mesh();
let moved = plan.evaluate();
assert_eq!(moved.triangles, local.triangles);
for (p, q) in local.positions.iter().zip(&moved.positions) {
assert!(crate::apply_point(m, *p).dist(*q) < 1e-9, "{p:?} -> {q:?}");
+3 -7
View File
@@ -55,7 +55,7 @@ impl Model {
}
}
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
if depth > crate::MAX_PLACEMENT_DEPTH {
return false;
}
@@ -240,12 +240,8 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
a == b
&& meshes.len() == other.len()
&& meshes.iter().zip(other).all(|(a, b)| {
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
*name == b.name
&& *color == b.color
&& Arc::ptr_eq(positions, &b.positions)
&& Arc::ptr_eq(triangles, &b.triangles)
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
let crate::MeshData { name, positions, triangles, color } = a;
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
})
}
(
+3 -32
View File
@@ -1,9 +1,7 @@
//! Progress and cooperative cancellation for a long evaluation (opening a large design) run
//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation
//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the
//! rest. The kernel work of the feature it is on stops too, at the kernel's next check (#10577):
//! a long boolean no longer runs to its end after Cancel. Nothing changes for evaluations that
//! are not watched.
//! rest. Nothing changes for evaluations that are not watched.
use std::cell::RefCell;
use std::sync::{
@@ -30,8 +28,6 @@ pub struct Progress {
partial_wanted: AtomicBool,
/// The latest such model and when it was offered.
partial: std::sync::Mutex<(Option<Partial>, Option<web_time::Instant>)>,
/// Over the kernel work of the watched evaluation ([`watch`]): stopped on Cancel.
stop: solidcraft_kernel::Stop,
}
/// The model as far as an evaluation has built it, placed in the world as the design places it.
@@ -58,18 +54,14 @@ impl Default for Progress {
item: std::sync::Mutex::new(None),
partial_wanted: AtomicBool::new(false),
partial: std::sync::Mutex::new((None, None)),
stop: solidcraft_kernel::Stop::new(),
}
}
}
impl Progress {
/// Ask the watched evaluation to stop at its next feature, and its kernel work at the
/// kernel's next check. Marked cancelled first: a feature its stopped kernel call fails is
/// then never kept in the evaluation cache ([`Evaluation::cancelled`]).
/// Ask the watched evaluation to stop at its next feature.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
self.stop.stop();
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
@@ -146,7 +138,6 @@ thread_local! {
/// Run `f` with `progress` watching every evaluation it makes on this thread.
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
let kernel = Arc::new(KernelWatch(progress.clone()));
let stop = progress.stop.clone();
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
struct Restore(Option<Option<Current>>);
impl Drop for Restore {
@@ -157,7 +148,7 @@ pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
}
}
let _restore = Restore(Some(previous));
stop.within(|| solidcraft_kernel::watch::watching(kernel, f))
solidcraft_kernel::watch::watching(kernel, f)
}
/// Entered at the start of an evaluation; the guard leaves it.
@@ -280,26 +271,6 @@ mod tests {
assert!(!begin(1).step(0), "the watch ends with its scope");
}
/// Cancel reaches the kernel work of a watched evaluation, even under the token of the
/// design whose job it is (#10577): a boolean asked for after it is refused, rather than
/// running to its end.
#[test]
fn cancel_stops_the_kernel_work_under_the_watch() {
use solidcore_record::Vec3;
// Two cylinders crossing at right angles: a boolean only the kernel answers.
let a = solidcraft_kernel::cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::Z, 3.0, 20.0).unwrap();
let b = solidcraft_kernel::cylinder(Vec3::new(-10.0, 0.0, 0.0), Vec3::X, 2.0, 20.0).unwrap();
let design = solidcraft_kernel::Stop::new();
let union = || design.within(|| solidcraft_kernel::boolean(&a, &b, solidcraft_kernel::BoolOp::Union));
let p = Arc::new(Progress::default());
assert!(watch(p.clone(), union).is_ok_and(|u| u.is_some()), "a watch alone stops nothing");
p.cancel();
let stopped = watch(p.clone(), union);
assert!(stopped.as_ref().is_err_and(|e| e.to_string().contains("cancel")), "{stopped:?}");
assert!(!design.is_stopped(), "the design's own token goes on");
assert!(union().is_ok_and(|u| u.is_some()), "and so does its work outside the watch");
}
/// The item a command is on reaches its watcher, and only a watcher.
#[test]
fn items_go_to_the_watcher() {
+11 -56
View File
@@ -86,22 +86,6 @@ fn cache_key(st: &ModelState, plane: &Plane, kind: LinkKind, src: &LinkSource) -
/// Resolve a link source against the model state before the sketch.
pub fn resolve(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, kind: LinkKind, src: &LinkSource) -> Result<Resolved> {
resolve_at(doc, vals, st, plane, kind, src, None)
}
/// [`resolve`] for a sketch evaluated in `frame`: its author component and that component's
/// world placement, including the author path of a face-attached sketch. A construction point
/// is kept in its own component's frame and is brought into this one (#12075); without a frame
/// it is taken in the root's.
pub fn resolve_at(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
kind: LinkKind,
src: &LinkSource,
frame: Option<&(u64, crate::Mat)>,
) -> Result<Resolved> {
let key = cache_key(st, plane, kind, src);
if let Some(k) = &key
&& let Ok(c) = CACHE.lock()
@@ -109,7 +93,7 @@ pub fn resolve_at(
{
return Ok(r.clone());
}
let r = resolve_uncached(doc, vals, st, plane, kind, src, frame)?;
let r = resolve_uncached(doc, vals, st, plane, kind, src)?;
if let Some(k) = key
&& let Ok(mut c) = CACHE.lock()
{
@@ -126,7 +110,6 @@ fn resolve_uncached(
plane: &Plane,
kind: LinkKind,
src: &LinkSource,
frame: Option<&(u64, crate::Mat)>,
) -> Result<Resolved> {
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
let mut moved = false;
@@ -309,13 +292,6 @@ fn resolve_uncached(
vec![LinkGeom::Point(plane.to_local(ss.plane.to_world(p)))]
}
LinkSource::Origin => vec![LinkGeom::Point(plane.to_local(Vec3::ZERO))],
// A construction point, by name (#12075), where the timeline so far puts it, in the
// sketch's frame.
LinkSource::ConstructionPoint { name } => {
let (author, frame) = frame.copied().unwrap_or((0, crate::IDENTITY));
let p = crate::eval::construction_point_in(doc, st, name, author, &frame).map_err(|_| lost(&format!("point {name}")))?;
vec![LinkGeom::Point(plane.to_local(p))]
}
LinkSource::Axis { name } => {
let (o, d) = match name.to_ascii_uppercase().as_str() {
"X" => (Vec3::ZERO, Vec3::X),
@@ -784,12 +760,8 @@ fn lost(reference: &str) -> DocError {
/// The edge a link's stored name finds, and the body it is on: the link's body, or, when no
/// body has that name any more (it was joined into another), the one body on which the name
/// finds an edge.
fn locate_named_edge(st: &ModelState, kind: LinkKind, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
let locate = |b: &crate::ModelBody| match kind {
LinkKind::Project => crate::name_lookup::locate_projected_edge(b, name, at),
_ => crate::naming::locate_edge(b, name, at),
};
let hit = |b: &crate::ModelBody| locate(b).ok().map(|hit| (b.name.clone(), hit));
fn locate_named_edge(st: &ModelState, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
let hit = |b: &crate::ModelBody| crate::naming::locate_edge(b, name, at).ok().map(|hit| (b.name.clone(), hit));
match st.body(body) {
Some(b) => hit(b),
None => {
@@ -946,7 +918,7 @@ pub fn refresh_face_anchor_links(
sk: &mut Sketch,
anchor: &crate::FaceAnchor,
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail, None)
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail)
}
/// What a link kept by the name of a face or edge does when the name finds nothing any more.
@@ -960,33 +932,17 @@ pub(crate) enum LostNames {
Mark,
}
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) for a sketch evaluated in
/// `frame`, as [`resolve_at`] takes it.
pub fn refresh_links_at(
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) that marks a link whose
/// named face or edge is gone lost instead of failing ([`LostNames::Mark`]).
pub(crate) fn refresh_links_marking_lost(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
frame: Option<&(u64, crate::Mat)>,
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Fail, frame)
}
/// [`refresh_links_at`] with `lost_names` [`LostNames::Mark`] for a link whose named face or edge
/// is gone to be marked lost instead of failing the sketch.
pub(crate) fn refresh_links_with(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
lost_names: LostNames,
frame: Option<&(u64, crate::Mat)>,
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, anchor, lost_names, frame)
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Mark)
}
/// Remove degree-two straight stations introduced by coplanar face subdivision.
@@ -1063,7 +1019,7 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
/// reference to be picked again ([`DocError::Reselect`]).
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail, None)
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail)
}
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
@@ -1100,7 +1056,6 @@ fn refresh_links_in(
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
lost_names: LostNames,
frame: Option<&(u64, crate::Mat)>,
) -> Result<Vec<String>> {
let mut warn = Vec::new();
let links = sk.links.clone();
@@ -1128,7 +1083,7 @@ fn refresh_links_in(
// edge has the name any more no other edge is taken for it: the sketch fails.
let mut named_edge = None;
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
let Some((body, hit)) = locate_named_edge(st, l.kind, body, name, *at) else {
let Some((body, hit)) = locate_named_edge(st, body, name, *at) else {
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
continue;
};
@@ -1155,7 +1110,7 @@ fn refresh_links_in(
} else if let Some((body, index, at)) = named_edge {
resolve_named_edge(st, plane, l.kind, &body, index, at)
} else {
resolve_at(doc, vals, st, plane, l.kind, &source, frame)
resolve(doc, vals, st, plane, l.kind, &source)
};
match resolved {
Ok(r) => {
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::{
render_surface::{RenderBodyTarget, bounded_sequence},
};
use serde::{Deserialize, Serialize};
use solidcraft_viewport::clear_coat::ClearCoat;
use solidcraft_render::clear_coat::ClearCoat;
use std::collections::BTreeSet;
/// Matches the existing runtime clear-coat override bound for either executor.
+3 -102
View File
@@ -4,13 +4,13 @@ use std::{fmt, marker::PhantomData};
use serde::{Deserialize, Serialize};
use solidcraft_render::{
Camera,
job::{
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
},
lighting::environment::SkyGradient,
};
use solidcraft_viewport::Camera;
use crate::{
DocError, Result,
@@ -82,18 +82,6 @@ impl RenderJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuJob {
/// Zero selects a bounded native worker count; one retains serial transport.
#[serde(skip_serializing_if = "zero_threads")]
pub threads: u16,
/// Spend HDR lighting samples according to captured environment radiance.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub environment_importance_sampling: bool,
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -110,13 +98,6 @@ pub struct CpuJob {
pub path: Option<solidcraft_render::job::CpuPathOptions>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -148,10 +129,6 @@ pub struct CpuJob {
impl Default for CpuJob {
fn default() -> Self {
Self {
threads: 0,
environment_importance_sampling: false,
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
volume: None,
sun: None,
@@ -159,9 +136,6 @@ impl Default for CpuJob {
aging: Default::default(),
adaptive: None,
denoise: false,
denoise_settings: DenoiseSettings::default(),
denoise_diagnostics: false,
supersampling: 1,
path: None,
lens: None,
output: None,
@@ -226,10 +200,6 @@ impl CpuJob {
})
.transpose()?;
let options = RaytraceOptions {
threads: self.threads,
environment_importance_sampling: self.environment_importance_sampling,
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
volume: self.volume,
sun: self.sun.map(Box::new),
@@ -237,9 +207,6 @@ impl CpuJob {
aging: (!aging.is_empty()).then(|| Box::new(aging)),
adaptive: self.adaptive.map(Box::new),
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
path: self.path,
lens: self.lens,
output: self.output.clone(),
@@ -266,12 +233,6 @@ impl CpuJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GpuJob {
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
@@ -294,13 +255,6 @@ pub struct GpuJob {
pub seed: u32,
pub tone_map: ToneMap,
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -315,8 +269,6 @@ impl Default for GpuJob {
fn default() -> Self {
let options = JobOptions::default();
Self {
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
aging: Default::default(),
nested_media: options.nested_media,
@@ -331,9 +283,6 @@ impl Default for GpuJob {
seed: options.seed,
tone_map: options.tone_map,
denoise: options.denoise,
denoise_settings: options.denoise_settings,
denoise_diagnostics: options.denoise_diagnostics,
supersampling: options.supersampling,
lens: options.lens,
lighting: options.lighting,
environment: None,
@@ -368,8 +317,6 @@ impl GpuJob {
environment.validate()?;
}
let options = JobOptions {
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
aging: self.aging.parameters_only()?,
nested_media: self.nested_media,
@@ -384,9 +331,6 @@ impl GpuJob {
seed: self.seed,
tone_map: self.tone_map,
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
lens: self.lens,
lighting: self.lighting,
exposure_ev,
@@ -421,10 +365,6 @@ impl SkyEnvironment {
}
}
fn zero_threads(threads: &u16) -> bool {
*threads == 0
}
fn unit() -> f64 {
1.0
}
@@ -538,42 +478,3 @@ mod cpu_volume_portable_tests {
assert_eq!(serde_json::to_value(job).unwrap(), old);
}
}
#[cfg(test)]
#[test]
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
assert_eq!(old_cpu.threads, 0);
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
assert!(old_cpu.denoise_settings.optical_filter && old_gpu.denoise_settings.optical_filter);
let opaque_only = DenoiseSettings { optical_filter: false, ..Default::default() };
for wire in [
serde_json::to_value(CpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
serde_json::to_value(GpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
] {
assert_eq!(wire["denoise_settings"]["optical_filter"], false);
}
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false, optical_filter: false };
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(cpu_reopened, cpu);
assert_eq!(gpu_reopened, gpu);
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
assert_eq!(cpu_runtime.denoise_settings, filter);
assert_eq!(gpu_runtime.denoise_settings, filter);
assert!(!cpu_runtime.denoise_settings.optical_filter && !gpu_runtime.denoise_settings.optical_filter);
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
}
+13 -48
View File
@@ -6,8 +6,10 @@ use crate::{
};
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_render::transmission::{Transmission, subsurface::Subsurface};
use solidcraft_viewport::surface::OpaqueSurface;
use solidcraft_render::{
surface::OpaqueSurface,
transmission::{Transmission, subsurface::Subsurface},
};
use std::collections::BTreeSet;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -145,8 +147,8 @@ impl OpticalBindings {
self.0.iter()
}
pub fn validate(&self) -> Result<()> {
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
if self.0.len() > 32 || self.0.capacity() > 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
}
let mut seen = BTreeSet::new();
for binding in &self.0 {
@@ -162,7 +164,7 @@ impl OpticalBindings {
}
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
self.validate()?;
if !path && self.len() > 8 {
if self.len() > 8 {
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
}
for binding in self.iter() {
@@ -181,11 +183,11 @@ impl OpticalBindings {
}
*old = binding;
} else {
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
if self.0.len() >= 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
}
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
if self.0.capacity() > 32 {
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
}
self.0.push(binding);
@@ -200,7 +202,7 @@ impl OpticalBindings {
}
impl<'de> Deserialize<'de> for OpticalBindings {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
result.validate().map_err(serde::de::Error::custom)?;
Ok(result)
}
@@ -210,25 +212,6 @@ impl<'de> Deserialize<'de> for OpticalBindings {
mod advanced_model_contract_tests {
use super::*;
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
#[test]
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
let mut bindings = OpticalBindings::default();
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
let binding = SavedOptics {
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
optics: OpticalMaterial::default(),
};
bindings.set(binding, false).unwrap();
}
bindings.validate_for_cpu_path(true).unwrap();
assert!(bindings.validate_for_cpu().is_err());
let raw = serde_json::to_value(&bindings).unwrap();
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
let mut too_many = raw.as_array().unwrap().clone();
too_many.push(too_many[0].clone());
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
}
#[test]
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
let model = GlassModel {
@@ -255,32 +238,14 @@ mod advanced_model_contract_tests {
#[test]
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
use crate::render_job::{CpuJob, GpuJob};
let caustics = Some(solidcraft_render::job::CausticOptions {
photons: 8192,
radius_mm: 0.4,
max_depth: 12,
seed: 381,
area_lights: false,
environment: false,
guided_emission: true,
refresh_per_pass: true,
power_weighted_sources: true,
});
let cpu = CpuJob {
caustics,
environment_importance_sampling: true,
path: Some(Default::default()),
progressive: Some(Default::default()),
..Default::default()
};
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
let gpu = GpuJob { caustics, ..Default::default() };
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
}
}
+12 -63
View File
@@ -4,8 +4,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_render::{BackendId, RenderFormat, RenderLimits, RenderMode};
use solidcraft_viewport::{Camera, UpAxis};
use solidcraft_render::{BackendId, Camera, RenderFormat, RenderLimits, RenderMode, UpAxis};
pub use crate::render_job::{CpuJob, GpuJob, RenderJob, SkyEnvironment};
use crate::{DocError, Result};
@@ -213,27 +212,15 @@ impl RenderSetup {
));
}
job.validate(&self.camera, self.exposure_ev)?;
if let RenderJob::GpuPath { options, .. } = job {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?;
}
if let RenderJob::CpuRaytrace { options, .. } = job {
// Existing native-size Workstation intent remains readable. X2
// uses the bounded 8K output envelope and pays for its internal grid.
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
(self.width, self.height)
} else {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?
};
if options.denoise || options.denoise_diagnostics {
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
if options.denoise {
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
}
options
.budget
.validate_image(
trace_width,
trace_height,
self.width,
self.height,
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
.map_err(|e| DocError::Invalid(e.to_string()))?,
)
@@ -593,7 +580,7 @@ mod tests {
exposure_ev: 2.5,
mode: RenderMode::Final,
format: RenderFormat::Exr,
camera: Camera { up: solidcraft_viewport::UpAxis::Y, ..Default::default() },
camera: Camera { up: solidcraft_render::UpAxis::Y, ..Default::default() },
..Default::default()
};
Arc::make_mut(&mut loaded.render).setups.insert("Studio".into(), setup);
@@ -683,7 +670,7 @@ mod tests {
gpu(3840, 2160).validate().unwrap();
gpu(3840, 2160).validate_for_gpu().unwrap();
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
assert!(!message.contains("CPU resource profile"), "{message}");
}
@@ -896,7 +883,7 @@ mod tests {
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
@@ -1034,7 +1021,7 @@ mod cpu_budget_tests {
let old = CpuJob::default();
assert!(old.path.is_none());
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
for depth in [1, 8, 16, 64] {
for depth in [1, 8, 16] {
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
setup.validate().unwrap();
let mut document = crate::Document::default();
@@ -1045,7 +1032,7 @@ mod cpu_budget_tests {
}
for raw in [
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":8}}),
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
] {
@@ -1086,7 +1073,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
setup.validate().unwrap();
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
options.denoise = true;
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
}
#[cfg(test)]
@@ -1233,9 +1220,7 @@ mod cpu_adaptive_saved_tests {
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
"16 pilots cannot be hidden outside Compact's 64-sample cap"
);
for (budget, maximum) in
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
{
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
let mut runtime = runtime.clone();
runtime.budget = budget;
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
@@ -1400,39 +1385,3 @@ mod cpu_celestial_saved_tests {
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
}
}
#[test]
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
}}
}))
.unwrap();
gpu.validate().unwrap();
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(reopened, gpu);
assert_eq!((reopened.width, reopened.height), (7680, 4320));
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
}}
}))
.unwrap();
cpu.validate().unwrap();
assert_eq!((cpu.width, cpu.height), (7680, 4320));
cpu.width = 1024;
cpu.height = 1024;
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.budget = solidcraft_render::job::CpuBudget::Compact;
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.supersampling = 1;
cpu.validate().unwrap();
let mut invalid = gpu;
invalid.width = 8193;
invalid.height = 1;
assert!(invalid.validate().is_err());
}
+28 -246
View File
@@ -3216,106 +3216,11 @@ impl SheetBody {
left.into_iter().min_by(|x, y| away(x).total_cmp(&away(y)))
}
/// The edge of a cut-out that opens onto a panel's outline (a notch: a cut across a flange's
/// wall leaves its top slanted) nearest `pick` (a world point), when it is nearer than every
/// edge of the panels' outlines and within `reach` of the pick: the panel, the cut-out's index
/// in `holes`, and the panel's material without it (its outline, with the notch). `None` for a
/// pick nearer an outline, or on a cut-out inside a panel or one that parts it.
fn notch_at(&self, pick: Vec3, reach: f64) -> Result<Option<(Option<usize>, usize, Region2)>> {
let Some(outline) = self.edge_at(pick).map(|e| e.4) else { return Ok(None) };
if self.holes.is_empty() || outline <= 1e-9 {
return Ok(None);
}
let mut work = FlatWork::new();
let mut best: Option<(f64, Option<usize>, usize, Region2)> = None;
for (pi, panel) in self.panels() {
let Some(inv) = mat_inverse(&self.world(pi)) else { continue };
let q = apply_point(&inv, pick);
let q2 = Vec2::new(q.x, q.y);
let panel_pts = dedup(&panel.outer.ccw().polyline(1e-3));
let panel_box = panel_pts.bounds().inflated(reach);
if !panel_box.contains(q2) {
continue;
}
for (k, hole) in self.holes.iter().enumerate() {
// Only a cut-out the pick is near can hold its edge.
if !hole.outer.polyline(1e-3).bounds().inflated(reach).contains(q2) {
continue;
}
let left = material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(hole), kernel::BoolOp::Cut, &mut work)?;
let [material] = left.as_slice() else { continue };
if material.holes.len() != panel.holes.len() || (material.area() - panel.area()).abs() <= TOUCH_AREA {
continue;
}
let pts = material.outer.ccw().polyline(1e-3);
let m = pts.len();
for i in 0..m {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue };
let d = b - a;
if d.len() < 1e-9 || near_outline(&panel_pts, (a + b) * 0.5, 1e-6) {
continue;
}
let t = ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0);
let flat = ((a + d * t) - q2).len();
let dist = flat.hypot(q.z.abs().min((q.z - self.t).abs()));
if dist <= reach && dist < outline - 1e-9 && best.as_ref().is_none_or(|x| dist < x.0) {
best = Some((dist, pi, k, material.clone()));
}
}
}
}
Ok(best.map(|(_, pi, k, material)| (pi, k, material)))
}
/// Before an edge flange on the edge of a cut-out that opens onto a panel's outline: that
/// cut-out becomes the panel's outline (the base's, or a flange panel's shape), so the flange
/// runs along its edge and the cut-out takes nothing of the new flange, which lies where it
/// was in the flat. Refused while the cut-out also takes material of another panel or a bend,
/// or overlaps another cut-out.
/// Says whether the pick was on such an edge.
fn open_notch(&mut self, pick: Vec3) -> Result<bool> {
let Some((pi, k, material)) = self.notch_at(pick, self.t * 2.0 + 1e-3)? else { return Ok(false) };
let hole = self.holes.get(k).cloned().ok_or_else(|| DocError::Invalid("cut-out".into()))?;
let mut work = FlatWork::new();
let mut others: Vec<Region2> = self.panels().into_iter().filter(|(p, _)| *p != pi).map(|(_, r)| r).collect();
others.extend(self.flanges.iter().map(|f| self.zone(f)));
others.extend(self.holes.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, h)| h.clone()));
for other in &others {
let common = material_boolean(std::slice::from_ref(other), std::slice::from_ref(&hole), kernel::BoolOp::Intersect, &mut work)?;
if common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA {
return Err(DocError::Invalid(
"not supported yet: a flange on the edge of a cut-out that also cuts another panel, a bend or another cut-out".into(),
));
}
}
self.set_panel_region(pi, material)?;
self.holes.remove(k);
Ok(true)
}
/// Give a panel (None = the base) this flat region: a flange panel takes its outline as its
/// shape (and, with arcs in it, as drawn).
fn set_panel_region(&mut self, panel: Option<usize>, region: Region2) -> Result<()> {
match panel {
None => self.base = region,
Some(i) => {
let lines = region.outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. }));
let outer = region.outer.ccw();
let shape = dedup(&outer.polyline(1e-3)).into_vec();
let f = self.flanges.get_mut(i).ok_or_else(|| DocError::Invalid("flange".into()))?;
f.shape = Some(shape);
f.exact = (!lines).then_some(outer);
}
}
Ok(())
}
/// Trim a panel's edge a→b back by `by` (toward the panel interior). Where a→b is what a
/// cut-out left of a longer edge, `cut_at` says at which of its ends (start, end) the
/// cut-out is: the base keeps its edge beyond such an end, and so does a flange panel when
/// a→b is the edge of a cut-out that `open_notch` made its outline (`notch`). Otherwise a
/// flange panel's far edge moves whole.
fn trim(&mut self, panel: Option<usize>, a: Vec2, b: Vec2, by: f64, cut_at: (bool, bool), notch: bool) -> Result<()> {
/// cut-out is: the base keeps its edge beyond such an end. A flange panel's far edge moves
/// whole.
fn trim(&mut self, panel: Option<usize>, a: Vec2, b: Vec2, by: f64, cut_at: (bool, bool)) -> Result<()> {
if by.abs() < 1e-12 {
return Ok(());
}
@@ -3382,23 +3287,10 @@ impl SheetBody {
if inward.dot(original.n) > 0.99 {
return Err(DocError::Invalid("pick a free panel edge, not its connection to the existing bend".into()));
}
if notch {
// Only the strip along a→b goes: the panel keeps its outline beyond both ends.
let region = self.panel(original);
let strip = rect(a, b, b + inward * by, a + inward * by);
let left = material_boolean(&[region], &[strip], kernel::BoolOp::Cut, &mut FlatWork::new())?;
let [kept] = left.as_slice() else {
return Err(DocError::Invalid("not supported yet: a flange setback that splits the panel".into()));
};
let kept = kept.clone();
return self.set_panel_region(panel, kept);
}
let partial = cut_at.0 || cut_at.1;
let shaped = original.shape.is_some() || original.exact.is_some();
if side && partial {
if side && (cut_at.0 || cut_at.1) {
return Err(DocError::Invalid("not supported yet: a partial side-edge flange beside a cut-out".into()));
}
let clipped = if side || shaped {
let clipped = if side || original.shape.is_some() || original.exact.is_some() {
// A panel with curves gives way to its 0.001 mm chords here.
let region = self.panel(original);
let points = clip(&region.outer.ccw().polyline(1e-3), a + inward * by, inward);
@@ -3618,28 +3510,18 @@ impl SheetBody {
}
sizes.push((setback, leg));
}
// An edge a cut-out left on a panel's outline: the cut-out becomes that outline first.
// On the base's outline that is `open_base_to` (#5306): its pick is on an edge of the base
// and its flange cuts like any other. On a flange panel it is `open_notch` (#2009). Which
// picks are on such an edge is known before any opens: once one has, another pick on the
// same cut-out finds its edge on the outline.
let reach = self.t * 2.0 + 1e-3;
let mut notched = Vec::with_capacity(edges.len());
for e in edges {
notched.push(self.base_cut_edge_at(e.pick).is_none() && self.notch_at(e.pick, reach)?.is_some());
}
// A pick on an edge of a notch (a cut-out that opens the base's outline) is on an edge of
// the base: its outline runs round the notch first (#5306).
for e in edges {
if let Some(k) = self.base_cut_edge_at(e.pick) {
self.open_base_to(k)?;
} else {
self.open_notch(e.pick)?;
}
}
// Find every edge before anything moves: of an edge that cut-outs have taken part of, the
// stretch they leave nearest the pick, and at which of its ends a cut-out is.
let cuts = self.cut_outs();
let mut found = Vec::new();
for (k, ((e, &(setback, leg)), &notch)) in edges.iter().zip(&sizes).zip(&notched).enumerate() {
for (k, (e, &(setback, leg))) in edges.iter().zip(&sizes).enumerate() {
let (panel, a, b, top, dist) = self.edge_at(e.pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
if dist > self.t * 2.0 + 1e-3 {
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
@@ -3673,13 +3555,12 @@ impl SheetBody {
None => self.bend_line_runs(a - outward * setback, d, a.dot(d), b.dot(d)),
Some(_) => (f64::MIN, f64::MAX),
};
// A cut-out's edge ends where the cut-out does: the panel beyond its ends stays.
let cut = (notch || s0 > 1e-9, notch || s1 < a.dist(b) - 1e-9);
let cut = (s0 > 1e-9, s1 < a.dist(b) - 1e-9);
let (a, b) = (if cut.0 { a + d * s0 } else { a }, if cut.1 { a + d * s1 } else { b });
found.push((panel, a, b, d, top, setback, leg, e.angle, cut, run));
}
for ((panel, a, b, _, _, setback, _, _, cut, _), &notch) in found.iter().zip(&notched) {
self.trim(*panel, *a, *b, *setback, *cut, notch)?;
for (panel, a, b, _, _, setback, _, _, cut, _) in &found {
self.trim(*panel, *a, *b, *setback, *cut)?;
}
let mut made = Vec::new();
// Whether a cut-out has freed each new bend's ends (start, end) of the base beside them.
@@ -3781,8 +3662,8 @@ impl SheetBody {
made.push(self.flanges.len() - 1);
}
let mut notes = Vec::new();
for ((&i, &free), &notch) in made.iter().zip(&freed).zip(&notched) {
notes.extend(self.relieve_bend_ends(i, free, notch));
for (&i, &free) in made.iter().zip(&freed) {
notes.extend(self.relieve_bend_ends(i, free));
}
notes.extend(self.close_corners(&made, relief));
notes.extend(self.close_return_corners(&made));
@@ -3893,20 +3774,11 @@ impl SheetBody {
/// so that the bend and the flange come free of the base, folded and flat. An end that a
/// cut-out has freed already (`free`: start, end) gets none. Returns a note for every other
/// such end left as it is.
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool), on_cut_out: bool) -> Vec<String> {
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
// The panel the bend leaves: the base, or a flange panel whose edge a cut-out made (the
// panel runs on beyond the bend's ends there, as the base does beside a cut-out).
let parent = f.parent;
let mut panel = match parent {
None => self.base.clone(),
Some(j) if on_cut_out => match self.flanges.get(j) {
Some(pf) => self.panel(pf),
None => return Vec::new(),
},
Some(_) => return Vec::new(),
};
let what = if parent.is_none() { "base" } else { "panel" };
if f.parent.is_some() {
return Vec::new();
}
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
let reach = self.allowance(&f) + f.leg;
let (u0, u1) = self.zone_span(&f);
@@ -3914,27 +3786,27 @@ impl SheetBody {
for (u, s, free) in [(u0, -1.0, free.0), (u1, 1.0, free.1)] {
// `du` past this end of the bend, `dv` past the bend line (into the flange).
let at = |du: f64, dv: f64| f.p + f.d * (u + s * du) + f.n * dv;
if free || !panel.contains(at(1e-4, 1e-4)) {
if free || !self.base.contains(at(1e-4, 1e-4)) {
continue;
}
let cut = if !(width >= MIN_BEND_RELIEF) {
Err(format!("the relief width {width:.4} mm is below {MIN_BEND_RELIEF} mm"))
} else if !panel.contains(at(width + 1e-4, 1e-4)) {
Err(format!("a relief {width:.3} mm wide would take away all of the {what} beside the bend"))
} else if !self.base.contains(at(width + 1e-4, 1e-4)) {
Err(format!("a relief {width:.3} mm wide would take away all of the base beside the bend"))
} else {
let relief = rect(at(0.0, -depth), at(width, -depth), at(width, reach + width), at(0.0, reach + width));
planar_region(&panel)
.and_then(|kept| material_boolean(&[kept], &[relief], kernel::BoolOp::Cut, &mut FlatWork::new()))
planar_region(&self.base)
.and_then(|base| material_boolean(&[base], &[relief], kernel::BoolOp::Cut, &mut FlatWork::new()))
.map_err(|e| e.to_string())
.and_then(|mut left| match (left.pop(), left.is_empty()) {
(Some(kept), true) if kept.holes.is_empty() => Ok(kept),
_ => Err(format!("it would not leave the {what} in one piece")),
(Some(base), true) if base.holes.is_empty() => Ok(base),
_ => Err("it would not leave the base in one piece".to_string()),
})
};
match cut.and_then(|kept| self.set_panel_region(parent, kept.clone()).map(|()| kept).map_err(|e| e.to_string())) {
Ok(kept) => panel = kept,
match cut {
Ok(base) => self.base = base,
Err(why) => {
let w = apply_point(&self.world(parent), v3(at(0.0, 0.0)));
let w = apply_point(&self.frame_mat(), v3(at(0.0, 0.0)));
notes.push(format!("no bend relief at ({:.3}, {:.3}, {:.3}): {why}", w.x, w.y, w.z));
}
}
@@ -5317,8 +5189,7 @@ impl SheetBody {
/// The persistent name of the sheet edge nearest a world point. An edge a notch made (see
/// `base_cut_edge_at`) has none: no line of the base sketch runs along it.
pub fn edge_name(&self, pick: Vec3) -> Option<String> {
// A cut-out's edge has no name of its own: what is made on it keeps its pick.
if self.base_cut_edge_at(pick).is_some() || self.notch_at(pick, self.t * 2.0 + 1e-3).ok().flatten().is_some() {
if self.base_cut_edge_at(pick).is_some() {
return None;
}
let (panel, a, b, top, _) = self.edge_at(pick)?;
@@ -5329,11 +5200,6 @@ impl SheetBody {
/// point of that edge nearest `near`, off its very ends, so that what is made on the edge
/// knows where along it the pick was.
pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option<Vec3> {
// No cut-out's edge has a name: a pick right on one stays there, whatever name it was
// saved with (before such an edge could be picked, it took the outline edge's nearest it).
if self.notch_at(near, 1e-4).ok().flatten().is_some() {
return Some(near);
}
let mut best: Option<(f64, Vec3)> = None;
for (pi, r) in self.panels() {
let pts = r.outer.ccw().polyline(1e-3);
@@ -8462,90 +8328,6 @@ mod tests {
}
}
#[test]
fn an_edge_flange_runs_along_the_edge_a_cut_left_on_a_flanges_panel() {
// #2009: a 40 high wall (outside) on the plate's y = 0 edge, its far edge at
// y = −(BA + 37) in the flat. A cut takes a triangle off the wall's corner at x = 0: the
// wall's edge there runs from a = (0, far + 20) to b = (40, far), 20·√5 long, at obtuse
// corners with the wall's side and far edges. A 15 high flange picked on that edge runs
// along all of it and no further, and the cut-out becomes the wall's outline. Set inside,
// its bend starts 3 inside the edge: the strip under it goes, square to the edge at its
// ends, and the wall keeps its corners beyond them. Beside the bend's end at b the wall
// gets the rule's 2 × 1 relief, up to the far edge: 7 mm² (the far edge falls 1 for every
// 2 along the bend there, from 3 past the bend line). At a the wall's side edge leaves no
// room for one, and a note says so. The flange used to land on what the cut left of the
// far edge, or run the length of the wall along the moved line.
let (ba, quarter) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2);
let far = -(ba + 37.0);
let mut wall = plate_with_cuts(&[]);
wall.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, "outside", false).unwrap();
wall.flanges[0].tag = "F3.0".into();
let corner = [Vec2::new(-20.0, far - 10.0), Vec2::new(60.0, far - 10.0), Vec2::new(-20.0, far + 30.0)];
wall.holes.push(Region2 { outer: Loop2::polygon(&corner).ccw(), holes: vec![] });
let pick = apply_point(&wall.world(Some(0)), Vec3::new(20.0, far + 10.0, 2.0));
// A cut-out's edge has no name of its own; a pick on one saved with the name of the edge
// it was nearest (the far edge) stays on it.
assert_eq!(wall.edge_name(pick), None);
assert!(wall.edge_by_name("F3.0:far:top", pick).is_some_and(|p| p.dist(pick) < 1e-9));
let root5 = 5f64.sqrt();
let (d, inward) = (Vec2::new(2.0, -1.0) * (1.0 / root5), Vec2::new(1.0, 2.0) * (1.0 / root5));
let len = 20.0 * root5;
for (position, setback, relief, notes) in [("outside", 0.0, 0.0, 0), ("inside", 3.0, 7.0, 1)] {
let mut s = wall.clone();
let (made, said) = s.add_flanges_noted(&[pick], 15.0, quarter, 1.0, position, false).unwrap();
assert_eq!(said.len(), notes, "{position}: {said:?}");
let f = s.flanges[made[0]].clone();
assert!(f.parent == Some(0) && (f.len - len).abs() < 1e-6 && f.d.dist(d) < 1e-9, "{position}: {f:?}");
assert!(f.p.dist(Vec2::new(0.0, far + 20.0) + inward * setback) < 1e-6, "{position}: {f:?}");
assert!(s.holes.is_empty(), "{position}: {:?}", s.holes);
// Flat: the plate, the wall less the cut's 400, the strip and the relief, and
// len × (BA + 12) of the new flange.
let want = 20_000.0 + 200.0 * (ba + 37.0) - 400.0 - setback * len - relief + len * (ba + 12.0);
assert!((flat_area(&s) - want).abs() < 1e-6, "{position}: {} vs {want}", flat_area(&s));
holds_the_flat_material(&s, position);
}
}
#[test]
fn edge_flanges_on_two_edges_of_one_cut_run_along_both() {
// #2009: a 40 high wall (outside) on the plate's y = 0 edge, its far edge at
// y = −(BA + 37) in the flat. A cut takes a 40 × 15 slot out of the wall's far edge, from
// x = 80 to 120. Flanges picked together on both sides of the slot each run along their
// side: the first one made opens the slot onto the wall's outline, which must not leave
// the second one moving the whole wall's edge (set inside, its bend line would cut the
// wall in two). Set inside, each bend starts 3 into the wall and ends beside the wall
// past the slot's bottom: there the rule's 2 × 1 relief goes into the wall, 2 wide, from
// 1 before the bend line to 2 past the flange's far edge, (BA + 15) × 2 each. Set outside,
// the bends lie in the slot and need none.
let (ba, quarter) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2);
let far = -(ba + 37.0);
let mut wall = plate_with_cuts(&[]);
wall.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, "outside", false).unwrap();
let slot = [Vec2::new(80.0, far - 5.0), Vec2::new(120.0, far - 5.0), Vec2::new(120.0, far + 15.0), Vec2::new(80.0, far + 15.0)];
wall.holes.push(Region2 { outer: Loop2::polygon(&slot).ccw(), holes: vec![] });
let to_world = wall.world(Some(0));
let picks = [80.0, 120.0].map(|x| apply_point(&to_world, Vec3::new(x, far + 7.5, 2.0)));
for (position, setback, relief) in [("outside", 0.0, 0.0), ("inside", 3.0, 4.0 * (ba + 15.0))] {
let mut s = wall.clone();
let (made, said) = s.add_flanges_noted(&picks, 15.0, quarter, 1.0, position, false).unwrap();
assert!(said.is_empty(), "{position}: {said:?}");
assert!(s.holes.is_empty(), "{position}: {:?}", s.holes);
for (&i, x) in made.iter().zip([80.0 - setback, 120.0 + setback]) {
let f = &s.flanges[i];
assert!(f.parent == Some(0) && f.d.x.abs() < 1e-9 && (f.p.x - x).abs() < 1e-6, "{position}: {f:?}");
assert!((f.len - 15.0).abs() < 1e-6, "{position}: {f:?}");
}
// The wall keeps all but the slot, the strips under the bends and their reliefs.
let kept = s.panel(&s.flanges[0]).area();
let want = 200.0 * 37.0 - 600.0 - 2.0 * setback * 15.0 - relief;
assert!((kept - want).abs() < 1e-6, "{position}: {kept} vs {want}");
holds_the_flat_material(&s, position);
// Flat: the plate, the wall as kept, and 15 × (BA + 12) of each flange.
let want = 20_000.0 + 200.0 * ba + want + 2.0 * 15.0 * (ba + 12.0);
assert!((flat_area(&s) - want).abs() < 1e-6, "{position}: {} vs {want}", flat_area(&s));
}
}
/// A sheet (2 mm, R 1, K 0.5) on an outline of lines, arcs and curves (counter-clockwise).
fn outlined(segs: Vec<Seg2>) -> SheetBody {
base_flange("Body1", "AL2", &alu(), Plane::XY, Region2 { outer: Loop2 { segs }, holes: vec![] })
+1 -1
View File
@@ -1177,7 +1177,7 @@ pub fn convert_body(
let made: Vec<usize> = (0..sh.flanges.len()).collect();
let mut notes = Vec::new();
for &i in &made {
notes.extend(sh.relieve_bend_ends(i, (false, false), false));
notes.extend(sh.relieve_bend_ends(i, (false, false)));
}
let corners = sh.corners(&made);
let mut closed = Vec::with_capacity(corners.len());
+1 -68
View File
@@ -1,4 +1,4 @@
use solidcore_record::{Plane, Vec2, Vec3};
use solidcore_record::{Vec2, Vec3};
use solidcraft_kernel::measure;
use solidcraft_sketch::{ConstraintKind, Sketch};
@@ -8,33 +8,6 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn component_lookup_refuses_duplicate_names_and_keeps_explicit_ids() {
let mut doc = Document::new("A");
let first = doc.add_component(Some("A"), 0).unwrap();
let second = doc.add_component(Some("A"), 0).unwrap();
assert_eq!(doc.find_component("A"), None, "a duplicate name must not choose the first component or the root");
let error = doc.resolve_component("A").unwrap_err().to_string();
assert!(error.contains(&format!("matching IDs: {first}, {second}")), "{error}");
for id in [first, second] {
assert_eq!(doc.find_component(&id.to_string()), Some(id));
}
for key in ["0", "root", "ROOT"] {
assert_eq!(doc.find_component(key), Some(0));
}
let numeric_name = doc.add_component(Some(&first.to_string()), 0).unwrap();
assert_ne!(numeric_name, first);
assert_eq!(doc.find_component(&first.to_string()), Some(first));
let leading_zero = doc.add_component(Some("01"), 0).unwrap();
assert_eq!(doc.find_component("01"), Some(leading_zero));
let reopened = Document::from_json(&doc.to_json()).unwrap();
assert_eq!(reopened.find_component("A"), None);
assert_eq!(reopened.find_component(&second.to_string()), Some(second));
assert_eq!(reopened.resolve_component("Missing").unwrap(), None);
let named = Document::new("Design");
assert_eq!(named.resolve_component("Design").unwrap(), Some(0));
}
/// 40 x 30 rectangle with its width and height driven by `width` / d2.
fn plate_doc() -> (Document, u64) {
let mut doc = Document::new("plate");
@@ -1309,43 +1282,3 @@ fn an_extruded_ellipse_or_spline_is_one_wall_named_by_its_curve() {
assert_eq!(names(&Document::from_json(&doc.to_json()).unwrap()), first, "reopened");
}
}
/// A construction plane of one component carries its place into the sketch of another (#11734):
/// the sketch lies where the plane is, not where the plane's numbers fall in the sketch's own,
/// placed component.
#[test]
fn a_sketch_on_the_construction_plane_of_another_component_lies_on_it() {
let mut doc = Document::new("planes between components");
let owner = doc.add_component(Some("Owner"), 0).unwrap();
let sketcher = doc.add_component(Some("Sketcher"), 0).unwrap();
let placed = rigid(Vec3::new(0.0, 138.0, -3.0), Vec3::ZERO, Vec3::Y, std::f64::consts::PI);
doc.occurrences.iter_mut().find(|o| o.component == sketcher).unwrap().transform = placed;
let offset = PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "XZ".into() }), distance: "60 mm".into() };
let plane = doc.add_feature(FeatureKind::ConstructionPlane { plane: offset }, Some("Plane3")).unwrap();
doc.feature_mut(plane).unwrap().component = owner;
let mut sketch = Sketch::new();
let a = sketch.add_point(Vec2::new(0.0, 0.0), None).unwrap();
let b = sketch.add_point(Vec2::new(10.0, 5.0), None).unwrap();
sketch.add_line_pts(a, b, None).unwrap();
let on_plane = FeatureKind::Sketch { plane: PlaneRef::Construction { feature: plane.into() }, sketch };
let id = doc.add_feature(on_plane, Some("Sketch3")).unwrap();
doc.feature_mut(id).unwrap().component = sketcher;
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.result(id).unwrap().error.is_none(), "{:?}", model.result(id).unwrap().error);
let state = model.state();
let solved = state.sketch(id).unwrap();
// The sketch is kept in its own component's frame; placed, it lies on the plane where the
// owner puts it (the owner is not moved): the same origin, axes, normal and line end.
use solidcraft_geom::prelude::*;
let want = Plane::named("XZ").unwrap().offset(60.0);
let m = doc.component_transform(sketcher);
let close = |a: Vec3, b: Vec3, what: &str| assert!((a - b).len() < 1e-6, "{what}: {a:?} instead of {b:?}");
close(crate::apply_point(&m, solved.plane.origin), want.origin, "origin");
close(crate::apply_vector(&m, solved.plane.x), want.x, "x axis");
close(crate::apply_vector(&m, solved.plane.y), want.y, "y axis");
close(crate::apply_vector(&m, solved.plane.normal()), want.normal(), "normal");
let end = solved.plane.origin + solved.plane.x * 10.0 + solved.plane.y * 5.0;
close(crate::apply_point(&m, end), want.origin + want.x * 10.0 + want.y * 5.0, "line end");
}
-383
View File
@@ -1,383 +0,0 @@
//! Semantic dimension annotations and text formatting, independent of layout, UI language and saved data.
use crate::display_units::{DecimalMarker, NumberFormat, Shown, format_number, format_value, grouped_digits};
use crate::{DocError, Result, expr};
/// Deviations are offsets from the unrounded nominal, in internal units (mm or rad).
#[derive(Clone, Debug, PartialEq)]
pub enum Tolerance {
None,
Symmetric {
deviation: f64,
},
Deviations {
upper: f64,
lower: f64,
},
/// A fit code only; no ISO 286 deviation table is supplied.
Fit {
class: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TolerancePresentation {
Deviations,
Limits,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Normal,
Auxiliary,
Exact,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Indicator {
Diameter,
Radius,
Square,
SphericalDiameter,
SphericalRadius,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Repeat {
Times,
X,
}
/// Text runs for a future paper layout; the symmetric run already includes its ± sign.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToleranceText {
None,
Symmetric(String),
/// Signed deviations beside the nominal; zero is unsigned and has no decimals.
Stacked {
upper: String,
lower: String,
},
/// Absolute bounds that replace the nominal in the displayed dimension.
Limits {
upper: String,
lower: String,
},
Code(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Frame {
None,
Box,
Parentheses,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DimensionWarning {
/// Rounding moved the nominal, leaving the unrounded value outside the bounds
/// centred on the rounded nominal. An unchanged nominal never raises this warning.
RoundingOutsideTolerance,
}
impl Tolerance {
pub(crate) fn validate(&self) -> Result<()> {
let deviation = |v: f64, name: &str| {
if !v.is_finite() {
Err(DocError::Invalid(format!("{name} deviation must be finite")))
} else if v.abs() > 1e6 {
Err(DocError::Invalid(format!("{name} deviation magnitude must be at most 1e6")))
} else {
Ok(())
}
};
match self {
Self::None => Ok(()),
Self::Symmetric { deviation: d } => {
deviation(*d, "symmetric")?;
if *d <= 0.0 {
return Err(DocError::Invalid("symmetric deviation must be greater than zero".into()));
}
Ok(())
}
Self::Deviations { upper, lower } => {
deviation(*upper, "upper")?;
deviation(*lower, "lower")?;
if upper <= lower {
return Err(DocError::Invalid("upper deviation must be greater than lower deviation".into()));
}
Ok(())
}
Self::Fit { class } if valid_fit(class) => Ok(()),
Self::Fit { .. } => {
Err(DocError::Invalid("fit class must have a permitted all-upper-case or all-lower-case letter code and grade 01, 0 or 1–18".into()))
}
}
}
pub(crate) fn deviations(&self) -> Option<(f64, f64)> {
match *self {
Self::Symmetric { deviation } => Some((deviation, -deviation)),
Self::Deviations { upper, lower } => Some((upper, lower)),
Self::None | Self::Fit { .. } => None,
}
}
}
fn valid_fit(class: &str) -> bool {
// The longest accepted code is two ASCII letters and a two-digit grade.
// Refuse hostile strings before allocating or scanning their contents.
if class.len() > 4 {
return false;
}
let code = class.trim_end_matches(|c: char| c.is_ascii_digit());
let grade = class.get(code.len()..).unwrap_or_default();
let upper = code.to_ascii_uppercase();
let uniform_case = code == upper || code == code.to_ascii_lowercase();
uniform_case
&& matches!(
upper.as_str(),
"A" | "B"
| "C"
| "CD"
| "D"
| "E"
| "EF"
| "F"
| "FG"
| "G"
| "H"
| "J"
| "JS"
| "K"
| "M"
| "N"
| "P"
| "R"
| "S"
| "T"
| "U"
| "V"
| "X"
| "Y"
| "Z"
| "ZA"
| "ZB"
| "ZC"
)
&& matches!(
grade,
"01" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18"
)
}
/// The unrounded measurement and semantic annotations of one dimension.
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionValue {
pub nominal: f64,
pub kind: expr::Kind,
pub tolerance: Tolerance,
pub presentation: TolerancePresentation,
pub role: Role,
pub indicator: Option<Indicator>,
pub count: u32,
pub not_to_scale: bool,
}
/// Drawing-owned presentation; independent of the user's UI locale.
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionStyle {
pub unit: String,
pub number: NumberFormat,
pub decimals: u8,
/// Maximum precision of both stacked values. Trailing-zero suppression removes
/// only shared zeros, so their decimal places match (except an unsigned zero deviation).
pub tolerance_decimals: u8,
pub repeat: Repeat,
}
/// Typed runs and layout metadata, with a single-line form for headless consumers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DimensionText {
pub count: Option<String>,
pub indicator: Option<String>,
/// Retained for inspection; limits replace this run in both plain text and layout.
pub nominal: String,
pub tolerance: ToleranceText,
pub frame: Frame,
pub underline: bool,
pub warnings: Vec<DimensionWarning>,
}
impl DimensionText {
/// Limits replace the nominal; boxes and underlines remain layout metadata.
pub fn plain(&self) -> String {
let mut text = String::new();
if self.frame == Frame::Parentheses {
text.push('(');
}
if let Some(count) = &self.count {
text.push_str(count);
}
if let Some(indicator) = &self.indicator {
text.push_str(indicator);
}
if !matches!(&self.tolerance, ToleranceText::Limits { .. }) {
text.push_str(&self.nominal);
}
match &self.tolerance {
ToleranceText::None => {}
ToleranceText::Symmetric(t) | ToleranceText::Code(t) => {
text.push(' ');
text.push_str(t);
}
ToleranceText::Stacked { upper, lower } => {
text.push(' ');
text.push_str(upper);
text.push('/');
text.push_str(lower);
}
ToleranceText::Limits { upper, lower } => {
text.push_str(upper);
text.push('/');
if let Some(indicator) = &self.indicator {
text.push_str(indicator);
}
text.push_str(lower);
}
}
if self.frame == Frame::Parentheses {
text.push(')');
}
text
}
}
/// Format a dimension without changing its value or dropping incompatible annotations.
pub fn format_dimension(value: &DimensionValue, style: &DimensionStyle) -> Result<DimensionText> {
if !value.nominal.is_finite() {
return Err(DocError::Invalid("dimension nominal must be finite".into()));
}
if style.decimals > 8 {
return Err(DocError::Invalid("dimension decimals must be at most 8".into()));
}
if style.tolerance_decimals > 8 {
return Err(DocError::Invalid("dimension tolerance_decimals must be at most 8".into()));
}
if !(1..=10_000).contains(&value.count) {
return Err(DocError::Invalid("dimension count must be between 1 and 10000".into()));
}
if value.role != Role::Normal && !matches!(&value.tolerance, Tolerance::None) {
return Err(DocError::Invalid("auxiliary and exact dimension roles cannot have a tolerance".into()));
}
value.tolerance.validate()?;
let shown = format_value(value.nominal, value.kind, &style.unit, style.decimals, &style.number)?;
let tolerance = match &value.tolerance {
Tolerance::None => ToleranceText::None,
Tolerance::Fit { class } => ToleranceText::Code(class.clone()),
t if value.presentation == TolerancePresentation::Limits => {
let (upper, lower) = t.deviations().ok_or_else(|| DocError::Invalid("limits require numeric deviations".into()))?;
let (upper, lower) = dimension_pair(value.nominal + upper, value.nominal + lower, value.kind, style)?;
ToleranceText::Limits { upper: upper.text, lower: lower.text }
}
Tolerance::Symmetric { deviation } => {
let d = dimension_deviation(*deviation, value.kind, style)?;
ToleranceText::Symmetric(format!("±{}", d.text))
}
Tolerance::Deviations { upper, lower } => {
let (upper, lower) = dimension_pair(*upper, *lower, value.kind, style)?;
ToleranceText::Stacked { upper: signed_deviation(upper), lower: signed_deviation(lower) }
}
};
let mut warnings = Vec::new();
if let Some((upper, lower)) = value.tolerance.deviations() {
// Shown.value is in display units. Centre the interval on the shown nominal,
// in internal units. Compare endpoints directly: subtracting the nominal can
// manufacture an outside offset at an inclusive decimal boundary (1.1 - 1).
let (_, scale) = expr::unit_info(&style.unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
let centre = shown.value * scale;
// A non-identity unit conversion can put an unchanged decimal nominal
// a few floating-point steps from its original value. Account for both
// conversions here, never an absolute margin or slack at the tolerance bounds.
let conversion_roundoff = if scale == 1.0 { 0.0 } else { 2.0 * f64::EPSILON * centre.abs().max(value.nominal.abs()) };
let moved = (centre - value.nominal).abs() > conversion_roundoff;
if moved && !(centre + lower..=centre + upper).contains(&value.nominal) {
warnings.push(DimensionWarning::RoundingOutsideTolerance);
}
}
let count = if value.count > 1 {
let repeat = match style.repeat {
Repeat::Times => '×',
Repeat::X => 'X',
};
Some(format!("{}{repeat} ", format_number(f64::from(value.count), 0, &style.number)?.text))
} else {
None
};
Ok(DimensionText {
count,
indicator: value.indicator.map(|i| {
match i {
Indicator::Diameter => "Ø",
Indicator::Radius => "R",
Indicator::Square => "□",
Indicator::SphericalDiameter => "SØ",
Indicator::SphericalRadius => "SR",
}
.into()
}),
nominal: shown.text,
tolerance,
frame: match value.role {
Role::Normal => Frame::None,
Role::Auxiliary => Frame::Parentheses,
Role::Exact => Frame::Box,
},
underline: value.not_to_scale,
warnings,
})
}
fn dimension_deviation(deviation: f64, kind: expr::Kind, style: &DimensionStyle) -> Result<Shown> {
let mut shown = format_value(deviation, kind, &style.unit, style.tolerance_decimals, &style.number)?;
if shown.value == 0.0 {
shown.text = "0".into();
}
Ok(shown)
}
/// Pad already-rounded fractions to their shared precision without rounding again.
fn dimension_pair(upper: f64, lower: f64, kind: expr::Kind, style: &DimensionStyle) -> Result<(Shown, Shown)> {
let mut upper = format_value(upper, kind, &style.unit, style.tolerance_decimals, &style.number)?;
let mut lower = format_value(lower, kind, &style.unit, style.tolerance_decimals, &style.number)?;
let marker = match style.number.marker {
DecimalMarker::Point => '.',
DecimalMarker::Comma => ',',
};
let places = |text: &str| text.split_once(marker).map_or(0, |(_, fraction)| fraction.chars().filter(char::is_ascii_digit).count());
let precision = places(&upper.text).max(places(&lower.text));
for shown in [&mut upper, &mut lower] {
if places(&shown.text) == precision {
continue;
}
let (integer, fraction) = shown.text.split_once(marker).unwrap_or((&shown.text, ""));
let mut digits: String = fraction.chars().filter(char::is_ascii_digit).collect();
// W09b has already bounded precision to at most eight decimal places.
digits.extend(std::iter::repeat_n('0', precision - digits.len()));
let mut text = if integer == "0" && !style.number.leading_zero { String::new() } else { integer.to_owned() };
text.push(marker);
grouped_digits(&mut text, &digits, false, style.number.groups);
shown.text = text;
}
Ok((upper, lower))
}
fn signed_deviation(shown: Shown) -> String {
if shown.value == 0.0 {
"0".into()
} else if shown.value > 0.0 {
format!("+{}", shown.text)
} else {
shown.text
}
}
-612
View File
@@ -1,612 +0,0 @@
use crate::display_units::{DecimalMarker, DimensionStyle, DimensionValue, NumberFormat, Ties, format_dimension};
use crate::expr::Kind;
use crate::tolerance::{DimensionWarning, Frame, Indicator, Repeat, Role, Tolerance, TolerancePresentation, ToleranceText};
use crate::{DocError, Result};
fn value(nominal: f64, tolerance: Tolerance) -> DimensionValue {
DimensionValue {
nominal,
kind: Kind::Length,
tolerance,
presentation: TolerancePresentation::Deviations,
role: Role::Normal,
indicator: None,
count: 1,
not_to_scale: false,
}
}
fn style() -> DimensionStyle {
DimensionStyle {
unit: "mm".into(),
number: NumberFormat { marker: DecimalMarker::Comma, ..NumberFormat::default() },
decimals: 0,
tolerance_decimals: 2,
repeat: Repeat::Times,
}
}
fn refused<T: std::fmt::Debug>(result: Result<T>, fragment: &str) {
assert!(matches!(&result, Err(DocError::Invalid(_))), "expected a descriptive Invalid error for {fragment:?}, got {result:?}");
if let Err(DocError::Invalid(message)) = result {
assert!(message.contains(fragment), "{message:?} does not name {fragment:?}");
}
}
#[test]
fn comma_deviations_symmetric_and_unsigned_zero() {
let s = style();
let mut v = value(40.0, Tolerance::Deviations { upper: 0.1, lower: -0.05 });
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.nominal, "40");
assert_eq!(text.tolerance, ToleranceText::Stacked { upper: "+0,10".into(), lower: "−0,05".into() });
assert_eq!(text.plain(), "40 +0,10/−0,05");
assert!(!text.plain().contains('-'));
assert_eq!(text.frame, Frame::None);
assert!(!text.underline);
assert!(text.warnings.is_empty());
for zero in [0.0, -0.0, -0.0001, 0.0001] {
v.tolerance = Tolerance::Deviations { upper: 0.1, lower: zero };
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "40 +0,10/0");
v.tolerance = Tolerance::Deviations { upper: zero, lower: -0.05 };
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "40 0/−0,05");
}
v.tolerance = Tolerance::Symmetric { deviation: 0.05 };
assert_eq!(format_dimension(&v, &s).unwrap().tolerance, ToleranceText::Symmetric("±0,05".into()));
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "40 ±0,05");
v.tolerance = Tolerance::Symmetric { deviation: 0.0001 };
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "40 ±0");
}
#[test]
fn limits_are_sums_of_the_unrounded_nominal() {
let s = style();
let mut v = value(40.0, Tolerance::Deviations { upper: 0.1, lower: -0.05 });
v.presentation = TolerancePresentation::Limits;
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.nominal, "40");
assert_eq!(text.tolerance, ToleranceText::Limits { upper: "40,10".into(), lower: "39,95".into() });
assert_eq!(text.plain(), "40,10/39,95");
v.nominal = 40.4;
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.nominal, "40");
assert_eq!(text.tolerance, ToleranceText::Limits { upper: "40,50".into(), lower: "40,35".into() });
v.tolerance = Tolerance::Symmetric { deviation: 0.1 };
assert_eq!(format_dimension(&v, &s).unwrap().tolerance, ToleranceText::Limits { upper: "40,50".into(), lower: "40,30".into() });
v.nominal = -0.05;
v.tolerance = Tolerance::Deviations { upper: 0.05, lower: -0.1 };
assert_eq!(
format_dimension(&v, &s).unwrap().tolerance,
ToleranceText::Limits { upper: "0,00".into(), lower: "−0,15".into() },
"limits are absolute numbers; a zero limit keeps its requested decimals"
);
}
#[test]
fn point_and_independent_precisions() {
let mut s = style();
s.number.marker = DecimalMarker::Point;
s.decimals = 1;
s.tolerance_decimals = 3;
let v = value(40.0, Tolerance::Symmetric { deviation: 0.05 });
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "40.0 ±0.050");
}
#[test]
fn roles_frame_text_and_refuse_every_kind_of_tolerance() {
let s = style();
for (role, frame, plain) in [(Role::Normal, Frame::None, "40"), (Role::Auxiliary, Frame::Parentheses, "(40)"), (Role::Exact, Frame::Box, "40")] {
let mut v = value(40.0, Tolerance::None);
v.role = role;
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.frame, frame);
assert_eq!(text.plain(), plain);
if role != Role::Normal {
for tolerance in
[Tolerance::Symmetric { deviation: 0.05 }, Tolerance::Deviations { upper: 0.1, lower: -0.05 }, Tolerance::Fit { class: "H7".into() }]
{
v.tolerance = tolerance;
refused(format_dimension(&v, &s), "roles");
}
}
}
}
#[test]
fn counts_indicators_and_metadata() {
let mut s = style();
for (indicator, prefix) in [
(Indicator::Diameter, "Ø"),
(Indicator::Radius, "R"),
(Indicator::Square, "□"),
(Indicator::SphericalDiameter, "SØ"),
(Indicator::SphericalRadius, "SR"),
] {
let mut v = value(40.0, Tolerance::None);
v.indicator = Some(indicator);
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.count, None);
assert_eq!(text.indicator.as_deref(), Some(prefix));
assert_eq!(text.plain(), format!("{prefix}40"));
v.count = 4;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), format!("4× {prefix}40"));
s.repeat = Repeat::X;
assert_eq!(format_dimension(&v, &s).unwrap().count.as_deref(), Some("4X "));
assert_eq!(format_dimension(&v, &s).unwrap().plain(), format!("4X {prefix}40"));
s.repeat = Repeat::Times;
v.not_to_scale = true;
let text = format_dimension(&v, &s).unwrap();
assert!(text.underline);
assert_eq!(text.plain(), format!("4× {prefix}40"));
v.role = Role::Auxiliary;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), format!("(4× {prefix}40)"));
}
let mut v = value(40.0, Tolerance::None);
v.count = 10_000;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "10000× 40");
}
#[test]
fn fits_accept_exact_letter_codes_and_grade_forms() {
let s = style();
let letters = [
"A", "B", "C", "CD", "D", "E", "EF", "F", "FG", "G", "H", "J", "JS", "K", "M", "N", "P", "R", "S", "T", "U", "V", "X", "Y", "Z", "ZA", "ZB",
"ZC",
];
for letter in letters {
for code in [letter.to_string(), letter.to_ascii_lowercase()] {
for grade in ["01", "0", "1", "6", "7", "18"] {
let class = format!("{code}{grade}");
let mut v = value(25.0, Tolerance::Fit { class: class.clone() });
v.indicator = Some(Indicator::Diameter);
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.tolerance, ToleranceText::Code(class.clone()));
assert_eq!(text.plain(), format!("Ø25 {class}"));
assert!(text.warnings.is_empty());
v.presentation = TolerancePresentation::Limits;
assert_eq!(format_dimension(&v, &s).unwrap(), text);
}
}
}
for class in [
"H19", "Hx", "jS6", "Js7", "7H", "", "I7", "L7", "O7", "Q7", "W7", "H00", "H02", "H001", "H-1", "H7/g6", " H7", "H7 ", "H7\n", "H7", "Η7",
"\0",
] {
refused(format_dimension(&value(25.0, Tolerance::Fit { class: class.into() }), &s), "fit class");
}
let v = value(25.0, Tolerance::Fit { class: "H".repeat(1_000_000) });
let error = format_dimension(&v, &s).unwrap_err();
assert!(error.to_string().len() < 160, "a hostile fit must not be copied into the error");
}
#[test]
fn warnings_use_the_shown_nominal_in_the_same_units() {
let s = style();
for presentation in [TolerancePresentation::Deviations, TolerancePresentation::Limits] {
for (nominal, expected) in [(10.4, true), (10.04, false), (-10.4, true), (-10.04, false)] {
let mut v = value(nominal, Tolerance::Symmetric { deviation: 0.1 });
v.presentation = presentation;
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.warnings, if expected { vec![DimensionWarning::RoundingOutsideTolerance] } else { vec![] });
}
}
// These asymmetric cases distinguish which nominal centres the tolerance interval.
for (upper, lower, warns) in [(0.5, -0.1, false), (0.1, -0.5, true)] {
let v = value(10.4, Tolerance::Deviations { upper, lower });
assert_eq!(!format_dimension(&v, &s).unwrap().warnings.is_empty(), warns);
}
// Inclusive bounds, with binary-exact fractions so the oracle has no decimal ambiguity.
for (nominal, warns) in [(10.125, false), (10.125_f64.next_up(), true), (9.875, false), (9.875_f64.next_down(), true)] {
let v = value(nominal, Tolerance::Symmetric { deviation: 0.125 });
assert_eq!(!format_dimension(&v, &s).unwrap().warnings.is_empty(), warns);
}
// Subtraction would make 1.1 - 1 slightly larger than 0.1, falsely excluding
// the exact endpoint. The next representable value beyond it must still warn.
for (nominal, warns) in [(1.1, false), (1.1_f64.next_up(), true), (-1.1, false), ((-1.1_f64).next_down(), true)] {
let v = value(nominal, Tolerance::Symmetric { deviation: 0.1 });
assert_eq!(!format_dimension(&v, &s).unwrap().warnings.is_empty(), warns, "decimal boundary {nominal:?}");
}
let mut inches = s.clone();
inches.unit = "in".into();
for (nominal, deviation, warns) in [(35.56, 2.54, true), (26.416, 2.54, false)] {
let v = value(nominal, Tolerance::Symmetric { deviation });
assert_eq!(!format_dimension(&v, &inches).unwrap().warnings.is_empty(), warns);
}
let v = value(10.4, Tolerance::None);
assert!(format_dimension(&v, &s).unwrap().warnings.is_empty());
}
#[test]
fn shared_number_policy_converts_nominals_deviations_and_limits() {
let mut s = style();
s.number.marker = DecimalMarker::Point;
s.unit = "in".into();
s.decimals = 3;
s.tolerance_decimals = 3;
let mut v = value(25.4, Tolerance::Symmetric { deviation: 0.254 });
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "1.000 ±0.010");
v.presentation = TolerancePresentation::Limits;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "1.010/0.990");
s.unit = "deg".into();
s.decimals = 1;
v.kind = Kind::Angle;
v.nominal = std::f64::consts::PI / 6.0;
v.tolerance = Tolerance::Symmetric { deviation: std::f64::consts::PI / 180.0 };
v.presentation = TolerancePresentation::Deviations;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "30.0 ±1.000");
v.kind = Kind::Unitless;
v.nominal = 2.665;
v.tolerance = Tolerance::Symmetric { deviation: 0.125 };
s.unit = "".into();
s.decimals = 2;
s.tolerance_decimals = 2;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "2.66 ±0.12");
s.number.ties = Ties::HalfAwayFromZero;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "2.67 ±0.13");
s.number = NumberFormat { marker: DecimalMarker::Comma, groups: true, leading_zero: false, trailing_zeros: false, ties: Ties::HalfEven };
s.tolerance_decimals = 5;
v.nominal = 12_345.0;
v.tolerance = Tolerance::Deviations { upper: 1.23456, lower: -0.50000 };
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "12\u{202f}345 +1,234\u{202f}56/−,500\u{202f}00");
}
#[test]
fn hostile_numbers_orders_counts_and_precisions_are_refused() {
let s = style();
for nominal in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
refused(format_dimension(&value(nominal, Tolerance::None), &s), "nominal");
}
for deviation in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e6_f64.next_up(), -1e6_f64.next_up()] {
refused(format_dimension(&value(40.0, Tolerance::Symmetric { deviation }), &s), "symmetric");
refused(format_dimension(&value(40.0, Tolerance::Deviations { upper: deviation, lower: -0.1 }), &s), "upper");
refused(format_dimension(&value(40.0, Tolerance::Deviations { upper: 0.1, lower: deviation }), &s), "lower");
}
for deviation in [0.0, -0.0, -0.1] {
refused(format_dimension(&value(40.0, Tolerance::Symmetric { deviation }), &s), "symmetric");
}
for (upper, lower) in [(0.0, 0.0), (-0.0, 0.0), (-0.1, 0.1), (0.1, 0.1)] {
refused(format_dimension(&value(40.0, Tolerance::Deviations { upper, lower }), &s), "upper");
}
for count in [0, 10_001, u32::MAX] {
let mut v = value(40.0, Tolerance::None);
v.count = count;
refused(format_dimension(&v, &s), "count");
}
for precision in [9, u8::MAX] {
let mut s = s.clone();
s.decimals = precision;
refused(format_dimension(&value(40.0, Tolerance::None), &s), "decimals");
s.decimals = 0;
s.tolerance_decimals = precision;
refused(format_dimension(&value(40.0, Tolerance::None), &s), "tolerance_decimals");
}
for unit in ["parsec", "deg", "", "IN", "\0", "../../mm"] {
let mut s = s.clone();
s.unit = unit.into();
refused(format_dimension(&value(40.0, Tolerance::None), &s), "unit");
}
// The magnitude cap is in internal units, not the selected display unit.
let mut s = s;
s.unit = "m".into();
refused(format_dimension(&value(0.0, Tolerance::Symmetric { deviation: 1_000_001.0 }), &s), "1e6");
assert!(format_dimension(&value(0.0, Tolerance::Symmetric { deviation: 1e6 }), &s).is_ok());
s.unit = "mm".into();
s.decimals = 8;
s.tolerance_decimals = 8;
assert!(format_dimension(&value(1e12, Tolerance::None), &s).is_ok());
refused(format_dimension(&value(1e12_f64.next_up(), Tolerance::None), &s), "display value");
let mut v = value(1e12, Tolerance::Symmetric { deviation: 1.0 });
v.presentation = TolerancePresentation::Limits;
refused(format_dimension(&v, &s), "display value");
}
fn random(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
#[test]
fn dimension_seeded_100k_never_panics_and_is_deterministic() {
let mut seed = 0x6782_b14e_920d_5ac3;
let units = [("mm", Kind::Length), ("in", Kind::Length), ("m", Kind::Length), ("deg", Kind::Angle), ("rad", Kind::Angle), ("", Kind::Unitless)];
let fits = ["H7", "h6", "js6", "JS7", "H01", "ZC18", "H19", "jS6", "", "I7", " H7", "H7", "\0", "a/../../b"];
let indicators = [
None,
Some(Indicator::Diameter),
Some(Indicator::Radius),
Some(Indicator::Square),
Some(Indicator::SphericalDiameter),
Some(Indicator::SphericalRadius),
];
let mut accepted = [0; 4];
let mut rejected = 0;
for case in 0..100_000 {
let nominal = match random(&mut seed) % 4 {
0 => f64::from_bits(random(&mut seed)),
1 => -0.0,
_ => (random(&mut seed) % 2_000_001) as f64 / 100.0 - 10_000.0,
};
let deviation =
if random(&mut seed).is_multiple_of(5) { f64::from_bits(random(&mut seed)) } else { (random(&mut seed) % 100_000 + 1) as f64 / 100.0 };
let category = (random(&mut seed) % 4) as usize;
let tolerance = match category {
0 => Tolerance::None,
1 => Tolerance::Symmetric { deviation },
2 => Tolerance::Deviations { upper: deviation, lower: if random(&mut seed).is_multiple_of(3) { 0.0 } else { -deviation } },
_ => {
let class = if random(&mut seed).is_multiple_of(3) {
(0..random(&mut seed) % 9).map(|_| char::from_u32((random(&mut seed) % 0x11_0000) as u32).unwrap_or('\u{fffd}')).collect()
} else {
fits[(random(&mut seed) % fits.len() as u64) as usize].into()
};
Tolerance::Fit { class }
}
};
let (unit, kind) = units[(random(&mut seed) % units.len() as u64) as usize];
let mut v = value(nominal, tolerance);
v.kind = kind;
v.presentation = if random(&mut seed).is_multiple_of(2) { TolerancePresentation::Deviations } else { TolerancePresentation::Limits };
v.role = match random(&mut seed) % 5 {
0 => Role::Exact,
1 => Role::Auxiliary,
_ => Role::Normal,
};
v.count = match random(&mut seed) % 10 {
0 => 0,
1 => 10_001,
2 => u32::MAX,
_ => (random(&mut seed) % 10_000 + 1) as u32,
};
v.indicator = indicators[(random(&mut seed) % indicators.len() as u64) as usize];
v.not_to_scale = random(&mut seed).is_multiple_of(2);
let bits = random(&mut seed);
let s = DimensionStyle {
unit: if bits.is_multiple_of(10) { "unknown".into() } else { unit.into() },
number: NumberFormat {
marker: if bits & 1 == 0 { DecimalMarker::Point } else { DecimalMarker::Comma },
groups: bits & 2 != 0,
leading_zero: bits & 4 != 0,
trailing_zeros: bits & 8 != 0,
ties: if bits & 16 == 0 { Ties::HalfEven } else { Ties::HalfAwayFromZero },
},
decimals: (random(&mut seed) % 11) as u8,
tolerance_decimals: (random(&mut seed) % 11) as u8,
repeat: if bits & 32 == 0 { Repeat::Times } else { Repeat::X },
};
let first = format_dimension(&v, &s);
let second = format_dimension(&v, &s);
assert_eq!(first, second, "determinism case {case}: {v:?}, {s:?}");
match first {
Ok(text) => {
accepted[category] += 1;
assert_eq!(text.nominal, crate::display_units::format_value(v.nominal, v.kind, &s.unit, s.decimals, &s.number).unwrap().text);
if !matches!(&text.tolerance, ToleranceText::Limits { .. }) {
assert!(text.plain().contains(&text.nominal), "nominal lost in case {case}: {text:?}");
}
if let ToleranceText::Stacked { upper, lower } | ToleranceText::Limits { upper, lower } = &text.tolerance
&& upper != "0"
&& lower != "0"
{
let places = |t: &str| t.split_once(['.', ',']).map_or(0, |(_, f)| f.chars().filter(char::is_ascii_digit).count());
assert_eq!(places(upper), places(lower), "stacked precision in case {case}: {text:?}");
}
assert!(!text.plain().contains('\n'), "plain output must stay on one line");
assert_eq!(text.underline, v.not_to_scale);
}
Err(DocError::Invalid(_)) => rejected += 1,
other => panic!("unexpected error in case {case}: {other:?}"),
}
}
assert!(accepted.iter().all(|n| *n > 1_000), "must exercise every successful tolerance kind: {accepted:?}");
assert!(rejected > 10_000, "must exercise hostile inputs");
eprintln!("seed=0x6782b14e920d5ac3 cases=100000 accepted_by_kind={accepted:?} rejected={rejected}");
}
#[test]
fn limits_replace_the_nominal_in_plain_text() {
let s = style();
let mut v = value(40.0, Tolerance::Deviations { upper: 0.1, lower: -0.05 });
v.presentation = TolerancePresentation::Limits;
let text = format_dimension(&v, &s).unwrap();
eprintln!("limits: input=40 +0.1/-0.05 expected=40,10/39,95 actual={}", text.plain());
assert_eq!(text.plain(), "40,10/39,95");
assert_eq!(text.nominal, "40", "the nominal remains available as metadata");
assert_eq!(text.tolerance, ToleranceText::Limits { upper: "40,10".into(), lower: "39,95".into() });
v.nominal = 40.4;
v.count = 4;
v.indicator = Some(Indicator::Diameter);
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "4× Ø40,50/Ø40,35");
}
#[test]
fn same_sign_deviations_warn_only_after_rounding_moves_the_nominal() {
let s = style();
let cases = [
(40.0, -0.02, -0.05, "40 −0,02/−0,05", false),
(40.0, 0.05, 0.02, "40 +0,05/+0,02", false),
(39.96, -0.02, -0.05, "40 −0,02/−0,05", false),
(40.04, 0.05, 0.02, "40 +0,05/+0,02", false),
(39.6, -0.02, -0.05, "40 −0,02/−0,05", true),
(40.4, 0.05, 0.02, "40 +0,05/+0,02", true),
];
let mut observed = Vec::new();
for (nominal, upper, lower, expected, warns) in cases {
let text = format_dimension(&value(nominal, Tolerance::Deviations { upper, lower }), &s).unwrap();
eprintln!(
"same-sign: input={nominal} {upper}/{lower} expected={expected}, warning={warns}; actual={}, warnings={:?}",
text.plain(),
text.warnings
);
observed.push((text.plain(), !text.warnings.is_empty()));
}
assert_eq!(observed, cases.map(|(_, _, _, text, warns)| (text.to_owned(), warns)));
// Compare before/after rounding in display units, avoiding a false change from
// converting the shown value back into internal units.
for (kind, unit, nominal, scale) in
[(Kind::Length, "in", 1016.0, 25.4), (Kind::Angle, "deg", std::f64::consts::FRAC_PI_2, std::f64::consts::PI / 180.0)]
{
let mut s = s.clone();
s.unit = unit.into();
let mut v = value(nominal, Tolerance::Deviations { upper: -0.02 * scale, lower: -0.05 * scale });
v.kind = kind;
assert!(format_dimension(&v, &s).unwrap().warnings.is_empty(), "{unit}");
}
}
#[test]
fn stacked_values_suppress_only_shared_trailing_zeros() {
let mut s = style();
s.number.trailing_zeros = false;
let mut observed = Vec::new();
let cases = [
(0.1, -0.05, "40 +0,10/−0,05"),
(0.1, -0.2, "40 +0,1/−0,2"),
(0.0, -0.05, "40 0/−0,05"),
(0.1, -0.0001, "40 +0,1/0"),
(0.1, 0.05, "40 +0,10/+0,05"),
(-0.05, -0.1, "40 −0,05/−0,10"),
];
for (upper, lower, expected) in cases {
let text = format_dimension(&value(40.0, Tolerance::Deviations { upper, lower }), &s).unwrap();
eprintln!("stacked: input=40 {upper}/{lower}, trim zeros; expected={expected}; actual={}", text.plain());
observed.push(text.plain());
}
assert_eq!(observed, cases.map(|(_, _, text)| text));
for marker in [DecimalMarker::Comma, DecimalMarker::Point] {
s.number.marker = marker;
s.number.leading_zero = false;
s.number.groups = true;
s.tolerance_decimals = 5;
let v = value(12_345.0, Tolerance::Deviations { upper: 1.23456, lower: -0.5 });
let expected = if marker == DecimalMarker::Comma {
"12\u{202f}345 +1,234\u{202f}56/−,500\u{202f}00"
} else {
"12\u{202f}345 +1.234\u{202f}56/−.500\u{202f}00"
};
assert_eq!(format_dimension(&v, &s).unwrap().plain(), expected);
}
s.number.marker = DecimalMarker::Comma;
s.tolerance_decimals = 2;
let mut v = value(-0.05, Tolerance::Deviations { upper: 0.05, lower: -0.1 });
v.presentation = TolerancePresentation::Limits;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), ",00/−,15", "a zero limit shares the pair's precision and leading-zero policy");
}
#[test]
fn unchanged_nominals_do_not_warn_for_unit_conversion_roundoff() {
let mut s = style();
s.decimals = 3;
let cases = [
(Kind::Length, "in", 1.3 * 25.4, 25.4, "1,300"),
(Kind::Length, "in", 33.02, 25.4, "1,300"),
(Kind::Length, "in", 0.762, 25.4, "0,030"),
(Kind::Length, "ft", 15.24, 304.8, "0,050"),
(Kind::Length, "cm", 0.7, 10.0, "0,070"),
(Kind::Angle, "deg", std::f64::consts::FRAC_PI_2, std::f64::consts::PI / 180.0, "90,000"),
];
let mut warned = Vec::new();
for (kind, unit, nominal, scale, expected) in cases {
s.unit = unit.into();
for (upper, lower) in [(-0.02, -0.05), (0.05, 0.02)] {
let mut v = value(nominal, Tolerance::Deviations { upper: upper * scale, lower: lower * scale });
v.kind = kind;
let text = format_dimension(&v, &s).unwrap();
assert_eq!(text.nominal, expected);
if !text.warnings.is_empty() {
warned.push((unit, nominal, text.plain()));
}
}
}
assert!(warned.is_empty(), "unit conversion alone must not warn: {warned:?}");
// The roundoff guard is relative, not a fixed tolerance that hides small values.
for unit in ["mm", "in"] {
s.unit = unit.into();
let v = value(1e-20, Tolerance::Symmetric { deviation: 1e-21 });
assert_eq!(format_dimension(&v, &s).unwrap().warnings, [DimensionWarning::RoundingOutsideTolerance]);
}
// Even when conversion underflows, a nonzero internal value can be outside
// the shown zero's tolerance. Do not mistake that for an unchanged nominal.
s.unit = "in".into();
let v = value(f64::from_bits(2), Tolerance::Symmetric { deviation: f64::from_bits(1) });
assert_eq!(format_dimension(&v, &s).unwrap().warnings, [DimensionWarning::RoundingOutsideTolerance]);
// Identity units need no roundoff allowance, even for a one-step change.
s.unit = "mm".into();
s.decimals = 0;
let v = value(1.0_f64.next_up(), Tolerance::Deviations { upper: -0.02, lower: -0.05 });
assert_eq!(format_dimension(&v, &s).unwrap().warnings, [DimensionWarning::RoundingOutsideTolerance]);
}
#[test]
fn repeat_counts_use_the_unitless_number_format() {
let mut s = style();
s.number.groups = true;
let mut v = value(25.4, Tolerance::None);
v.count = 10_000;
s.unit = "in".into();
s.decimals = 3;
let text = format_dimension(&v, &s).unwrap();
eprintln!("repeat: input=count 10000, 25.4 mm in inches, groups=true; expected=10\u{202f}000× 1,000; actual={}", text.plain());
assert_eq!(text.plain(), "10\u{202f}000× 1,000");
for (repeat, suffix) in [(Repeat::Times, "× "), (Repeat::X, "X ")] {
s.repeat = repeat;
for (groups, count, expected) in [(true, 9999, "9999"), (true, 10_000, "10\u{202f}000"), (false, 10_000, "10000")] {
s.number.groups = groups;
v.count = count;
assert_eq!(format_dimension(&v, &s).unwrap().count, Some(format!("{expected}{suffix}")));
}
v.count = 1;
assert_eq!(format_dimension(&v, &s).unwrap().count, None);
}
}
#[test]
fn legacy_limits_repeat_dimension_indicators() {
let mut s = style();
s.tolerance_decimals = 0;
let cases = [
(Some(Indicator::Diameter), 1, 11.0, "Ø12/Ø10"),
(Some(Indicator::Diameter), 4, 11.0, "4× Ø12/Ø10"),
(Some(Indicator::Diameter), 1, 1.0, "Ø2/Ø0"),
(Some(Indicator::SphericalDiameter), 1, 11.0, "SØ12/SØ10"),
(None, 1, 11.0, "12/10"),
];
let mut actual = Vec::new();
for (indicator, count, nominal, expected) in cases {
let mut v = value(nominal, Tolerance::Symmetric { deviation: 1.0 });
v.presentation = TolerancePresentation::Limits;
v.indicator = indicator;
v.count = count;
let text = format_dimension(&v, &s).unwrap();
eprintln!("limits input={nominal}±1 indicator={indicator:?} count={count}; expected={expected}; actual={}", text.plain());
assert_eq!(text.nominal, nominal.to_string());
assert!(matches!(&text.tolerance, ToleranceText::Limits { upper, lower } if !upper.contains('Ø') && !lower.contains('Ø')));
actual.push(text.plain());
}
assert_eq!(actual, cases.map(|(_, _, _, expected)| expected));
s.unit = "in".into();
s.number.marker = DecimalMarker::Point;
s.decimals = 3;
s.tolerance_decimals = 3;
let mut v = value(25.4, Tolerance::Symmetric { deviation: 0.127 });
v.indicator = Some(Indicator::Diameter);
v.presentation = TolerancePresentation::Limits;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "Ø1.005/Ø0.995");
v.presentation = TolerancePresentation::Deviations;
assert_eq!(format_dimension(&v, &s).unwrap().plain(), "Ø1.000 ±0.005");
}
#[test]
fn canonical_formatter_keeps_the_existing_public_paths() {
let mut v = value(11.0, Tolerance::Symmetric { deviation: 1.0 });
v.presentation = TolerancePresentation::Limits;
v.indicator = Some(Indicator::Diameter);
let s = style();
let canonical = crate::tolerance::format_dimension(&v, &s).unwrap();
let original = crate::display_units::format_dimension(&v, &s).unwrap();
assert_eq!(canonical, original);
let _: &crate::tolerance::DimensionValue = &v;
let _: &crate::tolerance::DimensionStyle = &s;
let _: crate::tolerance::DimensionText = original;
}
-1
View File
@@ -35,7 +35,6 @@ name = "sketch_target_bench"
required-features = ["solver-profiling"]
[dependencies]
solidcraft-viewport = { workspace = true }
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-control = { workspace = true }
@@ -1,231 +0,0 @@
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
//! No render is started. Geometry and the original OPUS Master setup are retained.
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_engine::{
Session,
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
};
use solidcraft_geom::prelude::*;
use std::{error::Error, fs, path::PathBuf, sync::Arc};
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
if condition { Ok(()) } else { Err(message.into()) }
}
const CURVED: &[&str] = &[
"A3 - continuous sixteen facet freeform glass knot",
"A3 - six lobe quilted aspheric witness optic",
"Glass - smoked rounded toroid",
"Glass - clear calibration sphere",
"O2 - Lens 1 - Biconvex crown",
"O2 - Lens 2 - Biconcave flint",
"O2 - Lens 3 - Thick meniscus",
"O2 - Lens 4 - Plano convex magnifier",
"O2 - Lens 5 - Aspheric correction optic",
"R2 - clear chest reactor lens",
"R2 - near clear glass eye",
"R2 - far clear glass eye",
"S2 - amber solid ellipsoid",
"S2 - cobalt fluted solid decanter",
"R2 - thorax ivory swept breastplates",
"S2 - glass gallery turned wood pedestal -217",
"S2 - glass gallery turned wood pedestal 211",
];
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
let triangles = bodies
.iter()
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
Ok((bodies.len(), triangles))
}
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
let mut setup = serde_json::to_value(master)?;
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
for binding in &mut optics {
binding["optics"]["shadow_transmission"] = json!(false);
}
setup["optics"] = json!(optics);
setup["mode"] = json!("final");
setup["width"] = json!(if pilot { 320 } else { 3840 });
setup["height"] = json!(if pilot { 180 } else { 2160 });
let samples = if pilot { 8u32 } else { 4096u32 };
let mut options = setup["job"]["options"].clone();
options["texture_filtering"] = json!(true);
options["linear_exr"] = json!(true);
options["max_depth"] = json!(64);
options["samples"] = json!(samples);
options["nested_media"] = json!(true);
options["denoise"] = json!(false);
if cpu {
let lighting = &options["lighting"];
let area_lights = lighting["area_lights"]
.as_array()
.ok_or("missing area lights")?
.iter()
.map(|a| {
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
let intensity = radiance.into_iter().fold(0.0, f64::max);
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
// GPU stores half-edges; the CPU rectangle contract takes full sides.
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
})
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
let cpu_options = json!({
"budget":"workstation", "texture_filtering":true,
"progressive":{"samples_per_pixel":samples,"seed":4619},
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
"lights":[], "area_lights":area_lights,"area_samples":1,
"punctual_lights":lighting["punctual_lights"],
"environment":{"color":lighting["environment"],"intensity":1.0},
"aging":options["aging"], "denoise":false,
"output":{"tone_map":options["tone_map"],"linear_exr":true}
});
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
} else {
setup["job"]["options"] = options;
}
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
checked.validate()?;
require(checked.camera == master.camera, "reference camera differs from Master")?;
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
Ok(setup)
}
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
let triangles = mesh.triangles.as_chunks::<3>().0;
let mut weld = std::collections::HashMap::new();
let mut canonical = Vec::with_capacity(positions.len());
for p in &positions {
let next = weld.len();
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
}
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
let mut face_normals = Vec::with_capacity(triangles.len());
for (i, t) in triangles.iter().enumerate() {
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
for k in 0..3 {
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
edges
.entry((a.min(b), a.max(b)))
.and_modify(|e| {
e.1 = Some(i * 3 + k);
e.2 += 1;
})
.or_insert((i * 3 + k, None, 1));
}
}
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
Ok(Vec3::new(n[0], n[1], n[2]))
};
let mut bins = [0u64; 7];
let mut sharp = 0u64;
let mut sharp_discontinuous = 0u64;
let mut boundary_or_nonmanifold = 0u64;
for (_, (a, b, count)) in edges {
let Some(b) = b.filter(|_| count == 2) else {
boundary_or_nonmanifold += 1;
continue;
};
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
bins[bin] += 1;
if angle > 10.0 + 1e-9 {
sharp += 1;
let (ak, bk) = (a % 3, b % 3);
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
if !same_a || !same_b {
sharp_discontinuous += 1;
}
}
}
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
}
fn main() -> Result<(), Box<dyn Error>> {
let mut args = std::env::args_os().skip(1);
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
require(args.next().is_none(), "unexpected argument")?;
fs::create_dir(&output)?;
let original = fs::read(&baseline)?;
let mut doc: Document = solidcraft_io::read_design(&original)?;
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
let baseline_doc = doc.clone();
let mut baseline_session = Session::new(doc.clone());
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
let mut edits = Vec::new();
for feature in &mut doc.features {
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
for mesh in meshes {
if CURVED.contains(&mesh.name.as_str()) {
let positions = mesh.positions.clone();
let triangles = mesh.triangles.clone();
mesh.generate_crease_normals(10.0)?;
require(
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
"normal generation changed geometry arrays",
)?;
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
}
}
}
}
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
let mut session = Session::new(doc);
let mut setup_receipts = Vec::new();
for (cpu, pilot, name) in [
(true, false, "OPUS Physical CPU 4K"),
(false, false, "OPUS Physical GPU 4K"),
(true, true, "OPUS Physical CPU Pilot"),
(false, true, "OPUS Physical GPU Pilot"),
] {
let setup = physical(&master, cpu, pilot)?;
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
}
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
let scene = session.execute("render.scene", &json!({}))?;
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
require(
session.doc.components == baseline_doc.components
&& session.doc.occurrences == baseline_doc.occurrences
&& session.doc.assembly == baseline_doc.assembly,
"assembly, pose or occurrence metadata changed",
)?;
let copy_path = output.join("OPUS-04-physical-reference.sc");
let copy = solidcraft_io::write_design_container(&session.doc)?;
fs::write(&copy_path, &copy)?;
let reopened = solidcraft_io::read_design(&copy)?;
require(reopened == *session.doc, "copy changed on cold reopen")?;
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
let report = json!({
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
"reference_shadow_transmission":false,"render_started":false,
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
});
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
+5 -10
View File
@@ -31,16 +31,14 @@ pub struct Refusal {
impl Refusal {
/// The batch reply for a batch refused before its first step: as a failed step's reply,
/// with nothing completed, nothing undone and every step not run. `rolled_back` is the
/// batch's `rollback`: nothing ran, so the design is where the batch started, as a rollback
/// that went back leaves it (#10749).
pub fn reply(&self, steps: usize, rollback: bool) -> Value {
/// with nothing completed, nothing undone and every step not run.
pub fn reply(&self, steps: usize) -> Value {
let mut reply = json!({
"completed": 0,
"results": [],
"failed": {"index": self.index, "command": self.command, "error": self.error},
"undone": 0,
"rolled_back": rollback,
"rolled_back": false,
"not_run": (0..steps).collect::<Vec<_>>(),
"note": "nothing was run: the batch was checked before its first step",
});
@@ -131,13 +129,10 @@ mod tests {
let r = check(&steps, ParamCheck::Off, &[]).unwrap();
assert_eq!((r.index, r.command.as_deref()), (160, Some("solid.extrud")));
assert!(r.error.starts_with("unknown command `solid.extrud`") && r.error.contains("solid.extrude"), "{}", r.error);
let reply = r.reply(steps.len(), true);
// Nothing ran, so the design is where the batch started (#10749).
assert_eq!((reply["completed"].clone(), reply["undone"].clone(), reply["rolled_back"].clone()), (json!(0), json!(0), json!(true)));
let reply = r.reply(steps.len());
assert_eq!((reply["completed"].clone(), reply["undone"].clone(), reply["rolled_back"].clone()), (json!(0), json!(0), json!(false)));
assert_eq!(reply["not_run"].as_array().map(Vec::len), Some(161), "nothing ran");
assert_eq!(reply["failed"]["index"], 160);
// Without `rollback` a batch reports no rollback, as a failure at a step's turn does.
assert_eq!(r.reply(steps.len(), false)["rolled_back"], json!(false));
}
#[test]
+7 -17
View File
@@ -913,29 +913,19 @@ fn unsupported_face_groups_are_atomic() {
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
let (revision, undo) = (s.revision, s.undo.len());
for faces in
[json!([]), json!([[5, 5, 10], [1000, 1000, 1000]]), json!([[5, 5, 10], [5, 0, 5], [1000, 1000, 1000]]), json!(vec![[5, 5, 10]; 1025])]
{
for faces in [
json!([]),
json!([[5, 5, 10], [5, 5, 0]]),
json!([[5, 5, 10], [5, 0, 5]]),
json!([[5, 5, 10], [1000, 1000, 1000]]),
json!(vec![[5, 5, 10]; 1025]),
] {
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
assert_eq!((s.revision, s.undo.len()), (revision, undo));
}
}
/// Faces in different planes extrude each from the body as picked (#11492): the top and a side of
/// a block, a later face is not the one the earlier extrusion grew.
#[test]
fn faces_in_different_planes_extrude_each_from_the_picked_body() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":40,"width":25,"height":3,"body_name":"Block"})).unwrap();
let before = s.undo.len();
s.execute("solid.extrude", &json!({"faces":[[20,12,3],[20,0,1.5]],"face_body":"Block","distance":10,"operation":"join"})).unwrap();
let volume = solidcraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume;
// 3000 + 10000 (above the top) + 1200 (out of the y = 0 side, 3 mm high).
assert!((volume - 14_200.0).abs() < 1e-5, "{volume}");
assert_eq!(s.undo.len(), before + 1);
}
#[test]
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
use solidcore_record::Vec3;
+2 -2
View File
@@ -8,7 +8,7 @@
use std::sync::{Arc, Condvar, Mutex, RwLock};
use std::time::{Duration, Instant};
use solidcraft_viewport::{Camera, Scene};
use solidcraft_render::{Camera, Scene};
/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`,
/// `gpu-software` or `cpu`).
@@ -138,7 +138,7 @@ pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width
/// [`png`] with `renderer` instead of the installed one.
pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?;
let bytes = solidcraft_viewport::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
Ok(Png { bytes, renderer })
}
+3 -16
View File
@@ -22,7 +22,7 @@ fn component(s: &Session, cmd: &str, value: &Value) -> Result<u64> {
_ => return Err(bad(cmd, "component must be a name or unsigned id")),
};
// A placement's name (`K:2`) means its component, as appearance.assign takes it (#9740).
s.component_or_placement(&key).map_err(|error| bad(cmd, error.to_string()))?.ok_or_else(|| bad(cmd, "unknown or root component"))
s.component_or_placement(&key).ok_or_else(|| bad(cmd, "unknown or root component"))
}
pub(super) fn chain(s: &mut Session, p: &Value) -> Result<Value> {
@@ -102,27 +102,14 @@ fn scope(s: &Session, cmd: &str, value: &Value) -> Result<AppearanceScope> {
// Build indexes once: 10000 repeated component targets must not scan a
// 10000-component design once per target.
let ids: BTreeSet<_> = s.doc.components.iter().map(|c| c.id).collect();
let mut names = BTreeMap::new();
for component in &s.doc.components {
names.entry(component.name.as_str()).and_modify(|id| *id = None).or_insert(Some(component.id));
}
let names: BTreeMap<_, _> = s.doc.components.iter().rev().map(|c| (c.name.as_str(), c.id)).collect();
// A placement's name (`K:2`) means its component (#9740).
let placements: BTreeMap<_, _> = s.doc.occurrences.iter().rev().map(|o| (o.name.as_str(), o.component)).collect();
for target in list("components")? {
let id = match target {
Value::String(_) => {
let key = name(cmd, target)?;
if let Some(id) = key.parse::<u64>().ok().filter(|id| id.to_string() == key && ids.contains(id)) {
Some(id)
} else {
match names.get(key) {
Some(Some(id)) => Some(*id),
// Only an ambiguous name needs the full resolver's diagnostic. The
// usual bulk path remains indexed, rather than rescanning per target.
Some(None) => s.doc.resolve_component(key).map_err(|error| bad(cmd, error.to_string()))?,
None => placements.get(key).copied(),
}
}
key.parse::<u64>().ok().filter(|id| ids.contains(id)).or_else(|| names.get(key).copied()).or_else(|| placements.get(key).copied())
}
Value::Number(_) => target.as_u64().filter(|id| ids.contains(id)),
_ => None,
+7 -4
View File
@@ -87,7 +87,7 @@ fn key(v: Option<&Value>) -> Option<String> {
fn component_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
let k = key(p.get("component")).ok_or_else(|| bad(cmd, "`component` must be a component id or name"))?;
s.doc.resolve_component(&k).map_err(|error| bad(cmd, error.to_string()))?.ok_or_else(|| bad(cmd, format!("no component `{k}`")))
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
}
fn read(s: &mut Session, p: &Value) -> Result<Value> {
@@ -421,10 +421,13 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
out
}
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
/// would run as a formula written as text.
/// A CSV field, always quoted.
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
fn csv(rows: &[Value]) -> String {
let quote = solidcraft_doc::csv::quoted;
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
-14
View File
@@ -201,20 +201,6 @@ fn csv_quotes_every_field_and_json_writes_the_rows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A field that a spreadsheet would run as a formula gets a `'` first, so it opens as text
/// (#12728). A count stays a number.
#[test]
fn a_csv_field_a_spreadsheet_would_run_opens_as_text() {
let dir = scratch("formula");
let mut s = assembly();
run(&mut s, "component.metadata.set", json!({"component": "Plate", "part_number": "=1+1", "description": "-5 mm shim", "revision": "@A"}));
let csv = dir.join("bom.csv");
run(&mut s, "assembly.bom", json!({"path": csv}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.contains("\n\"1\",\"'=1+1\",\"'-5 mm shim\",\"'@A\",\"3\",\"Plate\"\n"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn standard_parts_carry_a_part_number_that_follows_a_resize() {
let mut s = Session::default();
+18 -72
View File
@@ -156,7 +156,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&FOLD_CONTRACT)
.output(output_schema_for::<BrowserFoldResult>)
.params(
"path?: [occurrence ids] (that placement's branch); component?: id; omit both for the whole tree: every placement, folder and group at every level. The row it starts from (the design row for the whole tree) stays open, so its first level remains visible. \
"path?: [occurrence ids]; component?: id; omit both for the whole tree. The row it starts from (the design row for the whole tree) stays open, so its first level remains visible. \
Display only; not an undo step → open, open_by_default, closed_by_default",
),
];
@@ -933,36 +933,22 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
}
}
/// Every placement row from `start` down, at every level, as the browser lists them: a
/// component's placements under each placement of it, an id already on the path left out. The
/// walk reads the design's placements only, so one placement whose transform or component is
/// broken neither stops the fold nor costs a world placement per row (#11866).
fn walk_fold(s: &Session, start: &[u64], root_extras: bool, keys: &mut FoldKeys, cmd: &str) -> Result<()> {
let start_component = if start.is_empty() { 0 } else { s.occurrence_placement(start)?.component };
let mut children: BTreeMap<u64, Vec<(u64, u64)>> = BTreeMap::new();
for o in &s.doc.occurrences {
children.entry(o.parent).or_default().push((o.id, o.component));
}
let mut stack = vec![(start.to_vec(), start_component)];
let mut stack = vec![start.to_vec()];
let mut count = 0usize;
while let Some((path, component)) = stack.pop() {
while let Some(path) = stack.pop() {
count += 1;
if count > MAX_FOLD_PLACEMENTS {
return Err(bad(cmd, "expand/collapse exceeds 100000 placements"));
}
let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path)?.component };
let prefix = if path.is_empty() { "c0".to_string() } else { occurrence_fold_key(&path) };
collect_component_keys(s, &prefix, component, root_extras && path.is_empty(), keys);
if path.len() >= 64 {
continue;
}
for (id, child_component) in children.get(&component).into_iter().flatten() {
if path.contains(id) {
for child in s.occurrence_children(&path)? {
if child.path.len() > 64 {
continue;
}
let mut child = Vec::with_capacity(path.len() + 1);
child.extend_from_slice(&path);
child.push(*id);
stack.push((child, *child_component));
stack.push(child.path);
}
}
Ok(())
@@ -1104,7 +1090,7 @@ pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<solidcraft_viewport::Camera, String> {
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
if c.get("up").is_some() {
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
}
@@ -1117,7 +1103,7 @@ pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<solidcraft_v
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
}
let cam = solidcraft_viewport::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
if !usable_camera(&cam) {
return Err("the camera must be finite with a positive distance".into());
}
@@ -1126,7 +1112,7 @@ pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<solidcraft_v
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
/// at least 0.
fn usable_camera(cam: &solidcraft_viewport::Camera) -> bool {
fn usable_camera(cam: &crate::render::Camera) -> bool {
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
}
@@ -1157,11 +1143,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match solidcraft_viewport::StandardView::parse(name) {
(Some(name), None) => match crate::render::StandardView::parse(name) {
Some(sv) => {
let mut c = crate::view::home_camera(s)?;
c.set_view(sv);
c.fit(&solidcraft_render::view_bounds(&crate::view::bounds(s)?));
c.fit(&crate::view::bounds(s)?);
c
}
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
@@ -1175,8 +1161,8 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The orbit camera of a named view (Z up, no roll).
fn camera_of(v: &solidcraft_doc::NamedView) -> solidcraft_viewport::Camera {
solidcraft_viewport::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
fn camera_of(v: &solidcraft_doc::NamedView) -> crate::render::Camera {
crate::render::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
}
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
@@ -2466,7 +2452,7 @@ mod tests {
/// camera past a pole or a NaN behind.
#[test]
fn applying_a_camera_by_view_angles_reads_back_and_refuses_hostile_pitch() {
use solidcraft_viewport::ViewAngles;
use crate::render::ViewAngles;
let mut s = Session::default();
for (view, (yaw, pitch)) in
[("front", (0.0, 0.0)), ("left", (90.0, 0.0)), ("right", (-90.0, 0.0)), ("back", (180.0, 0.0)), ("top", (0.0, 90.0))]
@@ -2476,7 +2462,7 @@ mod tests {
assert!((a.yaw - yaw).abs() < 1e-3 && (a.pitch - pitch).abs() < 1e-3 && a.roll == 0.0, "{view}: {a:?}");
}
let typed = ViewAngles { yaw: -30.0, pitch: 15.0, roll: 5.0 };
let cam = solidcraft_viewport::Camera::default().with_view_angles(typed).expect("finite angles");
let cam = crate::render::Camera::default().with_view_angles(typed).expect("finite angles");
let p = json!({"camera": {"target": [0, 0, 0], "yaw": cam.yaw, "pitch": cam.pitch, "roll": cam.roll, "distance": 80}});
run(&mut s, "view.apply", p);
let a = s.camera.expect("the typed camera").view_angles();
@@ -2669,7 +2655,7 @@ mod tests {
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
let body = s.model.state().bodies[0].name.clone();
let drain_name = s.doc.feature(drain).unwrap().name.clone();
let cam = solidcraft_viewport::Camera::default();
let cam = solidcraft_render::Camera::default();
let lines = |s: &Session| crate::view::scene(s, &cam).unwrap().lines.len();
let shown = |s: &mut Session| {
let d = run(s, "document.inspect", json!({}));
@@ -2764,7 +2750,7 @@ mod tests {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
s.mark_saved();
let cam = solidcraft_viewport::Camera::default();
let cam = solidcraft_render::Camera::default();
let drawn = |s: &Session| crate::view::scene(s, &cam).unwrap().meshes.len();
assert!(drawn(&s) > 0, "the box is drawn");
assert!(!s.is_dirty());
@@ -3048,46 +3034,6 @@ mod tests {
assert!(!open.contains(&format!("o:{occ}")), "{open:?}");
}
/// #11866: Collapse All on the whole tree rolls up every nested level, placements inside
/// placements and their folders included, and one placement that cannot be placed doesn't
/// stop it.
#[test]
fn collapse_all_folds_every_nested_placement_and_survives_a_broken_one() {
let mut s = Session::default();
let a = run(&mut s, "component.create", json!({"name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let b = run(&mut s, "component.create", json!({"name": "B"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.finish", json!({}));
let c = run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "component.activate", json!({"component": 0}));
let [a, b, c] = [&a, &b, &c].map(|r| r["occurrence"].as_u64().unwrap());
let nested = [
format!("o:{a}"),
format!("o:{a}/bodies"),
format!("o:{a}/{b}"),
format!("o:{a}/{b}/bodies"),
format!("o:{a}/{b}/sketches"),
format!("o:{a}/{b}/{c}"),
];
let open = strs(&run(&mut s, "browser.collapse_all", json!({})), "open_by_default");
for key in &nested {
assert!(open.contains(key), "{key} folds: {open:?}");
}
// A placement whose transform went bad still has a row, and so do the ones under it.
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == b) {
o.transform[0][0] = f64::NAN;
}
assert!(s.occurrence_placement(&[a, b]).is_err());
let open = strs(&run(&mut s, "browser.collapse_all", json!({})), "open_by_default");
for key in &nested {
assert!(open.contains(key), "{key} folds with a broken placement: {open:?}");
}
}
#[test]
fn expand_and_collapse_all_refuse_hostile_scope() {
let mut s = Session::default();
+1 -27
View File
@@ -114,7 +114,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Value::String(name) => name.clone(),
_ => return Err(bad(cmd, "`component` must be a component id or name")),
};
s.doc.resolve_component(&key).map_err(|error| bad(cmd, error.to_string()))?.ok_or_else(|| bad(cmd, format!("no component `{key}`")))?
s.doc.find_component(&key).ok_or_else(|| bad(cmd, format!("no component `{key}`")))?
}
};
let given_path = match p.get("component_path") {
@@ -304,32 +304,6 @@ mod tests {
s.model.state().sketch(id).unwrap().profiles.iter().map(|p| p.area).sum()
}
#[test]
fn pasting_features_rejects_an_ambiguous_destination_component() {
let mut source = Session::default();
run(&mut source, "sketch.create", json!({"plane":"XY","name":"Source"}));
run(&mut source, "sketch.finish", json!({}));
run(&mut source, "timeline.copy", json!({"features":["Source"]}));
let mut target = Session::default();
let mut ids = Vec::new();
for _ in 0..2 {
ids.push(run(&mut target, "component.create", json!({"name":"A","activate":false}))["component"].as_u64().unwrap());
}
target.clipboard = source.clipboard.clone();
target.foreign_copy = source.foreign_copy.clone();
let doc = target.doc.clone();
let undo = target.undo.len();
for foreign in [false, true] {
let error = target.execute("timeline.paste", &json!({"component":"A","from_design":foreign})).unwrap_err().to_string();
assert!(error.contains("ambiguous component `A`"), "{error}");
for id in &ids {
assert!(error.contains(&id.to_string()), "{error}");
}
assert_eq!(target.doc, doc);
assert_eq!(target.undo.len(), undo);
}
}
#[test]
fn foreign_sketch_paste_keeps_parameter_graph_snapshot_and_undo() {
let mut source = dimensioned_sketch();
+1 -1
View File
@@ -183,7 +183,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Value::Number(n) => n.to_string(),
_ => return Err(bad(CMD, "`component` must be a component id or name")),
};
s.doc.resolve_component(&key).map_err(|error| bad(CMD, error.to_string()))?.ok_or_else(|| bad(CMD, format!("no component `{key}`")))?
s.doc.find_component(&key).ok_or_else(|| bad(CMD, format!("no component `{key}`")))?
}
};
let path = match p.get("component_path") {
+9 -71
View File
@@ -11,7 +11,7 @@ use solidcore_record::Vec3;
use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use super::CommandSpec;
use crate::params::{bad, bool_, name_or_id_values, str_, string_list, vec3};
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
@@ -195,7 +195,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("move")
.contract(&MOVE_SKETCHES_CONTRACT)
.output(output_schema_for::<MoveSketchesResult>)
.params("sketches: [ids or names] (a number is an id; a string is an exact name, then an id), component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
CommandSpec::new(
"component.move_sketches.to_parts",
"Move Sketches to Their Parts",
@@ -244,7 +244,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("move")
.contract(&MOVE_CONTRACT)
.output(output_schema_for::<MoveResult>)
.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]; to?: {position: [x,y,z], axis?: [x,y,z], angle?} or to_matrix?: [12 or 16 numbers] (absolute placement: where it goes in space, not a move; not with translate/axis/angle/origin/matrix); matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
.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]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
.icon("move")
.contract(&DRAG_CONTRACT)
@@ -306,7 +306,7 @@ fn key(v: Option<&Value>) -> Option<String> {
fn component(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<u64> {
let kk = key(p.get(k)).ok_or_else(|| bad(cmd, format!("`{k}` must be a component id or name")))?;
s.doc.resolve_component(&kk).map_err(|error| bad(cmd, error.to_string()))?.ok_or_else(|| bad(cmd, format!("no component `{kk}`")))
s.doc.find_component(&kk).ok_or_else(|| bad(cmd, format!("no component `{kk}`")))
}
fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
@@ -653,14 +653,12 @@ pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.move_sketches";
let to = component(s, p, "component", cmd)?;
// Named or numbered: an exact name wins over an id it looks like, as in the timeline (#9748).
let keys = name_or_id_values(cmd, p, "sketches")?;
let keys = string_list(cmd, p, "sketches")?;
let mut ids = Vec::new();
for k in keys {
let id = crate::cmd::edit::feature_id(s, Some(k), cmd)?;
match s.doc.feature(id) {
for k in &keys {
match s.doc.find_feature(k) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => ids.push(f.id),
_ => return Err(bad(cmd, format!("no sketch `{}`", k.as_str().map_or_else(|| k.to_string(), str::to_owned)))),
_ => return Err(bad(cmd, format!("no sketch `{k}`"))),
}
}
if ids.is_empty() {
@@ -797,10 +795,7 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
let target = absolute_target(s, p, cmd)?;
let delta = if target.is_some() {
solidcraft_doc::IDENTITY
} else if let Some(m) = p.get("matrix") {
let delta = if let Some(m) = p.get("matrix") {
if let Some(k) = ["translate", "axis", "angle", "origin"].into_iter().find(|k| p.get(*k).is_some()) {
return Err(bad(cmd, format!("give either matrix or {k}, not both")));
}
@@ -831,16 +826,6 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "space must be local or world"));
}
let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None };
if let Some(target) = target {
let local = if space == "world" {
let placement = s.occurrence_placement_with(path.as_deref().unwrap_or(&[]), &s.uncaptured_moves())?;
let inverse = solidcraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
solidcraft_doc::mat_mul(&inverse, &target)
} else {
target
};
return place_occurrence(s, p, id, local, cmd);
}
let delta = if space == "world" {
let placement = s.occurrence_placement_with(path.as_deref().unwrap_or(&[]), &s.uncaptured_moves())?;
let inverse = solidcraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
@@ -854,53 +839,6 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
};
let next = solidcraft_doc::mat_mul(&delta, &cur);
place_occurrence(s, p, id, next, cmd)
}
/// `occurrence.move`'s target pose in `space` (#12150): `to: {position, axis?, angle?}` or
/// `to_matrix`, or `None` for a relative move. Refused together with the relative keys.
fn absolute_target(s: &Session, p: &Value, cmd: &str) -> Result<Option<Mat>> {
let (to, to_matrix) = (p.get("to"), p.get("to_matrix"));
if to.is_none() && to_matrix.is_none() {
return Ok(None);
}
if to.is_some() && to_matrix.is_some() {
return Err(bad(cmd, "give either to or to_matrix, not both"));
}
let key = if to.is_some() { "to" } else { "to_matrix" };
if let Some(k) = ["translate", "axis", "angle", "origin", "matrix", "base_transform"].into_iter().find(|k| p.get(*k).is_some()) {
return Err(bad(cmd, format!("give either {key} or {k}, not both")));
}
if let Some(m) = to_matrix {
return matrix::rigid(m, matrix::tolerance(p, cmd)?, cmd).map(Some);
}
let to = to.and_then(Value::as_object).ok_or_else(|| bad(cmd, "to must be {position, axis?, angle?}"))?;
if let Some(k) = to.keys().find(|k| !matches!(k.as_str(), "position" | "axis" | "angle")) {
return Err(bad(cmd, format!("to: unknown key `{k}` (position, axis, angle)")));
}
let to = Value::Object(to.clone());
if to.get("position").is_none() {
return Err(bad(cmd, "to needs a position: [x, y, z]"));
}
let position = move_vector(&to, "position", Vec3::ZERO, true, cmd)?;
let axis = move_vector(&to, "axis", Vec3::Z, false, cmd)?;
let angle = match to.get("angle") {
Some(Value::String(e)) => s.doc.eval(e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
None => 0.0,
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "to.angle must be a number or angle expression"))?,
};
if !angle.is_finite() {
return Err(bad(cmd, "to.angle must be finite"));
}
if angle != 0.0 && axis.normalized().is_none() {
return Err(bad(cmd, "to.axis must have a finite nonzero length"));
}
Ok(Some(solidcraft_doc::rigid(position, Vec3::ZERO, axis, angle)))
}
/// Make `next` the occurrence's placement in its parent: captured (and the joints solved) or
/// pending, as `capture` says.
fn place_occurrence(s: &mut Session, p: &Value, id: u64, next: Mat, cmd: &str) -> Result<Value> {
if !valid_position(&next) {
return Err(bad(cmd, "move transform exceeds supported numeric range"));
}
+1 -1
View File
@@ -538,7 +538,7 @@ fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))),
};
s.doc.resolve_component(&k).map_err(|error| bad(cmd, error.to_string()))?.ok_or_else(|| bad(cmd, format!("no component `{k}`")))
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
}
/// A command's reply as JSON: they build their replies from the types their output schemas
@@ -12,13 +12,12 @@ use solidcraft_doc::{Feature, FeatureKind, is_identity, mat_inverse, mat_mul};
use super::super::clipboard::explicit_planes;
use super::*;
use crate::params::name_or_id_values;
use crate::params::name_or_id_list;
pub(super) fn move_features(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.move_features";
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
// Named or numbered: an exact name wins over an id it looks like, as in the timeline (#9748).
let keys = name_or_id_values(cmd, p, "features")?;
let keys = name_or_id_list(cmd, p, "features")?;
if keys.is_empty() || keys.len() > 1000 {
return Err(bad(cmd, "`features` must list 1…1000 features"));
}
@@ -29,8 +28,8 @@ pub(super) fn move_features(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "finish the component move in progress first"));
}
let mut ids = BTreeSet::new();
for k in keys {
ids.insert(crate::cmd::edit::feature_id(s, Some(k), cmd)?);
for k in &keys {
ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
}
let moving: BTreeSet<u64> = ids.into_iter().filter(|id| s.doc.feature(*id).is_some_and(|f| f.component != dest)).collect();
if moving.is_empty() {
@@ -13,7 +13,7 @@ use super::move_features::move_features;
use super::reparent::{follow_visibility, reparent};
use super::transfer;
use super::*;
use crate::params::{name_or_id_values, string_list};
use crate::params::{name_or_id_list, string_list};
/// Most items of each kind in one move.
const MAX_ITEMS: usize = 1000;
@@ -50,8 +50,7 @@ pub(super) fn reparent_items(s: &mut Session, p: &Value) -> Result<Value> {
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
let dest_path = p.get("component_path").cloned();
let occurrences = placements(s, p, cmd)?;
// Named or numbered: an exact name wins over an id it looks like, as in the timeline (#9748).
let features = name_or_id_values(cmd, p, "features")?;
let features = name_or_id_list(cmd, p, "features")?;
let bodies = string_list(cmd, p, "bodies")?;
if features.len() > MAX_ITEMS || bodies.len() > MAX_ITEMS {
return Err(bad(cmd, format!("`features` and `bodies` must list at most {MAX_ITEMS} items each")));
@@ -63,8 +62,8 @@ pub(super) fn reparent_items(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "finish timeline rollback before moving"));
}
let mut feature_ids = BTreeSet::new();
for k in features {
feature_ids.insert(crate::cmd::edit::feature_id(s, Some(k), cmd)?);
for k in &features {
feature_ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
}
{
let state = s.model.state();
@@ -6,7 +6,7 @@ use solidcore_record::Plane;
use solidcraft_doc::{EmbossFace, FeatureKind, PlaneRef, apply_point, apply_vector, mat_inverse, mat_mul};
use solidcraft_geom::prelude::*;
use crate::params::{bad, name_or_id_values};
use crate::params::{bad, name_or_id_list};
use crate::schema::component::{MoveSketchesToPartsResult, SketchKeptInPlace, SketchMovedToPart};
use crate::{Result, Session};
@@ -102,18 +102,16 @@ fn part_of(s: &Session, owner: u64, plane: &PlaneRef) -> std::result::Result<u64
pub(super) fn to_parts(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.move_sketches.to_parts";
let ids: Vec<u64> = if p.get("sketches").is_some() {
// Named or numbered: an exact name wins over an id it looks like, as in the timeline (#9748).
let keys = name_or_id_values(cmd, p, "sketches")?;
let keys = name_or_id_list(cmd, p, "sketches")?;
let mut ids = Vec::with_capacity(keys.len().min(10_000));
for key in keys {
let id = crate::cmd::edit::feature_id(s, Some(key), cmd)?;
match s.doc.feature(id) {
for key in &keys {
match s.doc.find_feature(key) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => {
if !ids.contains(&f.id) {
ids.push(f.id);
}
}
_ => return Err(bad(cmd, format!("no sketch `{}`", key.as_str().map_or_else(|| key.to_string(), str::to_owned)))),
_ => return Err(bad(cmd, format!("no sketch `{key}`"))),
}
}
ids
@@ -137,7 +135,7 @@ pub(super) fn to_parts(s: &mut Session, p: &Value) -> Result<Value> {
}
}
for (id, component) in moves {
super::move_sketches(s, &json!({"sketches": [id], "component": component}))?;
super::move_sketches(s, &json!({"sketches": [id.to_string()], "component": component}))?;
}
(result.moved, result.stayed) = (result.sketches.len(), result.kept.len());
serde_json::to_value(result).map_err(|e| bad(cmd, e.to_string()))

Some files were not shown because too many files have changed in this diff Show More