Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43f6ee6a98 | |||
| 0690ee5875 | |||
| 1dd921fc52 | |||
| 1d387d3f21 | |||
| 51d237829a | |||
| df4f9d3529 | |||
| ef2f485e15 | |||
| a43cd99e55 | |||
| 8f26457487 | |||
| 07ef94e102 | |||
| 02f276e296 | |||
| 9602587b0d | |||
| f8a98eed33 | |||
| 7629421375 | |||
| d034633265 | |||
| fc51df3b01 | |||
| 8f4d7959df | |||
| 338fa197d3 | |||
| 727bb23c7e | |||
| 8d6c45f640 | |||
| 6d16fae4ed | |||
| ef92f35b16 | |||
| 232a902680 | |||
| cfc4908291 | |||
| 1432131e95 | |||
| 35bc04847a | |||
| 06def2231d | |||
| d39fd6c397 | |||
| 4faf63e48a | |||
| 91ecea8cfd | |||
| c8c0c28fc8 | |||
| f91db06904 | |||
| 9df9d1fbdc | |||
| 8068e8f4b8 | |||
| 863b0777da | |||
| 80889762ce | |||
| a3f293c987 | |||
| e27d17c421 | |||
| 52bc27aaf6 | |||
| c357b1565e | |||
| de9a2bc846 | |||
| 6b6bd26648 | |||
| 8239b914ba | |||
| e95946ac82 | |||
| 46ce12b7f0 | |||
| aba00427fa | |||
| cccf8c1ece | |||
| fb80454368 | |||
| f92e16fa25 | |||
| dcbedbd805 | |||
| 9bb216a357 | |||
| fd9b3fd9f2 | |||
| 6488a540c9 | |||
| 3e70ad9876 | |||
| 30842efb22 | |||
| 549aef17a5 | |||
| 4d738cd210 | |||
| 1cfe77bb85 | |||
| e3f99de8af | |||
| 40b1b70dec | |||
| 0862f1679a | |||
| 2cd54b6af9 | |||
| c9c063620a | |||
| 9060abc83e | |||
| bec762fb2b | |||
| 826fce68eb | |||
| 73f50d7bb4 | |||
| d1927aac25 | |||
| b224ad6dae | |||
| ca10e02573 | |||
| 5500681d6a | |||
| fabeaa329a | |||
| 8f67c7fb95 | |||
| 6bca7d2854 | |||
| eeb686f164 | |||
| 9e3e403b3f | |||
| 0231eec4c6 | |||
| 69f202f1c8 | |||
| 1cbfb889d5 | |||
| ee3cc32525 | |||
| 954232cef1 | |||
| 9092df9b97 | |||
| c1a23f8f97 | |||
| 0fc4cd2747 | |||
| 6d83f95fba | |||
| bf625dff05 | |||
| ed3510a91f | |||
| 8f0130a61b | |||
| 72df682af7 | |||
| 41a450be7c | |||
| b227924be9 | |||
| 28b47059bb | |||
| a19a32ddd7 | |||
| 606e264f6d | |||
| d25f8198fa | |||
| 464f4ac748 | |||
| 03b5248dc1 | |||
| f9dd77e383 | |||
| 4afd24d29f | |||
| 8a74336713 | |||
| 5e4c52a7cb | |||
| 77e0e11775 | |||
| 7bd66b7c06 | |||
| 86db05e538 | |||
| d912f3533b | |||
| 9d7bbb7fa9 | |||
| 6312c2760a | |||
| 3a23d21795 | |||
| e199f9aeff | |||
| e06d6e36d8 | |||
| 9d1ad9f0b9 | |||
| d39a2d28db | |||
| 1e649653ef | |||
| 6988eb0043 | |||
| 23c2244307 | |||
| a52ee850aa | |||
| 65d23a78a5 |
@@ -0,0 +1,20 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# 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 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.
|
||||
pre_merge_checks:
|
||||
custom_checks:
|
||||
- name: "Existing defaults unchanged"
|
||||
mode: "warning"
|
||||
instructions: |
|
||||
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,6 +1,6 @@
|
||||
# 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
|
||||
# answer (CONTRIBUTING.md, "Kernel work goes to solidcore"). The logic is in scripts/kernel-paused/ (tested
|
||||
# in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
|
||||
|
||||
## Start here
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see.
|
||||
|
||||
## Hard rules (what enforces each)
|
||||
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
|
||||
@@ -11,7 +11,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), 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`.
|
||||
- New kernel cases go to solidcore: `cargo xtask lists`.
|
||||
- Kernel ops and math go to solidcore; features call its API: `cargo xtask lists`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
@@ -31,7 +31,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
## Working on X: read the skill first
|
||||
| Working on | Read |
|
||||
|---|---|
|
||||
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| A feature, PR, claim, review, CI, evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
|
||||
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
|
||||
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
|
||||
|
||||
@@ -317,7 +317,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -786,6 +785,19 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
|
||||
| assets/locales/ka.json | David Machakhelidze (devius) | Original Georgian translation of SolidCraft's English catalog, written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark.svg | SolidCraft contributors | static SolidCraft mark: the five faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg (the final frame of assets/styles/web/loading-logo.svg), cropped to the mark's bounds; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-black.svg | SolidCraft contributors | one-colour (black) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-white.svg | SolidCraft contributors | one-colour (white) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-512.png | SolidCraft contributors | transparent 512 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-1024.png | SolidCraft contributors | transparent 1024 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-2048.png | SolidCraft contributors | transparent 2048 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-wordmark.svg | SolidCraft contributors | the name SolidCraft set like the loading logo's wordmark (bold, +0.02 em tracking, #15499e) in IBM Plex Sans Bold from assets/fonts/ibm-plex-sans (SIL OFL 1.1, IBM Corp.), outlined to paths; no font file is embedded | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-lockup-horizontal.svg | SolidCraft contributors | assets/branding/logo/solidcraft-mark.svg beside assets/branding/logo/solidcraft-wordmark.svg, in the proportions of assets/branding/web/source/og.png; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/onyx.json | SolidCraft contributors | original Onyx stone-grey palette, option overlays and semantic icon-role mapping; existing SolidCraft artwork is unchanged | MIT OR Apache-2.0 |
|
||||
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/sketch-tool-hints.json | carlymx | Original sketch tool hints and engine error translations | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -837,7 +849,7 @@ into THIRD-PARTY-LICENSES.txt when `main` regenerates it.
|
||||
## FreeCAD and Rhino import library
|
||||
|
||||
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
|
||||
0.6.3 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
|
||||
0.6.7 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
|
||||
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
|
||||
in native and browser builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
|
||||
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
|
||||
|
||||
@@ -17,15 +17,27 @@ 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 is paused
|
||||
## Kernel work goes to solidcore
|
||||
|
||||
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 `vendor/truck*`, 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.
|
||||
Since 0.40, SolidCraft's geometry kernel is [solidcore](https://github.com/tangency-labs/solidcore),
|
||||
and the kernel operations still in this repository are migrating there. The split:
|
||||
|
||||
- **SolidCraft** is the application: commands, the timeline, sketches, the UI, file handling. A new
|
||||
feature calls the solidcore API and builds on the operations it offers.
|
||||
- **solidcore** holds the geometry: evaluators, intersections, booleans, blends, shells, sweeps,
|
||||
tessellation and the rest of the math. If a feature needs kernel functionality solidcore does not
|
||||
have yet, add it to solidcore first (its own issue and PR), then use it here.
|
||||
|
||||
So pull requests that add geometry math to SolidCraft (`crates/kernel/`, `crates/geom/`, or a new
|
||||
evaluator or solver anywhere else) are not taken; they get the `kernel-paused` label
|
||||
(`.github/workflows/kernel-paused.yml`) and a maintainer points them to solidcore. Crashes and data
|
||||
loss are still taken as issues here, and the dialogs and tools for kernel features carry on as
|
||||
normal.
|
||||
|
||||
Work started before 0.40 was written against the earlier kernel. It is still welcome: port it onto
|
||||
today's code, calling solidcore for the geometry, before it can land. Expect a substantial rework
|
||||
for anything that touched the kernel. Designs keep opening and rebuilding on the new kernel; where a
|
||||
result changes, the PR says so and why.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -329,8 +341,11 @@ and a fresh CI run:
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
bigger than a bug fix that has no issue yet, discuss it on [Discord](https://discord.gg/FtU4NQNVDH)
|
||||
first, then open an issue. Before you start, search **all** issues and pull requests, open and
|
||||
closed, for similar work (`gh issue list -R tangency-labs/solidcraft --state all --search "<words>"`,
|
||||
and the same with `gh pr list`). **Claim the issue with a draft PR** before you start, so two
|
||||
people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
@@ -443,7 +458,7 @@ correct them freely and it won't put them back.
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; the kernel is [solidcore](https://github.com/tangency-labs/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
|
||||
@@ -472,6 +487,16 @@ the same area), first resolve the command registry and source conflicts, then ru
|
||||
The generator tests cover stable ordering and additions that leave existing lines
|
||||
unchanged. The initial switch to sorted rows is a one-time larger diff.
|
||||
|
||||
## Pictures in the user guide
|
||||
|
||||
The user guide's screenshots and short clips (`docs/book/src/media/`) are captured from the real
|
||||
app by `examples/demo/doc_shots.py`, one function per figure. Each run starts a fresh app with its
|
||||
own settings, so your preferences never show. When a change makes a figure out of date, run
|
||||
`python3 examples/demo/doc_shots.py <page or shot>` (it needs Xvfb, ffmpeg, Pillow and a release
|
||||
build) and commit the new file. A new figure is a new function there, an ATTRIBUTION row in
|
||||
`attribution/pending/`, and a `<video>` (clips) or image link on the page. Keep a still under
|
||||
about 250 KB and a clip under about 1 MB.
|
||||
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
|
||||
@@ -564,9 +564,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-codec-freecad"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c4a8fa61748cbb3ce80838b00a8cebfe553460583941f5aba6e2b97c16b4d37"
|
||||
checksum = "6fe0ed1935ace2c203b87107a064818dec2bb2ec2f250c4ad121767f3da75d1b"
|
||||
dependencies = [
|
||||
"cadmpeg-container",
|
||||
"cadmpeg-core",
|
||||
@@ -580,9 +580,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-codec-rhino"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7af41440d4c1d69e88e612de02cc3c9bad79d03429531e640ac924fef53c6bf"
|
||||
checksum = "7dd2b76bd1d9301c949fbd731fd22cff9d37d10d1f481c754b557c2187bd0171"
|
||||
dependencies = [
|
||||
"cadmpeg-container",
|
||||
"cadmpeg-core",
|
||||
@@ -599,9 +599,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-codec-step"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35e1b6d17cf26b31f59f2edab8ef5c29d2a33216c581fd913f012ad4869bd559"
|
||||
checksum = "f3cbce33ed681a28b62d8b01ae64cca2b01f11f31b9a3e5e2455b9ffde915712"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cadmpeg-container",
|
||||
@@ -616,9 +616,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-container"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ab7bcef48ac7480b4917820272d85cd2c9533e17904e15b0e4d22f19a6045bd"
|
||||
checksum = "03f0d1c22b875294c5fbf4a4738aec8f8c0a4ad035eb28ece404f1dec21f371a"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"crc32fast",
|
||||
@@ -628,9 +628,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-core"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e86bb7a40521832504bb1f5953a1c706b2c52aad73bf6d84e1b35e5cb0beae3f"
|
||||
checksum = "795bdc6e2a10ecc0627138874c17e2948a3925cd22c6c6b0c2e4f6621d92f89c"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"memchr",
|
||||
@@ -643,9 +643,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-ir"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570a1a8c579e407a9fd7bb10fa1900f8827f1d578b8fcb4ea2a056b5f323c72"
|
||||
checksum = "5592036516aaf2574b159a86a3d1ae872dc245261de86e4c0ce6773fed00a249"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cadmpeg-core",
|
||||
@@ -658,9 +658,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadmpeg-registry"
|
||||
version = "0.6.3"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2be23469a9bca0b684f1811118fffb581ebd32ded08e6cc8b0716a41f8c1377"
|
||||
checksum = "fc163233d5f664337267192848505854de6aeed4843181adf87f58baddcee9a0"
|
||||
dependencies = [
|
||||
"cadmpeg-codec-freecad",
|
||||
"cadmpeg-codec-rhino",
|
||||
|
||||
@@ -81,9 +81,9 @@ gethostname = "1"
|
||||
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
|
||||
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
|
||||
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
|
||||
cadmpeg-core = "=0.6.3"
|
||||
cadmpeg-ir = "=0.6.3"
|
||||
cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
cadmpeg-core = "=0.6.7"
|
||||
cadmpeg-ir = "=0.6.7"
|
||||
cadmpeg-registry = { version = "=0.6.7", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std", "variable-fonts"] }
|
||||
|
||||
@@ -57,13 +57,13 @@ bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT)
|
||||
byteorder 1.5.0 (Unlicense OR MIT)
|
||||
byteorder-lite 0.1.0 (Unlicense OR MIT)
|
||||
bytes 1.12.1 (MIT)
|
||||
cadmpeg-codec-freecad 0.6.3 (Apache-2.0)
|
||||
cadmpeg-codec-rhino 0.6.3 (Apache-2.0)
|
||||
cadmpeg-codec-step 0.6.3 (Apache-2.0)
|
||||
cadmpeg-container 0.6.3 (Apache-2.0)
|
||||
cadmpeg-core 0.6.3 (Apache-2.0)
|
||||
cadmpeg-ir 0.6.3 (Apache-2.0)
|
||||
cadmpeg-registry 0.6.3 (Apache-2.0)
|
||||
cadmpeg-codec-freecad 0.6.7 (Apache-2.0)
|
||||
cadmpeg-codec-rhino 0.6.7 (Apache-2.0)
|
||||
cadmpeg-codec-step 0.6.7 (Apache-2.0)
|
||||
cadmpeg-container 0.6.7 (Apache-2.0)
|
||||
cadmpeg-core 0.6.7 (Apache-2.0)
|
||||
cadmpeg-ir 0.6.7 (Apache-2.0)
|
||||
cadmpeg-registry 0.6.7 (Apache-2.0)
|
||||
calloop 0.13.0 (MIT)
|
||||
calloop 0.14.5 (MIT)
|
||||
calloop-wayland-source 0.3.0 (MIT)
|
||||
@@ -15978,13 +15978,13 @@ accesskit_windows 0.32.1: MIT OR Apache-2.0 (https://github.com/AccessKit/access
|
||||
accesskit_winit 0.32.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
|
||||
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
|
||||
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
|
||||
cadmpeg-codec-freecad 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-codec-rhino 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-codec-step 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-container 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-core 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-ir 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-registry 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-codec-rhino 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-codec-step 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-container 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-core 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-ir 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
cadmpeg-registry 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
|
||||
clipboard-win 5.4.1: BSL-1.0 (https://github.com/DoumanAsh/clipboard-win)
|
||||
dispatch 0.2.0: MIT (http://github.com/SSheldon/rust-dispatch)
|
||||
dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
//! `?webgpu`), `?sample` opens the sample part, `?embed=editor` runs it as an editing embed in a
|
||||
//! host page (no application bar or start page; nothing kept in the browser; #8517).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -42,6 +43,8 @@ fn query_flag(query: &str, name: &str) -> bool {
|
||||
struct Launch {
|
||||
open: Option<String>,
|
||||
embed: bool,
|
||||
/// `?embed=editor` (#8517): an editing embed in a host page that keeps the files itself.
|
||||
editor: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -52,16 +55,19 @@ impl Launch {
|
||||
for (key, value) in url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"open" if launch.open.is_none() => launch.open = Some(value.into_owned()),
|
||||
"embed" if value == "editor" => launch.editor = true,
|
||||
"embed" => embed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
launch.embed = embed && launch.open.is_some();
|
||||
launch.embed = embed && launch.open.is_some() && !launch.editor;
|
||||
launch
|
||||
}
|
||||
|
||||
/// The browser's own storage (designs, autosave, recovery, preferences) is used: not when
|
||||
/// embedded, where the host keeps what is kept.
|
||||
fn persists(&self) -> bool {
|
||||
!self.embed
|
||||
!self.embed && !self.editor
|
||||
}
|
||||
|
||||
fn full_app_url(&self, mut url: url::Url) -> String {
|
||||
@@ -928,7 +934,7 @@ mod web {
|
||||
}
|
||||
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = if launch.embed { Services::default() } else { Services {
|
||||
let services = if !launch.persists() { Services::default() } else { Services {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it), else the
|
||||
// browser's files window. A command that reads another kind of file
|
||||
@@ -972,6 +978,7 @@ mod web {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
app.embedded = launch.embed;
|
||||
app.embed_editor = launch.editor;
|
||||
// Listening before reading: settings another tab writes from now on are
|
||||
// newer than those read here.
|
||||
let other_tab_prefs = PrefsInbox::default();
|
||||
@@ -1082,6 +1089,17 @@ mod tests {
|
||||
assert!(full.fragment().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_editor_needs_no_design_link_and_keeps_nothing_in_the_browser() {
|
||||
let editor = Launch::parse("?embed=editor");
|
||||
assert!(editor.editor && !editor.embed && !editor.persists());
|
||||
let linked = Launch::parse("?embed=editor&open=https://example.org/a.sc");
|
||||
assert!(linked.editor && !linked.embed, "an editing embed may open a linked design, still editable");
|
||||
let viewer = Launch::parse("?embed=viewer&open=https://example.org/a.sc");
|
||||
assert!(viewer.embed && !viewer.editor, "any other value is the viewer embed, as before");
|
||||
assert!(!Launch::parse("?other=editor").editor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
|
||||
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Is the file chooser that opens the desktop's own? (#10159)
|
||||
//!
|
||||
//! `rfd` opens the file chooser through the XDG desktop portal; without the portal it falls
|
||||
//! back to zenity, a window that does not belong to the app and that the app never mentions.
|
||||
//! The desktop's own chooser is the portal one, so the app has to know when it is not the one
|
||||
//! that opens, and say so in the session log.
|
||||
//!
|
||||
//! The session bus is asked once, on a thread of its own: a bus that answers late must not
|
||||
//! hold the window. What it answered is kept, and read when a file is opened.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
/// The portal's bus name: the desktop's own file chooser lives behind it.
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
|
||||
/// The bus has not answered yet.
|
||||
const UNKNOWN: u8 = 0;
|
||||
/// There is no session bus to reach the portal on.
|
||||
const NO_SESSION_BUS: u8 = 1;
|
||||
/// The bus is there but the portal does not answer on it.
|
||||
const NO_PORTAL_SERVICE: u8 = 2;
|
||||
/// The bus is there and the portal answers on it.
|
||||
const PORTAL_THERE: u8 = 3;
|
||||
|
||||
/// What the session bus said about the portal.
|
||||
static BUS: AtomicU8 = AtomicU8::new(UNKNOWN);
|
||||
/// A file chooser is opening: whether it is the desktop's own is to be said.
|
||||
static OPENING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Ask the session bus, on a thread of its own, whether the portal is there.
|
||||
pub fn start(ctx: &egui::Context) {
|
||||
let thread_ctx = ctx.clone();
|
||||
let probe = move || {
|
||||
let answer = match session_bus() {
|
||||
None => NO_SESSION_BUS,
|
||||
Some(connection) => match portal_on(&connection) {
|
||||
true => PORTAL_THERE,
|
||||
false => NO_PORTAL_SERVICE,
|
||||
},
|
||||
};
|
||||
BUS.store(answer, Ordering::Relaxed);
|
||||
// The answer is in: a file chooser waiting for it can be told now.
|
||||
thread_ctx.request_repaint();
|
||||
};
|
||||
if let Err(e) = std::thread::Builder::new().name("desktop-chooser".into()).spawn(probe) {
|
||||
log::warn!("file chooser: no thread to ask the session bus: {e}");
|
||||
// Nothing will answer: the desktop's own chooser cannot be told apart from the start.
|
||||
BUS.store(PORTAL_THERE, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The session bus, when there is one to talk to.
|
||||
fn session_bus() -> Option<zbus::blocking::Connection> {
|
||||
zbus::blocking::Connection::session().ok()
|
||||
}
|
||||
|
||||
/// Whether the portal answers on the bus: it is either running on it or waiting to be started.
|
||||
fn portal_on(connection: &zbus::blocking::Connection) -> bool {
|
||||
let Ok(bus) = zbus::blocking::fdo::DBusProxy::new(connection) else { return false };
|
||||
let named = |names: Vec<zbus::names::OwnedBusName>| names.iter().any(|name| name.as_str() == PORTAL);
|
||||
// A portal that is running is on the bus; one that is not running yet is still there when
|
||||
// the bus can start it, which is what the portal's service file is for.
|
||||
match (bus.list_names().map(named), bus.list_activatable_names().map(named)) {
|
||||
(Ok(running), Ok(startable)) => running || startable,
|
||||
(Ok(running), Err(_)) => running,
|
||||
(Err(_), Ok(startable)) => startable,
|
||||
// The bus does not answer: nothing answers on it.
|
||||
(Err(_), Err(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file chooser is opening: ask for a frame, so that the reason it is not the desktop's own
|
||||
/// is said (#10159).
|
||||
pub fn note_opening(ctx: &egui::Context) {
|
||||
OPENING.store(true, Ordering::Relaxed);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The reason the file chooser that is opening is not the desktop's own, if it is not.
|
||||
///
|
||||
/// The decision is the one the test states: without a session bus there is no bus to reach the
|
||||
/// portal on; with a bus but no portal on it, `rfd` falls back to zenity; and under XWayland the
|
||||
/// window is an X11 one while the session is a Wayland one, which the portal refuses to parent
|
||||
/// its chooser to.
|
||||
pub fn take_reason() -> Option<&'static str> {
|
||||
if !OPENING.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let (dbus_session, portal_service) = match BUS.load(Ordering::Relaxed) {
|
||||
NO_SESSION_BUS => (false, false),
|
||||
NO_PORTAL_SERVICE => (true, false),
|
||||
PORTAL_THERE => (true, true),
|
||||
// The bus has not answered yet: wait for it, and say nothing yet.
|
||||
_ => return None,
|
||||
};
|
||||
OPENING.store(false, Ordering::Relaxed);
|
||||
let wayland_display = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
let x11_display = super::window_system_now() == Some("x11");
|
||||
super::desktop_chooser_fallback(dbus_session, portal_service, wayland_display, x11_display)
|
||||
}
|
||||
@@ -23,6 +23,8 @@ mod app_icon;
|
||||
mod come_forward;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod desktop_chooser;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod desktop_theme;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
mod desktop_transparency;
|
||||
@@ -242,6 +244,12 @@ impl eframe::App for App {
|
||||
menu.tick(&mut self.solve, ctx);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
// The file chooser that opened is not the desktop's own: the user has to be told (#10159).
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(reason) = desktop_chooser::take_reason() {
|
||||
let text = self.solve.tr(reason).to_string();
|
||||
self.solve.set_status(text, false);
|
||||
}
|
||||
self.dialog_folder.design(self.solve.session.path.as_deref());
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
if !self.solve.file_picker_busy() {
|
||||
@@ -471,12 +479,19 @@ fn fullscreen_content_insets(raw: &mut egui::RawInput, title_height: f32, zoom:
|
||||
|
||||
fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
// The file chooser that opens may not be the desktop's own: the user is told when it does
|
||||
// not (#10159).
|
||||
#[cfg(target_os = "linux")]
|
||||
let chooser_ctx = cc.egui_ctx.clone();
|
||||
let open_parent = parent.clone();
|
||||
let program_parent = parent.clone();
|
||||
let file_parent = parent.clone();
|
||||
let (open_folder, file_folder, save_folder) = (folder.clone(), folder.clone(), folder.clone());
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |first: &[&str]| {
|
||||
// The chooser that opens may not be the desktop's own (#10159).
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_chooser::note_opening(&chooser_ctx);
|
||||
// The file type the command reads comes first, so the picker opens on it (#1801).
|
||||
let mut dialog = open_folder.start(open_parent.clone());
|
||||
if !first.is_empty() {
|
||||
@@ -785,6 +800,28 @@ fn graphics() -> Option<serde_json::Value> {
|
||||
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
}
|
||||
|
||||
fn restore_preferences(app: &mut SolveApp, stored_ui: Option<&str>, saved_graphics: Option<&serde_json::Value>, safe_mode: bool) {
|
||||
if let Some(p) = stored_ui.filter(|_| !safe_mode) {
|
||||
app.load_prefs(p);
|
||||
}
|
||||
// Even safe mode edits the real graphics.json. Keep its choices in Preferences so an
|
||||
// anti-aliasing change cannot erase them; safe mode's renderer still uses its own defaults.
|
||||
restore_graphics_preferences(app, saved_graphics);
|
||||
}
|
||||
|
||||
/// Restore startup choices after loading the separately saved UI preferences.
|
||||
fn restore_graphics_preferences(app: &mut SolveApp, saved: Option<&serde_json::Value>) {
|
||||
app.preferences.graphics_adapter = saved.and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str)).unwrap_or_default().to_string();
|
||||
// graphics.json chooses the window before app.ron is loaded. Reflect that saved choice,
|
||||
// not the current session's window system (which an environment override may change).
|
||||
app.preferences.window_system = saved
|
||||
.and_then(|v| v.get("window_system").and_then(serde_json::Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|choice| matches!(*choice, "wayland" | "x11"))
|
||||
.unwrap_or("auto")
|
||||
.to_string();
|
||||
}
|
||||
|
||||
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
|
||||
fn backend_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
@@ -1003,6 +1040,8 @@ fn main() -> eframe::Result {
|
||||
solidcraft_engine::crash::install(dir, build)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// The recovery folder also holds the reopen guard marker (#5064); read once, reused below.
|
||||
let recovery_dir = solidcraft_engine::recovery::default_dir();
|
||||
// A crash in native code (a graphics driver) says where it happened: the exception or signal
|
||||
// and the module on Windows and macOS, and on every system the startup step the app was in.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1085,6 +1124,10 @@ fn main() -> eframe::Result {
|
||||
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
|
||||
mark("fonts");
|
||||
let dialog_folder = DialogFolder::default();
|
||||
// The file chooser that opens may not be the desktop's own: the session bus is asked
|
||||
// now, so the answer is there when a file is opened (#10159).
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_chooser::start(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services(cc, &dialog_folder));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
@@ -1105,9 +1148,8 @@ fn main() -> eframe::Result {
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
app.graphics_fallback = fallback_note.clone();
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
let stored_ui = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k)));
|
||||
restore_preferences(&mut app, stored_ui.as_deref(), graphics().as_ref(), safe_mode);
|
||||
if let Some(path) = &theme_file
|
||||
&& let Err(e) = solidcraft_ui_egui::prefs::follow_theme_file_for_run(&mut app, path)
|
||||
{
|
||||
@@ -1120,10 +1162,6 @@ fn main() -> eframe::Result {
|
||||
lost_at: None,
|
||||
reason: String::new(),
|
||||
});
|
||||
if !safe_mode {
|
||||
app.preferences.graphics_adapter =
|
||||
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.services.graphics_adapters = rs
|
||||
.available_adapters
|
||||
@@ -1196,8 +1234,25 @@ fn main() -> eframe::Result {
|
||||
};
|
||||
// No design given: the designs open at the last quit, when the preference asks (#5542).
|
||||
// Not when driven over the control channel, whose clients expect a fresh design.
|
||||
let files =
|
||||
if files.is_empty() && !sample && control_port.is_none() { solidcraft_ui_egui::documents::designs_to_reopen(&app) } else { files };
|
||||
// A design that stopped the last session while it was being reopened is skipped this
|
||||
// time, so one bad file cannot brick every launch (#5064).
|
||||
app.reopen_guard_dir = recovery_dir.clone();
|
||||
let files = if files.is_empty() && !sample && control_port.is_none() {
|
||||
let (reopen, skipped) = solidcraft_ui_egui::documents::reopen_plan(&app, recovery_dir.as_deref());
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> = skipped
|
||||
.iter()
|
||||
.map(|p| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()))
|
||||
.collect();
|
||||
notes.push(format!(
|
||||
"The last session stopped while reopening {}. It was not reopened this time; open it yourself if you need it.",
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
reopen
|
||||
} else {
|
||||
files
|
||||
};
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
@@ -1213,9 +1268,9 @@ fn main() -> eframe::Result {
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
||||
if let Some(dir) = recovery_dir.as_ref() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
|
||||
if safe_mode {
|
||||
app.recoverable.clear();
|
||||
@@ -1403,6 +1458,47 @@ fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayla
|
||||
display && !(niri && wayland_display)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CHOOSER_NO_SESSION_BUS: &str = "file.open.portal_fallback.no_session_bus";
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CHOOSER_NO_PORTAL_SERVICE: &str = "file.open.portal_fallback.no_portal_service";
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CHOOSER_XWAYLAND: &str = "file.open.portal_fallback.xwayland";
|
||||
|
||||
/// Whether the chooser that opens is NOT the desktop's own, and why.
|
||||
///
|
||||
/// `rfd` opens the file chooser through the XDG desktop portal; when the portal is not there it
|
||||
/// falls back to zenity, which opens a window that does not belong to SolidCraft and that the
|
||||
/// application never mentions. The application has to know that: this decision is what the warning
|
||||
/// is built on.
|
||||
///
|
||||
/// * `dbus_session`: the session bus is there (`DBUS_SESSION_BUS_ADDRESS`).
|
||||
/// * `portal_service`: the file chooser portal answers on it.
|
||||
/// * `wayland_display`: a Wayland session runs (`WAYLAND_DISPLAY`).
|
||||
/// * `x11_display`: the window is an X11 one (`DISPLAY`, or XWayland).
|
||||
///
|
||||
/// `None` means the desktop's own chooser is the one that opens and there is nothing to say;
|
||||
/// `Some` carries the reason to report.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn desktop_chooser_fallback(dbus_session: bool, portal_service: bool, wayland_display: bool, x11_display: bool) -> Option<&'static str> {
|
||||
// Without a session bus there is no bus to reach the portal on, so the chooser is not the
|
||||
// desktop's own whatever else is there.
|
||||
if !dbus_session {
|
||||
return Some(CHOOSER_NO_SESSION_BUS);
|
||||
}
|
||||
// The bus is there but the portal does not answer on it: `rfd` falls back to zenity.
|
||||
if !portal_service {
|
||||
return Some(CHOOSER_NO_PORTAL_SERVICE);
|
||||
}
|
||||
// Under XWayland the window is an X11 one while the session is a Wayland one, and the portal
|
||||
// refuses to parent its chooser to an X11 window: `rfd` falls back to zenity.
|
||||
if wayland_display && x11_display {
|
||||
return Some(CHOOSER_XWAYLAND);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod backend_tests {
|
||||
use super::prefer_x11;
|
||||
@@ -1438,6 +1534,30 @@ mod backend_tests {
|
||||
assert!(prefer_x11(true, true, Some("niri"), over(Some("x11"))));
|
||||
}
|
||||
|
||||
/// The file chooser must say when it is NOT the desktop's own: `rfd` falls back to zenity
|
||||
/// when the XDG portal is not there, and the user gets a foreign window with no explanation.
|
||||
#[test]
|
||||
fn the_file_picker_says_when_it_is_not_the_desktops_own_chooser() {
|
||||
use super::desktop_chooser_fallback;
|
||||
// The normal case: the portal is there, the desktop's own chooser opens, nothing to say.
|
||||
assert_eq!(desktop_chooser_fallback(true, true, true, false), None);
|
||||
// No session bus: the portal can not be reached at all, so `rfd` falls back to zenity.
|
||||
assert!(
|
||||
desktop_chooser_fallback(false, true, true, false).is_some(),
|
||||
"without a D-Bus session bus the file chooser is not the desktop's own: the user has to be told"
|
||||
);
|
||||
// The bus is there but the portal service is not: same fallback, same warning.
|
||||
assert!(
|
||||
desktop_chooser_fallback(true, false, true, false).is_some(),
|
||||
"without the desktop portal service the file chooser is not the desktop's own: the user has to be told"
|
||||
);
|
||||
// XWayland: the parent window is an X11 one and the portal refuses it, so `rfd` falls back.
|
||||
assert!(
|
||||
desktop_chooser_fallback(true, true, true, true).is_some(),
|
||||
"under XWayland the portal rejects the parent window: the user has to be told"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktops_title_bar_only_when_asked_for() {
|
||||
use super::titlebar_choice;
|
||||
@@ -1715,6 +1835,88 @@ mod viewport_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
/// Safe mode uses default UI settings but edits the real graphics.json for the next start.
|
||||
#[test]
|
||||
fn safe_mode_preserves_graphics_choices_when_changing_antialiasing() {
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
|
||||
for choice in ["wayland", "x11"] {
|
||||
let mut h = Harness::new();
|
||||
let path = h.dir.join("graphics.json");
|
||||
let saved = json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 8});
|
||||
std::fs::write(&path, saved.to_string()).unwrap();
|
||||
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
|
||||
let defaults = h.app.preferences.clone();
|
||||
let stored_ui = json!({"preferences": {"window_system": "auto", "graphics_adapter": "Stale GPU", "msaa": 2,
|
||||
"display_quality": "fine"}})
|
||||
.to_string();
|
||||
super::restore_preferences(&mut h.app, Some(&stored_ui), Some(&saved), true);
|
||||
|
||||
assert_eq!(h.app.preferences.window_system, choice);
|
||||
assert_eq!(h.app.preferences.graphics_adapter, "Selected GPU");
|
||||
assert_eq!(h.app.preferences.msaa, defaults.msaa, "safe mode ignores saved anti-aliasing");
|
||||
assert_eq!(h.app.preferences.display_quality, defaults.display_quality, "safe mode ignores saved UI settings");
|
||||
assert_eq!(serde_json::from_str::<Value>(&std::fs::read_to_string(&path).unwrap()).unwrap(), saved, "startup does not write settings");
|
||||
|
||||
assert_eq!(h.call("ui.prefs", json!({"open": "Graphics"}))["ok"], true);
|
||||
assert_eq!(h.call("ui.prefs", json!({"msaa": 1}))["ok"], true);
|
||||
assert_eq!(h.call("ui.prefs", json!({"button": "ok"}))["ok"], true);
|
||||
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(after, json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 1}));
|
||||
|
||||
let mut normal = Harness::new();
|
||||
super::restore_preferences(&mut normal.app, Some(&stored_ui), Some(&after), false);
|
||||
assert_eq!(normal.app.preferences.window_system, choice);
|
||||
assert_eq!(normal.app.preferences.graphics_adapter, "Selected GPU");
|
||||
assert_eq!(normal.app.preferences.display_quality, "fine", "normal startup still restores UI settings");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_startup_window_system_uses_automatic() {
|
||||
use serde_json::json;
|
||||
|
||||
for saved in [None, Some(json!(null)), Some(json!({})), Some(json!({"window_system": 7})), Some(json!({"window_system": "unknown"}))] {
|
||||
let mut app = super::SolveApp::new(super::Session::default(), super::Services::default());
|
||||
app.preferences.window_system = "wayland".into();
|
||||
super::restore_graphics_preferences(&mut app, saved.as_ref());
|
||||
assert_eq!(app.preferences.window_system, "auto", "{saved:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A manual graphics.json choice wins over stale app.ron preferences and survives an
|
||||
/// unrelated graphics edit (#10116), including on a fresh launch with no saved UI state.
|
||||
#[test]
|
||||
fn startup_window_system_survives_changing_antialiasing() {
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
|
||||
for choice in ["wayland", "x11", "auto", " wayland ", " x11 "] {
|
||||
for stale in [None, Some("auto"), Some("wayland"), Some("x11")] {
|
||||
let mut h = Harness::new();
|
||||
if let Some(stale) = stale {
|
||||
h.app.load_prefs(&json!({"preferences": {"window_system": stale}}).to_string());
|
||||
}
|
||||
let path = h.dir.join("graphics.json");
|
||||
std::fs::write(&path, json!({"window_system": choice, "backend": "vulkan", "adapter": "Selected GPU", "msaa": 4}).to_string())
|
||||
.unwrap();
|
||||
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
|
||||
let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
super::restore_graphics_preferences(&mut h.app, Some(&saved));
|
||||
|
||||
let result = h.call("ui.prefs", json!({"msaa": 2}));
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(after["window_system"], choice.trim(), "saved choice {choice:?}, stale UI choice {stale:?}");
|
||||
assert_eq!(result["result"]["window_system"], choice.trim());
|
||||
assert_eq!(after["msaa"], 2);
|
||||
assert_eq!(after["backend"], "vulkan");
|
||||
assert_eq!(after["adapter"], "Selected GPU");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_launch_adopts_the_previous_release_settings() {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
|
||||
|
||||
@@ -17,6 +17,7 @@ assets/
|
||||
ui/design/tools/ # authoring bases, checks and included review app
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
branding/logo/ # print/web logo files; guide in branding/README.md
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# SolidCraft brand kit
|
||||
|
||||
Logo files for stickers, shirts, the web and social posts. Everything in `logo/` is drawn
|
||||
from the app icon (`assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`),
|
||||
which is also the last frame of the loading animation (`assets/styles/web/loading-logo.svg`).
|
||||
The SVGs are plain paths: no scripts, fonts, animation or external links, so any printer can use them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Use it for |
|
||||
|---|---|
|
||||
| `logo/solidcraft-mark.svg` | The mark in full colour. First choice for stickers, print and the web. |
|
||||
| `logo/solidcraft-mark-mono-black.svg` | One-colour print on light material: screen-printed shirts, stamps, engraving, vinyl. |
|
||||
| `logo/solidcraft-mark-mono-white.svg` | One-colour print on dark material, such as a white print on a dark shirt. |
|
||||
| `logo/solidcraft-mark-512.png`, `-1024.png`, `-2048.png` | Transparent square PNGs for places that don't take SVG: avatars, social posts, slides, print shops that ask for a bitmap (send the 2048). |
|
||||
| `logo/solidcraft-wordmark.svg` | The name on its own, outlined to paths. |
|
||||
| `logo/solidcraft-lockup-horizontal.svg` | Mark and name side by side: headers, banners, shirt fronts, wide stickers. |
|
||||
| `web/source/og.png` | The existing link preview card for the docs site. |
|
||||
|
||||
The gaps between the faces are part of the logo. In the colour and one-colour files they are
|
||||
transparent, so the material shows through. For a die-cut sticker, cut around the outer shape
|
||||
and let the backing colour (white is best) fill the gaps.
|
||||
|
||||
## Colours
|
||||
|
||||
The mark's five faces each use a two-stop gradient from the icon source:
|
||||
|
||||
| Face | From | To |
|
||||
|---|---|---|
|
||||
| Top | `#51adff` | `#2b83f1` |
|
||||
| Left | `#258df5` | `#1461da` |
|
||||
| Right | `#1b66cf` | `#124596` |
|
||||
| Lower step (top) | `#459fff` | `#2379e7` |
|
||||
| Upper step (inner side) | `#1d63c6` | `#15499e` |
|
||||
|
||||
Other colours, all already used in the project:
|
||||
|
||||
| Colour | Hex | Where it comes from |
|
||||
|---|---|---|
|
||||
| Wordmark blue | `#15499e` | loading logo `--word` |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
If a printer can't do gradients, use the one-colour files, or fill every face with `#2678da`.
|
||||
|
||||
## Clear space and size
|
||||
|
||||
- Keep empty space around the mark of at least one fifth of its height on every side. Treat
|
||||
the lockup the same way, measured from the mark.
|
||||
- Smallest mark: 16 px tall on screen, 12 mm tall in print. Below that the gaps fill in.
|
||||
- Smallest lockup: 120 px wide on screen, 40 mm wide in print.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- Don't recolour it outside the colours above.
|
||||
- Don't stretch, squash, rotate, skew or crop it. Scale it evenly.
|
||||
- Don't add shadows, outlines, glows, bevels or other effects.
|
||||
- Don't fill or close the gaps between the faces, or move the faces apart.
|
||||
- Don't rearrange the lockup, retype the name in another font, or put the mark on a busy
|
||||
background where it loses contrast.
|
||||
|
||||
## Name and logo
|
||||
|
||||
The SolidCraft code and these files are licensed MIT OR Apache-2.0 (see the [README](../../README.md#licence)),
|
||||
so you may copy and change them. The name SolidCraft and this logo identify the SolidCraft
|
||||
project. Use them to refer to the project, for example in an article, a talk, or a sticker for a
|
||||
meetup. If you publish a fork or a modified build, give it its own name and logo so people
|
||||
don't mistake it for SolidCraft.
|
||||
|
||||
The wordmark is set in IBM Plex Sans Bold (SIL Open Font License 1.1, the interface font in
|
||||
`assets/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 2748.71 904.436" width="2749" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g><path fill="#15499e" transform="translate(1092.78 632.20) scale(0.36281)" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#000000"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#ffffff"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="38.0 -749.0 4847.0 761.0" width="4847" height="761" role="img"><title>SolidCraft</title><path fill="#15499e" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -60,7 +60,6 @@ circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
colored_components false
|
||||
colored_components_off false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
@@ -161,6 +160,7 @@ min_radius false
|
||||
mirror false
|
||||
motion_link false
|
||||
move false
|
||||
nav_settings false
|
||||
new false
|
||||
normal false
|
||||
offset false
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disable component colors</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "أسفل اليمين",
|
||||
"Boundary": "الحد",
|
||||
"Bounding box": "الصندوق المحيط",
|
||||
"Bridge": "جسر",
|
||||
"CHECK": "تحقق",
|
||||
"COMPONENTS": "المكوّنات",
|
||||
"CONFIGURATIONS": "التكوينات",
|
||||
@@ -744,22 +745,22 @@
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجدداً.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجددًا.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
|
||||
"ai.prefs.connect_at_start": "توصيل آخر وكيل عند البدء",
|
||||
"ai.prefs.connect_at_start_note": "يبدأ في الخلفية الوكيل الذي اتصلت به آخر مرة (أو الوكيل الافتراضي) في كل مرة يبدأ فيها SolidCraft، ليكون جاهزًا للمحادثة. لا يحدث ذلك عند بدء SolidCraft مع --control. إذا تعذّر الاتصال، يعرض المساعد «فشل الاتصال» وزر «إعادة المحاولة».",
|
||||
"ai.prefs.follow_camera": "اتباع الكاميرا",
|
||||
"ai.prefs.follow_camera": "تتبّع الكاميرا",
|
||||
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
|
||||
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
|
||||
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
|
||||
"ai.prefs.make_default": "تعيين كافتراضي",
|
||||
"ai.prefs.make_default": "تعيين افتراضيًا",
|
||||
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
|
||||
"ai.prefs.no_saved_approvals": "لا توجد موافقات محفوظة.",
|
||||
"ai.prefs.open_at_start": "فتح المحادثة عند البدء",
|
||||
"ai.prefs.open_at_start_note": "يعرض لوحة المساعد (أو نافذتها المستقلة) في كل مرة يبدأ فيها SolidCraft. لا يحدث ذلك عند بدء SolidCraft مع --control.",
|
||||
"ai.prefs.override_all_warning": "قد يحفظ هذا الوكيل التصميم أو يكتب فوقه أو يصدّره أو يستبدله دون أن يسأل.",
|
||||
"ai.prefs.permission_all": "فعل كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبداً",
|
||||
"ai.prefs.permission_ask": "السؤال أولاً",
|
||||
"ai.prefs.permission_all": "تنفيذ كل شيء",
|
||||
"ai.prefs.permission_all_summary": "لا يسأل أبدًا",
|
||||
"ai.prefs.permission_ask": "السؤال أولًا",
|
||||
"ai.prefs.permission_ask_summary": "يسأل عن كل تغيير",
|
||||
"ai.prefs.permission_edits": "إجراء تعديلات",
|
||||
"ai.prefs.permission_edits_summary": "يعدّل بحرية ويسأل عن الباقي",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "تحليل العرض",
|
||||
"cmd.inspect_center_of_mass": "مركز الكتلة",
|
||||
"cmd.inspect_clearance": "الخلوص",
|
||||
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components": "ألوان المكوّنات",
|
||||
"cmd.inspect_compare": "مقارنة الأجسام",
|
||||
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
|
||||
"cmd.inspect_continuity": "تحليل الاستمرارية",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"cmd.timeline_stop": "إيقاف السجل",
|
||||
"cmd.timeline_suppress": "تعطيل الميزة",
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
|
||||
"cmd.view.navigator": "متصفح العرض المخصص",
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ محور مخصص",
|
||||
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
|
||||
"dialog.dimension_name": "البُعد: {name}",
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
|
||||
"dialog.dimension_step_up": "زيادة بمقدار {step}",
|
||||
"dialog.edit_title": "تعديل {title}",
|
||||
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
|
||||
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ {count} محدد",
|
||||
"dialog.selection_constraint": "قيد {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · منحنى {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.",
|
||||
"dialog.selection_midpoint": "نقطة المنتصف · {id}",
|
||||
"dialog.selection_profile": "{sketch} · مقطع {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "فُتح هذا التصميم للقراءة فقط: احفظه كملف جديد",
|
||||
"document.save_changes": "حفظ التغييرات على {name}؟",
|
||||
"document.save_copy": "حفظ نسخة…",
|
||||
"document.tab_unsaved_changes": "تغييرات غير محفوظة",
|
||||
"document.unsaved_changes": "في {name} تغييرات غير محفوظة. إن لم تحفظها فستضيع.",
|
||||
"drive.rigid": "{joint} مفصل صلب: يثبّت أجزاءه في مكانها، لذا لا يوجد ما يمكن تحريكه. لتتحرك الأجزاء، غيّر نوعه في «تعديل المفصل»: «دوراني» للدوران، «منزلق» للانزلاق، «أسطواني» لكليهما.",
|
||||
"drive.stopped_by_contact": "توقف عند {value}: سيخترق {a} الجسم {b}",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "يجب أن تكون منحنيات كل دليل في رسم واحد",
|
||||
"feature computation ended without a result": "انتهى حساب الميزة دون نتيجة",
|
||||
"file.open.drawing_filter": "التصاميم وملفات CAD والشبكات والرسومات",
|
||||
"file.open.portal_fallback.no_portal_service": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: خدمة منتقي الملفات في سطح المكتب غير متاحة.",
|
||||
"file.open.portal_fallback.no_session_bus": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: لا يوجد ناقل جلسات D-Bus.",
|
||||
"file.open.portal_fallback.xwayland": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: في XWayland ترفض البوابة النافذة الأصلية.",
|
||||
"file_load.built": "بُني حتى الآن: {built} من {total} ميزة.",
|
||||
"file_load.converting": "تحويل الجسم الصلب {done} من {total}",
|
||||
"file_load.inserting": "إدراج الهندسة",
|
||||
@@ -1600,7 +1609,7 @@
|
||||
"help.parts.resize": "يغيّر مقاس قطعة قياسية مدرجة أو طولها ويعيد بناءها، وتبقيها مفاصلها مثبتة في مكانها. استخدمه عندما يتبين أن برغيًا أطول من اللازم أو أن ثقبًا يحتاج إلى مقاس آخر.",
|
||||
"help.plastic.assign_rule": "يعيّن قاعدة بلاستيك لأجسام، فتأخذ ميزاتها البلاستيكية ميلها وخلوصها منها كقيم افتراضية. استخدمه لإعداد قطعة مقولبة مرة واحدة لكل ميزاتها.",
|
||||
"help.plastic.boss": "يضيف نتوءًا لمسمار على وجه: عمودًا مع ثقب اختياري وميل وتقويس عند الجذر وأضلاع تقوية. استخدمه لنقاط البراغي في الهياكل المقولبة أو المطبوعة.",
|
||||
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول حافة جسم مفرَّغ. استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
|
||||
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول إطار جسم مفرَّغ (فرّغ الجسم بفتح أحد وجوهه أولًا، ثم اختر الإطار المحيط بتلك الفتحة). استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
|
||||
"help.plastic.manage_rules": "ينشئ قواعد البلاستيك ويعدّلها: المادة وسماكة الجدار وأنصاف الأقطار والخلوص والميل. استخدمها للحفاظ على اتساق ميزات القطعة المقولبة.",
|
||||
"help.plastic.rest": "يضيف مسندًا على وجه: وسادة مستديرة أو مستطيلة، مصمتة أو مجوفة، مع ميل. استخدمه كدعامة أو مُباعِد في هيكل مقولب.",
|
||||
"help.plastic.snap_fit": "يضيف خطاف وصلة كبس على وجه، بطوله وسماكته ونتوء التثبيت. استخدمه لتثبيت القطع معًا بلا براغي.",
|
||||
@@ -1918,7 +1927,7 @@
|
||||
"joint.limit_loop": "{side} {value}: لا يمكن لحلقة مغلقة أن تنغلق بعده",
|
||||
"joint.limit_range": "{side}: لا تلامس ضمن {range}، تُرك كما كان",
|
||||
"joint.limit_turn": "{side} {value}: لا تلامس ضمن دورة واحدة",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا تداخلت أعمق.",
|
||||
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا ازداد تداخلها.",
|
||||
"joint.one_body": "كلا الاختيارين على {body}. اختر الثاني على قطعة أخرى.",
|
||||
"joint.one_component": "هذه القطع أجسام لمكوّن واحد: {bodies}. المفصل يحرّك مكوّنًا مقابل آخر، لذا تحتاج كل قطعة إلى مكوّن خاص بها.",
|
||||
"joint.rigid_group_one_component": "هذه الأجسام وعددها {count} كلها في مكوّن واحد (أو في التصميم نفسه): استخدم {command} أولًا.",
|
||||
@@ -1933,7 +1942,7 @@
|
||||
"joint_move.fine": "الخطوة بعيدًا عن المحور",
|
||||
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
|
||||
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
|
||||
"joint_move.legacy_hint": "إيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.legacy_hint": "عند الإيقاف: لوحة التحريك السابقة.",
|
||||
"joint_move.precision": "اسحب خارج الدليل المتقطع للخطوة الدقيقة.",
|
||||
"joint_move.rotation": "الدوران من نقطة البداية",
|
||||
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "مواد مختلفة",
|
||||
"measure.at": "عند",
|
||||
"measure.item": "العنصر {number}",
|
||||
"measure.line_to_centre": "من الخط إلى المركز",
|
||||
"measure.quick_copied": "تم نسخ القياس",
|
||||
"measure.quick_copy": "انقر لنسخ القياس",
|
||||
"measure.quick_edges": "{count} حواف",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا",
|
||||
"nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع السحب العمودي بالأوسط يكبّره أو يصغّره (السحب لأسفل للتكبير)؛ وتدوير العجلة يكبّره أو يصغّره",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "يستمر السحب بالزر الأوسط أو الأيمن في التدوير أو التحريك أو التكبير كما بدأ حتى تحرير الزر، مهما ضُغطت المفاتيح أو حُرّرت في أثناء ذلك. عند الإيقاف: تقرر المفاتيح المضغوطة في كل لحظة من السحب.",
|
||||
"nav.drag_keeps_mode.label": "يحتفظ السحب بطريقة التنقل التي بدأ بها",
|
||||
"nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.",
|
||||
"nav.free_orbit.label": "تدوير حر",
|
||||
"nav.free_orbit.snap_help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. اضغط مطولًا على Shift أثناء سحب التدوير لالتقاط اتجاه العرض واللف.",
|
||||
@@ -2240,7 +2252,7 @@
|
||||
"prefs.anti_aliasing_amd_dx12": "يستخدم تنعيم الحواف 1× لأن فحص بدء التشغيل لم يرسم شيئًا مع أخذ العينات المتعددة ونجح عند 1×. يمكنك اختيار إعداد آخر أعلاه؛ أعد التشغيل لتطبيقه إن نجح الفحص.",
|
||||
"prefs.anti_aliasing_note": "ينعّم الحواف المسننة.",
|
||||
"prefs.appearance.colour_vision_help": "يغيّر الألوان ذات الدلالة: حالات الرسم والمحاور والثنيات والتحليلات والأيقونات.",
|
||||
"prefs.appearance.desktop_only": "لسطح المكتب فقط",
|
||||
"prefs.appearance.desktop_only": "لتطبيق سطح المكتب فقط",
|
||||
"prefs.appearance.follow_help": "يعيد التحميل عندما تعيد أداة أخرى كتابة الملف.",
|
||||
"prefs.appearance.interface_scale": "مقياس الواجهة",
|
||||
"prefs.appearance.next_themes": "السمات التالية",
|
||||
@@ -2250,11 +2262,11 @@
|
||||
"prefs.appearance.theme_count": "{count} سمات",
|
||||
"prefs.appearance.theme_own": "ألوان السمة",
|
||||
"prefs.appearance.viewport_colours": "ألوان العرض ثلاثي الأبعاد",
|
||||
"prefs.appearance.viewport_logo": "منفذ العرض والشعار",
|
||||
"prefs.appearance.viewport_logo": "منطقة العرض والشعار",
|
||||
"prefs.applies_on_restart": "يُطبَّق عند إعادة التشغيل",
|
||||
"prefs.assistant": "المساعد",
|
||||
"prefs.assistant_connecting": "الاتصال",
|
||||
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
|
||||
"prefs.assistant_follow_help": "النظر إلى الرسومات يحرّك العرض مع المؤشر.",
|
||||
"prefs.assistant_limit": "الحد",
|
||||
"prefs.assistant_limited": "محدود",
|
||||
"prefs.assistant_pointer_speed": "سرعة المؤشر",
|
||||
@@ -2262,21 +2274,21 @@
|
||||
"prefs.assistant_seconds_aria": "الثواني",
|
||||
"prefs.assistant_use_care": "استخدم بحذر",
|
||||
"prefs.auto_operation": "اختيار العملية تلقائيًا",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسومات أو مسار العرض هذا",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
|
||||
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
|
||||
"prefs.blur_off": "إيقاف",
|
||||
"prefs.blur_reduce_transparency": "معطّل بسبب إعداد تقليل الشفافية في النظام",
|
||||
"prefs.blur_software": "غير متاح مع الرسومات البرمجية (gpu-software)",
|
||||
"prefs.blur_software": "غير متاح مع الرسوميات البرمجية (gpu-software)",
|
||||
"prefs.blur_source": "غير متاح في عرض مساحة العمل هذا",
|
||||
"prefs.blur_web": "غير متاح في مسار عرض الويب هذا",
|
||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||
"prefs.bodies_folders_always": "دائمًا",
|
||||
"prefs.bodies_folders_never": "أبدًا",
|
||||
"prefs.bodies_folders_several": "عدة",
|
||||
"prefs.bodies_folders_several": "عند التعدد",
|
||||
"prefs.bottom": "أسفل",
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "عند حافة العرض",
|
||||
"prefs.dialogs_left_column": "في العمود الأيسر",
|
||||
"prefs.dialogs_note": "مكان فتح حوارات الأوامر: مثبتة عند حافة العرض، أو في العمود الأيسر مكان المستعرض والسجل، اللذين يعودان كما كانا عند إغلاق الحوار.",
|
||||
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الزاوية",
|
||||
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الطول",
|
||||
"prefs.dimension_steps": "خطوات النافذة",
|
||||
"prefs.dimension_values": "قيم الأبعاد",
|
||||
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
|
||||
"prefs.dimension_values_view": "في العرض",
|
||||
"prefs.dimension_values_window": "في نافذة",
|
||||
"prefs.display": "العرض",
|
||||
"prefs.edges": "الحواف والتظليل",
|
||||
"prefs.extend": "توسيع",
|
||||
@@ -2368,7 +2387,7 @@
|
||||
"prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.",
|
||||
"prefs.graphics_page.current_view": "العرض الحالي",
|
||||
"prefs.graphics_page.fastest": "الأسرع",
|
||||
"prefs.graphics_page.fold_items": "طي العناصر",
|
||||
"prefs.graphics_page.fold_items": "تلخيص العناصر",
|
||||
"prefs.graphics_page.in_view": "داخل العرض",
|
||||
"prefs.graphics_page.live_details": "تفاصيل مباشرة",
|
||||
"prefs.graphics_page.own_mix": "مزيجك الخاص",
|
||||
@@ -2377,7 +2396,7 @@
|
||||
"prefs.graphics_page.restart": "تلزم إعادة التشغيل",
|
||||
"prefs.graphics_page.sharpest": "الأكثر وضوحًا",
|
||||
"prefs.graphics_page.single_surface_analysis": "تحليل سطح واحد",
|
||||
"prefs.graphics_page.status_chips": "شرائح الحالة",
|
||||
"prefs.graphics_page.status_chips": "شارات الحالة",
|
||||
"prefs.graphics_page.subtitle": "وازن بين السرعة والنعومة.",
|
||||
"prefs.graphics_page.supersampling": "التنعيم الفائق",
|
||||
"prefs.graphics_page.supersampling_adaptive": "فقط أثناء سكون العرض",
|
||||
@@ -2424,7 +2443,7 @@
|
||||
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
|
||||
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
|
||||
"prefs.layout_page.on_hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.open": "فتح",
|
||||
"prefs.layout_page.open": "تُفتح",
|
||||
"prefs.layout_page.origin_bodies": "الأصل والأجسام في الأعلى",
|
||||
"prefs.layout_page.panels": "اللوحات",
|
||||
"prefs.layout_page.pinned_menus": "استبعاد الأوامر المثبتة من القوائم",
|
||||
@@ -2464,8 +2483,8 @@
|
||||
"prefs.navigation.cube_compact": "المكعب",
|
||||
"prefs.navigation.device": "الجهاز",
|
||||
"prefs.navigation.free": "حر",
|
||||
"prefs.navigation.orbit_style": "نمط الدوران",
|
||||
"prefs.navigation.radial_help": "اضغط مطولاً أو انقر سريعاً بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.orbit_style": "نمط التدوير",
|
||||
"prefs.navigation.radial_help": "اضغط مطولًا أو انقر سريعًا بالزر الأيمن لعرض حلقة من الأدوات.",
|
||||
"prefs.navigation.turntable": "طاولة دوارة",
|
||||
"prefs.navigation_note": "كيف تحرك الفأرة ولوحة اللمس والفأرة ثلاثية الأبعاد العرض.",
|
||||
"prefs.navigator_fit": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض",
|
||||
@@ -2483,7 +2502,7 @@
|
||||
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
|
||||
"prefs.overall_speed": "السرعة العامة",
|
||||
"prefs.parameter_list_rows": "صفوف قائمة المعاملات",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعلمات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعاملات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
|
||||
"prefs.persistent_ribbon_pressed": "تمييز أوامر الشريط النشطة",
|
||||
"prefs.persistent_ribbon_pressed_help": "تمييز أوامر الشريط أثناء تفعيل تأثيراتها. أوقف هذا الخيار للاحتفاظ بالمظهر المحايد السابق ومساعدة الأوامر.",
|
||||
"prefs.perspective_fov": "مجال الرؤية",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "حسب الجزء",
|
||||
"prefs.section_hatch_one": "نمط واحد",
|
||||
"prefs.select": "تحديد",
|
||||
"prefs.select_hidden_edges": "تحديد الحواف المخفية في العرض المظلل مع الحواف المخفية",
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأسطح عند تفعيل العرض المظلل مع الحواف المخفية، بما في ذلك استخدام أداة Fillet. يحافظ إيقافه على سلوك التحديد في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
@@ -2552,19 +2573,21 @@
|
||||
"prefs.shortcuts_import": "استيراد",
|
||||
"prefs.shortcuts_intro": "مفاتيح كل أمر.",
|
||||
"prefs.shortcuts_not_assigned": "غير معيّن",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحاً...",
|
||||
"prefs.shortcuts_press": "اضغط مفتاحًا...",
|
||||
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
|
||||
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
|
||||
"prefs.sketch.align_points": "المحاذاة مع نقاط الرسم الأخرى",
|
||||
"prefs.sketch.align_points_note": "تتحاذى الخطوط أثناء الرسم مع النقاط على المحورين معًا.",
|
||||
"prefs.sketch.auto_dimensions": "إضافة كل الأبعاد للأشكال الجديدة",
|
||||
"prefs.sketch.auto_dimensions_note": "القيم المدخلة وكل بُعد يثبّت الشكل.",
|
||||
"prefs.sketch.auto_locate": "التحديد انطلاقًا من أصل الرسم",
|
||||
"prefs.sketch.auto_locate": "تحديد الموضع من أصل الرسم",
|
||||
"prefs.sketch.auto_locate_note": "مع إضافة كل الأبعاد.",
|
||||
"prefs.sketch.center_arc": "قوس المركز يتبع المؤشر",
|
||||
"prefs.sketch.center_arc": "القوس بنقطة مركز يتبع المؤشر",
|
||||
"prefs.sketch.click_selects": "النقر يحدد",
|
||||
"prefs.sketch.conflict_colour": "لون التعارض",
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -2576,7 +2599,7 @@
|
||||
"prefs.sketch.prefer_free_points": "تفضيل النقاط القابلة للتحريك عند تداخلها",
|
||||
"prefs.sketch.project_options": "السؤال عن خيارات الإسقاط أولًا",
|
||||
"prefs.sketch.recent_snap": "الالتقاط عند تقاطع دليلين",
|
||||
"prefs.sketch.select_other_hidden": "اختيار آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.select_other_hidden": "تحديد آخر يشمل الهندسة المخفية",
|
||||
"prefs.sketch.shortcut_cycles_modes": "الاختصار مجددًا يبدّل الوضع",
|
||||
"prefs.sketch.shortcut_cycles_modes_tip": "عند الإيقاف: تبدأ الأداة مجددًا.",
|
||||
"prefs.sketch.snapping_guides": "الالتقاط والأدلة",
|
||||
@@ -2699,7 +2722,7 @@
|
||||
"prefs.validation.each_step": "بعد كل خطوة",
|
||||
"prefs.validation.label": "فحص النواة الكامل",
|
||||
"prefs.validation.off": "متوقف (تجريبي)",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية سحب ودمج وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية كنس وعملية منطقية وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
|
||||
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها",
|
||||
"prefs.value_boxes_in_view_tip": "مسافة البثق ونصف قطر التدوير... في مربع داخل العرض.",
|
||||
"prefs.view": "سلوك العرض",
|
||||
@@ -4104,7 +4127,7 @@
|
||||
"settings.display_quality_note": "المستويات الأدق ترسم الأوجه والحواف المنحنية بنعومة أكبر وتستغرق وقتًا أطول لإنشاء الشبكة. يتغير العرض فقط، لا التصميم ولا الملفات المصدّرة.",
|
||||
"settings.double_click_components": "النقر المزدوج على مكوّن لتنشيطه",
|
||||
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض.",
|
||||
"settings.insert_lists_open_designs": "إدراج تصميم يسرد التصاميم المفتوحة أولًا",
|
||||
"settings.measure_axis_distances": "مسافات المحاور في القياس",
|
||||
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "تبقّت {count} درجات حرية",
|
||||
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
|
||||
"sketch.draw_curves": "الرسم كـ {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "تحويل التحديد إلى {linetype}",
|
||||
"sketch.open_ends": "مفتوحة {count}",
|
||||
"sketch.status_conflict": "تعارض القيود",
|
||||
@@ -4214,13 +4263,13 @@
|
||||
"thread.automatic_size": "حجم تلقائي",
|
||||
"thread.chamfer_depth_needed": "مطلوب لهذا الشكل",
|
||||
"thread.designation": "التسمية",
|
||||
"thread.drill_range": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_custom": "المثقاب هو القطر الأكبر ناقص الخطوة، {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.drill_range_taper": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
|
||||
"thread.end_square": "قائمة",
|
||||
"thread.fade_short": "1.25 P (ISO 3508)",
|
||||
"thread.fade_standard": "2.5 P (ISO 3508)",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلاً {example}",
|
||||
"thread.fits_the_face": "يناسب الوجه، مثلًا {example}",
|
||||
"thread.form_bspp": "سن أنابيب G (ISO 228-1)",
|
||||
"thread.form_bspt": "سن أنابيب مخروطي R، Rc (ISO 7-1)",
|
||||
"thread.form_npt": "سن أنابيب مخروطي NPT (ASME B1.20.1)",
|
||||
@@ -4477,7 +4526,7 @@
|
||||
"ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور",
|
||||
"ui.agent_chat_no_picture": "لا توجد صورة في الحافظة",
|
||||
"ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.",
|
||||
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط تحديث.",
|
||||
"ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:",
|
||||
"ui.agent_chat_none_found_short": "لم يُعثر على وكيل",
|
||||
"ui.agent_chat_not_installed": "غير مثبت",
|
||||
@@ -4518,7 +4567,7 @@
|
||||
"ui.agent_chat_send_now_tip": "إيقاف خطوة الوكيل الحالية وإرسال هذه بعدها (Ctrl+Enter)",
|
||||
"ui.agent_chat_setup_body": "يتواصل SolidCraft مع وكيل برمجة مثبّت على هذا الحاسوب. اختر واحدًا من التفضيلات › الوكيل.",
|
||||
"ui.agent_chat_setup_title": "إعداد مساعد",
|
||||
"ui.agent_chat_side": "إرساء المحادثة",
|
||||
"ui.agent_chat_side": "تثبيت المحادثة",
|
||||
"ui.agent_chat_signed_in": "سجّل الدخول",
|
||||
"ui.agent_chat_state_auth": "يلزم تسجيل الدخول",
|
||||
"ui.agent_chat_state_busy": "جارٍ العمل…",
|
||||
@@ -4583,10 +4632,10 @@
|
||||
"ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.",
|
||||
"ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}",
|
||||
"ui.ai_kind": "النوع",
|
||||
"ui.ai_mode_all": "فعل كل شيء",
|
||||
"ui.ai_mode_all": "تنفيذ كل شيء",
|
||||
"ui.ai_mode_all_note": "لا شيء يسأل أولًا. تبقى تقارير الأعطال محظورة وتظل حدود المجلدات سارية.",
|
||||
"ui.ai_mode_all_warning": "يمكن للوكيل حفظ تصميمك والكتابة فوقه وتصديره واستبداله دون سؤال. استخدم هذا فقط مع وكيل تثق به، وعلى تصاميم حفظتها.",
|
||||
"ui.ai_mode_ask": "السؤال أولاً",
|
||||
"ui.ai_mode_ask": "السؤال أولًا",
|
||||
"ui.ai_mode_ask_note": "كل تغيير في التصميم وكل إجراء على الملفات ينتظر سماحك.",
|
||||
"ui.ai_mode_edits": "إجراء تعديلات",
|
||||
"ui.ai_mode_edits_note": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
|
||||
@@ -4757,21 +4806,21 @@
|
||||
"ui.construction.ribbon_label": "خطوط مساعدة",
|
||||
"ui.construction.side_hint": "خط إنشائي: انقر الجهة التي يمتد نحوها الخط؛ انقر بزر الفأرة الأيمن للبدء من جديد",
|
||||
"ui.context_menu": "القائمة السياقية",
|
||||
"ui.continuity_crease": "تجعّد",
|
||||
"ui.continuity_crease": "ثنية",
|
||||
"ui.continuity_curvature": "الانحناء",
|
||||
"ui.continuity_curvature_change": "تغيّر الانحناء",
|
||||
"ui.continuity_curvature_change_tip": "تغيّر الانحناء (G3): يتغير الانحناء أيضًا بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_curvature_tip": "متصل الانحناء (G2): تمر الانعكاسات دون انكسار.",
|
||||
"ui.continuity_g0": "G0 تجعّد",
|
||||
"ui.continuity_g0": "G0 ثنية",
|
||||
"ui.continuity_g1": "G1 مماس",
|
||||
"ui.continuity_g2": "G2 انحناء",
|
||||
"ui.continuity_gap": "فجوة",
|
||||
"ui.continuity_position": "الموضع",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع تجعّد عند التقائها.",
|
||||
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع ثنية عند التقائها.",
|
||||
"ui.continuity_smooth_curvature_change": "تغيّر انحناء سلس",
|
||||
"ui.continuity_smooth_curvature_change_tip": "تغيّر انحناء سلس (G4): حتى طريقة تغيّر الانحناء تستمر بسلاسة عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent": "مماس",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا تجعّد، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.continuity_tangent_tip": "مماس (G1): لا ثنية، لكن الانحناء يقفز عبر خط الالتقاء.",
|
||||
"ui.copy": "نسخ",
|
||||
"ui.copy_details": "نسخ التفاصيل",
|
||||
"ui.copy_details_tip": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي",
|
||||
"ui.delete": "حذف",
|
||||
"ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكوّنات أو هندسة الرسم",
|
||||
"ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته",
|
||||
"ui.depth": "العمق",
|
||||
"ui.design": "التصميم",
|
||||
"ui.design_properties.author": "المؤلف",
|
||||
@@ -4922,7 +4972,7 @@
|
||||
"ui.extract_done": "أصبح المكوّن تصميمًا مستقلًا، مرتبطًا هنا.",
|
||||
"ui.extrude": "بثق",
|
||||
"ui.extrude_auto_to_object": "البثق إلى هدف مختار",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر المدى إلى «إلى كائن».",
|
||||
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر الامتداد إلى «إلى كائن».",
|
||||
"ui.factor": "المعامل",
|
||||
"ui.fade": "تلاشٍ",
|
||||
"ui.fade_length": "طول التلاشي",
|
||||
@@ -4952,13 +5002,13 @@
|
||||
"ui.fillet_setback": "مسافة الإرجاع",
|
||||
"ui.fillet_setback_hint": "{factor} × نصف القطر",
|
||||
"ui.fillet_setback_least": "أكثر من {least}",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
|
||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، وهي التي تلتقي عندها التقويسات دون إرجاع: نصف القطر حين تكون الأوجه متعامدة.",
|
||||
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
|
||||
"ui.fillet_turn": "المنعطفات",
|
||||
"ui.fillet_turn": "الالتفاف",
|
||||
"ui.fillet_turn_mitre": "وصلة مائلة",
|
||||
"ui.fillet_turn_swept": "مُدار",
|
||||
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة بروز عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nمُدار: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_turn_swept": "ملتف",
|
||||
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة نتوء عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nملتف: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى شريحي: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||
"ui.fillet_type_curvature": "متصل الانحناء",
|
||||
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
|
||||
"ui.filter_commands_or_keys": "تصفية الأوامر أو المفاتيح",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "إنشاء مرجعي",
|
||||
"ui.heading_create": "إنشاء",
|
||||
"ui.heading_create_sketch": "إنشاء رسم",
|
||||
"ui.heading_custom_view_navigator": "متصفح العرض المخصص",
|
||||
"ui.heading_cylinder": "أسطوانة",
|
||||
"ui.heading_design": "التصميم",
|
||||
"ui.heading_draft": "ميل السحب",
|
||||
@@ -5070,7 +5121,7 @@
|
||||
"ui.hidePlanes": "إخفاء المستويات",
|
||||
"ui.hideProfile": "إخفاء المقطع",
|
||||
"ui.hide_component_history": "إخفاء سجله",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_by_default": "إخفاء مجلد نقطة الأصل في التصاميم الجديدة",
|
||||
"ui.hide_origin_note": "تبدأ التصاميم الجديدة ومجلد نقطة الأصل مخفي. يحتفظ كل تصميم بإعداد نقطة الأصل الخاص به بمجرد إظهارها أو إخفائها.",
|
||||
"ui.history_back": "الرجوع خطوة",
|
||||
"ui.history_below_browser": "أسفل المستعرض",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
|
||||
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
|
||||
"ui.report_a_bug": "الإبلاغ عن خطأ",
|
||||
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
|
||||
"ui.report_characters_left_other": "الأحرف المتبقية: {count}",
|
||||
"ui.reset": "إعادة تعيين",
|
||||
"ui.reset_all": "إعادة تعيين الكل",
|
||||
"ui.reset_the_settings_on_this_page": "إعادة تعيين إعدادات هذه الصفحة",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "يجب أن تكون القيم أعدادًا أو تعابير",
|
||||
"view.auto_ortho_faces": "منظوري أثناء التدوير، ومتعامد على الأوجه",
|
||||
"view.render_inspector": "مفتش التصيير",
|
||||
"view_gizmo.arrows": "أسهم التدوير",
|
||||
"view_gizmo.home": "زر العرض الرئيسي",
|
||||
"view_gizmo.no_such_body": "لا يوجد في هذا التصميم جسم بهذا الاسم.",
|
||||
"view_gizmo.note": "اختر جسمًا في العرض. يُحفظ مع تفضيلاتك وملفات تعريف الإعدادات، ويظهر في كل تصميم. انقر أحد أوجهه للنظر إليه مباشرة.",
|
||||
"view_gizmo.texture": "الخامة",
|
||||
"view_gizmo.themed": "حسب السمة",
|
||||
"view_gizmo.too_detailed": "مفصّل أكثر مما يناسب متصفح العرض (أكثر من 10,000 مثلث)",
|
||||
"view_gizmo.too_thin": "طويل ورفيع أكثر مما يناسب متصفح العرض: يجب ألا يقل أرفع جانب عن خُمس أطول جانب",
|
||||
"web.files.insert_drawings_hint": "ملفات CAD أو شبكات أو DXF أو SVG إلى التصميم المفتوح",
|
||||
"web.files.insert_stored_drawings_hint": "إلى التصميم المفتوح (ملفات CAD، شبكات، DXF، SVG)",
|
||||
"web.open_full": "فتح في SolidCraft",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Centar mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Uporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Uporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analýza přístupnosti",
|
||||
"cmd.inspect_center_of_mass": "Těžiště",
|
||||
"cmd.inspect_clearance": "Vůle",
|
||||
"cmd.inspect_colored_components": "Zapnout barvy komponent",
|
||||
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
|
||||
"cmd.inspect_compare": "Porovnat tělesa",
|
||||
"cmd.inspect_compare_picture": "Porovnat obrázek",
|
||||
"cmd.inspect_continuity": "Analýza spojitosti",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massecentrum",
|
||||
"cmd.inspect_clearance": "Frigangskontrol",
|
||||
"cmd.inspect_colored_components": "Aktivér komponentfarver",
|
||||
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
|
||||
"cmd.inspect_compare": "Sammenlign emner",
|
||||
"cmd.inspect_compare_picture": "Sammenlign billede",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -960,8 +960,6 @@
|
||||
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
|
||||
"cmd.inspect_clearance": "Freiraum",
|
||||
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
|
||||
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
|
||||
"cmd.inspect_compare": "Körper vergleichen",
|
||||
"cmd.inspect_continuity": "Kontinuitätsprüfung",
|
||||
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
|
||||
@@ -1291,6 +1289,7 @@
|
||||
"cmd.timeline_stop": "Verlauf anhalten",
|
||||
"cmd.timeline_suppress": "Merkmal unterdrücken",
|
||||
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
|
||||
"cmd.view.navigator": "Eigener Ansichtsnavigator",
|
||||
"cmd.view.render_browser": "Render-Browser",
|
||||
"cmd.view.render_dock": "Render-Panel verschieben",
|
||||
"cmd.view.render_focus": "Render-Fokusansicht",
|
||||
@@ -1391,6 +1390,9 @@
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
|
||||
"dialog.dimension_name": "Bemaßung: {name}",
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
|
||||
"dialog.dimension_step_up": "Um {step} erhöhen",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
|
||||
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
|
||||
@@ -1415,6 +1417,7 @@
|
||||
"document.read_only_save": "Dieses Design wurde schreibgeschützt geöffnet: Speichern Sie es als neue Datei",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.save_copy": "Kopie speichern…",
|
||||
"document.tab_unsaved_changes": "Ungespeicherte Änderungen",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"drive.rigid": "{joint} ist ein starres Gelenk. Es hält die verbundenen Teile fest zusammen und ermöglicht daher keine Bewegung. Um die Teile beweglich zu machen, ändern Sie den Gelenktyp unter „Gelenk bearbeiten“: Drehgelenk für Drehbewegungen, Schubgelenk für lineare Bewegungen oder Zylindergelenk für beides.",
|
||||
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
|
||||
@@ -1435,6 +1438,9 @@
|
||||
"each rail's curves must be in one sketch": "die Kurven jeder Führungskurve müssen in derselben Skizze liegen",
|
||||
"feature computation ended without a result": "die Berechnung des Merkmals wurde ohne Ergebnis beendet",
|
||||
"file.open.drawing_filter": "Designs, CAD-Dateien, Netze und Zeichnungen",
|
||||
"file.open.portal_fallback.no_portal_service": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Der Dateiauswahl-Dienst des Desktops ist nicht verfügbar.",
|
||||
"file.open.portal_fallback.no_session_bus": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Es gibt keinen D-Bus-Sitzungsbus.",
|
||||
"file.open.portal_fallback.xwayland": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Unter XWayland lehnt das Portal das Elternfenster ab.",
|
||||
"file_load.built": "Bisher erstellt: {built} von {total} Merkmalen.",
|
||||
"file_load.converting": "Volumenkörper {done} von {total} wird konvertiert",
|
||||
"file_load.inserting": "Geometrie wird eingefügt",
|
||||
@@ -1982,6 +1988,7 @@
|
||||
"material.selection.mixed": "Unterschiedliche Materialien",
|
||||
"measure.at": "Bei",
|
||||
"measure.item": "Element {number}",
|
||||
"measure.line_to_centre": "Linie zum Mittelpunkt",
|
||||
"measure.quick_copied": "Messwert kopiert",
|
||||
"measure.quick_copy": "Klicken, um den Messwert zu kopieren",
|
||||
"measure.quick_edges": "{count} Kanten",
|
||||
@@ -2012,6 +2019,8 @@
|
||||
"nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert",
|
||||
"nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "Ein Ziehen mit der mittleren oder rechten Taste dreht, verschiebt oder zoomt weiter wie begonnen, bis die Taste losgelassen wird, egal welche Tasten dabei gedrückt oder losgelassen werden. Aus: Die gehaltenen Tasten entscheiden in jedem Moment des Ziehens.",
|
||||
"nav.drag_keeps_mode.label": "Ziehen behält die Navigation bei, mit der es begann",
|
||||
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
|
||||
"nav.free_orbit.label": "Freie Drehung",
|
||||
"nav.free_orbit.snap_help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollausrichtung bei. Halten Sie beim Ziehen zum Drehen die Umschalttaste gedrückt, um Blickrichtung und Rollausrichtung einrasten zu lassen.",
|
||||
@@ -2314,6 +2323,13 @@
|
||||
"prefs.dialogs_edge": "Am Rand der Ansicht",
|
||||
"prefs.dialogs_left_column": "In der linken Spalte",
|
||||
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
|
||||
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
|
||||
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
|
||||
"prefs.dimension_steps": "Schritte im Fenster",
|
||||
"prefs.dimension_values": "Bemaßungswerte",
|
||||
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster",
|
||||
"prefs.display": "Anzeige",
|
||||
"prefs.edges": "Kanten und Schattierung",
|
||||
"prefs.extend": "Erweitern",
|
||||
@@ -2519,6 +2535,8 @@
|
||||
"prefs.search": "Einstellungen suchen",
|
||||
"prefs.search_empty": "Keine passenden Einstellungen",
|
||||
"prefs.select": "Auswählen",
|
||||
"prefs.select_hidden_edges": "Verdeckte Kanten in der schattierten Ansicht mit verdeckten Kanten auswählen",
|
||||
"prefs.select_hidden_edges_help": "Kanten durch Flächen auswählen, wenn die schattierte Ansicht mit verdeckten Kanten aktiv ist, auch zum Verrunden. Aus behält das Auswahlverhalten von v0.30.1 bei. Ausgeblendete Körper bleiben ausgeschlossen.",
|
||||
"prefs.select_one_item": "Ein Element auswählen",
|
||||
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
|
||||
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
|
||||
@@ -2543,6 +2561,8 @@
|
||||
"prefs.sketch.click_selects": "Klick wählt",
|
||||
"prefs.sketch.conflict_colour": "Konfliktfarbe",
|
||||
"prefs.sketch.drawing": "Zeichnen",
|
||||
"prefs.sketch.ellipse_axes": "Ellipsen mit ihren Achsen zeichnen",
|
||||
"prefs.sketch.ellipse_axes_note": "Haupt- und Nebenachse als Konstruktionslinien, damit Halbachsen und Achsen bemaßt werden können. Aus: nur die Ellipse, und D darauf ergibt ihren Nebenradius, wohin der Wert auch gezogen wird, gezeichnet und mit R beschriftet wie bisher.",
|
||||
"prefs.sketch.exit_keep": "Beibehalten",
|
||||
"prefs.sketch.exit_previous": "Zur Ansicht davor zurückkehren",
|
||||
"prefs.sketch.exit_unless_rotated": "Außer bei Drehung",
|
||||
@@ -4772,6 +4792,7 @@
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.delete_select_first": "Wählen Sie zuerst aus, was gelöscht werden soll: Features, Körper, Komponenten oder Skizzengeometrie",
|
||||
"ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Autor",
|
||||
@@ -4976,6 +4997,7 @@
|
||||
"ui.heading_construct": "KONSTRUKTION",
|
||||
"ui.heading_create": "ERSTELLEN",
|
||||
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
|
||||
"ui.heading_custom_view_navigator": "EIGENER ANSICHTSNAVIGATOR",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
@@ -5283,6 +5305,8 @@
|
||||
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.report_characters_left_one": "{count} Zeichen übrig",
|
||||
"ui.report_characters_left_other": "{count} Zeichen übrig",
|
||||
"ui.reset": "Zurücksetzen",
|
||||
"ui.reset_all": "Alle zurücksetzen",
|
||||
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
|
||||
@@ -5564,6 +5588,14 @@
|
||||
"values must be numbers or expressions": "Werte müssen Zahlen oder Ausdrücke sein",
|
||||
"view.auto_ortho_faces": "Perspektivisch beim Drehen, orthografisch auf Flächen",
|
||||
"view.render_inspector": "Render-Inspektor",
|
||||
"view_gizmo.arrows": "Drehpfeile",
|
||||
"view_gizmo.home": "Schaltfläche „Ausgangsansicht“",
|
||||
"view_gizmo.no_such_body": "Diese Konstruktion hat keinen Körper mit diesem Namen.",
|
||||
"view_gizmo.note": "Wählen Sie einen Körper in der Ansicht. Er wird mit Ihren Einstellungen und Einstellungsprofilen gespeichert und in jeder Konstruktion angezeigt. Klicken Sie auf eine seiner Flächen, um direkt darauf zu blicken.",
|
||||
"view_gizmo.texture": "Textur",
|
||||
"view_gizmo.themed": "Design",
|
||||
"view_gizmo.too_detailed": "Zu detailliert für den Ansichtsnavigator (über 10.000 Dreiecke)",
|
||||
"view_gizmo.too_thin": "Zu lang und dünn für den Ansichtsnavigator: Die dünnste Seite muss mindestens ein Fünftel der längsten sein",
|
||||
"web.files.insert_drawings_hint": "CAD-Dateien, Netze, DXF oder SVG in das geöffnete Design",
|
||||
"web.files.insert_stored_drawings_hint": "In das geöffnete Design (CAD-Dateien, Netze, DXF, SVG)",
|
||||
"web.open_full": "In SolidCraft öffnen",
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"cmd.component_cycle_color": "Cycle Component Colour",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.inspect_colored_components": "Enable Component Colours",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colours",
|
||||
"cmd.inspect_colored_components": "Component Colours",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.render_aging_describe": "Layered Surface Aging",
|
||||
"cmd.render_effects_describe": "Lens and Creative Effects",
|
||||
@@ -69,6 +68,7 @@
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"measure.line_to_centre": "Line to centre",
|
||||
"navbar.custom.anchor.bottom_centre": "Bottom centre",
|
||||
"navbar.custom.anchor.top_centre": "Top centre",
|
||||
"navbar.custom.customise": "Customise navigation bar…",
|
||||
@@ -86,6 +86,7 @@
|
||||
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow.",
|
||||
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behaviour. Hidden bodies stay unselectable.",
|
||||
"prefs.sketch.center_arc": "Centre-point arc follows the cursor",
|
||||
"prefs.sketch.conflict_colour": "Conflict colour",
|
||||
"prefs.sketch.tracking_guides": "Centre and midpoint guides",
|
||||
@@ -234,6 +235,10 @@
|
||||
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
|
||||
"render.studio_center": "Centre X, Y, Z (mm)",
|
||||
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
|
||||
"sketch.hint.arc.center_point": "Arc: click centre and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.circle.center": "Circle: click the centre, then a point on the circle",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to centre the second on the first",
|
||||
"sketch.hint.rectangle.center": "Centre rectangle: click the centre, then a corner",
|
||||
"sketch_text.align_center": "Centre",
|
||||
"ui.3d_mouse_kind_cap": "Return to Centre",
|
||||
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "Bottom Right",
|
||||
"Boundary": "Boundary",
|
||||
"Bounding box": "Bounding box",
|
||||
"Bridge": "Bridge",
|
||||
"CHECK": "CHECK",
|
||||
"COMPONENTS": "COMPONENTS",
|
||||
"CONFIGURATIONS": "CONFIGURATIONS",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "View Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_clearance": "Clearance",
|
||||
"cmd.inspect_colored_components": "Enable Component Colors",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colors",
|
||||
"cmd.inspect_colored_components": "Component Colors",
|
||||
"cmd.inspect_compare": "Compare Bodies",
|
||||
"cmd.inspect_compare_picture": "Compare Picture",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"cmd.sketch_arc_tangent": "Tangent Arc",
|
||||
"cmd.sketch_arc_three_point": "3-Point Arc",
|
||||
"cmd.sketch_auto_constrain": "AutoConstrain",
|
||||
"cmd.sketch_auto_constrain_from_datum": "AutoConstrain from datum",
|
||||
"cmd.sketch_auto_constrain_from_datum": "AutoConstrain from Datum",
|
||||
"cmd.sketch_auto_project": "Auto-Project Edges on Reference",
|
||||
"cmd.sketch_blend_curve": "Blend Curve",
|
||||
"cmd.sketch_cancel": "Cancel Sketch",
|
||||
@@ -1186,7 +1186,7 @@
|
||||
"cmd.sketch_circle_two_point": "2-Point Circle",
|
||||
"cmd.sketch_circle_two_tangent": "2-Tangent Circle",
|
||||
"cmd.sketch_constrainer": "Sketch Constrainer",
|
||||
"cmd.sketch_constraint_fix": "Fix/UnFix",
|
||||
"cmd.sketch_constraint_fix": "Fix/Unfix",
|
||||
"cmd.sketch_constraint_midpoint": "MidPoint",
|
||||
"cmd.sketch_construction": "Normal/Construction",
|
||||
"cmd.sketch_copy": "Copy Sketch Entities",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"cmd.timeline_stop": "Stop History",
|
||||
"cmd.timeline_suppress": "Suppress Feature",
|
||||
"cmd.ui.assistant": "Toggle Assistant",
|
||||
"cmd.view.navigator": "Custom View Navigator",
|
||||
"cmd.view.render_browser": "Render Browser",
|
||||
"cmd.view.render_dock": "Move Render Dock",
|
||||
"cmd.view.render_focus": "Render Focus View",
|
||||
@@ -1311,13 +1312,13 @@
|
||||
"cmd.view_front": "Front View",
|
||||
"cmd.view_graphics_preset": "Graphics Preset",
|
||||
"cmd.view_grid": "Grid",
|
||||
"cmd.view_ground_shadow": "Ground shadow",
|
||||
"cmd.view_home": "Home view",
|
||||
"cmd.view_ground_shadow": "Ground Shadow",
|
||||
"cmd.view_home": "Home View",
|
||||
"cmd.view_isometric": "Isometric View",
|
||||
"cmd.view_left": "Left View",
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_navigate": "Navigate View",
|
||||
"cmd.view_navigation_bar": "Navigation bar",
|
||||
"cmd.view_navigation_bar": "Navigation Bar",
|
||||
"cmd.view_next_tab": "Next Tab",
|
||||
"cmd.view_normal_to": "Normal to Selection",
|
||||
"cmd.view_previous_tab": "Previous Tab",
|
||||
@@ -1326,10 +1327,10 @@
|
||||
"cmd.view_right": "Right View",
|
||||
"cmd.view_save": "New Named View",
|
||||
"cmd.view_shots_strip": "Shots Strip",
|
||||
"cmd.view_spin": "Continuous orbit",
|
||||
"cmd.view_tool": "Navigation tool",
|
||||
"cmd.view_spin": "Continuous Orbit",
|
||||
"cmd.view_tool": "Navigation Tool",
|
||||
"cmd.view_top": "Top View",
|
||||
"cmd.view_visual_style": "Visual style",
|
||||
"cmd.view_visual_style": "Visual Style",
|
||||
"color.blue": "Blue",
|
||||
"color.copy": "Click to copy color values",
|
||||
"color.edit": "Click to edit color",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
|
||||
"dialog.dimension_name": "Dimension: {name}",
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
|
||||
"dialog.dimension_step_up": "Up by {step}",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
|
||||
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ {count} selected",
|
||||
"dialog.selection_constraint": "Constraint {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · curve {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
|
||||
"dialog.selection_midpoint": "Midpoint · {id}",
|
||||
"dialog.selection_profile": "{sketch} · profile {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "This design was opened read-only: save it as a new file",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.save_copy": "Save a Copy…",
|
||||
"document.tab_unsaved_changes": "Unsaved changes",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"drive.rigid": "{joint} is a rigid joint: it holds its parts still, so it has nothing to drive. To let them move, change its type in Edit Joint: Revolute to rotate, Slider to slide, Cylindrical for both.",
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "each rail's curves must be in one sketch",
|
||||
"feature computation ended without a result": "feature computation ended without a result",
|
||||
"file.open.drawing_filter": "Designs, CAD files, meshes and drawings",
|
||||
"file.open.portal_fallback.no_portal_service": "The file chooser that opened is not the desktop's own: the desktop portal service is not available.",
|
||||
"file.open.portal_fallback.no_session_bus": "The file chooser that opened is not the desktop's own: there is no D-Bus session bus.",
|
||||
"file.open.portal_fallback.xwayland": "The file chooser that opened is not the desktop's own: under XWayland the portal refuses the parent window.",
|
||||
"file_load.built": "Built so far: {built} of {total} features.",
|
||||
"file_load.converting": "Converting solid {done} of {total}",
|
||||
"file_load.inserting": "Inserting geometry",
|
||||
@@ -1600,7 +1609,7 @@
|
||||
"help.parts.resize": "Changes the size or length of an inserted standard part and rebuilds it; its joints keep it seated. Use it when a screw turns out too long or a hole needs another size.",
|
||||
"help.plastic.assign_rule": "Assigns a plastic rule to bodies, so their plastic features take its draft and clearance as defaults. Use it to set up a moulded part once for all its features.",
|
||||
"help.plastic.boss": "Adds a screw boss on a face: a post with an optional hole, draft, root fillet and supporting ribs. Use it for screw points in moulded or printed housings.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body (Shell a face open first, then pick the rim around that opening). Use it so that two housing halves locate on each other and close tightly.",
|
||||
"help.plastic.manage_rules": "Creates and edits plastic rules: material, wall thickness, radii, clearance and draft. Use them to keep the features of a moulded part consistent.",
|
||||
"help.plastic.rest": "Adds a rest on a face: a round or rectangular pad, solid or hollow, with draft. Use it as a support or standoff in a moulded housing.",
|
||||
"help.plastic.snap_fit": "Adds a snap-fit hook on a face, with its length, thickness and catch. Use it to clip parts together without screws.",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "Different materials",
|
||||
"measure.at": "At",
|
||||
"measure.item": "Item {number}",
|
||||
"measure.line_to_centre": "Line to center",
|
||||
"measure.quick_copied": "Measurement copied",
|
||||
"measure.quick_copy": "Click to copy the measurement",
|
||||
"measure.quick_edges": "{count} edges",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
|
||||
"nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "A middle or right drag goes on orbiting, panning or zooming as it began until the button is released, whatever keys are pressed or let go meanwhile. Off: the keys held decide at every moment of the drag.",
|
||||
"nav.drag_keeps_mode.label": "A drag keeps the navigation it started with",
|
||||
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
|
||||
"nav.free_orbit.label": "Free orbit",
|
||||
"nav.free_orbit.snap_help": "Mouse and 3D mouse rotate through both poles and preserve roll. Hold Shift during an orbit drag to snap the view direction and roll.",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "At the view's edge",
|
||||
"prefs.dialogs_left_column": "In the left column",
|
||||
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
|
||||
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
|
||||
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
|
||||
"prefs.dimension_steps": "Window steps",
|
||||
"prefs.dimension_values": "Dimension values",
|
||||
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window",
|
||||
"prefs.display": "Display",
|
||||
"prefs.edges": "Edges and shading",
|
||||
"prefs.extend": "Extend",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "By part",
|
||||
"prefs.section_hatch_one": "One style",
|
||||
"prefs.select": "Select",
|
||||
"prefs.select_hidden_edges": "Select hidden edges in Shaded with hidden edges",
|
||||
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behavior. Hidden bodies stay unselectable.",
|
||||
"prefs.select_one_item": "Select one item",
|
||||
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
|
||||
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
|
||||
@@ -2565,6 +2586,8 @@
|
||||
"prefs.sketch.click_selects": "Click selects",
|
||||
"prefs.sketch.conflict_colour": "Conflict color",
|
||||
"prefs.sketch.drawing": "Drawing",
|
||||
"prefs.sketch.ellipse_axes": "Draw ellipses with their axes",
|
||||
"prefs.sketch.ellipse_axes_note": "The major and minor axes as construction lines, so the half-axes and axes can be dimensioned. Off: the ellipse alone, and D on it gives its minor radius wherever the value is pulled, drawn and labelled R as before.",
|
||||
"prefs.sketch.exit_keep": "Keep",
|
||||
"prefs.sketch.exit_previous": "Return to the view before",
|
||||
"prefs.sketch.exit_unless_rotated": "Unless turned",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "{count} degrees of freedom left",
|
||||
"sketch.dof_left_one": "{count} degree of freedom left",
|
||||
"sketch.draw_curves": "Draw as {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "Convert the selection to {linetype}",
|
||||
"sketch.open_ends": "Open {count}",
|
||||
"sketch.status_conflict": "constraints conflict",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "Default modeling orientation",
|
||||
"ui.delete": "Delete",
|
||||
"ui.delete_select_first": "Select what to delete first: features, bodies, components or sketch geometry",
|
||||
"ui.dependents_select_first": "Select what to list the dependents of first",
|
||||
"ui.depth": "Depth",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Author",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "CONSTRUCT",
|
||||
"ui.heading_create": "CREATE",
|
||||
"ui.heading_create_sketch": "CREATE SKETCH",
|
||||
"ui.heading_custom_view_navigator": "CUSTOM VIEW NAVIGATOR",
|
||||
"ui.heading_cylinder": "CYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "DRAFT",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.report_characters_left_one": "{count} character left",
|
||||
"ui.report_characters_left_other": "{count} characters left",
|
||||
"ui.reset": "Reset",
|
||||
"ui.reset_all": "Reset All",
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "values must be numbers or expressions",
|
||||
"view.auto_ortho_faces": "Perspective while orbiting, ortho on faces",
|
||||
"view.render_inspector": "Render Inspector",
|
||||
"view_gizmo.arrows": "Rotation arrows",
|
||||
"view_gizmo.home": "Home button",
|
||||
"view_gizmo.no_such_body": "This design has no body of that name.",
|
||||
"view_gizmo.note": "Pick a body in the view. It is kept with your preferences and settings profiles, and shown in every design. Click one of its faces to look straight at it.",
|
||||
"view_gizmo.texture": "Texture",
|
||||
"view_gizmo.themed": "Themed",
|
||||
"view_gizmo.too_detailed": "Too detailed for the view navigator (over 10,000 triangles)",
|
||||
"view_gizmo.too_thin": "Too long and thin for the view navigator: its thinnest side must be at least a fifth of its longest",
|
||||
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
|
||||
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
|
||||
"web.open_full": "Open in SolidCraft",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_colored_components": "Activer les couleurs des composants",
|
||||
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_compare_picture": "Comparer les images",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
@@ -1391,6 +1389,7 @@
|
||||
"document.read_only_save": "Cette conception a été ouverte en lecture seule : enregistrez-la dans un nouveau fichier",
|
||||
"document.save_changes": "Enregistrer les modifications de {name} ?",
|
||||
"document.save_copy": "Enregistrer une copie…",
|
||||
"document.tab_unsaved_changes": "Modifications non enregistrées",
|
||||
"document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.",
|
||||
"drive.rigid": "{joint} est une liaison rigide : elle maintient ses pièces immobiles, il n'y a donc rien à piloter. Pour les laisser bouger, changer son type dans Modifier la liaison : Revolute pour tourner, Slider pour coulisser, Cylindrical pour les deux.",
|
||||
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Análise de accesibilidade",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Folga",
|
||||
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
|
||||
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
|
||||
"cmd.inspect_compare": "Comparar corpos",
|
||||
"cmd.inspect_compare_picture": "Comparar a imaxe",
|
||||
"cmd.inspect_continuity": "Análise de continuidade",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "למטה מימין",
|
||||
"Boundary": "גבול",
|
||||
"Bounding box": "תיבה תוחמת",
|
||||
"Bridge": "גשר",
|
||||
"CHECK": "בדיקה",
|
||||
"COMPONENTS": "רכיבים",
|
||||
"CONFIGURATIONS": "תצורות",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "ניתוח תצוגה",
|
||||
"cmd.inspect_center_of_mass": "מרכז מסה",
|
||||
"cmd.inspect_clearance": "מרווח",
|
||||
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
|
||||
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
|
||||
"cmd.inspect_colored_components": "צבעי רכיבים",
|
||||
"cmd.inspect_compare": "השוואת גופים",
|
||||
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
|
||||
"cmd.inspect_continuity": "ניתוח רציפות",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"cmd.timeline_stop": "עצירת ציר הזמן",
|
||||
"cmd.timeline_suppress": "השבתת פיצ'ר",
|
||||
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
|
||||
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
|
||||
"cmd.view.render_browser": "דפדפן רינדור",
|
||||
"cmd.view.render_dock": "הזז את עגינת הרינדור",
|
||||
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ ציר מותאם",
|
||||
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
|
||||
"dialog.dimension_name": "מידה: {name}",
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
|
||||
"dialog.dimension_step_up": "הוספה של {step}",
|
||||
"dialog.edit_title": "עריכת {title}",
|
||||
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
|
||||
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ {count} נבחרו",
|
||||
"dialog.selection_constraint": "אילוץ {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · עקומה {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "יש לבחור קלט כדי לשנות אותו. לחיצה על אובייקט שנבחר מסירה אותו; גם Ctrl/Cmd+לחיצה מחליפה את מצבו. לאחר בדיקת התצוגה המקדימה יש ללחוץ על אישור.",
|
||||
"dialog.selection_midpoint": "נקודת אמצע · {id}",
|
||||
"dialog.selection_profile": "{sketch} · פרופיל {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "התכן הזה נפתח לקריאה בלבד: יש לשמור אותו כקובץ חדש",
|
||||
"document.save_changes": "לשמור את השינויים ב־{name}?",
|
||||
"document.save_copy": "שמירת עותק…",
|
||||
"document.tab_unsaved_changes": "שינויים שלא נשמרו",
|
||||
"document.unsaved_changes": "ב־{name} יש שינויים שלא נשמרו. ללא שמירה, הם יאבדו.",
|
||||
"drive.rigid": "{joint} הוא מפרק קשיח: הוא מחזיק את החלקים שלו במקומם, ולכן אין מה להניע. כדי שהחלקים יזוזו, שנו את הסוג שלו ב״עריכת מפרק״: Revolute לסיבוב, Slider להחלקה, Cylindrical לשניהם.",
|
||||
"drive.stopped_by_contact": "נעצר ב־{value}: {a} היה עובר דרך {b}",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "עקומות כל מסילה חייבות להיות בסקיצה אחת",
|
||||
"feature computation ended without a result": "חישוב הפיצ'ר הסתיים ללא תוצאה",
|
||||
"file.open.drawing_filter": "תכנים, קובצי CAD, רשתות משולשים ושרטוטים",
|
||||
"file.open.portal_fallback.no_portal_service": "בורר הקבצים שנפתח אינו של שולחן העבודה: שירות בחירת הקבצים של שולחן העבודה אינו זמין.",
|
||||
"file.open.portal_fallback.no_session_bus": "בורר הקבצים שנפתח אינו של שולחן העבודה: אין אוטובוס הפעלה של D-Bus.",
|
||||
"file.open.portal_fallback.xwayland": "בורר הקבצים שנפתח אינו של שולחן העבודה: תחת XWayland הפורטל דוחה את חלון האב.",
|
||||
"file_load.built": "נבנו עד כה: {built} מתוך {total} פיצ'רים.",
|
||||
"file_load.converting": "המרת גוף מוצק {done} מתוך {total}",
|
||||
"file_load.inserting": "הוספת גאומטריה",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "חומרים שונים",
|
||||
"measure.at": "מיקום",
|
||||
"measure.item": "פריט {number}",
|
||||
"measure.line_to_centre": "מהקו למרכז",
|
||||
"measure.quick_copied": "המדידה הועתקה",
|
||||
"measure.quick_copy": "לחיצה מעתיקה את המדידה",
|
||||
"measure.quick_edges": "{count} מקצועות",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "גרירה אמצעית מסובבת, Shift וגרירה אמצעית מזיזים, Ctrl וגרירה אמצעית מבצעים זום; גרירה ימנית לא מזיזה את המצלמה",
|
||||
"nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "גרירה בכפתור האמצעי או הימני ממשיכה לסובב, להזיז או לשנות זום כפי שהתחילה עד שמשחררים את הכפתור, בלי קשר למקשים שנלחצים או משתחררים בינתיים. כבוי: המקשים הלחוצים קובעים בכל רגע של הגרירה.",
|
||||
"nav.drag_keeps_mode.label": "גרירה שומרת על הניווט שבו התחילה",
|
||||
"nav.free_orbit.help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. הצמדת הצירים עם Shift מושבתת.",
|
||||
"nav.free_orbit.label": "סיבוב חופשי",
|
||||
"nav.free_orbit.snap_help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. יש להחזיק את Shift בזמן גרירת סיבוב כדי להצמיד את כיוון התצוגה ואת הגלגול.",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "בקצה התצוגה",
|
||||
"prefs.dialogs_left_column": "בעמודה השמאלית",
|
||||
"prefs.dialogs_note": "היכן נפתחות תיבות הדו־שיח של הפקודות: מעוגנות בקצה התצוגה, או בעמודה השמאלית במקום הדפדפן וההיסטוריה, שחוזרים כפי שהיו כשהתיבה נסגרת.",
|
||||
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
|
||||
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
|
||||
"prefs.dimension_steps": "צעדי החלון",
|
||||
"prefs.dimension_values": "ערכי מידות",
|
||||
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
|
||||
"prefs.dimension_values_view": "בתצוגה",
|
||||
"prefs.dimension_values_window": "בחלון",
|
||||
"prefs.display": "תצוגה",
|
||||
"prefs.edges": "מקצועות והצללה",
|
||||
"prefs.extend": "הרחבה",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "לפי חלק",
|
||||
"prefs.section_hatch_one": "סגנון אחד",
|
||||
"prefs.select": "בחירה",
|
||||
"prefs.select_hidden_edges": "בחירת קצוות מוסתרים בתצוגה מוצללת עם קצוות מוסתרים",
|
||||
"prefs.select_hidden_edges_help": "מאפשר לבחור קצוות דרך פאות בתצוגה מוצללת עם קצוות מוסתרים, גם עבור Fillet. כיבוי שומר על אופן הבחירה של v0.30.1. גופים שהוסתרו עדיין אינם ניתנים לבחירה.",
|
||||
"prefs.select_one_item": "בחירת פריט אחד",
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
@@ -2565,6 +2586,8 @@
|
||||
"prefs.sketch.click_selects": "לחיצה בוחרת",
|
||||
"prefs.sketch.conflict_colour": "צבע התנגשות",
|
||||
"prefs.sketch.drawing": "שרטוט",
|
||||
"prefs.sketch.ellipse_axes": "שרטוט אליפסות עם הצירים שלהן",
|
||||
"prefs.sketch.ellipse_axes_note": "הציר הראשי והציר המשני כקווי עזר, כדי שאפשר יהיה לתת מידות לחצאי הצירים ולצירים. כבוי: האליפסה בלבד, ו-D עליה נותן את הרדיוס המשני שלה בכל מקום שאליו נגרר הערך, משורטט ומסומן R כמו קודם.",
|
||||
"prefs.sketch.exit_keep": "שמירה",
|
||||
"prefs.sketch.exit_previous": "חזרה לתצוגה הקודמת",
|
||||
"prefs.sketch.exit_unless_rotated": "אלא אם סובבה",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "נותרו {count} דרגות חופש",
|
||||
"sketch.dof_left_one": "נותרה {count} דרגת חופש",
|
||||
"sketch.draw_curves": "שרטוט כ־{linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "המרת הבחירה ל־{linetype}",
|
||||
"sketch.open_ends": "קצוות פתוחים: {count}",
|
||||
"sketch.status_conflict": "אילוצים סותרים",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "כיוון מידול ברירת מחדל",
|
||||
"ui.delete": "מחיקה",
|
||||
"ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה",
|
||||
"ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו",
|
||||
"ui.depth": "עומק",
|
||||
"ui.design": "תכן",
|
||||
"ui.design_properties.author": "מחבר",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "עזר",
|
||||
"ui.heading_create": "יצירה",
|
||||
"ui.heading_create_sketch": "יצירת סקיצה",
|
||||
"ui.heading_custom_view_navigator": "ניווט תצוגה מותאם אישית",
|
||||
"ui.heading_cylinder": "גליל",
|
||||
"ui.heading_design": "תכן",
|
||||
"ui.heading_draft": "שיפוע יציקה",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
|
||||
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
|
||||
"ui.report_a_bug": "דיווח על באג",
|
||||
"ui.report_characters_left_one": "תווים שנותרו: {count}",
|
||||
"ui.report_characters_left_other": "תווים שנותרו: {count}",
|
||||
"ui.reset": "איפוס",
|
||||
"ui.reset_all": "איפוס הכול",
|
||||
"ui.reset_the_settings_on_this_page": "איפוס ההגדרות בדף זה",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "הערכים חייבים להיות מספרים או ביטויים",
|
||||
"view.auto_ortho_faces": "פרספקטיבה בזמן סיבוב, אורתוגרפי על פאות",
|
||||
"view.render_inspector": "מפקח רינדור",
|
||||
"view_gizmo.arrows": "חיצי סיבוב",
|
||||
"view_gizmo.home": "לחצן תצוגת בית",
|
||||
"view_gizmo.no_such_body": "אין בעיצוב הזה גוף בשם הזה.",
|
||||
"view_gizmo.note": "בחרו גוף בתצוגה. הוא נשמר עם ההעדפות ופרופילי ההגדרות שלכם ומוצג בכל עיצוב. לחצו על אחת מפאותיו כדי להביט בה ישירות.",
|
||||
"view_gizmo.texture": "מרקם",
|
||||
"view_gizmo.themed": "לפי ערכת הנושא",
|
||||
"view_gizmo.too_detailed": "מפורט מדי לניווט התצוגה (יותר מ-10,000 משולשים)",
|
||||
"view_gizmo.too_thin": "ארוך ודק מדי לניווט התצוגה: הצד הדק ביותר חייב להיות לפחות חמישית מהצד הארוך ביותר",
|
||||
"web.files.insert_drawings_hint": "קובצי CAD, רשתות משולשים, DXF או SVG אל התכן הפתוח",
|
||||
"web.files.insert_stored_drawings_hint": "אל התכן הפתוח (קובצי CAD, רשתות משולשים, DXF, SVG)",
|
||||
"web.open_full": "פתיחה ב־SolidCraft",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Središte mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Usporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Usporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
|
||||
"cmd.inspect_center_of_mass": "Tömegközéppont",
|
||||
"cmd.inspect_clearance": "Hézag",
|
||||
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
|
||||
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
|
||||
"cmd.inspect_compare": "Testek összehasonlítása",
|
||||
"cmd.inspect_compare_picture": "Kép összehasonlítása",
|
||||
"cmd.inspect_continuity": "Folytonosságelemzés",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
|
||||
"cmd.inspect_center_of_mass": "Pusat Massa",
|
||||
"cmd.inspect_clearance": "Kelonggaran",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Bodi",
|
||||
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
|
||||
"cmd.inspect_continuity": "Analisis Kontinuitas",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisi di accessibilità",
|
||||
"cmd.inspect_center_of_mass": "Centro di massa",
|
||||
"cmd.inspect_clearance": "Distanza libera",
|
||||
"cmd.inspect_colored_components": "Attiva colori dei componenti",
|
||||
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
|
||||
"cmd.inspect_compare": "Confronta corpi",
|
||||
"cmd.inspect_compare_picture": "Confronta immagine",
|
||||
"cmd.inspect_continuity": "Analisi di continuità",
|
||||
@@ -1392,6 +1390,7 @@
|
||||
"document.read_only_save": "Questa progettazione è stata aperta in sola lettura: salvala come nuovo file",
|
||||
"document.save_changes": "Salvare le modifiche a {name}?",
|
||||
"document.save_copy": "Salva una copia…",
|
||||
"document.tab_unsaved_changes": "Modifiche non salvate",
|
||||
"document.unsaved_changes": "{name} contiene modifiche non salvate. Se non salvi, andranno perse.",
|
||||
"drive.rigid": "{joint} è un giunto rigido: tiene ferme le sue parti, quindi non c'è nulla da azionare. Per farle muovere, cambia il suo tipo in Modifica giunto: Rotante per ruotare, Scorrevole per scorrere, Cilindrico per entrambi.",
|
||||
"drive.stopped_by_contact": "Arrestato a {value}: {a} attraverserebbe {b}",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "右下",
|
||||
"Boundary": "境界",
|
||||
"Bounding box": "境界ボックス",
|
||||
"Bridge": "ブリッジ",
|
||||
"CHECK": "チェック",
|
||||
"COMPONENTS": "コンポーネント",
|
||||
"CONFIGURATIONS": "構成",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "表示解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_clearance": "クリアランス",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
|
||||
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分け",
|
||||
"cmd.inspect_compare": "ボディを比較",
|
||||
"cmd.inspect_compare_picture": "参考画像と比較",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"cmd.timeline_stop": "履歴を停止",
|
||||
"cmd.timeline_suppress": "フィーチャーを抑制",
|
||||
"cmd.ui.assistant": "アシスタントの表示切り替え",
|
||||
"cmd.view.navigator": "カスタム ビューナビゲーター",
|
||||
"cmd.view.render_browser": "Renderブラウザー",
|
||||
"cmd.view.render_dock": "レンダードックを移動",
|
||||
"cmd.view.render_focus": "Renderフォーカス表示",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
|
||||
"dialog.dimension_name": "寸法: {name}",
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
|
||||
"dialog.dimension_step_up": "{step} 増やす",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
|
||||
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ {count} 個選択中",
|
||||
"dialog.selection_constraint": "拘束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲線 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · プロファイル {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "このデザインは読み取り専用で開かれています:新しいファイルとして保存してください",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.save_copy": "コピーを保存…",
|
||||
"document.tab_unsaved_changes": "未保存の変更",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"drive.rigid": "{joint} は剛体ジョイントです。部品をその場に固定するため、駆動するものがありません。動かすには「ジョイントを編集」で種類を変更してください: 回転させるなら Revolute、スライドさせるなら Slider、両方なら Cylindrical。",
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "各レールの曲線は1つのスケッチ内にある必要があります",
|
||||
"feature computation ended without a result": "フィーチャーの計算が結果なしで終了しました",
|
||||
"file.open.drawing_filter": "デザイン、CADファイル、メッシュ、図面",
|
||||
"file.open.portal_fallback.no_portal_service": "開いたファイル選択はデスクトップのものではありません。デスクトップのファイル選択サービスが利用できません。",
|
||||
"file.open.portal_fallback.no_session_bus": "開いたファイル選択はデスクトップのものではありません。D-Bus セッションがありません。",
|
||||
"file.open.portal_fallback.xwayland": "開いたファイル選択はデスクトップのものではありません。XWayland ではポータルが親ウィンドウを拒否します。",
|
||||
"file_load.built": "構築済み: 全{total}フィーチャー中{built}件。",
|
||||
"file_load.converting": "ソリッドを変換中: {done}/{total}",
|
||||
"file_load.inserting": "ジオメトリを挿入中",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "異なる材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "項目 {number}",
|
||||
"measure.line_to_centre": "線から中心まで",
|
||||
"measure.quick_copied": "測定値をコピーしました",
|
||||
"measure.quick_copy": "クリックで測定値をコピー",
|
||||
"measure.quick_edges": "エッジ {count} 本",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。",
|
||||
"nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "中ボタンまたは右ボタンのドラッグは、途中でキーを押したり離したりしても、ボタンを離すまで開始時の回転・パン・ズームを続けます。オフにすると、ドラッグ中の各時点で押しているキーによって決まります。",
|
||||
"nav.drag_keeps_mode.label": "ドラッグは開始時のナビゲーションを維持",
|
||||
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
|
||||
"nav.free_orbit.label": "自由回転",
|
||||
"nav.free_orbit.snap_help": "マウスと3Dマウスで両極を越えて回転でき、ロールも保持されます。オービットのドラッグ中にShiftを押すと、視線方向とロールがスナップします。",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "ビューの端",
|
||||
"prefs.dialogs_left_column": "左の列",
|
||||
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
|
||||
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
|
||||
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
|
||||
"prefs.dimension_steps": "ウィンドウの増減量",
|
||||
"prefs.dimension_values": "寸法値の入力",
|
||||
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ",
|
||||
"prefs.display": "表示",
|
||||
"prefs.edges": "エッジと陰影",
|
||||
"prefs.extend": "拡張",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "部品ごと",
|
||||
"prefs.section_hatch_one": "単一スタイル",
|
||||
"prefs.select": "選択",
|
||||
"prefs.select_hidden_edges": "隠線表示で隠れた辺を選択する",
|
||||
"prefs.select_hidden_edges_help": "「Shaded with hidden edges」で面の向こうの辺を選択し、Filletにも使用できます。オフではv0.30.1相当の選択動作になります。非表示のボディは選択対象になりません。",
|
||||
"prefs.select_one_item": "項目を 1 つ選択",
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
@@ -2565,6 +2586,8 @@
|
||||
"prefs.sketch.click_selects": "クリックで選択",
|
||||
"prefs.sketch.conflict_colour": "競合色",
|
||||
"prefs.sketch.drawing": "作図",
|
||||
"prefs.sketch.ellipse_axes": "楕円を軸と一緒に描く",
|
||||
"prefs.sketch.ellipse_axes_note": "長軸と短軸をコンストラクション線として描き、半軸と軸に寸法を付けられるようにします。オフ: 楕円のみを描き、D で短半径を寸法にします。値をどこへ引いても、これまでどおり R 付きで描かれます。",
|
||||
"prefs.sketch.exit_keep": "維持",
|
||||
"prefs.sketch.exit_previous": "開始前のビューに戻る",
|
||||
"prefs.sketch.exit_unless_rotated": "回転した場合を除く",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "残り自由度 {count}",
|
||||
"sketch.dof_left_one": "残り自由度 {count}",
|
||||
"sketch.draw_curves": "{linetype}として描く",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "選択を{linetype}にする",
|
||||
"sketch.open_ends": "開放 {count}",
|
||||
"sketch.status_conflict": "拘束が競合しています",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "モデリングのデフォルト方向",
|
||||
"ui.delete": "削除",
|
||||
"ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ",
|
||||
"ui.dependents_select_first": "先に、依存先を表示する対象を選択してください",
|
||||
"ui.depth": "深さ",
|
||||
"ui.design": "デザイン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "構築",
|
||||
"ui.heading_create": "作成",
|
||||
"ui.heading_create_sketch": "スケッチを作成",
|
||||
"ui.heading_custom_view_navigator": "カスタム ビューナビゲーター",
|
||||
"ui.heading_cylinder": "円柱",
|
||||
"ui.heading_design": "デザイン",
|
||||
"ui.heading_draft": "勾配",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.report_characters_left_one": "残り {count} 文字",
|
||||
"ui.report_characters_left_other": "残り {count} 文字",
|
||||
"ui.reset": "リセット",
|
||||
"ui.reset_all": "すべてリセット",
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "値は数値または式にしてください",
|
||||
"view.auto_ortho_faces": "回転中は透視投影、面を正面に見ると平行投影",
|
||||
"view.render_inspector": "レンダーインスペクター",
|
||||
"view_gizmo.arrows": "回転矢印",
|
||||
"view_gizmo.home": "ホーム ボタン",
|
||||
"view_gizmo.no_such_body": "このデザインにその名前のボディはありません。",
|
||||
"view_gizmo.note": "ビューでボディを選択してください。ボディは環境設定と設定プロファイルに保存され、すべてのデザインで表示されます。面をクリックすると、その面を正面から見ます。",
|
||||
"view_gizmo.texture": "テクスチャ",
|
||||
"view_gizmo.themed": "テーマ",
|
||||
"view_gizmo.too_detailed": "ビューナビゲーターには細かすぎます (10,000 三角形を超えています)",
|
||||
"view_gizmo.too_thin": "ビューナビゲーターには細長すぎます: 最も薄い辺が最も長い辺の 5 分の 1 以上である必要があります",
|
||||
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
|
||||
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
|
||||
"web.open_full": "SolidCraft で開く",
|
||||
|
||||
@@ -1133,6 +1133,7 @@
|
||||
"cmd.timeline_stop": "기록 재생 중지",
|
||||
"cmd.timeline_suppress": "특성 비활성화",
|
||||
"cmd.ui.assistant": "도우미 표시 전환",
|
||||
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
|
||||
"cmd.view.render_browser": "렌더 브라우저",
|
||||
"cmd.view.render_dock": "렌더 도크 이동",
|
||||
"cmd.view.render_focus": "렌더 집중 보기",
|
||||
@@ -1207,6 +1208,9 @@
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
|
||||
"dialog.dimension_name": "치수: {name}",
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -1237,6 +1241,9 @@
|
||||
"dxf.tolerance_help": "표시된 바디와 스케치만 내보내며 곡선은 직선 구간으로 근사합니다. 허용 오차가 작을수록 곡선이 매끄러워지지만 처리 시간과 메모리 사용량이 늘어납니다.",
|
||||
"each rail's curves must be in one sketch": "각 레일의 곡선은 하나의 스케치 안에 있어야 합니다",
|
||||
"feature computation ended without a result": "특성 계산이 결과 없이 끝났습니다",
|
||||
"file.open.portal_fallback.no_portal_service": "열린 파일 선택은 데스크톱의 것이 아닙니다: 데스크톱의 파일 선택 서비스를 사용할 수 없습니다.",
|
||||
"file.open.portal_fallback.no_session_bus": "열린 파일 선택은 데스크톱의 것이 아닙니다: D-Bus 세션 버스가 없습니다.",
|
||||
"file.open.portal_fallback.xwayland": "열린 파일 선택은 데스크톱의 것이 아닙니다: XWayland에서는 포털이 부모 창을 거부합니다.",
|
||||
"file_load.built": "지금까지 빌드됨: 피처 {total}개 중 {built}개.",
|
||||
"file_load.converting": "솔리드 변환 중 {done}/{total}",
|
||||
"file_load.inserting": "기하 삽입 중",
|
||||
@@ -1431,6 +1438,7 @@
|
||||
"material.selection.mixed": "서로 다른 재질",
|
||||
"measure.at": "위치",
|
||||
"measure.item": "항목 {number}",
|
||||
"measure.line_to_centre": "선에서 중심까지",
|
||||
"messages.ago_h": "{n}시간 전",
|
||||
"messages.ago_min": "{n}분 전",
|
||||
"messages.ago_s": "{n}초 전",
|
||||
@@ -1457,6 +1465,8 @@
|
||||
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
|
||||
"nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "가운데 또는 오른쪽 버튼 드래그는 도중에 키를 누르거나 떼어도 버튼을 놓을 때까지 시작할 때의 회전, 이동 또는 확대/축소를 계속합니다. 끄면 드래그하는 동안 누르고 있는 키가 매 순간 결정합니다.",
|
||||
"nav.drag_keeps_mode.label": "드래그는 시작할 때의 탐색 방식을 유지",
|
||||
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
|
||||
"nav.free_orbit.label": "자유 회전",
|
||||
"nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.",
|
||||
@@ -1720,6 +1730,13 @@
|
||||
"prefs.dialogs_edge": "뷰 가장자리",
|
||||
"prefs.dialogs_left_column": "왼쪽 열",
|
||||
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
|
||||
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
|
||||
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
|
||||
"prefs.dimension_steps": "창의 증감 단위",
|
||||
"prefs.dimension_values": "치수 값",
|
||||
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서",
|
||||
"prefs.display": "표시",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
@@ -1878,6 +1895,8 @@
|
||||
"prefs.search": "설정 검색",
|
||||
"prefs.search_empty": "일치하는 설정이 없습니다",
|
||||
"prefs.select": "선택",
|
||||
"prefs.select_hidden_edges": "숨은 모서리가 표시된 음영 뷰에서 숨은 모서리 선택",
|
||||
"prefs.select_hidden_edges_help": "숨은 모서리가 표시된 음영 뷰에서 면 뒤의 모서리를 선택하여 필렛에 사용할 수 있습니다. 끄면 v0.30.1의 선택 동작을 유지합니다. 숨긴 바디는 선택되지 않습니다.",
|
||||
"prefs.select_one_item": "항목 하나 선택",
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
@@ -1902,6 +1921,8 @@
|
||||
"prefs.sketch.click_selects": "클릭으로 선택",
|
||||
"prefs.sketch.conflict_colour": "충돌 색상",
|
||||
"prefs.sketch.drawing": "그리기",
|
||||
"prefs.sketch.ellipse_axes": "타원을 축과 함께 그리기",
|
||||
"prefs.sketch.ellipse_axes_note": "장축과 단축을 구성선으로 그려 반축과 축에 치수를 지정할 수 있습니다. 끄면: 타원만 그리며, D는 값을 어디로 끌든 단반경을 주며, 이전처럼 R로 표시되어 그려집니다.",
|
||||
"prefs.sketch.exit_keep": "유지",
|
||||
"prefs.sketch.exit_previous": "이전 뷰로 돌아가기",
|
||||
"prefs.sketch.exit_unless_rotated": "회전하지 않았다면",
|
||||
@@ -3575,6 +3596,7 @@
|
||||
"ui.default_modelling_orientation": "기본 모델링 방향",
|
||||
"ui.delete": "삭제",
|
||||
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
|
||||
"ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요",
|
||||
"ui.depth": "깊이",
|
||||
"ui.design": "디자인",
|
||||
"ui.design_properties.author": "작성자",
|
||||
@@ -3772,6 +3794,7 @@
|
||||
"ui.heading_construct": "구성",
|
||||
"ui.heading_create": "만들기",
|
||||
"ui.heading_create_sketch": "스케치 만들기",
|
||||
"ui.heading_custom_view_navigator": "사용자 지정 뷰 탐색기",
|
||||
"ui.heading_cylinder": "원통",
|
||||
"ui.heading_design": "디자인",
|
||||
"ui.heading_draft": "구배",
|
||||
@@ -4299,5 +4322,13 @@
|
||||
"values must be finite numbers": "값은 유한한 숫자여야 합니다",
|
||||
"values must be numbers or expressions": "값은 숫자 또는 식이어야 합니다",
|
||||
"view.render_inspector": "렌더 검사기",
|
||||
"view_gizmo.arrows": "회전 화살표",
|
||||
"view_gizmo.home": "홈 버튼",
|
||||
"view_gizmo.no_such_body": "이 디자인에 그런 이름의 바디가 없습니다.",
|
||||
"view_gizmo.note": "뷰에서 바디를 선택하십시오. 바디는 환경설정과 설정 프로필에 저장되어 모든 디자인에 표시됩니다. 면을 클릭하면 그 면을 정면으로 봅니다.",
|
||||
"view_gizmo.texture": "텍스처",
|
||||
"view_gizmo.themed": "테마",
|
||||
"view_gizmo.too_detailed": "뷰 탐색기에 너무 세밀합니다 (삼각형 10,000개 초과)",
|
||||
"view_gizmo.too_thin": "뷰 탐색기에 너무 가늘고 깁니다: 가장 얇은 변이 가장 긴 변의 5분의 1 이상이어야 합니다",
|
||||
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 선택 · Enter / Tab 완성 · Esc 닫기"
|
||||
}
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
|
||||
"cmd.inspect_center_of_mass": "Pusat Jisim",
|
||||
"cmd.inspect_clearance": "Kelegaan",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Jasad",
|
||||
"cmd.inspect_compare_picture": "Banding dengan Imej",
|
||||
"cmd.inspect_continuity": "Analisis Keselanjaran",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massesenter",
|
||||
"cmd.inspect_clearance": "Klaring",
|
||||
"cmd.inspect_colored_components": "Aktiver komponentfarger",
|
||||
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
|
||||
"cmd.inspect_compare": "Sammenlign kropper",
|
||||
"cmd.inspect_compare_picture": "Sammenlign bilde",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Zwaartepunt",
|
||||
"cmd.inspect_clearance": "Speling",
|
||||
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
|
||||
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
|
||||
"cmd.inspect_compare": "Lichamen vergelijken",
|
||||
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
|
||||
"cmd.inspect_continuity": "Continuïteitsanalyse",
|
||||
@@ -1368,6 +1366,9 @@
|
||||
"dialog.custom_axis": "↖ Aangepaste as",
|
||||
"dialog.dimension_insert_hint": "Klik op een andere aandrijvende maat om de naam ervan in te voegen",
|
||||
"dialog.dimension_name": "Maat: {name}",
|
||||
"dialog.dimension_step_down": "{step} omlaag",
|
||||
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
|
||||
"dialog.dimension_step_up": "{step} omhoog",
|
||||
"dialog.edit_title": "{title} BEWERKEN",
|
||||
"dialog.no_shown_body": "Geen zichtbaar lichaam om te wijzigen: toon er een of kies Nieuw lichaam",
|
||||
"dialog.pattern_sketch_not_object": "Kies lichamen of kenmerken: een schets is geen van beide. Herhaal de curven ervan met het eigen Patroon of Spiegelen van de schets, of extrudeer de schets eerst.",
|
||||
@@ -1392,6 +1393,7 @@
|
||||
"document.read_only_save": "Dit ontwerp is alleen-lezen geopend: sla het op als nieuw bestand",
|
||||
"document.save_changes": "Wijzigingen in {name} opslaan?",
|
||||
"document.save_copy": "Kopie opslaan…",
|
||||
"document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen",
|
||||
"document.unsaved_changes": "{name} heeft niet-opgeslagen wijzigingen. Als u niet opslaat, gaan ze verloren.",
|
||||
"drive.rigid": "{joint} is een starre verbinding: die houdt zijn onderdelen vast en heeft dus niets om aan te sturen. Wijzig het type in Verbinding bewerken om ze te laten bewegen: Draaiend om te draaien, Schuivend om te schuiven, Cilindrisch voor beide.",
|
||||
"drive.stopped_by_contact": "Gestopt bij {value}: {a} zou door {b} heen gaan",
|
||||
@@ -2194,6 +2196,13 @@
|
||||
"prefs.dialogs_edge": "Aan de rand van de weergave",
|
||||
"prefs.dialogs_left_column": "In de linkerkolom",
|
||||
"prefs.dialogs_note": "Waar opdrachtdialogen openen: vastgezet aan de rand van de weergave, of in de linkerkolom in plaats van de browser en de geschiedenis, die terugkomen zoals ze waren wanneer de dialoog sluit.",
|
||||
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
|
||||
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
|
||||
"prefs.dimension_steps": "Stappen in het venster",
|
||||
"prefs.dimension_values": "Maatwaarden",
|
||||
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
|
||||
"prefs.dimension_values_view": "In de weergave",
|
||||
"prefs.dimension_values_window": "In een venster",
|
||||
"prefs.display": "Weergave",
|
||||
"prefs.edges": "Randen en schaduwing",
|
||||
"prefs.extend": "Verlengen",
|
||||
|
||||
@@ -934,8 +934,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostępności",
|
||||
"cmd.inspect_center_of_mass": "Środek masy",
|
||||
"cmd.inspect_clearance": "Prześwit",
|
||||
"cmd.inspect_colored_components": "Włącz kolory komponentów",
|
||||
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
|
||||
"cmd.inspect_compare": "Porównaj bryły",
|
||||
"cmd.inspect_continuity": "Analiza ciągłości",
|
||||
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
|
||||
@@ -1347,6 +1345,7 @@
|
||||
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
|
||||
"document.save_changes": "Zapisać zmiany w {name}?",
|
||||
"document.save_copy": "Zapisz kopię…",
|
||||
"document.tab_unsaved_changes": "Niezapisane zmiany",
|
||||
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
|
||||
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
|
||||
"drive.stopped_by_contact": "Zatrzymano przy {value}: {a} przeszedłby przez {b}",
|
||||
@@ -1985,6 +1984,8 @@
|
||||
"prefs.shortcuts": "Skróty",
|
||||
"prefs.single_surface_analysis": "Używaj jednej analizy powierzchni",
|
||||
"prefs.single_surface_analysis_help": "Gdy wyłączone, cieniowanie powierzchni i Ciągłość mogą być włączone jednocześnie. Włączenie zachowuje ostatnio uruchomioną lub zmienioną analizę.",
|
||||
"prefs.sketch.ellipse_axes": "Rysuj elipsy z osiami",
|
||||
"prefs.sketch.ellipse_axes_note": "Oś wielka i mała jako linie konstrukcyjne, aby można było wymiarować półosie i osie. Wyłączone: sama elipsa, a D na niej daje promień małej półosi, gdziekolwiek przeciągnąć wartość, rysowany i oznaczony R jak dotąd.",
|
||||
"prefs.sketch_align_points": "Wyrównuj do innych punktów szkicu podczas rysowania",
|
||||
"prefs.sketch_align_points_note": "Podczas rysowania punkt wyrównuje się jednocześnie poziomo z jednym punktem szkicu i pionowo z innym, z kropkowaną linią pomocniczą, a kliknięcie zachowuje oba wyrównania. Wyłączone: tylko z poprzednim punktem, po jednej osi.",
|
||||
"prefs.sketch_conflict_colour": "Geometria w konflikcie w kolorze konfliktu",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza accesibilității",
|
||||
"cmd.inspect_center_of_mass": "Centru de masă",
|
||||
"cmd.inspect_clearance": "Joc",
|
||||
"cmd.inspect_colored_components": "Activează culorile componentelor",
|
||||
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
|
||||
"cmd.inspect_compare": "Compară corpurile",
|
||||
"cmd.inspect_compare_picture": "Compară imaginea",
|
||||
"cmd.inspect_continuity": "Analiza continuității",
|
||||
|
||||
@@ -182,6 +182,8 @@
|
||||
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
|
||||
"Diameter": "Диаметр",
|
||||
"Did this assembly open the wrong way up?": "Эта сборка открылась не той стороной вверх?",
|
||||
"Did this part open the wrong way up?": "Эта деталь открылась не той стороной вверх?",
|
||||
"Dimension new shapes": "Проставлять размеры новых фигур",
|
||||
"Direction": "Направление",
|
||||
"Discard": "Отменить",
|
||||
@@ -606,6 +608,7 @@
|
||||
"Square": "Квадрат",
|
||||
"Stand it up": "Поставить вертикально",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
|
||||
"Stand this assembly up?": "Поставить эту сборку вертикально?",
|
||||
"Stand this part up?": "Поставить эту деталь вертикально?",
|
||||
"Start angle": "Начальный угол",
|
||||
"Start bulge": "Выпуклость в начале",
|
||||
@@ -953,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Анализ доступности",
|
||||
"cmd.inspect_center_of_mass": "Центр масс",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_colored_components": "Включить цвета компонентов",
|
||||
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
|
||||
"cmd.inspect_compare": "Сравнить тела",
|
||||
"cmd.inspect_compare_picture": "Сравнить с изображением",
|
||||
"cmd.inspect_continuity": "Анализ непрерывности",
|
||||
@@ -1356,6 +1357,7 @@
|
||||
"dialog.dimension_insert_hint": "Дважды щёлкните другой управляющий размер, чтобы вставить его имя",
|
||||
"dialog.dimension_name": "Размер: {name}",
|
||||
"dialog.edit_title": "ИЗМЕНИТЬ: {title}",
|
||||
"dialog.no_shown_body": "Нет отображаемого тела для изменения: покажите тело или выберите «Новое тело»",
|
||||
"dialog.pattern_sketch_not_object": "Выберите тела или операции: эскиз — ни то, ни другое. Повторите его кривые собственным инструментом «Массив» или «Зеркало» в эскизе либо сначала выдавите его.",
|
||||
"dialog.select": "↖ Выбрать",
|
||||
"dialog.selected_count": "↖ Выбрано: {count}",
|
||||
@@ -1717,6 +1719,8 @@
|
||||
"help.view.spin": "Непрерывно вращает вид вокруг модели до следующего щелчка или нажатия клавиши. Используйте, чтобы показать модель со всех сторон.",
|
||||
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
|
||||
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
|
||||
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"history.sketch_under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"hole_presets.confirm_delete": "Подтвердить удаление",
|
||||
"hole_presets.delete": "Удалить",
|
||||
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
|
||||
@@ -2128,6 +2132,8 @@
|
||||
"prefs.anti_aliasing_note": "Сглаживает неровные края.",
|
||||
"prefs.applies_on_restart": "Применяется после перезапуска",
|
||||
"prefs.assistant": "Ассистент",
|
||||
"prefs.auto_operation": "Выбирать операцию автоматически",
|
||||
"prefs.auto_operation_note": "Выбор «Объединить», «Новое тело» или «Вырезать» по тому, чего касается материал.",
|
||||
"prefs.axes": "Оси",
|
||||
"prefs.axis": "Ось",
|
||||
"prefs.bodies_folders": "Папки тел",
|
||||
@@ -2349,6 +2355,8 @@
|
||||
"prefs.sketch_selection": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_heading": "Эскиз и выделение",
|
||||
"prefs.sketch_selection_note": "Как работает привязка в эскизах и как щелчки выделяют объекты.",
|
||||
"prefs.sketch_state_icons": "Значки эскизов в цвете их состояния",
|
||||
"prefs.sketch_state_icons_note": "В истории и браузере значок эскиза принимает цвет его состояния (недоопределён, полностью определён или с конфликтом), а подсказка называет это состояние. Выкл.: у всех эскизов одинаковый значок.",
|
||||
"prefs.sketch_tab_always": "Всегда показывать вкладку «Эскиз»",
|
||||
"prefs.sketch_tab_always_note": "Вкладка «Эскиз» остаётся на ленте сразу после «Модель», пока не открыт ни один эскиз. Её инструменты рисования создают эскиз на выбранной плоскости или грани либо на плоскости, которую вы выберете следом, и сразу включают нужный инструмент.",
|
||||
"prefs.sketch_tracking_guides": "Направляющие выравнивания по центру и середине",
|
||||
@@ -2360,6 +2368,11 @@
|
||||
"prefs.source": "Источник",
|
||||
"prefs.speed": "Скорость",
|
||||
"prefs.stand_up_note": "После открытия файла STEP или IGES с осью Z вверх предлагать повернуть его на четверть оборота вокруг X.",
|
||||
"prefs.stand_up_unknown": "Для файлов из неизвестных программ",
|
||||
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
|
||||
"prefs.stand_up_unknown.note": "Показывать уведомление",
|
||||
"prefs.stand_up_unknown.off": "Не предлагать",
|
||||
"prefs.stand_up_unknown_note": "Как делается предложение, когда в файле не указана известная программа.",
|
||||
"prefs.status_bar": "Строка состояния",
|
||||
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
|
||||
"prefs.status_done": "Готово",
|
||||
@@ -3863,7 +3876,7 @@
|
||||
"status.navigation": "Назначение кнопок мыши: {mapping}",
|
||||
"status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение",
|
||||
"status.under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить",
|
||||
"the angle must be a number": "угол должен быть числом",
|
||||
"the curves must be in one sketch": "кривые должны быть в одном эскизе",
|
||||
@@ -3902,8 +3915,10 @@
|
||||
"timeline.move_to_group": "Переместить операции в папку",
|
||||
"timeline.rename_group": "Переименовать папку операций",
|
||||
"timeline.reorder_many": "Изменить порядок операций",
|
||||
"timeline.suppress_features": "Подавить операции",
|
||||
"timeline.suppress_folders_tip": "Подавить или снять подавление со всех операций в папках и вложенных папках",
|
||||
"timeline.ungroup": "Убрать папки операций",
|
||||
"timeline.unsuppress_features": "Снять подавление с операций",
|
||||
"to the face": "до грани",
|
||||
"to the next face": "до следующей грани",
|
||||
"toolbar.customize_note": "Вкладка {tab}. Отметьте команду, чтобы добавить её кнопку на панель инструментов. Можно также перетащить строку из выпадающего меню панели на панель инструментов, перетащить кнопку за пределы панели для удаления или использовать значок закрепления в строке выпадающего меню.",
|
||||
@@ -4349,6 +4364,8 @@
|
||||
"ui.cancel": "Отмена",
|
||||
"ui.canvases": "Подложки",
|
||||
"ui.center": "Центр",
|
||||
"ui.center_arc_follows_cursor": "Дуга из центра следует направлению курсора",
|
||||
"ui.center_arc_follows_cursor_tip": "Дуга из центра идёт по часовой стрелке или против неё — в ту сторону, в которую курсор обходит её центр. Выкл.: всегда против часовой стрелки от начальной точки.",
|
||||
"ui.centerline": "Осевая линия",
|
||||
"ui.chain": "Цепочка",
|
||||
"ui.chamfer": "Фаска",
|
||||
@@ -5160,11 +5177,13 @@
|
||||
"update.installed": "SolidCraft {version} установлен. Перезапустите, чтобы использовать его.",
|
||||
"update.installer_started": "Установщик запустится после закрытия SolidCraft.",
|
||||
"update.later": "Напомнить позже",
|
||||
"update.managed": "Доступно обновление: обновите SolidCraft в {manager}",
|
||||
"update.no_notes": "У этого выпуска нет примечаний.",
|
||||
"update.package_hint": "Эта копия установлена из {kind}. Скачайте новый пакет, затем установите его командой:",
|
||||
"update.prefs_dev": "Это сборка для разработки ({version}): она не проверяет обновления.",
|
||||
"update.prefs_flatpak": "Эту копию ({version}) обновляет Flatpak: выполните {command} или воспользуйтесь центром приложений. SolidCraft не проверяет обновления.",
|
||||
"update.prefs_label": "Обновления",
|
||||
"update.prefs_managed": "Это SolidCraft {version}, которым управляет {manager}. Проверка запрашивает у GitHub последний релиз и больше ничего не отправляет; обновляйте SolidCraft в {manager}.",
|
||||
"update.prefs_note": "Это SolidCraft {version} ({kind}). При проверке у GitHub запрашивается последний выпуск, больше ничего не отправляется.",
|
||||
"update.prefs_web": "Перезагрузите страницу, чтобы получить последнюю версию веб-приложения. Если сеть недоступна или обновление не успевает загрузиться, открывается сохранённая версия; незавершённая загрузка продолжится до следующей перезагрузки.",
|
||||
"update.refresh_checking": "Проверка наличия новейшего выпуска…",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Анализа доступности",
|
||||
"cmd.inspect_center_of_mass": "Центар масе",
|
||||
"cmd.inspect_clearance": "Провера зазора",
|
||||
"cmd.inspect_colored_components": "Омогући боје компоненти",
|
||||
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
|
||||
"cmd.inspect_compare": "Упореди тела",
|
||||
"cmd.inspect_compare_picture": "Упореди слику",
|
||||
"cmd.inspect_continuity": "Анализа континуитета",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
|
||||
"cmd.inspect_center_of_mass": "Masscentrum",
|
||||
"cmd.inspect_clearance": "Frigång",
|
||||
"cmd.inspect_colored_components": "Aktivera komponentfärger",
|
||||
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
|
||||
"cmd.inspect_compare": "Jämför kroppar",
|
||||
"cmd.inspect_compare_picture": "Jämför bild",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalys",
|
||||
@@ -1392,6 +1390,7 @@
|
||||
"document.read_only_save": "Den här konstruktionen öppnades skrivskyddad: spara den som en ny fil",
|
||||
"document.save_changes": "Spara ändringar i {name}?",
|
||||
"document.save_copy": "Spara en kopia…",
|
||||
"document.tab_unsaved_changes": "Osparade ändringar",
|
||||
"document.unsaved_changes": "{name} har osparade ändringar. Om du inte sparar går de förlorade.",
|
||||
"drive.rigid": "{joint} är ett stelt led: det håller sina delar stilla och har därför inget att driva. Om delarna ska kunna röra sig, ändra ledets typ i Redigera led: Roterande för att rotera, Glidande för att glida, Cylindrisk för båda.",
|
||||
"drive.stopped_by_contact": "Stoppade vid {value}: {a} skulle gå igenom {b}",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
|
||||
"cmd.inspect_center_of_mass": "Kütle merkezi",
|
||||
"cmd.inspect_clearance": "Açıklık",
|
||||
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
|
||||
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
|
||||
"cmd.inspect_compare": "Gövdeleri karşılaştır",
|
||||
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
|
||||
"cmd.inspect_continuity": "Süreklilik kontrolü",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "Dưới cùng bên phải",
|
||||
"Boundary": "Biên dạng bao",
|
||||
"Bounding box": "Hộp bao ngoài",
|
||||
"Bridge": "Cầu nối",
|
||||
"CHECK": "KIỂM TRA",
|
||||
"COMPONENTS": "CHI TIẾT",
|
||||
"CONFIGURATIONS": "CẤU HÌNH",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "Phân tích khung nhìn",
|
||||
"cmd.inspect_center_of_mass": "Trọng tâm",
|
||||
"cmd.inspect_clearance": "Khoảng cách an toàn",
|
||||
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components": "Màu cụm chi tiết",
|
||||
"cmd.inspect_compare": "So sánh các thân khối",
|
||||
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
|
||||
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"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.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",
|
||||
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ Trục tùy chỉnh",
|
||||
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
|
||||
"dialog.dimension_name": "Kích thước: {name}",
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
|
||||
"dialog.dimension_step_up": "Tăng {step}",
|
||||
"dialog.edit_title": "CHỈNH SỬA {title}",
|
||||
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
|
||||
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ {count} đã chọn",
|
||||
"dialog.selection_constraint": "Ràng buộc {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · đường cong {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Chọn một mục đầu vào để thay đổi. Nhấp vào đối tượng đã chọn để bỏ chọn; Ctrl/Cmd-nhấp để bật/tắt chọn. Xem trước kết quả, sau đó nhấn OK.",
|
||||
"dialog.selection_midpoint": "Trung điểm · {id}",
|
||||
"dialog.selection_profile": "{sketch} · biên dạng {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "Thiết kế này được mở ở chế độ chỉ đọc: hãy lưu thành tệp mới",
|
||||
"document.save_changes": "Lưu các thay đổi đối với {name}?",
|
||||
"document.save_copy": "Lưu bản sao…",
|
||||
"document.tab_unsaved_changes": "Thay đổi chưa lưu",
|
||||
"document.unsaved_changes": "{name} có các thay đổi chưa lưu. Nếu không lưu, các thay đổi sẽ bị mất.",
|
||||
"drive.rigid": "{joint} là khớp nối cứng: nó giữ cố định các chi tiết, nên không có gì để dẫn động. Để các chi tiết chuyển động, hãy đổi loại khớp nối trong “Chỉnh sửa khớp nối”: Revolute để xoay, Slider để trượt, Cylindrical cho cả hai.",
|
||||
"drive.stopped_by_contact": "Đã dừng tại {value}: {a} sẽ xuyên qua {b}",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "các đường cong của mỗi đường dẫn hướng phải nằm trong cùng một phác thảo",
|
||||
"feature computation ended without a result": "việc tính toán đặc trưng kết thúc mà không có kết quả",
|
||||
"file.open.drawing_filter": "Thiết kế, tệp CAD, lưới đa giác và bản vẽ",
|
||||
"file.open.portal_fallback.no_portal_service": "Bộ chọn tệp đã mở không phải của riêng desktop: dịch vụ chọn tệp của desktop không khả dụng.",
|
||||
"file.open.portal_fallback.no_session_bus": "Bộ chọn tệp đã mở không phải của riêng desktop: không có bus phiên D-Bus.",
|
||||
"file.open.portal_fallback.xwayland": "Bộ chọn tệp đã mở không phải của riêng desktop: dưới XWayland, portal từ chối cửa sổ cha.",
|
||||
"file_load.built": "Đã dựng được: {built} trên {total} tính năng.",
|
||||
"file_load.converting": "Đang chuyển đổi khối đặc {done} trên {total}",
|
||||
"file_load.inserting": "Đang chèn hình học",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "Các vật liệu khác nhau",
|
||||
"measure.at": "Tại",
|
||||
"measure.item": "Mục {number}",
|
||||
"measure.line_to_centre": "Từ đường đến tâm",
|
||||
"measure.quick_copied": "Đã sao chép số đo",
|
||||
"measure.quick_copy": "Nhấp để sao chép số đo",
|
||||
"measure.quick_edges": "{count} cạnh",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để trượt, Ctrl+chuột giữa để thu phóng; kéo chuột phải giữ nguyên camera",
|
||||
"nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "Kéo bằng nút giữa hoặc nút phải sẽ tiếp tục xoay, di chuyển hoặc thu phóng như lúc bắt đầu cho đến khi nhả nút, dù trong lúc đó có nhấn hay nhả phím nào. Tắt: các phím đang giữ quyết định ở mọi thời điểm của thao tác kéo.",
|
||||
"nav.drag_keeps_mode.label": "Thao tác kéo giữ nguyên kiểu điều hướng lúc bắt đầu",
|
||||
"nav.free_orbit.help": "Chuột và chuột 3D xoay tự do qua cả hai cực và giữ nguyên độ lăn nghiêng (Roll). Bắt dính trục bằng phím Shift sẽ bị tắt.",
|
||||
"nav.free_orbit.label": "Xoay góc nhìn tự do",
|
||||
"nav.free_orbit.snap_help": "Chuột và chuột 3D xoay qua cả hai cực và giữ nguyên góc nghiêng cuộn. Giữ Shift trong khi kéo xoay quỹ đạo để bắt dính hướng nhìn và góc nghiêng cuộn.",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "Ở cạnh khung nhìn",
|
||||
"prefs.dialogs_left_column": "Trong cột bên trái",
|
||||
"prefs.dialogs_note": "Nơi hộp thoại lệnh mở ra: gắn ở cạnh khung nhìn, hoặc trong cột bên trái thay cho trình duyệt và Lịch sử, hai phần này trở lại như cũ khi hộp thoại đóng.",
|
||||
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
|
||||
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
|
||||
"prefs.dimension_steps": "Bước của cửa sổ",
|
||||
"prefs.dimension_values": "Giá trị kích thước",
|
||||
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
|
||||
"prefs.dimension_values_view": "Trong khung nhìn",
|
||||
"prefs.dimension_values_window": "Trong một cửa sổ",
|
||||
"prefs.display": "Hiển thị",
|
||||
"prefs.edges": "Cạnh và đổ bóng",
|
||||
"prefs.extend": "Kéo dài",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "Theo chi tiết",
|
||||
"prefs.section_hatch_one": "Một kiểu",
|
||||
"prefs.select": "Chọn",
|
||||
"prefs.select_hidden_edges": "Chọn cạnh bị che trong chế độ tô bóng có cạnh ẩn",
|
||||
"prefs.select_hidden_edges_help": "Cho phép chọn cạnh xuyên qua bề mặt khi dùng chế độ tô bóng có cạnh ẩn, kể cả cho lệnh Fillet. Tắt để giữ cách chọn của v0.30.1. Các khối đã ẩn vẫn không thể chọn.",
|
||||
"prefs.select_one_item": "Chọn một mục",
|
||||
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
|
||||
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
|
||||
@@ -2565,6 +2586,8 @@
|
||||
"prefs.sketch.click_selects": "Nhấp để chọn",
|
||||
"prefs.sketch.conflict_colour": "Màu xung đột",
|
||||
"prefs.sketch.drawing": "Vẽ",
|
||||
"prefs.sketch.ellipse_axes": "Vẽ hình elip kèm các trục",
|
||||
"prefs.sketch.ellipse_axes_note": "Trục lớn và trục nhỏ dưới dạng đường gióng dựng hình, để có thể ghi kích thước bán trục và trục. Tắt: chỉ vẽ hình elip, và D trên nó cho bán kính nhỏ dù kéo giá trị đến đâu, được vẽ và ghi nhãn R như trước.",
|
||||
"prefs.sketch.exit_keep": "Giữ nguyên",
|
||||
"prefs.sketch.exit_previous": "Trở về góc nhìn trước đó",
|
||||
"prefs.sketch.exit_unless_rotated": "Trừ khi đã xoay",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "Còn {count} bậc tự do",
|
||||
"sketch.dof_left_one": "Còn {count} bậc tự do",
|
||||
"sketch.draw_curves": "Vẽ dưới dạng {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "Chuyển đổi vùng chọn sang {linetype}",
|
||||
"sketch.open_ends": "Đầu hở: {count}",
|
||||
"sketch.status_conflict": "ràng buộc xung đột",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "Hướng mô hình hóa mặc định",
|
||||
"ui.delete": "Xóa",
|
||||
"ui.delete_select_first": "Hãy chọn trước những gì cần xóa: tính năng, khối, thành phần hoặc hình học phác thảo",
|
||||
"ui.dependents_select_first": "Hãy chọn trước đối tượng để liệt kê các phần phụ thuộc của nó",
|
||||
"ui.depth": "Độ sâu",
|
||||
"ui.design": "Thiết kế",
|
||||
"ui.design_properties.author": "Tác giả",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "DỰNG HÌNH",
|
||||
"ui.heading_create": "TẠO MỚI",
|
||||
"ui.heading_create_sketch": "TẠO PHÁC THẢO",
|
||||
"ui.heading_custom_view_navigator": "KHỐI ĐIỀU HƯỚNG GÓC NHÌN TÙY CHỈNH",
|
||||
"ui.heading_cylinder": "HÌNH TRỤ",
|
||||
"ui.heading_design": "THIẾT KẾ",
|
||||
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
|
||||
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
|
||||
"ui.report_a_bug": "Báo cáo lỗi",
|
||||
"ui.report_characters_left_one": "Còn {count} ký tự",
|
||||
"ui.report_characters_left_other": "Còn {count} ký tự",
|
||||
"ui.reset": "Đặt lại",
|
||||
"ui.reset_all": "Đặt lại tất cả",
|
||||
"ui.reset_the_settings_on_this_page": "Đặt lại các cài đặt trên trang này",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "các giá trị phải là số hoặc biểu thức",
|
||||
"view.auto_ortho_faces": "Phối cảnh khi xoay quỹ đạo, trực giao khi nhìn trực diện mặt",
|
||||
"view.render_inspector": "Bảng thuộc tính kết xuất",
|
||||
"view_gizmo.arrows": "Mũi tên xoay",
|
||||
"view_gizmo.home": "Nút góc nhìn mặc định (Home)",
|
||||
"view_gizmo.no_such_body": "Thiết kế này không có thân khối nào mang tên đó.",
|
||||
"view_gizmo.note": "Chọn một thân khối trong khung nhìn. Thân khối được lưu cùng tùy chọn và hồ sơ cài đặt của bạn, và hiển thị trong mọi thiết kế. Nhấp vào một mặt của nó để nhìn thẳng vào mặt đó.",
|
||||
"view_gizmo.texture": "Kết cấu",
|
||||
"view_gizmo.themed": "Theo chủ đề",
|
||||
"view_gizmo.too_detailed": "Quá chi tiết cho khối điều hướng góc nhìn (hơn 10.000 tam giác)",
|
||||
"view_gizmo.too_thin": "Quá dài và mảnh cho khối điều hướng góc nhìn: cạnh mỏng nhất phải ít nhất bằng một phần năm cạnh dài nhất",
|
||||
"web.files.insert_drawings_hint": "Tệp CAD, lưới đa giác, DXF hoặc SVG vào thiết kế đang mở",
|
||||
"web.files.insert_stored_drawings_hint": "Vào thiết kế đang mở (tệp CAD, lưới đa giác, DXF, SVG)",
|
||||
"web.open_full": "Mở trong SolidCraft",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "可達性分析",
|
||||
"cmd.inspect_center_of_mass": "質心",
|
||||
"cmd.inspect_clearance": "間隙",
|
||||
"cmd.inspect_colored_components": "啟用元件顏色",
|
||||
"cmd.inspect_colored_components_disable": "停用元件顏色",
|
||||
"cmd.inspect_compare": "比較實體",
|
||||
"cmd.inspect_compare_picture": "與圖片比較",
|
||||
"cmd.inspect_continuity": "連續性分析",
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"Bottom Right": "右下",
|
||||
"Boundary": "边界",
|
||||
"Bounding box": "边界框",
|
||||
"Bridge": "桥接",
|
||||
"CHECK": "检查",
|
||||
"COMPONENTS": "组件",
|
||||
"CONFIGURATIONS": "配置",
|
||||
@@ -963,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "视图分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_clearance": "间隙",
|
||||
"cmd.inspect_colored_components": "启用组件颜色",
|
||||
"cmd.inspect_colored_components_disable": "禁用组件颜色",
|
||||
"cmd.inspect_colored_components": "组件颜色",
|
||||
"cmd.inspect_compare": "比较实体",
|
||||
"cmd.inspect_compare_picture": "比较参考图",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
@@ -1296,6 +1296,7 @@
|
||||
"cmd.timeline_stop": "停止历史播放",
|
||||
"cmd.timeline_suppress": "抑制特征",
|
||||
"cmd.ui.assistant": "切换助手显示",
|
||||
"cmd.view.navigator": "自定义视图导航器",
|
||||
"cmd.view.render_browser": "渲染浏览器",
|
||||
"cmd.view.render_dock": "移动渲染停靠面板",
|
||||
"cmd.view.render_focus": "渲染专注视图",
|
||||
@@ -1396,6 +1397,9 @@
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
|
||||
"dialog.dimension_name": "尺寸:{name}",
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
|
||||
"dialog.dimension_step_up": "增加 {step}",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
|
||||
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
|
||||
@@ -1403,6 +1407,7 @@
|
||||
"dialog.selected_count": "↖ 已选择 {count} 个",
|
||||
"dialog.selection_constraint": "约束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲线 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · 轮廓 {number}",
|
||||
@@ -1420,6 +1425,7 @@
|
||||
"document.read_only_save": "此设计以只读方式打开:请另存为新文件",
|
||||
"document.save_changes": "保存对 {name} 的更改?",
|
||||
"document.save_copy": "保存副本…",
|
||||
"document.tab_unsaved_changes": "未保存的更改",
|
||||
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
|
||||
"drive.rigid": "{joint} 是刚性接头:它把零件固定在原位,因此没有可驱动的运动。要让零件运动,请在“编辑接头”中更改其类型:旋转用 Revolute,滑动用 Slider,两者兼有用 Cylindrical。",
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
@@ -1440,6 +1446,9 @@
|
||||
"each rail's curves must be in one sketch": "每条导轨的曲线必须位于同一个草图中",
|
||||
"feature computation ended without a result": "特征计算结束时没有返回结果",
|
||||
"file.open.drawing_filter": "设计、CAD 文件、网格和图纸",
|
||||
"file.open.portal_fallback.no_portal_service": "打开的文件选择器并非桌面自带:桌面的文件选择服务不可用。",
|
||||
"file.open.portal_fallback.no_session_bus": "打开的文件选择器并非桌面自带:没有 D-Bus 会话总线。",
|
||||
"file.open.portal_fallback.xwayland": "打开的文件选择器并非桌面自带:在 XWayland 下,门户会拒绝父窗口。",
|
||||
"file_load.built": "已构建:{built}/{total} 个特征。",
|
||||
"file_load.converting": "正在转换实体 {done}/{total}",
|
||||
"file_load.inserting": "正在插入几何体",
|
||||
@@ -1999,6 +2008,7 @@
|
||||
"material.selection.mixed": "不同材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "项目 {number}",
|
||||
"measure.line_to_centre": "线到圆心",
|
||||
"measure.quick_copied": "已复制测量值",
|
||||
"measure.quick_copy": "点击复制测量值",
|
||||
"measure.quick_edges": "{count} 条边",
|
||||
@@ -2029,6 +2039,8 @@
|
||||
"nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机",
|
||||
"nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "中键或右键拖动会保持开始时的旋转、平移或缩放,直到松开按键,期间按下或松开任何键都不会改变。关闭后:拖动的每一刻都由当时按住的键决定。",
|
||||
"nav.drag_keeps_mode.label": "拖动保持开始时的导航方式",
|
||||
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
|
||||
"nav.free_orbit.label": "自由旋转",
|
||||
"nav.free_orbit.snap_help": "鼠标和3D鼠标可越过两极旋转,并保留画面旋转角度。在拖动环绕时按住Shift,可吸附视图方向和画面旋转角度。",
|
||||
@@ -2331,6 +2343,13 @@
|
||||
"prefs.dialogs_edge": "视图边缘",
|
||||
"prefs.dialogs_left_column": "左侧栏",
|
||||
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
|
||||
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
|
||||
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
|
||||
"prefs.dimension_steps": "窗口步长",
|
||||
"prefs.dimension_values": "尺寸值",
|
||||
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中",
|
||||
"prefs.display": "显示",
|
||||
"prefs.edges": "边缘与着色",
|
||||
"prefs.extend": "扩展",
|
||||
@@ -2541,6 +2560,8 @@
|
||||
"prefs.section_hatch_by_part": "按零件",
|
||||
"prefs.section_hatch_one": "单一样式",
|
||||
"prefs.select": "选择",
|
||||
"prefs.select_hidden_edges": "在带隐藏边线的着色视图中选择隐藏边",
|
||||
"prefs.select_hidden_edges_help": "在带隐藏边线的着色视图中,可穿过面选择边并用于圆角。关闭时保留v0.30.1的选择行为。隐藏的实体仍不可选择。",
|
||||
"prefs.select_one_item": "选择一个项目",
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
@@ -2565,6 +2586,8 @@
|
||||
"prefs.sketch.click_selects": "单击选择",
|
||||
"prefs.sketch.conflict_colour": "冲突颜色",
|
||||
"prefs.sketch.drawing": "绘制",
|
||||
"prefs.sketch.ellipse_axes": "绘制椭圆时带上轴线",
|
||||
"prefs.sketch.ellipse_axes_note": "将长轴和短轴绘制为构造线,以便标注半轴和轴。关闭:仅绘制椭圆,对其按 D 得到短半径,无论将数值拖到何处,都像以前一样绘制并标注 R。",
|
||||
"prefs.sketch.exit_keep": "保持",
|
||||
"prefs.sketch.exit_previous": "返回之前的视图",
|
||||
"prefs.sketch.exit_unless_rotated": "除非已旋转",
|
||||
@@ -4155,6 +4178,32 @@
|
||||
"sketch.dof_left": "剩余 {count} 个自由度",
|
||||
"sketch.dof_left_one": "剩余 {count} 个自由度",
|
||||
"sketch.draw_curves": "绘制为 {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.make_selection": "将选择转换为 {linetype}",
|
||||
"sketch.open_ends": "开放 {count}",
|
||||
"sketch.status_conflict": "约束冲突",
|
||||
@@ -4797,6 +4846,7 @@
|
||||
"ui.default_modelling_orientation": "默认建模方向",
|
||||
"ui.delete": "删除",
|
||||
"ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何",
|
||||
"ui.dependents_select_first": "请先选择要列出其依赖项的对象",
|
||||
"ui.depth": "深度",
|
||||
"ui.design": "设计",
|
||||
"ui.design_properties.author": "作者",
|
||||
@@ -5003,6 +5053,7 @@
|
||||
"ui.heading_construct": "构造",
|
||||
"ui.heading_create": "创建",
|
||||
"ui.heading_create_sketch": "创建草图",
|
||||
"ui.heading_custom_view_navigator": "自定义视图导航器",
|
||||
"ui.heading_cylinder": "圆柱体",
|
||||
"ui.heading_design": "设计",
|
||||
"ui.heading_draft": "拔模",
|
||||
@@ -5314,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.report_characters_left_one": "剩余 {count} 个字符",
|
||||
"ui.report_characters_left_other": "剩余 {count} 个字符",
|
||||
"ui.reset": "重置",
|
||||
"ui.reset_all": "全部重置",
|
||||
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
|
||||
@@ -5598,6 +5651,14 @@
|
||||
"values must be numbers or expressions": "值必须是数字或表达式",
|
||||
"view.auto_ortho_faces": "旋转时透视,正对表面时正交",
|
||||
"view.render_inspector": "渲染检查器",
|
||||
"view_gizmo.arrows": "旋转箭头",
|
||||
"view_gizmo.home": "主页按钮",
|
||||
"view_gizmo.no_such_body": "此设计中没有该名称的实体。",
|
||||
"view_gizmo.note": "在视图中选择一个实体。它随首选项和设置配置文件保存,并在每个设计中显示。单击它的一个面即可正视该面。",
|
||||
"view_gizmo.texture": "纹理",
|
||||
"view_gizmo.themed": "主题",
|
||||
"view_gizmo.too_detailed": "对视图导航器而言过于精细(超过 10,000 个三角形)",
|
||||
"view_gizmo.too_thin": "对视图导航器而言过于细长:最薄的一边至少要有最长一边的五分之一",
|
||||
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
|
||||
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
|
||||
"web.open_full": "在 SolidCraft 中打开",
|
||||
|
||||
@@ -366,6 +366,13 @@ fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
|
||||
return opaque_depth(i, front);
|
||||
}
|
||||
|
||||
// The virtual ground preserves the viewport background; only contact occlusion is blended.
|
||||
@fragment
|
||||
fn fs_ground_ao(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 1.0 - occlusion(i));
|
||||
}
|
||||
|
||||
/// The AO prepass's depth of an opaque surface's fragment.
|
||||
fn opaque_depth(i: TOut, front: bool) -> AoDepth {
|
||||
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
|
||||
@@ -802,7 +809,9 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
let sb = cb.xy / max(cb.w, 1e-6) * half;
|
||||
var dir = sb - sa;
|
||||
let len = length(dir);
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
|
||||
// Intel OpenGL can miscompile vector/scalar division here (#8053). Scalar
|
||||
// quotients keep the same direction and the fallback for a point.
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = vec2<f32>(dir.x / len, dir.y / len); }
|
||||
let nrm = vec2<f32>(-dir.y, dir.x);
|
||||
// Two triangles: corners (t, side).
|
||||
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
{
|
||||
"order": 260,
|
||||
"provenance": "Original SolidCraft stone-grey palette and semantic icon-role mapping",
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Onyx",
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"accent": "#9eafbd",
|
||||
"accent_soft": "#3b4852",
|
||||
"agent_busy": "#9eafbd",
|
||||
"agent_ok": "#a5b9a5",
|
||||
"app_bar": "#1c1e20",
|
||||
"app_bar_hover": "#dededc16",
|
||||
"app_bar_hover_button": "#dededc1c",
|
||||
"app_bar_hover_strong": "#dededc28",
|
||||
"app_bar_hover_subtle": "#dededc0c",
|
||||
"app_bar_separator": "#383c3f",
|
||||
"app_bar_tab_active": "#303235",
|
||||
"app_bar_text": "#dededc",
|
||||
"axis_x": "#d7938f",
|
||||
"axis_y": "#a5b9a5",
|
||||
"axis_z": "#98b2c8",
|
||||
"bend_down": "#b8a7c3",
|
||||
"bend_up": "#a5b9a5",
|
||||
"body": "#93989c",
|
||||
"body_edge": "#60666b",
|
||||
"body_selected": "#3b4852",
|
||||
"border": "#3d4145",
|
||||
"box_window": "#9eafbd",
|
||||
"chip": "#758b9b",
|
||||
"construction_geom": "#b9a78e",
|
||||
"construction_plane": "#b9a78e24",
|
||||
"continuity": [
|
||||
"#a5b9a5",
|
||||
"#98b2c8",
|
||||
"#c7b28b",
|
||||
"#b8a7c3"
|
||||
],
|
||||
"control_border": "#4a4f54",
|
||||
"control_fill": "#303235",
|
||||
"cube_border": "#50565b",
|
||||
"cube_face": "#34373a",
|
||||
"cube_hover": "#53616c",
|
||||
"cube_text": "#dededc",
|
||||
"dialog_bg": "#2b2d2f",
|
||||
"dialog_body": "#2b2d2f",
|
||||
"dialog_border": "#3d4145",
|
||||
"dialog_footer": "#27292b",
|
||||
"dialog_label": "#dededc",
|
||||
"dialog_rule": "#383c3f",
|
||||
"dimension": "#c7b28b",
|
||||
"dimension_driven": "#a59474",
|
||||
"error": "#d7938f",
|
||||
"field": "#1c1e20",
|
||||
"form_accent": "#9d8faa",
|
||||
"form_dim": "#c6c8c9",
|
||||
"form_error": "#d7938f",
|
||||
"form_error_fill": "#493334",
|
||||
"form_fill": "#b9abc5",
|
||||
"form_ink": "#dededc",
|
||||
"form_primary": "#9eafbd",
|
||||
"form_primary_fill": "#9eafbd",
|
||||
"form_soft": "#343039",
|
||||
"form_success": "#a5b9a5",
|
||||
"form_success_fill": "#344139",
|
||||
"form_tab_ink": "#b9abc5",
|
||||
"glyph_bg": "#34373a",
|
||||
"glyph_edge": "#d7938f",
|
||||
"glyph_text": "#d7938f",
|
||||
"grid": "#252729",
|
||||
"grid_major": "#2e3235",
|
||||
"hover": "#3b4852",
|
||||
"hover_edge": "#b1c1ce",
|
||||
"hover_edge_halo": "#181a1c",
|
||||
"hover_profile": "#9eafbd30",
|
||||
"hover_profile_edge": "#b1c1ce",
|
||||
"icon": "#dededc",
|
||||
"icons.agent": "#b8a7c3",
|
||||
"icons.highlight": "#dededc",
|
||||
"icons.marker_halo": "#1c1e20cc",
|
||||
"interference": "#d7938f",
|
||||
"joint_axis": "#98b2c8",
|
||||
"joint_manipulator": "#9eafbd",
|
||||
"logo": [
|
||||
"#c9d4dc",
|
||||
"#9eafbd",
|
||||
"#647986"
|
||||
],
|
||||
"manipulator": "#9eafbd",
|
||||
"mesh_accent": "#a29772",
|
||||
"mesh_fill": "#beb48d",
|
||||
"mesh_ink": "#beb48d",
|
||||
"mesh_soft": "#34332b",
|
||||
"nav_dim": "#b5b7b8",
|
||||
"nav_field": "#1c1e20",
|
||||
"nav_hover": "#43494f",
|
||||
"nav_press": "#59636c",
|
||||
"nav_tip": "#2b2d2f",
|
||||
"nav_tip_ink": "#dededc",
|
||||
"nav_tip_key_border": "#3b3e41",
|
||||
"nav_viewport": "#27292b",
|
||||
"on_accent": "#17191b",
|
||||
"origin_plane": "#b9a78e2c",
|
||||
"origin_plane_edge": "#b9a78e94",
|
||||
"origin_plane_hover": "#9eafbd60",
|
||||
"origin_point": "#b5b7b8",
|
||||
"overlay": "#2b2d2f",
|
||||
"panel": "#2b2d2f",
|
||||
"panel_header": "#303235",
|
||||
"preview_add": "#9eafbd",
|
||||
"preview_cut": "#d7938f68",
|
||||
"preview_new": "#c7b28b",
|
||||
"preview_sketch": "#a5b9a5",
|
||||
"profile_fill": "#9eafbd28",
|
||||
"render_accent": "#a88e88",
|
||||
"render_fill": "#c1a49e",
|
||||
"render_ink": "#c1a49e",
|
||||
"render_soft": "#38302f",
|
||||
"rubber_band": "#9eafbd",
|
||||
"section_cap": "#c7b28b",
|
||||
"sel_edge": "#b1c1ce",
|
||||
"sel_edge_rim": "#d0d9df",
|
||||
"sel_face": "#3b4852",
|
||||
"sel_profile": "#3b4852",
|
||||
"sel_profile_edge": "#b1c1ce",
|
||||
"sel_vertex": "#9eafbd",
|
||||
"selected_bg": "#3b4852",
|
||||
"selected_ink": "#dededc",
|
||||
"sheet_fill": "#9eb9bd",
|
||||
"sheet_ink": "#9eb9bd",
|
||||
"sheet_soft": "#29363a",
|
||||
"sketch": "#9eafbd",
|
||||
"sketch_accent": "#b2b995",
|
||||
"sketch_centerline": "#b5b7b8",
|
||||
"sketch_conflict": "#d7938f",
|
||||
"sketch_construction": "#b5b7b8",
|
||||
"sketch_fixed": "#dededc",
|
||||
"sketch_halo": "#181a1c",
|
||||
"sketch_ink": "#b2b995",
|
||||
"sketch_locked": "#a5b9a5",
|
||||
"sketch_lost": "#d7938f",
|
||||
"sketch_point": "#dededc",
|
||||
"sketch_point_free": "#9eafbd",
|
||||
"sketch_projected": "#b8a7c3",
|
||||
"sketch_soft": "#32352c",
|
||||
"surface_accent": "#a6947d",
|
||||
"surface_fill": "#c0ae96",
|
||||
"surface_ink": "#c0ae96",
|
||||
"surface_soft": "#35322e",
|
||||
"tab_on_fill_dark": "#17191b",
|
||||
"text": "#dededc",
|
||||
"text_dim": "#c6c8c9",
|
||||
"timeline": "#2b2d2f",
|
||||
"timeline_item": "#303235",
|
||||
"timeline_item_error": "#493334",
|
||||
"timeline_marker": "#b5b7b8",
|
||||
"toolbar": "#2b2d2f",
|
||||
"viewport_bottom": "#1b1d1f",
|
||||
"viewport_top": "#222426",
|
||||
"warning": "#c7b28b"
|
||||
},
|
||||
"radii": {
|
||||
"radius_sm": 3,
|
||||
"radius_md": 6,
|
||||
"radius_lg": 8,
|
||||
"radius_xl": 10,
|
||||
"control_radius": 4,
|
||||
"sketch_mode_radius": 6
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#1b1d1f",
|
||||
"line": "#d7d9d8",
|
||||
"passive-1": "#b9bdc0",
|
||||
"passive-2": "#8d9398",
|
||||
"passive-3": "#646c72",
|
||||
"guide": "#a2a8ac",
|
||||
"solid": [
|
||||
"#c9d4dc",
|
||||
"#b2c1cc",
|
||||
"#9eafbd"
|
||||
],
|
||||
"surface": [
|
||||
"#d6cab8",
|
||||
"#c0ae96",
|
||||
"#a6947d"
|
||||
],
|
||||
"mesh": [
|
||||
"#d4ccad",
|
||||
"#beb48d",
|
||||
"#a29772"
|
||||
],
|
||||
"form": [
|
||||
"#d0c5d9",
|
||||
"#b9abc5",
|
||||
"#9d8faa"
|
||||
],
|
||||
"sketch": [
|
||||
"#c9cfb3",
|
||||
"#b2b995",
|
||||
"#979f78"
|
||||
],
|
||||
"constraint": [
|
||||
"#e1b7b2",
|
||||
"#d7938f",
|
||||
"#b77875"
|
||||
],
|
||||
"construction": [
|
||||
"#d2c6b4",
|
||||
"#b9a78e",
|
||||
"#9f8c73"
|
||||
]
|
||||
},
|
||||
"options": [
|
||||
{
|
||||
"id": "opacity",
|
||||
"type": "range",
|
||||
"default": 80,
|
||||
"min": 60,
|
||||
"max": 100
|
||||
},
|
||||
{
|
||||
"id": "blur",
|
||||
"type": "range",
|
||||
"default": 60,
|
||||
"max": 60
|
||||
},
|
||||
{
|
||||
"id": "tab_colours",
|
||||
"type": "bool",
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"id": "tab_fade_ms",
|
||||
"type": "range",
|
||||
"default": 240
|
||||
},
|
||||
{
|
||||
"id": "accent",
|
||||
"type": "choice",
|
||||
"default": "slate",
|
||||
"label": {
|
||||
"en": "Accent",
|
||||
"de": "Akzent",
|
||||
"ja": "アクセント",
|
||||
"ko": "강조색",
|
||||
"zh": "强调色"
|
||||
},
|
||||
"help": {
|
||||
"en": "Selection, highlights and Model controls; workspace and status colours keep their meaning.",
|
||||
"de": "Auswahl, Hervorhebungen und Modell-Steuerung; Arbeitsbereichs- und Statusfarben behalten ihre Bedeutung.",
|
||||
"ja": "選択・強調・モデルの操作色。ワークスペースや状態の色の意味は変えません。",
|
||||
"ko": "선택, 강조 및 모델 조작의 색상입니다. 작업 공간과 상태 색상의 의미는 유지됩니다.",
|
||||
"zh": "更改选择、高亮与模型控件的色调;工作空间和状态色保持原有含义。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "slate",
|
||||
"label": {
|
||||
"en": "Slate blue",
|
||||
"de": "Schieferblau",
|
||||
"ja": "石板の青",
|
||||
"ko": "슬레이트 블루",
|
||||
"zh": "岩蓝"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "silver",
|
||||
"label": {
|
||||
"en": "Silver",
|
||||
"de": "Silber",
|
||||
"ja": "シルバー",
|
||||
"ko": "은색",
|
||||
"zh": "银灰"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sage",
|
||||
"label": {
|
||||
"en": "Sage",
|
||||
"de": "Salbei",
|
||||
"ja": "セージ",
|
||||
"ko": "세이지",
|
||||
"zh": "鼠尾草绿"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "contrast",
|
||||
"type": "choice",
|
||||
"default": "soft",
|
||||
"label": {
|
||||
"en": "Contrast",
|
||||
"de": "Kontrast",
|
||||
"ja": "コントラスト",
|
||||
"ko": "대비",
|
||||
"zh": "对比度"
|
||||
},
|
||||
"help": {
|
||||
"en": "Soft stone layers or stronger separation between panels and fields.",
|
||||
"de": "Weiche Steinschichten oder stärkere Trennung von Flächen und Feldern.",
|
||||
"ja": "柔らかな石の階調か、パネルと入力欄を明確に分ける階調を選びます。",
|
||||
"ko": "부드러운 석재 톤 또는 패널과 입력란을 더 뚜렷하게 구분합니다.",
|
||||
"zh": "选择柔和的石色层次,或增强面板与输入框的区分。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "soft",
|
||||
"label": {
|
||||
"en": "Soft",
|
||||
"de": "Weich",
|
||||
"ja": "ソフト",
|
||||
"ko": "부드럽게",
|
||||
"zh": "柔和"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "defined",
|
||||
"label": {
|
||||
"en": "Defined",
|
||||
"de": "Deutlich",
|
||||
"ja": "くっきり",
|
||||
"ko": "뚜렷하게",
|
||||
"zh": "分明"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brightness",
|
||||
"type": "range",
|
||||
"effect": "surface_brightness",
|
||||
"default": 100,
|
||||
"min": 60,
|
||||
"max": 120,
|
||||
"step": 1,
|
||||
"unit": "%",
|
||||
"label": {
|
||||
"en": "Brightness",
|
||||
"de": "Helligkeit",
|
||||
"ja": "明るさ",
|
||||
"ko": "밝기",
|
||||
"zh": "亮度"
|
||||
},
|
||||
"help": {
|
||||
"en": "Adjust stone surfaces, viewport backgrounds and grid brightness. 100% keeps the authored tones; text and semantic colours stay unchanged.",
|
||||
"de": "Helligkeit von Steinflächen, Ansichtsflächen und Raster. 100% erhält die ursprünglichen Töne; Text und Bedeutungsfarben bleiben unverändert.",
|
||||
"ja": "石調の面・ビューポート背景・グリッドの明るさを調整します。100%は元の階調です。文字と意味のある色は変えません。",
|
||||
"ko": "석재 표면·뷰포트 배경·격자의 밝기를 조절합니다. 100%는 원래 톤을 유지하며 글자와 의미 색상은 바꾸지 않습니다.",
|
||||
"zh": "调整石色表面、视口背景和网格的亮度。100%保留原有色调;文字和语义颜色保持不变。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "foreground_colours",
|
||||
"type": "choice",
|
||||
"default": "vibrant",
|
||||
"label": {
|
||||
"en": "Foreground colours",
|
||||
"de": "Vordergrundfarben",
|
||||
"ja": "前景の色",
|
||||
"ko": "전경 색상",
|
||||
"zh": "前景颜色"
|
||||
},
|
||||
"help": {
|
||||
"en": "Vibrant strengthens axes, icons and guides. Muted keeps the original quiet palette. Accent controls selection.",
|
||||
"de": "Leuchtend verstärkt Achsen, Symbole und Hilfen. Gedämpft behält die ruhige ursprüngliche Palette. Akzent steuert die Auswahl.",
|
||||
"ja": "鮮やかは軸・アイコン・作図ガイドを強調します。落ち着いた配色は元の控えめな色を保ちます。選択の色はアクセントで設定します。",
|
||||
"ko": "선명함은 축·아이콘·가이드를 강조합니다. 차분함은 기존의 은은한 팔레트를 유지합니다. 선택 색상은 강조색 설정을 따릅니다.",
|
||||
"zh": "鲜艳增强轴、图标和辅助线的色彩。柔和保留原有的沉静配色。选择颜色仍由强调色控制。"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
"id": "muted",
|
||||
"label": {
|
||||
"en": "Muted",
|
||||
"de": "Gedämpft",
|
||||
"ja": "落ち着いた",
|
||||
"ko": "차분함",
|
||||
"zh": "柔和"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vibrant",
|
||||
"label": {
|
||||
"en": "Vibrant",
|
||||
"de": "Leuchtend",
|
||||
"ja": "鮮やか",
|
||||
"ko": "선명함",
|
||||
"zh": "鲜艳"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overlays": [
|
||||
{
|
||||
"when": {
|
||||
"opt": "contrast",
|
||||
"eq": "defined"
|
||||
},
|
||||
"colors": {
|
||||
"app_bar": "#151719",
|
||||
"panel": "#25282a",
|
||||
"dialog_bg": "#25282a",
|
||||
"dialog_body": "#25282a",
|
||||
"dialog_footer": "#202325",
|
||||
"panel_header": "#373b3e",
|
||||
"field": "#171a1c",
|
||||
"control_fill": "#383d41",
|
||||
"border": "#4e555b",
|
||||
"dialog_border": "#4e555b",
|
||||
"dialog_rule": "#444b51",
|
||||
"control_border": "#626c73",
|
||||
"text": "#eeeeec",
|
||||
"text_dim": "#ced1d2",
|
||||
"form_ink": "#eeeeec",
|
||||
"form_dim": "#ced1d2",
|
||||
"dialog_label": "#eeeeec",
|
||||
"toolbar": "#25282a",
|
||||
"timeline": "#25282a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "accent",
|
||||
"eq": "silver"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "#b7bdc2",
|
||||
"manipulator": "#b7bdc2",
|
||||
"joint_manipulator": "#b7bdc2",
|
||||
"preview_add": "#b7bdc2",
|
||||
"rubber_band": "#b7bdc2",
|
||||
"box_window": "#b7bdc2",
|
||||
"form_primary": "#b7bdc2",
|
||||
"form_primary_fill": "#b7bdc2",
|
||||
"sketch": "#b7bdc2",
|
||||
"sketch_point_free": "#b7bdc2",
|
||||
"sel_vertex": "#b7bdc2",
|
||||
"accent_soft": "#454b50",
|
||||
"hover": "#454b50",
|
||||
"selected_bg": "#454b50",
|
||||
"sel_face": "#454b50",
|
||||
"sel_profile": "#454b50",
|
||||
"body_selected": "#454b50",
|
||||
"sel_edge": "#d5dadf",
|
||||
"sel_profile_edge": "#d5dadf",
|
||||
"hover_edge": "#d5dadf",
|
||||
"hover_profile_edge": "#d5dadf",
|
||||
"sel_edge_rim": "#dde0e3",
|
||||
"hover_profile": "#b7bdc230",
|
||||
"origin_plane_hover": "#b7bdc260",
|
||||
"profile_fill": "#b7bdc228",
|
||||
"cube_hover": "#454b50",
|
||||
"chip": "#b7bdc2",
|
||||
"logo": [
|
||||
"#dde0e3",
|
||||
"#c9cfd4",
|
||||
"#b7bdc2"
|
||||
]
|
||||
},
|
||||
"icon_palette": {
|
||||
"solid": [
|
||||
"#dde0e3",
|
||||
"#c9cfd4",
|
||||
"#b7bdc2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "accent",
|
||||
"eq": "sage"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "#aabcae",
|
||||
"manipulator": "#aabcae",
|
||||
"joint_manipulator": "#aabcae",
|
||||
"preview_add": "#aabcae",
|
||||
"rubber_band": "#aabcae",
|
||||
"box_window": "#aabcae",
|
||||
"form_primary": "#aabcae",
|
||||
"form_primary_fill": "#aabcae",
|
||||
"sketch": "#aabcae",
|
||||
"sketch_point_free": "#aabcae",
|
||||
"sel_vertex": "#aabcae",
|
||||
"accent_soft": "#414d45",
|
||||
"hover": "#414d45",
|
||||
"selected_bg": "#414d45",
|
||||
"sel_face": "#414d45",
|
||||
"sel_profile": "#414d45",
|
||||
"body_selected": "#414d45",
|
||||
"sel_edge": "#c2d2c5",
|
||||
"sel_profile_edge": "#c2d2c5",
|
||||
"hover_edge": "#c2d2c5",
|
||||
"hover_profile_edge": "#c2d2c5",
|
||||
"sel_edge_rim": "#d1ddd4",
|
||||
"hover_profile": "#aabcae30",
|
||||
"origin_plane_hover": "#aabcae60",
|
||||
"profile_fill": "#aabcae28",
|
||||
"cube_hover": "#414d45",
|
||||
"chip": "#aabcae",
|
||||
"logo": [
|
||||
"#d1ddd4",
|
||||
"#becec2",
|
||||
"#aabcae"
|
||||
]
|
||||
},
|
||||
"icon_palette": {
|
||||
"solid": [
|
||||
"#d1ddd4",
|
||||
"#becec2",
|
||||
"#aabcae"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"when": {
|
||||
"opt": "foreground_colours",
|
||||
"eq": "vibrant"
|
||||
},
|
||||
"colors": {
|
||||
"axis_x": "#ff6d79",
|
||||
"axis_y": "#4fd681",
|
||||
"axis_z": "#4da7ff",
|
||||
"agent_busy": "#4da7ff",
|
||||
"agent_ok": "#4fd681",
|
||||
"warning": "#ffd166",
|
||||
"error": "#ff6d79",
|
||||
"form_error": "#ff6d79",
|
||||
"form_success": "#4fd681",
|
||||
"glyph_edge": "#ff6d79",
|
||||
"glyph_text": "#ff6d79",
|
||||
"interference": "#ff6d79",
|
||||
"sketch_conflict": "#ff6d79",
|
||||
"sketch_lost": "#ff6d79",
|
||||
"sketch_locked": "#4fd681",
|
||||
"sketch_projected": "#c78bff",
|
||||
"dimension": "#ffd166",
|
||||
"dimension_driven": "#ffb166",
|
||||
"construction_geom": "#ffb166",
|
||||
"sketch_centerline": "#b5b7b8",
|
||||
"sketch_construction": "#b5b7b8",
|
||||
"joint_axis": "#42d9dd",
|
||||
"bend_down": "#c78bff",
|
||||
"bend_up": "#4fd681",
|
||||
"preview_cut": "#ff6d7968",
|
||||
"preview_new": "#ffd166",
|
||||
"preview_sketch": "#4fd681",
|
||||
"section_cap": "#ffd166",
|
||||
"sketch_accent": "#4fd681",
|
||||
"sketch_ink": "#4fd681",
|
||||
"surface_accent": "#ffb166",
|
||||
"surface_fill": "#ffb166",
|
||||
"surface_ink": "#ffb166",
|
||||
"mesh_accent": "#ffd166",
|
||||
"mesh_fill": "#ffd166",
|
||||
"mesh_ink": "#ffd166",
|
||||
"form_accent": "#c78bff",
|
||||
"form_fill": "#c78bff",
|
||||
"form_tab_ink": "#c78bff",
|
||||
"sheet_fill": "#63cbd5",
|
||||
"sheet_ink": "#63cbd5",
|
||||
"render_accent": "#ff6d79",
|
||||
"render_fill": "#ff6d79",
|
||||
"render_ink": "#ff6d79",
|
||||
"icons.agent": "#c78bff",
|
||||
"continuity": [
|
||||
"#4fd681",
|
||||
"#4da7ff",
|
||||
"#ffd166",
|
||||
"#c78bff"
|
||||
],
|
||||
"origin_plane_edge": "#ffb16694",
|
||||
"sheet_soft": "#283a3e"
|
||||
},
|
||||
"icon_palette": {
|
||||
"ink": "#1b1d1f",
|
||||
"line": "#d7d9d8",
|
||||
"passive-1": "#d3d8dc",
|
||||
"passive-2": "#b2bbc2",
|
||||
"passive-3": "#929fa9",
|
||||
"guide": "#b4c8d6",
|
||||
"solid": [
|
||||
"#82c1ff",
|
||||
"#62b2ff",
|
||||
"#4da7ff"
|
||||
],
|
||||
"surface": [
|
||||
"#ffc894",
|
||||
"#ffba78",
|
||||
"#ffb166"
|
||||
],
|
||||
"mesh": [
|
||||
"#ffdf94",
|
||||
"#ffd778",
|
||||
"#ffd166"
|
||||
],
|
||||
"form": [
|
||||
"#d8aeff",
|
||||
"#ce99ff",
|
||||
"#c78bff"
|
||||
],
|
||||
"sketch": [
|
||||
"#84e2a7",
|
||||
"#64db90",
|
||||
"#4fd681"
|
||||
],
|
||||
"constraint": [
|
||||
"#ff99a1",
|
||||
"#ff7f89",
|
||||
"#ff6d79"
|
||||
],
|
||||
"construction": [
|
||||
"#ffc894",
|
||||
"#ffba78",
|
||||
"#ffb166"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabs": {
|
||||
"MODEL": {
|
||||
"viewport_top": "#24292d",
|
||||
"viewport_bottom": "#1b2023",
|
||||
"panel": "#303438"
|
||||
},
|
||||
"SURFACE": {
|
||||
"viewport_top": "#292622",
|
||||
"viewport_bottom": "#211e1b",
|
||||
"panel": "#36332f"
|
||||
},
|
||||
"MESH": {
|
||||
"viewport_top": "#282820",
|
||||
"viewport_bottom": "#202019",
|
||||
"panel": "#35352e"
|
||||
},
|
||||
"FORM": {
|
||||
"viewport_top": "#26232b",
|
||||
"viewport_bottom": "#1e1b22",
|
||||
"panel": "#343137"
|
||||
},
|
||||
"SHEET METAL": {
|
||||
"viewport_top": "#22282a",
|
||||
"viewport_bottom": "#1b2022",
|
||||
"panel": "#303638"
|
||||
},
|
||||
"RENDER": {
|
||||
"viewport_top": "#2b2423",
|
||||
"viewport_bottom": "#221c1b",
|
||||
"panel": "#373130"
|
||||
},
|
||||
"SKETCH": {
|
||||
"viewport_top": "#252921",
|
||||
"viewport_bottom": "#1d211a",
|
||||
"panel": "#33362f"
|
||||
}
|
||||
},
|
||||
"borders": {
|
||||
"primary_inset": {
|
||||
"width": 0.5,
|
||||
"gap": 1,
|
||||
"color": "#dce2e70e"
|
||||
}
|
||||
},
|
||||
"requires": 2
|
||||
}
|
||||
@@ -2,6 +2,46 @@
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A profile moved into the component receiving its own body is read through the recorded
|
||||
/// transfer frame. Reading its live placement would move the source geometry when that
|
||||
/// component moves, then move the transferred body a second time. All outputs of a constructor
|
||||
/// must reach the sketch's component through the same frame; independent cross-component
|
||||
/// references retain their ordinary live placement.
|
||||
pub(crate) fn profile_frame(doc: &Document, feature: &crate::Feature, sketch: u64) -> Option<Mat> {
|
||||
if !matches!(
|
||||
feature.kind,
|
||||
crate::FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
|
||||
| crate::FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
|
||||
) || feature.body_names.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let owner = doc.feature(sketch)?.component;
|
||||
if owner == feature.component {
|
||||
return None;
|
||||
}
|
||||
let mut frames: std::collections::BTreeMap<&str, (u64, Mat)> =
|
||||
feature.body_names.iter().map(|name| (name.as_str(), (feature.component, crate::IDENTITY))).collect();
|
||||
let start = doc.feature_index(feature.id)?.saturating_add(1);
|
||||
for step in doc.features.iter().take(doc.marker.unwrap_or(usize::MAX)).skip(start).filter(|step| !step.suppressed) {
|
||||
let crate::FeatureKind::BodyTransfer { bodies, from, to, transform } = &step.kind else { continue };
|
||||
for name in bodies {
|
||||
if let Some((component, frame)) = frames.get_mut(name.as_ref()) {
|
||||
if *component != *from || !valid_transfer_matrix(transform) {
|
||||
return None;
|
||||
}
|
||||
*component = *to;
|
||||
*frame = crate::mat_mul(transform, frame);
|
||||
}
|
||||
}
|
||||
if frames.values().all(|(component, _)| *component == owner) {
|
||||
let (_, first) = frames.values().next()?;
|
||||
return frames.values().all(|(_, frame)| frame == first).then(|| crate::mat_inverse(first)).flatten();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn valid_transfer_matrix(m: &Mat) -> bool {
|
||||
crate::is_placement(m)
|
||||
}
|
||||
|
||||
@@ -89,20 +89,45 @@ pub fn format_value(value: f64, kind: expr::Kind, unit: &str, decimals: u8, f: &
|
||||
if unit_kind != kind {
|
||||
return Err(DocError::Invalid("number display unit has a different kind".into()));
|
||||
}
|
||||
format_display(value / scale, scale, decimals, f)
|
||||
format_display(value / scale, scale, decimals, f, None)
|
||||
}
|
||||
|
||||
/// Format a unit-less number with the same validation and decimal rounding policy.
|
||||
pub fn format_number(x: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
format_display(x, 1.0, decimals, f)
|
||||
format_display(x, 1.0, decimals, f, None)
|
||||
}
|
||||
|
||||
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
|
||||
/// Format an internal value with a rounding step in the selected display unit.
|
||||
/// The step is a shortest round-trip decimal, finite, positive and at most 1e6.
|
||||
/// Input decimal preparation precedes step rounding; requested decimal precision follows it.
|
||||
/// Both rounding operations use `f.ties`. The original value is never changed.
|
||||
/// The exact snapped display magnitude must be at most 1e12. An out-of-range snap
|
||||
/// returns [`DocError::Invalid`] before final decimal-place rounding, even if that
|
||||
/// final rounding would bring the displayed value back into range.
|
||||
pub fn format_value_with_step(value: f64, kind: expr::Kind, unit: &str, decimals: u8, step: f64, f: &NumberFormat) -> Result<Shown> {
|
||||
let (unit_kind, scale) = expr::unit_info(unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
|
||||
if unit_kind != kind {
|
||||
return Err(DocError::Invalid("number display unit has a different kind".into()));
|
||||
}
|
||||
format_display(value / scale, scale, decimals, f, Some(step))
|
||||
}
|
||||
|
||||
/// Format a unit-less number with the step policy of [`format_value_with_step`].
|
||||
/// Decimal precision can round a step multiple again; `Shown.value` always matches the text.
|
||||
/// Out-of-range snapped magnitudes are rejected before final decimal-place rounding.
|
||||
pub fn format_number_with_step(x: f64, decimals: u8, step: f64, f: &NumberFormat) -> Result<Shown> {
|
||||
format_display(x, 1.0, decimals, f, Some(step))
|
||||
}
|
||||
|
||||
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat, step: Option<f64>) -> Result<Shown> {
|
||||
// The unit table supplies a positive finite scale. Its decimal step must be at most
|
||||
// 1e-9 internal units; the precision floor can make the effective tie window narrower.
|
||||
let unit_resolution = (scale.log10() + 9.0).ceil().max(0.0) as usize;
|
||||
let resolution = unit_resolution.max(usize::from(decimals) + 3);
|
||||
let (rounded, value) = round_decimal(x, decimals, resolution, f.ties)?;
|
||||
let (rounded, value) = match step {
|
||||
Some(step) => round_decimal_step(x, step, decimals, resolution, f.ties)?,
|
||||
None => round_decimal(x, decimals, resolution, f.ties)?,
|
||||
};
|
||||
let (integer, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
|
||||
let fraction = if f.trailing_zeros { fraction } else { fraction.trim_end_matches('0') };
|
||||
let integer = if !f.leading_zero && integer == "0" && !fraction.is_empty() { "" } else { integer };
|
||||
@@ -121,16 +146,25 @@ fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<
|
||||
Ok(Shown { text, value })
|
||||
}
|
||||
|
||||
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
|
||||
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
|
||||
/// Validate and prepare the magnitude once, identically for both rounding paths.
|
||||
fn prepare_decimal(x: f64, decimals: u8, resolution: usize) -> Result<String> {
|
||||
if decimals > 8 {
|
||||
return Err(DocError::Invalid("number precision must be at most 8 decimal places".into()));
|
||||
}
|
||||
if !x.is_finite() || x.abs() > 1e12 {
|
||||
return Err(DocError::Invalid("display value must be finite with magnitude at most 1e12".into()));
|
||||
}
|
||||
let prepared = format!("{:.*}", resolution, x.abs());
|
||||
let (integer, fraction) = prepared.split_once('.').unwrap_or((&prepared, ""));
|
||||
Ok(format!("{:.*}", resolution, x.abs()))
|
||||
}
|
||||
|
||||
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
|
||||
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
|
||||
let prepared = prepare_decimal(x, decimals, resolution)?;
|
||||
round_prepared(&prepared, x.is_sign_negative(), decimals, ties)
|
||||
}
|
||||
|
||||
fn round_prepared(prepared: &str, negative: bool, decimals: u8, ties: Ties) -> Result<(String, f64)> {
|
||||
let (integer, fraction) = prepared.split_once('.').unwrap_or((prepared, ""));
|
||||
let places = usize::from(decimals);
|
||||
let mut digits: Vec<u8> = integer.bytes().chain(fraction.bytes().take(places)).collect();
|
||||
let discarded = fraction.as_bytes().get(places..).unwrap_or_default();
|
||||
@@ -166,10 +200,81 @@ fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<
|
||||
}
|
||||
let rounded = String::from_utf8(digits).map_err(|_| DocError::Invalid("invalid prepared digits".into()))?;
|
||||
let magnitude = rounded.parse::<f64>().map_err(|_| DocError::Invalid("invalid rounded number".into()))?;
|
||||
let negative = x.is_sign_negative() && magnitude != 0.0;
|
||||
let negative = negative && magnitude != 0.0;
|
||||
Ok((rounded, if negative { -magnitude } else { magnitude }))
|
||||
}
|
||||
|
||||
/// Divide decimal digits by a small decimal coefficient, retaining the full quotient.
|
||||
/// A shortest f64 coefficient has at most 17 significant digits. With the unit table's
|
||||
/// resolution <= 15, a scaled divisor is <= 1e21, so remainder * 10 fits u128.
|
||||
/// At most 324 appended zeros are needed, even for the smallest positive subnormal.
|
||||
fn round_decimal_step(x: f64, step: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
|
||||
if !step.is_finite() || step <= 0.0 || step > 1e6 {
|
||||
return Err(DocError::Invalid("number rounding step must be finite, positive and at most 1e6".into()));
|
||||
}
|
||||
let prepared = prepare_decimal(x, decimals, resolution)?;
|
||||
let scientific = format!("{step:e}");
|
||||
let (mantissa, exponent) = scientific.split_once('e').ok_or_else(|| DocError::Invalid("invalid decimal step".into()))?;
|
||||
let fraction = mantissa.split_once('.').map_or(0, |(_, f)| f.len());
|
||||
let coefficient = mantissa.replace('.', "").parse::<u128>().map_err(|_| DocError::Invalid("invalid step coefficient".into()))?;
|
||||
let exponent = exponent.parse::<i32>().map_err(|_| DocError::Invalid("invalid step exponent".into()))? - fraction as i32;
|
||||
let shift = exponent + resolution as i32;
|
||||
let divisor = if shift > 0 { 10_u128.checked_pow(shift as u32).and_then(|n| coefficient.checked_mul(n)) } else { Some(coefficient) }
|
||||
.ok_or_else(|| DocError::Invalid("rounding step scale overflow".into()))?;
|
||||
let mut remainder = 0_u128;
|
||||
let mut quotient = Vec::new();
|
||||
for digit in prepared.bytes().filter(|d| *d != b'.').chain(std::iter::repeat_n(b'0', (-shift).max(0) as usize)) {
|
||||
remainder = remainder * 10 + u128::from(digit - b'0');
|
||||
quotient.push((remainder / divisor) as u8);
|
||||
remainder %= divisor;
|
||||
}
|
||||
let up = remainder * 2 > divisor
|
||||
|| (remainder * 2 == divisor && (ties == Ties::HalfAwayFromZero || quotient.last().is_some_and(|d| !d.is_multiple_of(2))));
|
||||
// Exact quotient * coefficient, with the rounding increment folded into the carry.
|
||||
let mut carry = if up { coefficient } else { 0 };
|
||||
let mut product = Vec::new();
|
||||
for digit in quotient.iter().rev() {
|
||||
let n = u128::from(*digit) * coefficient + carry;
|
||||
product.push(b'0' + (n % 10) as u8);
|
||||
carry = n / 10;
|
||||
}
|
||||
while carry > 0 {
|
||||
product.push(b'0' + (carry % 10) as u8);
|
||||
carry /= 10;
|
||||
}
|
||||
product.reverse();
|
||||
let product = String::from_utf8(product).map_err(|_| DocError::Invalid("invalid step digits".into()))?;
|
||||
let mut magnitude = product.trim_start_matches('0').to_string();
|
||||
if magnitude.is_empty() {
|
||||
magnitude.push('0');
|
||||
}
|
||||
if exponent >= 0 {
|
||||
if magnitude != "0" {
|
||||
magnitude.extend(std::iter::repeat_n('0', exponent as usize));
|
||||
}
|
||||
} else {
|
||||
let places = (-exponent) as usize;
|
||||
if magnitude.len() <= places {
|
||||
magnitude = format!("{}{}", "0".repeat(places + 1 - magnitude.len()), magnitude);
|
||||
}
|
||||
magnitude.insert(magnitude.len() - places, '.');
|
||||
}
|
||||
let (integer, fraction) = magnitude.split_once('.').unwrap_or((&magnitude, ""));
|
||||
let whole = integer.parse::<u64>().map_err(|_| DocError::Invalid("rounded display magnitude overflow".into()))?;
|
||||
if whole > 1_000_000_000_000 || (whole == 1_000_000_000_000 && fraction.bytes().any(|d| d != b'0')) {
|
||||
return Err(DocError::Invalid("rounded display magnitude must be at most 1e12".into()));
|
||||
}
|
||||
// round_prepared needs at least the requested fraction digits, including for integer steps.
|
||||
let missing = usize::from(decimals).saturating_sub(fraction.len());
|
||||
if missing > 0 {
|
||||
if !magnitude.contains('.') {
|
||||
magnitude.push('.');
|
||||
}
|
||||
magnitude.extend(std::iter::repeat_n('0', missing));
|
||||
}
|
||||
round_prepared(&magnitude, x.is_sign_negative(), decimals, ties)
|
||||
}
|
||||
|
||||
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) } {
|
||||
@@ -471,6 +576,214 @@ mod number_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_ties_and_decimal_fractions() {
|
||||
for (x, step, dp, even, away) in [
|
||||
(1.25, 0.5, 1, "1.0", "1.5"),
|
||||
(1.75, 0.5, 1, "2.0", "2.0"),
|
||||
(12.5, 5.0, 0, "10", "15"),
|
||||
(25.0, 10.0, 0, "20", "30"),
|
||||
(0.125, 0.05, 2, "0.10", "0.15"),
|
||||
(0.375, 0.25, 2, "0.50", "0.50"),
|
||||
(0.15, 0.1, 1, "0.2", "0.2"),
|
||||
(0.25, 0.1, 1, "0.2", "0.3"),
|
||||
(1.25, 0.5, 0, "1", "2"),
|
||||
] {
|
||||
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
|
||||
let f = NumberFormat { ties, ..NumberFormat::default() };
|
||||
for sign in [1.0, -1.0] {
|
||||
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
|
||||
assert_eq!(shown.text, if sign < 0.0 { format!("−{expected}") } else { expected.into() });
|
||||
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_prepares_display_before_division_and_keeps_boundary_neighbors() {
|
||||
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
|
||||
let f = NumberFormat { ties, ..NumberFormat::default() };
|
||||
for x in [1.75_f64.next_down(), 1.75, 1.75_f64.next_up(), 1.7499999996] {
|
||||
assert_eq!(format_number_with_step(x, 1, 0.5, &f).unwrap().text, "2.0");
|
||||
}
|
||||
for x in [1.25_f64.next_down(), 1.25, 1.25_f64.next_up()] {
|
||||
let expected = if ties == Ties::HalfEven { "1.0" } else { "1.5" };
|
||||
assert_eq!(format_number_with_step(x, 1, 0.5, &f).unwrap().text, expected);
|
||||
assert_eq!(format_number_with_step(-x, 1, 0.5, &f).unwrap().text, format!("−{expected}"));
|
||||
}
|
||||
assert_eq!(format_number_with_step(1.7499999994, 1, 0.5, &f).unwrap().text, "1.5");
|
||||
assert_eq!(format_number_with_step(1.7500000006, 1, 0.5, &f).unwrap().text, "2.0");
|
||||
}
|
||||
let f = NumberFormat::default();
|
||||
// The precision floor narrows the preparation window at eight decimals.
|
||||
assert_eq!(format_number_with_step(1.7499999996, 8, 0.5, &f).unwrap().text, "1.50000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_tiny_values_large_magnitudes_and_exact_final_digits() {
|
||||
let f = NumberFormat::default();
|
||||
for step in [f64::from_bits(1), f64::MIN_POSITIVE, 1e-200, 1e-20, 1e-8] {
|
||||
for x in [0.0, -0.0, f64::from_bits(1), -1e11, 1e11, 34947628153.461784] {
|
||||
let shown = format_number_with_step(x, 5, step, &f).unwrap();
|
||||
assert!(shown.value.is_finite());
|
||||
assert_eq!(shown.value, shown.text.replace('−', "-").parse::<f64>().unwrap());
|
||||
}
|
||||
}
|
||||
assert_eq!(format_number_with_step(1e12, 8, f64::from_bits(1), &f).unwrap().text, "1000000000000.00000000");
|
||||
assert_eq!(format_number_with_step(34947628153.461784, 5, 1e-8, &f).unwrap().text, "34947628153.46178");
|
||||
// A tiny step can move an exact decimal tie by less than any f64 ULP.
|
||||
// Keep those digits through the second rounding rather than parsing the snap to f64.
|
||||
assert_eq!(format_number_with_step(1.25, 1, 3e-323, &f).unwrap().text, "1.3");
|
||||
assert_eq!(format_number_with_step(-1.25, 1, 3e-323, &f).unwrap().text, "−1.3");
|
||||
for step in [0.5, 10.0, 1e6] {
|
||||
for x in [-0.0, -0.1] {
|
||||
let shown = format_number_with_step(x, 2, step, &f).unwrap();
|
||||
assert_eq!(shown.text, "0.00");
|
||||
assert_eq!(shown.value.to_bits(), 0.0_f64.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_extreme_coefficients_have_independent_expected_digits() {
|
||||
// Shortest decimals: largest subnormal = 2225073858507201 * 10^-323,
|
||||
// smallest normal = 22250738585072014 * 10^-324. For x=1.25, integer
|
||||
// division leaves remainders 2221948386734081 and 354296156594724.
|
||||
// Twice the first is above its divisor, twice the second below: the
|
||||
// snap is just above/below 1.25, so final rounding gives 1.3/1.2 in BOTH modes.
|
||||
// Other expected digits were calculated with independent rational arithmetic.
|
||||
for (step, quarter) in [(f64::MIN_POSITIVE.next_down(), "1.3"), (f64::MIN_POSITIVE, "1.2")] {
|
||||
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
|
||||
let f = NumberFormat { ties, ..NumberFormat::default() };
|
||||
for (x, dp, expected) in [
|
||||
(1.25, 1, quarter),
|
||||
(99999999999.75, 0, "100000000000"),
|
||||
(99999999999.75, 1, "99999999999.7"),
|
||||
(99999999999.75, 2, "99999999999.75"),
|
||||
(34947628153.461784, 2, "34947628153.46"),
|
||||
(34947628153.461784, 5, "34947628153.46178"),
|
||||
] {
|
||||
for sign in [1.0, -1.0] {
|
||||
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
|
||||
let text = if sign < 0.0 { format!("−{expected}") } else { expected.into() };
|
||||
assert_eq!(shown.text, text, "x={x:?}, step={step:e}, dp={dp}, ties={ties:?}");
|
||||
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_and_final_decimal_rounding_carry_independently() {
|
||||
// 9.975 / .05 = 199.5: both modes increment the step index to 200.
|
||||
// 9.96 / .03 = 332 exactly: only the final rounding carries to 10.0.
|
||||
// 1.26 / .1 = 12.6: the snap is 1.3, then zero decimals give 1 (not 1.26).
|
||||
// 1.49 / .5 = 2.98: the snap is 1.5, then zero decimals give 2 (not 1).
|
||||
for (x, step, dp, even, away) in [
|
||||
(9.975, 0.05, 2, "10.00", "10.00"),
|
||||
(9.96, 0.03, 1, "10.0", "10.0"),
|
||||
(1.26, 0.1, 0, "1", "1"),
|
||||
(1.49, 0.5, 0, "2", "2"),
|
||||
(100000000000.25, 0.1, 0, "100000000000", "100000000000"),
|
||||
(100000000000.25, 0.1, 1, "100000000000.2", "100000000000.3"),
|
||||
(100000000000.25, 0.1, 2, "100000000000.20", "100000000000.30"),
|
||||
(999999999999.75, 0.25, 0, "1000000000000", "1000000000000"),
|
||||
] {
|
||||
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
|
||||
let f = NumberFormat { ties, ..NumberFormat::default() };
|
||||
for sign in [1.0, -1.0] {
|
||||
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
|
||||
assert_eq!(shown.text, if sign < 0.0 { format!("−{expected}") } else { expected.into() });
|
||||
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_rejects_invalid_inputs_and_out_of_range_snap() {
|
||||
let f = NumberFormat::default();
|
||||
for step in [0.0, -0.0, -0.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e6_f64.next_up()] {
|
||||
assert!(matches!(format_number_with_step(1.0, 2, step, &f), Err(DocError::Invalid(_))));
|
||||
assert!(matches!(format_value_with_step(1.0, Length, "mm", 2, step, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e12_f64.next_up(), -1e12_f64.next_up()] {
|
||||
assert!(matches!(format_number_with_step(x, 2, 0.5, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
for dp in [9, 255] {
|
||||
assert!(matches!(format_number_with_step(1.0, dp, 0.5, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
for x in [1e12, -1e12] {
|
||||
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
|
||||
let f = NumberFormat { ties, ..f };
|
||||
for step in [600_000.0, 0.6] {
|
||||
// The .6 snap has magnitude 1e12 + .2: reject it even though
|
||||
// zero decimal places would otherwise bring it back to 1e12.
|
||||
assert!(matches!(format_number_with_step(x, 0, step, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
}
|
||||
assert_eq!(format_number_with_step(x, 0, 1e6, &f).unwrap().value, x);
|
||||
}
|
||||
for (kind, unit) in [(Length, "deg"), (Angle, "mm"), (Length, "unknown")] {
|
||||
assert!(matches!(format_value_with_step(1.0, kind, unit, 2, 0.5, &f), Err(DocError::Invalid(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_units_options_and_input_preservation() {
|
||||
let f = NumberFormat::default();
|
||||
let input: f64 = 25.4 * 1.25;
|
||||
let bits = input.to_bits();
|
||||
assert_eq!(format_value_with_step(input, Length, "in", 1, 0.5, &f).unwrap().text, "1.0");
|
||||
assert_eq!(input.to_bits(), bits);
|
||||
assert_eq!(format_value_with_step(std::f64::consts::PI / 6.0, Angle, "deg", 1, 5.0, &f).unwrap().text, "30.0");
|
||||
assert_eq!(format_value_with_step(1e15, Length, "m", 1, 10.0, &f).unwrap().value, 1e12);
|
||||
let styled = NumberFormat { marker: DecimalMarker::Comma, groups: true, ..f };
|
||||
assert_eq!(format_number_with_step(-12345.25, 2, 0.5, &styled).unwrap().text, "−12\u{202f}345,00");
|
||||
let trim = NumberFormat { leading_zero: false, trailing_zeros: false, ..styled };
|
||||
assert_eq!(format_number_with_step(-0.6, 3, 0.5, &trim).unwrap().text, "−,5");
|
||||
assert_eq!(format_number_with_step(1.23456789, 8, 1e-8, &styled).unwrap().text, "1,234\u{202f}567\u{202f}89");
|
||||
for (distance, expected) in [(0.4, "1.0"), (0.6, "0.5")] {
|
||||
assert_eq!(format_value_with_step((0.75 - distance * 1e-4) * 1e-6, Length, "nm", 1, 0.5, &f).unwrap().text, expected);
|
||||
}
|
||||
for step in [1e6_f64.next_down(), 0.12345678901234567, 2.2250738585072014e-308] {
|
||||
let shown = format_number_with_step(12345.6789, 8, step, &f).unwrap();
|
||||
assert_eq!(shown.value, shown.text.parse::<f64>().unwrap());
|
||||
}
|
||||
// Explicitly keep both historical policies, including their different decimal ties.
|
||||
assert_eq!(format_number(2.665, 2, &f).unwrap().text, "2.66");
|
||||
assert_eq!(length_number(2.665, "mm", 2), "2.67");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_seeded_integer_reference_and_hostile_domain() {
|
||||
let mut seed = 0x4896_1138_0abc_def1;
|
||||
for _ in 0..20_000 {
|
||||
// Independent bounded integer oracle: x and step are thousandths; dp=3.
|
||||
let n = random(&mut seed) % 1_000_000;
|
||||
let s = random(&mut seed) % 100_000 + 1;
|
||||
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
|
||||
let f = NumberFormat { ties, ..NumberFormat::default() };
|
||||
let q = n / s;
|
||||
let r = n % s;
|
||||
let up = 2 * r > s || (2 * r == s && (ties == Ties::HalfAwayFromZero || q % 2 == 1));
|
||||
let expected = (q + u64::from(up)) * s;
|
||||
let text = format!("{}.{:03}", expected / 1000, expected % 1000);
|
||||
let shown = format_number_with_step(n as f64 / 1000.0, 3, s as f64 / 1000.0, &f).unwrap();
|
||||
assert_eq!(shown.text, text);
|
||||
// Exercise arbitrary positive subnormals and normal steps without quotient overflow.
|
||||
let step = f64::from_bits(random(&mut seed));
|
||||
if step.is_finite() && step > 0.0 && step <= 1e6 {
|
||||
let shown = format_number_with_step(n as f64, 3, step, &f).unwrap();
|
||||
assert!(shown.value.is_finite() && shown.value >= 0.0);
|
||||
assert_eq!(shown.value, shown.text.parse::<f64>().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
|
||||
@@ -2410,6 +2410,12 @@ pub enum HoleKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Tip angle")]
|
||||
tip_angle: Option<Expr>,
|
||||
/// Printing aid: two bridge layers this thick below the counterbore's floor (a slot along
|
||||
/// X, then a square), so a printer bridges the step instead of sagging into it. Absent:
|
||||
/// a plain counterbore.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Bridge layer")]
|
||||
bridge: Option<Expr>,
|
||||
},
|
||||
Countersink {
|
||||
#[walk(length = "Countersink diameter")]
|
||||
|
||||
@@ -1650,6 +1650,13 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
|
||||
for (sketch, _) in f.kind.profile_sels() {
|
||||
if doc.feature(sketch).is_some_and(|s| s.component != f.component) {
|
||||
reads.sketch(doc, sketch);
|
||||
// A later transfer can preserve this profile's historical frame. Changes to that
|
||||
// boundary (including suppression and rollback) must invalidate the constructor.
|
||||
if let Some(relative) = crate::body_transfer::profile_frame(doc, f, sketch) {
|
||||
"transferred profile".hash(&mut h);
|
||||
sketch.hash(&mut h);
|
||||
hash_placement(&relative, &mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = match &f.kind {
|
||||
@@ -2930,13 +2937,67 @@ fn clear_tools_as_one(tools: Vec<Traced>) -> Result<Vec<Traced>> {
|
||||
return Ok(tools);
|
||||
}
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
Ok(match kernel::union_apart(&bodies)? {
|
||||
let one = match kernel::union_apart(&bodies)? {
|
||||
Some(one) => Some(one),
|
||||
// The kernel only compares the tools' boxes, so copies of a through hole close together
|
||||
// on a circle (their boxes overlap) seem to meet (#9007). Their exact shapes may still
|
||||
// be apart.
|
||||
None if tools_clearly_apart(&bodies) => kernel::join_apart(&bodies).ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.cut_tools += one.as_ref().map_or(tools.len(), |_| 1));
|
||||
Ok(match one {
|
||||
// Each piece's faces keep the names of the tool it is.
|
||||
Some(one) => vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())],
|
||||
None => tools,
|
||||
})
|
||||
}
|
||||
|
||||
/// Most pairs of tools whose boxes meet that [`tools_clearly_apart`] tests.
|
||||
const MAX_CLOSE_TOOL_PAIRS: usize = 4096;
|
||||
|
||||
/// Whether no two of `bodies` meet or touch, decided on their exact shapes. Only pairs whose
|
||||
/// exact boxes, grown by twice the bodies' tolerances (as [`kernel::apart`] grows them), meet
|
||||
/// are tested, by [`apart_by_union`]. `false` whenever that cannot be shown: a mesh body, a body
|
||||
/// without an exact box, too many pairs to test, a union that fails.
|
||||
fn tools_clearly_apart(bodies: &[Body]) -> bool {
|
||||
if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) {
|
||||
return false;
|
||||
}
|
||||
let Some(boxes) = bodies.iter().map(more::exact_box).collect::<Option<Vec<_>>>() else { return false };
|
||||
let mut order: Vec<usize> = (0..boxes.len()).collect();
|
||||
order.sort_by(|&i, &j| boxes.get(i).map_or(0.0, |b| b.min.x).total_cmp(&boxes.get(j).map_or(0.0, |b| b.min.x)));
|
||||
let mut close = Vec::new();
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
let Some(a) = boxes.get(i) else { return false };
|
||||
for &j in order.iter().skip(k + 1) {
|
||||
let Some(b) = boxes.get(j) else { return false };
|
||||
if b.min.x > a.max.x {
|
||||
break;
|
||||
}
|
||||
if more::boxes_apart(&Some(*a), &Some(*b)) {
|
||||
continue;
|
||||
}
|
||||
if close.len() == MAX_CLOSE_TOOL_PAIRS {
|
||||
return false;
|
||||
}
|
||||
close.push((i, j));
|
||||
}
|
||||
}
|
||||
close.into_iter().all(|(i, j)| matches!((bodies.get(i), bodies.get(j)), (Some(a), Some(b)) if apart_by_union(a, b)))
|
||||
}
|
||||
|
||||
/// Whether the exact union of `a` and `b` comes out in two pieces that share no face, edge or
|
||||
/// vertex. Crossing and nested bodies come out in one piece, touching ones in one or in two that
|
||||
/// share an edge or a face.
|
||||
fn apart_by_union(a: &Body, b: &Body) -> bool {
|
||||
match kernel::boolean(a, b, BoolOp::Union) {
|
||||
Ok(Some(u)) => u.record().material_regions().len() == 2 && u.region_contacts().is_ok_and(|c| c.is_empty()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An intersect never deletes a body by accident (#5295): it is refused when nothing of a named
|
||||
/// target would remain, when nothing of any target would remain, or when the tools meet a target
|
||||
/// and the boolean still came out empty. Only an unnamed target the tools miss altogether is
|
||||
@@ -4635,18 +4696,41 @@ fn hole_tools_and_prisms(
|
||||
match hole {
|
||||
HoleKind::Simple => tools.extend(bore(None)?),
|
||||
HoleKind::Drilled { tip_angle } => tools.extend(bore(Some(tip_angle))?),
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle } => {
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle, bridge } => {
|
||||
let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?);
|
||||
if !(rc > r && dc > 0.0 && -dc > bottom) {
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top, "cbore")?);
|
||||
if let Some(layer) = bridge {
|
||||
let layer = val(vals, layer, Kind::Length)?;
|
||||
tools.extend(bridge_tools(&plane, r, rc, dc, bottom, layer)?);
|
||||
// Through all, `bottom` is the tool's reach, not the material's: the second layer
|
||||
// must end inside a body (exact classification), short of the exit face.
|
||||
if depth.is_none() {
|
||||
let past = *position + dir * (dc + 2.0 * layer + size * 1e-6);
|
||||
let classes = st.bodies.iter().map(|b| b.body.classify_point(past)).collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
if !classes.contains(&kernel::PointClass::Inside) {
|
||||
return Err(bridge_too_deep(layer, "make the counterbore shallower or the bridge thinner"));
|
||||
}
|
||||
}
|
||||
}
|
||||
tools.extend(bore(tip_angle.as_ref())?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle, tip_angle } => {
|
||||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||||
if !(rc > r && half > 1e-3 && half < std::f64::consts::FRAC_PI_2) {
|
||||
// The angle and the width are told apart: an angle out of range is not a countersink
|
||||
// too narrow (#9625). The cone's length grows as 1 / tan(half), so a near-flat angle
|
||||
// is refused at a floor the message names, rather than built kilometres long.
|
||||
const MIN_ANGLE_DEG: f64 = 0.115;
|
||||
if !(2.0 * half > MIN_ANGLE_DEG.to_radians() && half < std::f64::consts::FRAC_PI_2) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"the countersink angle must be more than {MIN_ANGLE_DEG}° and less than 180°, not {}°",
|
||||
((2.0 * half).to_degrees() * 1e6).round() / 1e6
|
||||
)));
|
||||
}
|
||||
if !(rc > r) {
|
||||
return Err(DocError::Invalid("the countersink must be wider than the hole".into()));
|
||||
}
|
||||
// One revolved tool: the cone from its rim on the face to just inside the hole
|
||||
@@ -4670,6 +4754,59 @@ fn hole_tools_and_prisms(
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// How much wider than the hole a bridged counterbore's slot and square are, in mm.
|
||||
const BRIDGE_CLEARANCE: f64 = 0.02;
|
||||
|
||||
/// Why a bridged counterbore's two layers of `layer` were refused: they reach past the hole, and
|
||||
/// `fix` says what to change.
|
||||
fn bridge_too_deep(layer: f64, fix: &str) -> DocError {
|
||||
DocError::Invalid(format!("the two bridge layers ({:.3} mm) must fit in the hole below the counterbore: {fix}", 2.0 * layer))
|
||||
}
|
||||
|
||||
/// The bridge layers of a counterbore printed opening down: below its floor, a slot across the
|
||||
/// whole counterbore, along X (one `layer` thick), then a square (one more), both
|
||||
/// [`BRIDGE_CLEARANCE`] wider than the hole, so each layer spans between supported edges.
|
||||
/// `plane` is the hole's, its normal out of the material; the bore crosses both layers, clear of
|
||||
/// their sides.
|
||||
fn bridge_tools(plane: &Plane, r: f64, rc: f64, dc: f64, bottom: f64, layer: f64) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
if !(layer > 0.0 && layer.is_finite()) {
|
||||
return Err(DocError::Invalid("the bridge layer must be a positive length, such as 0.2 mm".into()));
|
||||
}
|
||||
let (t1, t2) = (-dc - layer, -dc - 2.0 * layer);
|
||||
if t2 <= bottom {
|
||||
return Err(bridge_too_deep(layer, "make the hole deeper or the bridge thinner"));
|
||||
}
|
||||
// Both a hair wider than the hole, so the bore's rim doesn't touch their sides.
|
||||
let q = r + BRIDGE_CLEARANCE / 2.0;
|
||||
// The slot's ends need room on the counterbore's wall past that.
|
||||
if !(rc >= q + BRIDGE_CLEARANCE / 2.0) {
|
||||
return Err(DocError::Invalid(format!("a bridged counterbore must be at least {} mm wider than the hole", 2.0 * BRIDGE_CLEARANCE)));
|
||||
}
|
||||
// The slot along world X where the axis allows it, else along the hole plane's own x.
|
||||
let n = plane.normal();
|
||||
let x = (Vec3::X - n * Vec3::X.dot(n)).normalized().filter(|_| Vec3::X.dot(n).abs() < 0.99).unwrap_or(plane.x);
|
||||
let bp = Plane::new(plane.origin, x, n.cross(x)).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
// The slot ends on the counterbore's wall: two lines and two arcs of its circle.
|
||||
let a = (rc * rc - q * q).sqrt();
|
||||
let phi = q.atan2(a);
|
||||
let slot = solidcore_record::Loop2 {
|
||||
segs: vec![
|
||||
solidcore_record::Seg2::Line { a: Vec2::new(-a, -q), b: Vec2::new(a, -q) },
|
||||
solidcore_record::Seg2::Arc { center: Vec2::ZERO, radius: rc, start: -phi, sweep: 2.0 * phi },
|
||||
solidcore_record::Seg2::Line { a: Vec2::new(a, q), b: Vec2::new(-a, q) },
|
||||
solidcore_record::Seg2::Arc { center: Vec2::ZERO, radius: rc, start: std::f64::consts::PI - phi, sweep: 2.0 * phi },
|
||||
],
|
||||
};
|
||||
let square = solidcore_record::Loop2::polygon(&[Vec2::new(-q, -q), Vec2::new(q, -q), Vec2::new(q, q), Vec2::new(-q, q)]);
|
||||
let mut tools = Vec::new();
|
||||
for (outline, lo, hi) in [(slot, t1, -dc), (square, t2, t1)] {
|
||||
let region = Region2 { outer: outline, holes: vec![] };
|
||||
let tool = kernel::extrude(&bp, &[region], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))?;
|
||||
tools.push((crate::lineage::part_tool(tool, "bridge"), None));
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// The angular interval used by both the solid and its interactive extent handles.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RevolveExtent {
|
||||
@@ -4798,6 +4935,22 @@ struct ExtrudeConstructor {
|
||||
interval: (f64, f64),
|
||||
}
|
||||
|
||||
/// How far from its sketch plane an extrude may reach, in mm. It mirrors the limit the kernel's
|
||||
/// `finite` check in `crates/kernel/src/build.rs` puts on an extrusion's start and end (a literal
|
||||
/// there, not exported), so a too-long extrusion is refused here, by name, before the kernel is
|
||||
/// called: the kernel's own message, `end = 1000000000`, names no field (#9625).
|
||||
const MAX_EXTRUDE_REACH: f64 = 1e7;
|
||||
|
||||
/// Refuse an extrusion whose ends lie past [`MAX_EXTRUDE_REACH`] from the sketch plane.
|
||||
fn extrude_within_reach(ends: [f64; 2]) -> Result<()> {
|
||||
match ends.into_iter().find(|x| x.abs() >= MAX_EXTRUDE_REACH) {
|
||||
Some(x) => Err(DocError::Invalid(format!(
|
||||
"the extrusion is too long: its end is {x} mm from the sketch plane, past the limit of {MAX_EXTRUDE_REACH} mm"
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct planar caps locally before placing the completed tool.
|
||||
fn centered_extrude(
|
||||
plane: &Plane,
|
||||
@@ -4905,10 +5058,10 @@ fn feature_tools_with_constructor(
|
||||
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
let ss = constructor_profile_in_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
f,
|
||||
)?;
|
||||
let regions = match thin {
|
||||
Some(thin) => thin_regions(vals, &ss, thin)?,
|
||||
@@ -4981,6 +5134,7 @@ fn feature_tools_with_constructor(
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
extrude_within_reach([base, base + sign * len])?;
|
||||
let start_plane = ss.plane.offset(base);
|
||||
let caps = [
|
||||
Some(crate::lineage::extrude_cap_role(base)),
|
||||
@@ -5050,6 +5204,7 @@ fn feature_tools_with_constructor(
|
||||
lo = hs.iter().cloned().fold(lo, f64::min) - margin;
|
||||
}
|
||||
}
|
||||
extrude_within_reach([lo + off, hi + off])?;
|
||||
let untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
|
||||
let mut tools = Vec::new();
|
||||
for r in ®ions {
|
||||
@@ -5130,10 +5285,10 @@ fn feature_tools_with_constructor(
|
||||
Ok(tools)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
let ss = constructor_profile_in_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
f,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) =
|
||||
@@ -5538,16 +5693,27 @@ fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64)
|
||||
profile_in_frame(doc, sketch, consumer)
|
||||
}
|
||||
|
||||
fn constructor_profile_in_frame(doc: &Document, sketch: &SolvedSketch, feature: &Feature) -> Result<SolvedSketch> {
|
||||
match crate::body_transfer::profile_frame(doc, feature, sketch.feature) {
|
||||
Some(relative) => profile_with_transform(sketch, &relative),
|
||||
None => sketch_in_feature_frame(doc, sketch, feature.component),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
|
||||
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let inverse =
|
||||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
|
||||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||||
profile_with_transform(sketch, &relative)
|
||||
}
|
||||
|
||||
fn profile_with_transform(sketch: &SolvedSketch, relative: &Mat) -> Result<SolvedSketch> {
|
||||
let mut mapped = sketch.clone();
|
||||
mapped.plane = Plane::new(
|
||||
crate::apply_point(&relative, sketch.plane.origin),
|
||||
crate::apply_vector(&relative, sketch.plane.x),
|
||||
crate::apply_vector(&relative, sketch.plane.y),
|
||||
crate::apply_point(relative, sketch.plane.origin),
|
||||
crate::apply_vector(relative, sketch.plane.x),
|
||||
crate::apply_vector(relative, sketch.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
|
||||
Ok(mapped)
|
||||
@@ -6075,7 +6241,6 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
|
||||
(Vec3::ZERO, Vec3::X)
|
||||
}
|
||||
|
||||
/// Whether a copy has no material to cut in any of its eligible targets.
|
||||
/// The bodies a hole cuts: those its feature names, else its component's and its own (as
|
||||
/// `apply_traced` picks a cut's targets).
|
||||
fn hole_targets<'a>(st: &'a ModelState, f: &Feature) -> Vec<&'a ModelBody> {
|
||||
@@ -6105,18 +6270,120 @@ fn hole_misses(st: &ModelState, f: &Feature, tool: &Body) -> bool {
|
||||
targets.iter().all(|b| more::boxes_apart(&tool_box, &b.body.exact_bounds().ok()))
|
||||
}
|
||||
|
||||
fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced]) -> bool {
|
||||
/// The bodies a pattern's cut copies are checked against for missing them ([`copy_misses`]).
|
||||
fn miss_scope<'a>(st: &'a ModelState, f: &'a Feature, targets: &'a [String]) -> impl Iterator<Item = &'a ModelBody> + 'a {
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| all || if targets.is_empty() { b.component == f.component || b.feature == f.id } else { targets.contains(&b.name) })
|
||||
.all(|b| {
|
||||
tools.iter().all(|t| {
|
||||
// An inconclusive probe is not a miss: it must not reject an otherwise valid cut.
|
||||
matches!(kernel::boolean(&b.body, &t.body, BoolOp::Intersect), Ok(None))
|
||||
})
|
||||
.filter(move |b| all || if targets.is_empty() { b.component == f.component || b.feature == f.id } else { targets.contains(&b.name) })
|
||||
}
|
||||
|
||||
/// Whether a copy has no material to cut in any of its eligible targets.
|
||||
fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced]) -> bool {
|
||||
miss_scope(st, f, targets).all(|b| {
|
||||
tools.iter().all(|t| {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.miss_intersects += 1);
|
||||
// An inconclusive probe is not a miss: it must not reject an otherwise valid cut.
|
||||
matches!(kernel::boolean(&b.body, &t.body, BoolOp::Intersect), Ok(None))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// How many of a pattern's cut copies (`instances`, ranges of `copies`) remove no material, and
|
||||
/// how many copies there are. [`copy_misses`] intersects a copy with every target, which on a
|
||||
/// body with many holes costs far more than cutting the copy (#9007). A copy that a point test
|
||||
/// shows takes material from a target ([`shares_material`]) is not a miss, and skips it; only
|
||||
/// copies the test cannot place (in an earlier hole, on the edge, thinner than its samples) are
|
||||
/// intersected.
|
||||
fn count_copy_misses(
|
||||
st: &ModelState,
|
||||
f: &Feature,
|
||||
targets: &[String],
|
||||
copies: &[Traced],
|
||||
instances: &[std::ops::Range<usize>],
|
||||
axes: &[Vec<(Vec3, Vec3)>],
|
||||
) -> (usize, usize) {
|
||||
let scope: Vec<&ModelBody> = miss_scope(st, f, targets).collect();
|
||||
let (mut missed, mut total) = (0, 0);
|
||||
for (k, tools) in instances.iter().enumerate().filter_map(|(k, i)| Some((k, copies.get(i.clone())?))) {
|
||||
total += 1;
|
||||
let lines = axes.get(k).map_or(&[][..], Vec::as_slice);
|
||||
let takes = tools.iter().any(|t| scope.iter().any(|b| shares_material(&b.body, &t.body, lines)));
|
||||
if !takes && copy_misses(st, f, targets, tools) {
|
||||
missed += 1;
|
||||
}
|
||||
}
|
||||
(missed, total)
|
||||
}
|
||||
|
||||
/// Whether some point lies in the material of both B-rep bodies `target` and `tool`, by their
|
||||
/// exact point classification (a point on either's boundary does not count). Tried along
|
||||
/// `lines` (a hole's axis: through the wall it drills into, however thin and slanted), along the
|
||||
/// axes of the tool's round faces (a cylinder's axis, the middle of a slot's ends) and along the
|
||||
/// three lines parallel to the axes through the middle of the box both exact boxes hold (the
|
||||
/// axis of a straight hole through a plate), within that box. `false` says nothing.
|
||||
fn shares_material(target: &Body, tool: &Body, lines: &[(Vec3, Vec3)]) -> bool {
|
||||
if target.is_mesh() || tool.is_mesh() {
|
||||
return false;
|
||||
}
|
||||
let (Some(a), Some(b)) = (more::exact_box(target), more::exact_box(tool)) else { return false };
|
||||
let both = a.intersection(&b);
|
||||
if both.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let c = both.center();
|
||||
let mut all = lines.to_vec();
|
||||
all.extend(round_face_axes(tool, MAX_PROBED_FACES));
|
||||
all.extend([(c, Vec3::X), (c, Vec3::Y), (c, Vec3::Z)]);
|
||||
let inside = |b: &Body, p: Vec3| matches!(b.classify_point(p), Ok(kernel::PointClass::Inside));
|
||||
all.iter().any(|&(o, d)| {
|
||||
let Some((lo, hi)) = line_in_box(&both, o, d) else { return false };
|
||||
(0..SAMPLES_PER_LINE).any(|k| {
|
||||
let p = o + d * (lo + (hi - lo) * (k as f64 + 0.5) / SAMPLES_PER_LINE as f64);
|
||||
inside(tool, p) && inside(target, p)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Points tried on each line by [`shares_material`].
|
||||
const SAMPLES_PER_LINE: usize = 16;
|
||||
/// Most faces of a tool whose surfaces [`shares_material`] looks along.
|
||||
const MAX_PROBED_FACES: usize = 32;
|
||||
|
||||
/// The axes of the round faces among the first `max` faces of `body`, from their exact surfaces:
|
||||
/// a cylinder's, a cone's and a torus's axis, and the lines along X, Y and Z through a sphere's
|
||||
/// centre.
|
||||
fn round_face_axes(body: &Body, max: usize) -> Vec<(Vec3, Vec3)> {
|
||||
let mut lines = Vec::new();
|
||||
for face in 0..body.face_count().min(max) {
|
||||
match kernel::face_surface(body, face) {
|
||||
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => lines.push((origin, Vec3::from(axis))),
|
||||
Some(kernel::FaceSurface::Cone { apex, axis, .. }) => lines.push((apex, Vec3::from(axis))),
|
||||
Some(kernel::FaceSurface::Torus { center, axis, .. }) => lines.push((center, Vec3::from(axis))),
|
||||
Some(kernel::FaceSurface::Sphere { center, .. }) => lines.extend([(center, Vec3::X), (center, Vec3::Y), (center, Vec3::Z)]),
|
||||
Some(kernel::FaceSurface::Plane { .. }) | None => {}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// The stretch of the line `o + t d` inside `b`, as the range of `t`; `None` when it misses.
|
||||
fn line_in_box(b: &solidcraft_geom::Aabb3, o: Vec3, d: Vec3) -> Option<(f64, f64)> {
|
||||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
for (o, d, min, max) in [(o.x, d.x, b.min.x, b.max.x), (o.y, d.y, b.min.y, b.max.y), (o.z, d.z, b.min.z, b.max.z)] {
|
||||
if d.abs() < 1e-12 {
|
||||
if o < min || o > max {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let (a, c) = ((min - o) / d, (max - o) / d);
|
||||
lo = lo.max(a.min(c));
|
||||
hi = hi.min(a.max(c));
|
||||
}
|
||||
}
|
||||
(lo.is_finite() && hi.is_finite() && lo <= hi).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// The deepest a pattern or mirror reaches through patterns and mirrors of features (#395).
|
||||
@@ -6358,6 +6625,9 @@ fn replay(
|
||||
let targets = if present.is_empty() { targets } else { present };
|
||||
let mut copies = Vec::new();
|
||||
let mut instances = Vec::with_capacity(mats.len());
|
||||
// Each hole copy's axes (spot and direction), where the check for copies that miss looks
|
||||
// for material first.
|
||||
let mut axes: Vec<Vec<(Vec3, Vec3)>> = Vec::new();
|
||||
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
|
||||
// A hole is drilled again at each copy of its spot (found on the surface there), so
|
||||
// a blind hole's depth counts from the face at the copy, not from the bottom of the
|
||||
@@ -6366,8 +6636,10 @@ fn replay(
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let start = copies.len();
|
||||
let mut on = Vec::with_capacity(spots.len());
|
||||
for p in &spots {
|
||||
let at = hole_on_surface(st, crate::apply_point(m, *p), d, hole_scope(src));
|
||||
on.push((at, d));
|
||||
let to = to.map(|t| crate::apply_point(m, t.0));
|
||||
let depth = hole_depth_to(st, &at, d, &to, depth)?;
|
||||
// Each copy's faces are named as the hole's, with the instance.
|
||||
@@ -6378,6 +6650,7 @@ fn replay(
|
||||
);
|
||||
}
|
||||
instances.push(start..copies.len());
|
||||
axes.push(on);
|
||||
}
|
||||
} else if let FeatureKind::Rib { .. } = &src.kind {
|
||||
// A rib is built again at each copy of its sketch, against the bodies there (#4593):
|
||||
@@ -6404,12 +6677,9 @@ fn replay(
|
||||
}
|
||||
}
|
||||
if op == Operation::Cut {
|
||||
for instance in &instances {
|
||||
if let Some(tools) = copies.get(instance.clone()) {
|
||||
total += 1;
|
||||
missed += usize::from(copy_misses(st, f, &targets, tools));
|
||||
}
|
||||
}
|
||||
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
|
||||
missed += m;
|
||||
total += t;
|
||||
}
|
||||
// Copies of a cut through sheet metal are cut-outs of its flat pattern, as the cut is
|
||||
// (#6841): each machined into the sheets in turn, then cut from the other targets.
|
||||
|
||||
@@ -18,8 +18,10 @@ use crate::*;
|
||||
|
||||
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
|
||||
/// measured, bodies searched for by name, features searched for by id, bodies a cut (or
|
||||
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on, and
|
||||
/// bodies whose faces a tapped hole read for its wall.
|
||||
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on,
|
||||
/// bodies whose faces a tapped hole read for its wall, intersects run to find a pattern's copies
|
||||
/// that cut nothing, and the tools a cut of many tools is made with (one when they are cut in one
|
||||
/// go).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Counts {
|
||||
pub moved: usize,
|
||||
@@ -31,6 +33,8 @@ pub(super) struct Counts {
|
||||
pub cut: usize,
|
||||
pub cylinder_search: usize,
|
||||
pub tapped_wall: usize,
|
||||
pub miss_intersects: usize,
|
||||
pub cut_tools: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -45,6 +49,8 @@ thread_local! {
|
||||
cut: 0,
|
||||
cylinder_search: 0,
|
||||
tapped_wall: 0,
|
||||
miss_intersects: 0,
|
||||
cut_tools: 0,
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1484,3 +1490,150 @@ fn a_hole_into_a_rim_still_bores_its_sheet_beside_far_ones() {
|
||||
let bore = std::f64::consts::PI * (d / 2.0).powi(2) * 60.0;
|
||||
assert!((removed - bore).abs() < 0.02 * bore, "removed {removed} of {bore}");
|
||||
}
|
||||
|
||||
/// A 1.5 mm disc of 50 mm radius with a 3.5 mm through hole at `radius` on X, patterned `count`
|
||||
/// times around Z (#9007). The pattern's id.
|
||||
fn disc_with_hole_pattern(doc: &mut Document, radius: f64, count: usize) -> u64 {
|
||||
let disc = FeatureKind::Cylinder {
|
||||
base: point_expr(Vec3::ZERO),
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: "50".into(),
|
||||
height: "1.5".into(),
|
||||
operation: Operation::NewBody,
|
||||
references: Vec::new(),
|
||||
};
|
||||
doc.add_feature(disc, Some("Disc")).unwrap();
|
||||
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||
"type": "hole", "position": [radius, 0, 1.5], "direction": [0, 0, -1], "diameter": "3.5",
|
||||
}))
|
||||
.unwrap();
|
||||
let hole = doc.add_feature(hole, Some("Hole")).unwrap();
|
||||
hole_pattern(doc, hole, count)
|
||||
}
|
||||
|
||||
fn hole_pattern(doc: &mut Document, hole: u64, count: usize) -> u64 {
|
||||
let pattern: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||
"type": "pattern", "features": [hole],
|
||||
"pattern": {"type": "circular", "origin": [0, 0, 0], "axis": [0, 0, 1], "count": count.to_string(), "angle": "360 deg"},
|
||||
}))
|
||||
.unwrap();
|
||||
doc.add_feature(pattern, None).unwrap()
|
||||
}
|
||||
|
||||
fn disc_volume(model: &Model) -> f64 {
|
||||
let st = model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
kernel::measure(&st.bodies[0].body).unwrap().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circular_pattern_of_a_through_hole_cuts_its_copies_at_once() {
|
||||
// The copies' tools run twice the disc's size along Z, so the kernel's box test, which
|
||||
// grows each box by a share of the tool's size, finds them meeting (#9007); they are still
|
||||
// 4.3 mm apart, centre to centre, and are cut in one go. No copy is intersected with the
|
||||
// disc to see whether it takes material: each passes through the disc's material.
|
||||
let mut doc = Document::new("sieve");
|
||||
let pattern = disc_with_hole_pattern(&mut doc, 20.0, 29);
|
||||
let mut model = Model::new();
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
assert!(model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", model.results);
|
||||
assert_eq!((work.cut_tools, work.miss_intersects), (1, 0), "{work:?}");
|
||||
let hole = std::f64::consts::PI * 1.75 * 1.75 * 1.5;
|
||||
let disc = std::f64::consts::PI * 2500.0 * 1.5;
|
||||
let v = disc_volume(&model);
|
||||
assert!((v - (disc - 29.0 * hole)).abs() < 1e-3 * hole, "{v}");
|
||||
// The same copies again, landing in the holes cut already: each is intersected, and each
|
||||
// still counts as a copy that removes no material.
|
||||
let hole_id = doc.features.iter().find(|f| f.name == "Hole").unwrap().id;
|
||||
hole_pattern(&mut doc, hole_id, 29);
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
let again = model.results.last().unwrap();
|
||||
assert!(again.error.is_none(), "{again:?}");
|
||||
// (The kernel may add a note on a retry of a boolean on faces that lie on each other.)
|
||||
assert!(again.warning.as_deref().is_some_and(|w| w.starts_with("28 of 28 cut copies remove no material")), "{again:?}");
|
||||
assert_eq!(work.miss_intersects, 28, "{work:?}");
|
||||
assert!((disc_volume(&model) - v).abs() < 1e-3 * hole);
|
||||
assert!(model.results.iter().any(|r| r.id == pattern && r.warning.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_of_a_hole_that_come_close_are_cut_one_by_one() {
|
||||
// At 8.5 mm radius, 16 copies of a 3.5 mm hole overlap their neighbours: they are not apart,
|
||||
// and each is cut on its own, as before.
|
||||
let mut doc = Document::new("crowded");
|
||||
disc_with_hole_pattern(&mut doc, 8.5, 16);
|
||||
let mut model = Model::new();
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
assert!(model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", model.results);
|
||||
assert_eq!((work.cut_tools, work.miss_intersects), (15, 0), "{work:?}");
|
||||
// At 9 mm they come within 0.012 mm of each other without touching: their exact shapes are
|
||||
// apart, so they are cut in one go, and all 16 holes are cut.
|
||||
let mut doc = Document::new("close");
|
||||
disc_with_hole_pattern(&mut doc, 9.0, 16);
|
||||
let mut model = Model::new();
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
assert!(model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", model.results);
|
||||
assert_eq!((work.cut_tools, work.miss_intersects), (1, 0), "{work:?}");
|
||||
let hole = std::f64::consts::PI * 1.75 * 1.75 * 1.5;
|
||||
let disc = std::f64::consts::PI * 2500.0 * 1.5;
|
||||
let v = disc_volume(&model);
|
||||
assert!((v - (disc - 16.0 * hole)).abs() < 1e-3 * hole, "{v}");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// surfaces are, and the work.
|
||||
fn grooved_ring_faces() -> (usize, Counts) {
|
||||
let mut doc = Document::new("grooves");
|
||||
let cylinder = |base: Vec3, radius: &str, height: &str, operation: Operation| FeatureKind::Cylinder {
|
||||
base: point_expr(base),
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: radius.into(),
|
||||
height: height.into(),
|
||||
operation,
|
||||
references: Vec::new(),
|
||||
};
|
||||
doc.add_feature(cylinder(Vec3::ZERO, "25", "55", Operation::NewBody), Some("Ring")).unwrap();
|
||||
doc.add_feature(cylinder(Vec3::new(0.0, 0.0, -1.0), "16", "57", Operation::Cut), Some("Bore")).unwrap();
|
||||
let groove = doc.add_feature(cylinder(Vec3::new(16.0, 0.0, -1.0), "4", "31", Operation::Cut), Some("Groove")).unwrap();
|
||||
hole_pattern(&mut doc, groove, 10);
|
||||
let mut model = Model::new();
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
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);
|
||||
(kernel::measure(&st.bodies[0].body).unwrap().merged.faces, work)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_that_notch_the_same_round_face_cut_at_once_leave_the_faces_of_one_by_one() {
|
||||
// The ten grooves notch the same round bore. Cut all in one go, they leave it in as many
|
||||
// faces as cutting them one after another does (#9007).
|
||||
let (patterned, work) = grooved_ring_faces();
|
||||
let cut = |a: kernel::Body, b: &kernel::Body| kernel::boolean(&a, b, kernel::BoolOp::Cut).unwrap().unwrap();
|
||||
let ring =
|
||||
cut(kernel::cylinder(Vec3::ZERO, Vec3::Z, 25.0, 55.0).unwrap(), &kernel::cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 16.0, 57.0).unwrap());
|
||||
let groove = kernel::cylinder(Vec3::new(16.0, 0.0, -1.0), Vec3::Z, 4.0, 31.0).unwrap();
|
||||
let one_by_one = (0..10).fold(ring, |ring, k| {
|
||||
let copy = kernel::transform(&groove, Vec3::ZERO, Vec3::ZERO, Vec3::Z, (36.0 * k as f64).to_radians()).unwrap();
|
||||
cut(ring, ©)
|
||||
});
|
||||
assert_eq!(patterned, kernel::measure(&one_by_one).unwrap().merged.faces);
|
||||
assert_eq!(work.cut_tools, 1, "{work:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_are_apart_only_when_they_neither_meet_nor_nest() {
|
||||
let rod = |x: f64, y: f64, r: f64| kernel::cylinder(Vec3::new(x, y, -100.0), Vec3::Z, r, 200.0).unwrap();
|
||||
// Long rods whose boxes, grown as the kernel grows them, meet: 0.5 mm apart.
|
||||
assert!(super::tools_clearly_apart(&[rod(0.0, 0.0, 1.0), rod(2.5, 0.0, 1.0)]));
|
||||
// Touching, crossing and nested rods are not apart.
|
||||
assert!(!super::tools_clearly_apart(&[rod(0.0, 0.0, 1.0), rod(2.0, 0.0, 1.0)]));
|
||||
assert!(!super::tools_clearly_apart(&[rod(0.0, 0.0, 1.0), rod(1.5, 0.0, 1.0)]));
|
||||
assert!(!super::tools_clearly_apart(&[rod(0.0, 0.0, 3.0), rod(0.5, 0.0, 1.0)]));
|
||||
let tube = kernel::boolean(&rod(0.0, 0.0, 5.0), &rod(0.0, 0.0, 4.0), kernel::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(!super::tools_clearly_apart(&[tube.clone(), rod(0.0, 0.0, 4.5)]));
|
||||
// A rod in the tube's bore, clear of its wall, is apart from it.
|
||||
assert!(super::tools_clearly_apart(&[tube, rod(0.0, 0.0, 3.0)]));
|
||||
}
|
||||
|
||||
@@ -2318,11 +2318,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
_ => false,
|
||||
};
|
||||
let (Some(_), Some(_), false) = (outer, openings.first(), alike) else {
|
||||
// A solid box top has no opening: Shell that face first, then pick the rim
|
||||
// around the cavity (#10437).
|
||||
let what = match (loops.len(), openings.len()) {
|
||||
(0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(),
|
||||
(_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening"
|
||||
(0 | 1, _) => "Lip needs the rim of a shelled body: Shell a face open first, then pick the rim around that opening (a face with an outer edge and an inner hole)"
|
||||
.to_string(),
|
||||
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
|
||||
(_, 0) => "Lip needs a rim around an opening: this face's inner edges are where other features stand on it, not a shell cavity. Shell a face open, then pick that rim"
|
||||
.to_string(),
|
||||
(_, k) => format!(
|
||||
"Lip needs one clear opening as the rim: this face has {k} openings alike in size (for example a plate of holes). Shell one cavity, or pick a rim that rings a single opening"
|
||||
),
|
||||
};
|
||||
return Err(DocError::Invalid(what));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A counterbore hole may save a `bridge` length: two printing layers that thick below the
|
||||
//! counterbore's floor (a slot as wide as the hole along X, then a square) so a 3D printer bridges
|
||||
//! the step. An older design has none and keeps a plain counterbore. The last release refuses a
|
||||
//! design that has it (it asks for an update) instead of opening it without the bridge layers.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A new-body extrusion or revolve whose outputs transfer into its source sketch's component
|
||||
//! reads the profile through the recorded transfer frame. Capturing that component's position
|
||||
//! therefore moves the body once, as the pending preview does. Existing designs use their
|
||||
//! saved transfer transforms without rewriting fields; independent cross-component references
|
||||
//! keep their live placement. The last release refuses the newer design-format number rather
|
||||
//! than reopening the design with the former double-placement behavior.
|
||||
@@ -757,10 +757,19 @@ pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::n
|
||||
// cut body's own feature's and the rest are the cut's, and `body.rename` stores a name in
|
||||
// the `body_names` of the feature a body says made it (#10438).
|
||||
let names: Vec<(String, u64)> = members.iter().filter_map(|i| bodies.get(*i).map(|b| (b.name.clone(), b.feature))).collect();
|
||||
for ((name, feature), (_, i)) in names.into_iter().zip(keys) {
|
||||
if let Some(b) = bodies.get_mut(i) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
// The pieces take the family's places in that order too, so the state lists them as
|
||||
// their names run. The session pins a feature's body names in state order, and the next
|
||||
// evaluation hands them out in this order: listed in the kernel's order, the pieces
|
||||
// swapped names once the design was evaluated again from its pinned names (opened).
|
||||
let moved: Vec<crate::ModelBody> = keys.iter().filter_map(|(_, i)| bodies.get(*i).cloned()).collect();
|
||||
if moved.len() != members.len() || names.len() != members.len() {
|
||||
continue;
|
||||
}
|
||||
for ((mut b, (name, feature)), place) in moved.into_iter().zip(names).zip(&members) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
if let Some(slot) = bodies.get_mut(*place) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,6 +769,8 @@ impl Model {
|
||||
let mut exact = true;
|
||||
let mut prev = 0;
|
||||
let mut results = Vec::with_capacity(features.len());
|
||||
let mut construction = HashMap::new();
|
||||
let mut prepared_states = std::collections::HashSet::new();
|
||||
for (i, (f, r)) in doc.features.iter().zip(features).enumerate() {
|
||||
let saved = unhex(r.get("fingerprint").unwrap_or(&Json::Null))?;
|
||||
// A fingerprint reads the state before its feature (where the bodies it takes lie).
|
||||
@@ -780,8 +782,26 @@ impl Model {
|
||||
}
|
||||
let ms = r.get("ms").and_then(Json::as_f64).filter(|ms| ms.is_finite()).ok_or_else(|| bad("saved model: time"))?;
|
||||
// A file without room for every state keeps some features without one (#3220).
|
||||
let state =
|
||||
r.get("state").map(|s| states.get(index(Some(s), states.len())?).cloned().ok_or_else(|| bad("saved model: state"))).transpose()?;
|
||||
let state = r.get("state").map(|s| index(Some(s), states.len())).transpose()?;
|
||||
if let Some(at) = state
|
||||
&& prepared_states.insert(at)
|
||||
{
|
||||
let original = matches!(
|
||||
&f.kind,
|
||||
FeatureKind::Box { operation: Operation::NewBody, .. } | FeatureKind::Extrude { operation: Operation::NewBody, .. }
|
||||
);
|
||||
let current = states.get_mut(at).ok_or_else(|| bad("saved model: state"))?;
|
||||
// Each state is prepared before its first shared clone. Record and NameCell
|
||||
// identity prove this is still the original geometry and its exact names.
|
||||
for body in &mut Arc::make_mut(current).bodies {
|
||||
let key = (std::ptr::from_ref(body.body.record()).addr(), body.names.id());
|
||||
if original && body.feature == f.id && before.body(&body.name).is_none() {
|
||||
construction.insert(key, f.id);
|
||||
}
|
||||
body.names.cached_construction = construction.get(&key).copied();
|
||||
}
|
||||
}
|
||||
let state = state.map(|at| states.get(at).cloned().ok_or_else(|| bad("saved model: state"))).transpose()?;
|
||||
let result = FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
//! that uses it fails, asking for the reference to be picked again
|
||||
//! ([`crate::DocError::Reselect`]). A face whose name was worked out from geometry, found where
|
||||
//! the reference's point no longer lies, is reported: only geometry decided.
|
||||
//! Saved numbered Project links also have a compatibility check for a whole face that
|
||||
//! contains the saved pick and comes directly from its original construction feature.
|
||||
//! This check is local to manual sketch projection.
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Mesh;
|
||||
use solidcraft_geom::brep::eval::SurfaceEval;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::history_names::{Match, Names};
|
||||
use crate::naming::{self, closest, edge_names, face_names, strip_piece};
|
||||
use crate::piece_names::strip_edge_number;
|
||||
use crate::{ModelBody, ModelState};
|
||||
use crate::{FeatureKind, ModelBody, ModelState, Operation};
|
||||
|
||||
/// The mark on a name saved before format 14 (`?F2:top#2`). Those names were worked out from
|
||||
/// geometry, and some of them name a face that exact history names differently (a tube's outer
|
||||
@@ -433,6 +437,84 @@ pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit
|
||||
locate_in(std::iter::once((0, body)), name, p).ok()
|
||||
}
|
||||
|
||||
/// 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. 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;
|
||||
}
|
||||
match locate_in(std::iter::once((0, body)), name, p) {
|
||||
Ok(hit) => Some((hit.face, hit.point)),
|
||||
Err(Miss::Gone) => recover_numbered_projected_face(body, name, p),
|
||||
Err(Miss::Several) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound the quadratic scan in remove_faces, which takes the list of faces to remove.
|
||||
const MAX_PROJECTION_RECOVERY_FACES: usize = 1024;
|
||||
|
||||
fn recover_numbered_projected_face(body: &ModelBody, name: &str, pick: Vec3) -> Option<(usize, Vec3)> {
|
||||
let (stem, number) = name.rsplit_once('#')?;
|
||||
let count = body.body.face_count();
|
||||
if count > MAX_PROJECTION_RECOVERY_FACES
|
||||
|| !pick.is_finite()
|
||||
|| stem.is_empty()
|
||||
|| strip_piece(stem) != stem
|
||||
|| number.is_empty()
|
||||
|| number.starts_with('0')
|
||||
|| !number.bytes().all(|digit| digit.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A changed body may keep the same face name while the face absorbs a smaller piece.
|
||||
// Projecting that larger face could silently grow a downstream feature's material.
|
||||
let producer = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let live_construction = body.names.origin.as_ref().is_some_and(|origin| {
|
||||
producer == origin.feature.id
|
||||
&& matches!(
|
||||
&origin.feature.kind,
|
||||
FeatureKind::Box { operation: Operation::NewBody, .. } | FeatureKind::Extrude { operation: Operation::NewBody, .. }
|
||||
)
|
||||
&& origin.before.body(&body.name).is_none()
|
||||
});
|
||||
if producer != body.feature || !(live_construction || body.names.cached_construction == Some(producer)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidates = faces_with_stem(body, stem);
|
||||
let &[face] = candidates.as_slice() else { return None };
|
||||
let names = naming::names(body);
|
||||
let info = names.info.get(face)?;
|
||||
if names.faces.get(face)?.as_str() != stem || !info.verified || info.aliases.iter().any(|alias| strip_piece(alias) != stem) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let record = body.body.record().faces.get(face)?;
|
||||
let [[u0, u1], [v0, v1]] = record.domain;
|
||||
let surface = SurfaceEval::new(record.parameters()).ok()?;
|
||||
let tolerance = (body.body.size() * 1e-3).max(1e-3);
|
||||
if !tolerance.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let point = surface.point(surface.project(pick, Some([u0.midpoint(u1), v0.midpoint(v1)]), tolerance)?)?;
|
||||
if !point.is_finite() || !(point.dist(pick) <= tolerance) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Isolate the face so a point on another face of the same surface cannot pass the trim test.
|
||||
let others: Vec<_> = (0..count).filter(|index| *index != face).collect();
|
||||
let isolated;
|
||||
let trimmed = if others.is_empty() {
|
||||
&body.body
|
||||
} else {
|
||||
isolated = solidcraft_kernel::remove_faces(&body.body, &others).ok()?;
|
||||
&isolated
|
||||
};
|
||||
matches!(trimmed.classify_point(point), Ok(solidcraft_kernel::PointClass::OnBoundary)).then_some((face, point))
|
||||
}
|
||||
|
||||
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
|
||||
/// any body. `Err` says why there is no such face, or why the name does not name just one (the
|
||||
/// pieces of a split face, or the same face on several bodies): a command that takes a face by
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{Document, ModelBody, ModelState};
|
||||
use solidcore_record::{Plane, Vec3};
|
||||
use solidcore_record::{Plane, Vec2, Vec3};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -49,3 +49,197 @@ fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
|
||||
assert_eq!(sketch.links[0].id, link);
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_manual_projection_rebuilds_from_the_whole_planar_face() {
|
||||
let mut doc = Document::default();
|
||||
doc.set_param("size", "5", Some("mm"), None).unwrap();
|
||||
let kind = serde_json::from_value(serde_json::json!({
|
||||
"type": "box", "corner": [0, 0, 0], "length": "size", "width": "size", "height": "10"
|
||||
}))
|
||||
.unwrap();
|
||||
let feature = doc.add_feature(kind, None).unwrap();
|
||||
doc.feature_mut(feature).unwrap().body_names = vec!["Source".into()];
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let plane = Plane::XY.offset(20.);
|
||||
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(2., 2., 10.) };
|
||||
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &model.state(), &plane, LinkKind::Project, &source).unwrap();
|
||||
let mut sketch = Sketch::default();
|
||||
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
|
||||
let numbered = format!("F{feature}:+z#1");
|
||||
sketch.links[0].face_name = Some(numbered.clone());
|
||||
|
||||
doc.set_param("size", "20", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), &model.state(), &plane, &mut sketch).unwrap().is_empty());
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert_eq!(sketch.links[0].face_name.as_deref(), Some(numbered.as_str()));
|
||||
assert_eq!(sketch.link_curves(&link).len(), 4);
|
||||
assert!(sketch.points.iter().any(|point| point.pos.x > 19. && point.pos.y > 19.));
|
||||
|
||||
let saved = model.saved(&doc).unwrap();
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let cached = crate::Model::adopt(&reopened, &saved).unwrap();
|
||||
let state = cached.state();
|
||||
assert!(state.body("Source").unwrap().names.origin.is_none());
|
||||
assert_eq!(state.body("Source").unwrap().names.cached_construction, Some(feature));
|
||||
assert!(crate::project::refresh_links(&reopened, &BTreeMap::new(), &state, &plane, &mut sketch).unwrap().is_empty());
|
||||
assert!(!sketch.links[0].lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_round_face_projection_recovers_the_whole_face() {
|
||||
let doc = 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": 20., "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": "10"},
|
||||
"operation": "new_body", "profiles": {"loops": [["c1"]], "type": "curves"}, "sketch": 1
|
||||
},
|
||||
{
|
||||
"id": 3, "name": "Sketch2", "type": "sketch", "plane": {"name": "YZ", "type": "origin"},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{"fixed": true, "id": "origin", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p1", "pos": [-20., 0.]},
|
||||
{"fixed": false, "id": "p2", "pos": [20., 0.]},
|
||||
{"fixed": false, "id": "p3", "pos": [-20., 10.]},
|
||||
{"fixed": false, "id": "p4", "pos": [20., 10.]}
|
||||
],
|
||||
"curves": [
|
||||
{"a": 1, "b": 2, "construction": false, "id": "l1", "link": "j1", "type": "line"},
|
||||
{"a": 3, "b": 4, "construction": false, "id": "l2", "link": "j1", "type": "line"}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {"k": 0, "l": 2, "p": 4},
|
||||
"links": [{
|
||||
"face_name": "F2:side:c1#1", "id": "j1", "kind": "project",
|
||||
"source": {"at": [-19.87, -2.3, 6.], "body": "Body1", "type": "face"}
|
||||
}]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
..Document::default()
|
||||
};
|
||||
let doc = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Body1").unwrap();
|
||||
assert!(crate::naming::face_names(body).iter().any(|name| name == "F2:side:c1"));
|
||||
let result = model.result(3).unwrap();
|
||||
assert!(result.error.is_none(), "{result:?}");
|
||||
let projected = &state.sketch(3).expect("the saved projection should rebuild").sketch;
|
||||
assert_eq!(projected.links[0].face_name.as_deref(), Some("F2:side:c1#1"));
|
||||
assert_eq!(projected.link_curves("j1").len(), 2);
|
||||
|
||||
// The edge-on projection of the round wall can retain its saved geometry with a warning.
|
||||
// The numbered reference must evaluate the same way as the whole face's existing name.
|
||||
let mut exact_doc = doc.clone();
|
||||
exact_doc.sketch_mut(3).unwrap().links[0].face_name = Some("F2:side:c1".into());
|
||||
let mut exact_model = crate::Model::new();
|
||||
exact_model.evaluate(&exact_doc);
|
||||
let exact_state = exact_model.state();
|
||||
let exact = &exact_state.sketch(3).unwrap().sketch;
|
||||
assert_eq!(result.warning, exact_model.result(3).unwrap().warning);
|
||||
assert_eq!(projected.links[0].lost, exact.links[0].lost);
|
||||
assert_eq!(projected.curves, exact.curves);
|
||||
assert_eq!(projected.points, exact.points);
|
||||
|
||||
let saved = model.saved(&doc).expect("the healthy projection should save its model cache");
|
||||
let mut reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut reopened_model = crate::Model::adopt(&reopened, &saved).unwrap();
|
||||
assert!(reopened_model.state().body("Body1").unwrap().names.origin.is_none());
|
||||
assert_eq!(reopened_model.state().body("Body1").unwrap().names.cached_construction, Some(2));
|
||||
let crate::FeatureKind::Sketch { plane, .. } = &mut reopened.feature_mut(3).unwrap().kind else { panic!("projection sketch") };
|
||||
*plane = crate::PlaneRef::Custom { plane: Plane::YZ.offset(0.1) };
|
||||
reopened_model.evaluate(&reopened);
|
||||
assert!(reopened_model.result(3).unwrap().error.is_none(), "{:?}", reopened_model.result(3));
|
||||
let pick = Vec3::new(-19.87, -2.3, 6.);
|
||||
assert!(crate::naming::face_on_body(body, "F2:side:c1#1", pick).is_none(), "ordinary lookup stays strict");
|
||||
let mut split = body.clone();
|
||||
let mut names = crate::naming::face_names(body).as_ref().clone();
|
||||
*names.iter_mut().find(|name| name.as_str() != "F2:side:c1").unwrap() = "F2:side:c1#2#[wall]".into();
|
||||
let origin = split.names.origin.clone();
|
||||
split.names = crate::naming::NameCell::known(Arc::new(names));
|
||||
split.names.origin = origin;
|
||||
assert!(crate::naming::face_on_body(&split, "F2:side:c1#1", pick).is_none());
|
||||
assert!(crate::name_lookup::locate_projected_face(&split, "F2:side:c1#1", pick).is_none(), "another piece still exists");
|
||||
|
||||
let mut outside = doc.sketch(3).unwrap().clone();
|
||||
outside.links[0].source = LinkSource::Face { body: "Body1".into(), at: Vec3::new(-19.87, -2.3, 12.) };
|
||||
let error = crate::project::refresh_links(&doc, &BTreeMap::new(), &state, &Plane::YZ, &mut outside).unwrap_err();
|
||||
assert!(matches!(&error, crate::DocError::Reselect { reference, .. } if reference == "F2:side:c1#1"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_projection_refuses_modified_or_moved_cached_sources() {
|
||||
for (kind, pick) in [
|
||||
(serde_json::json!({"type": "shell", "body": "Source", "faces": [[10, 10, 10]], "thickness": "1"}), Vec3::new(0., 5., 5.)),
|
||||
(serde_json::json!({"type": "move", "bodies": ["Source"], "translate": ["1", "0", "0"]}), Vec3::new(1., 5., 5.)),
|
||||
] {
|
||||
let mut doc = Document::default();
|
||||
let source = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "box", "corner": [0, 0, 0], "length": "20", "width": "20", "height": "10"
|
||||
}))
|
||||
.unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(source).unwrap().body_names = vec!["Source".into()];
|
||||
doc.add_feature(serde_json::from_value(kind).unwrap(), None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()));
|
||||
let saved = model.saved(&doc).unwrap();
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let cached = crate::Model::adopt(&reopened, &saved).unwrap();
|
||||
let state = cached.state();
|
||||
let body = state.body("Source").unwrap();
|
||||
let whole = format!("F{source}:-x");
|
||||
let numbered = format!("{whole}#1");
|
||||
assert!(crate::naming::face_on_body(body, &whole, pick).is_some());
|
||||
assert!(crate::naming::face_on_body(body, &numbered, pick).is_none());
|
||||
assert!(body.names.origin.is_none());
|
||||
assert_eq!(body.names.cached_construction, None);
|
||||
assert!(crate::name_lookup::locate_projected_face(body, &numbered, pick).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
|
||||
let mut doc = Document::default();
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_circle(Vec2::ZERO, 10., Some(0), Some("c1")).unwrap();
|
||||
sketch.add_circle(Vec2::ZERO, 2., Some(0), Some("c2")).unwrap();
|
||||
let profile = doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let kind = serde_json::from_value(serde_json::json!({
|
||||
"type": "extrude", "sketch": profile, "profiles": {"type": "curves", "loops": [["c1"]]}, "extent": {"distance": "5"}
|
||||
}))
|
||||
.unwrap();
|
||||
let feature = doc.add_feature(kind, None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Body1").unwrap();
|
||||
let whole = format!("F{feature}:top");
|
||||
let top = crate::naming::face_names(body).iter().position(|name| name == &whole).unwrap();
|
||||
let numbered = format!("{whole}#1");
|
||||
|
||||
let pick = |x| crate::name_lookup::locate_projected_face(body, &numbered, Vec3::new(x, 0., 5.));
|
||||
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
|
||||
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct NamingOrigin {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NameCell {
|
||||
pub origin: Option<Arc<NamingOrigin>>,
|
||||
/// Direct construction verified from identical cached body/name cells during adoption.
|
||||
/// Used only by manual Project compatibility, independently of general naming history.
|
||||
pub(crate) cached_construction: Option<u64>,
|
||||
/// What the body's faces came from, when the feature that made it reports it.
|
||||
lineage: Option<Arc<Lineage>>,
|
||||
names: Arc<OnceLock<Arc<Names>>>,
|
||||
@@ -57,7 +60,7 @@ impl NameCell {
|
||||
}
|
||||
/// Names of a body made by `origin`'s feature, following `lineage` where it is known.
|
||||
pub(crate) fn traced(origin: Option<Arc<NamingOrigin>>, lineage: Option<Arc<Lineage>>) -> Self {
|
||||
NameCell { origin, lineage, names: Arc::new(OnceLock::new()), copy_of: None, saved_depth: None }
|
||||
NameCell { origin, cached_construction: None, lineage, names: Arc::new(OnceLock::new()), copy_of: None, saved_depth: None }
|
||||
}
|
||||
/// Names already known, taken as they are.
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
@@ -98,7 +101,7 @@ impl NameCell {
|
||||
fn shared(names: Arc<Names>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { origin: None, lineage: None, names: Arc::new(cell), copy_of: None, saved_depth: None }
|
||||
NameCell { origin: None, cached_construction: None, lineage: None, names: Arc::new(cell), copy_of: None, saved_depth: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1034,8 +1034,16 @@ fn refresh_links_in(
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
let (index, p) =
|
||||
st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)).ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let (index, p) = st
|
||||
.body(body)
|
||||
.and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
}
|
||||
|
||||
@@ -451,6 +451,9 @@ pub struct RuleValues {
|
||||
pub angle_display: AngleDisplay,
|
||||
}
|
||||
|
||||
/// A sheet rule's thickness is less than this (mm).
|
||||
const MAX_SHEET_THICKNESS: f64 = 1e4;
|
||||
|
||||
impl Document {
|
||||
/// A rule by name, the active one, or the default steel rule.
|
||||
pub fn sheet_rule(&self, name: Option<&str>) -> SheetRule {
|
||||
@@ -474,8 +477,8 @@ impl Document {
|
||||
Some(t) => t,
|
||||
None => expr::eval_with(&r.thickness, &look)?.to_kind(Kind::Length)?,
|
||||
};
|
||||
if !(t > 0.0 && t < 1e4) {
|
||||
return Err(DocError::Invalid("the sheet thickness must be positive".into()));
|
||||
if !(t > 0.0 && t < MAX_SHEET_THICKNESS) {
|
||||
return Err(DocError::Invalid("the sheet thickness must be more than 0 and less than 10000 mm".into()));
|
||||
}
|
||||
let with_t = |e: &str, k: Kind| -> Result<f64> {
|
||||
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
|
||||
@@ -496,8 +499,12 @@ impl Document {
|
||||
angle_display: AngleDisplay::parse(&r.angle_display)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown bend angle display `{}` (bend or included)", r.angle_display)))?,
|
||||
};
|
||||
if !(0.0..=1.0).contains(&v.k_factor) || v.bend_radius < 0.0 {
|
||||
return Err(DocError::Invalid("the K-factor must be 0…1 and the bend radius positive".into()));
|
||||
// Each value named on its own: a sharp bend (radius 0) is allowed (#9625).
|
||||
if !(0.0..=1.0).contains(&v.k_factor) {
|
||||
return Err(DocError::Invalid("the K-factor must be between 0 and 1".into()));
|
||||
}
|
||||
if !(v.bend_radius >= 0.0) {
|
||||
return Err(DocError::Invalid("the bend radius must not be negative".into()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -2538,6 +2545,123 @@ impl SheetBody {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Include the physical holes that perforation fields leave out of the B-rep measurement.
|
||||
/// `placement` maps this sheet's geometry into the measurement's frame. Hole cylinders
|
||||
/// lie on panels, clear of bends; their volume moments follow each panel's current fold.
|
||||
/// Bounding boxes and B-rep topology are unchanged by these interior holes.
|
||||
pub fn correct_perforation_properties(&self, measured: &mut kernel::BodyMeasure, placement: &Mat) -> Result<()> {
|
||||
let (mut removed, mut area_delta, mut moment_delta) = (0.0, 0.0, Vec3::ZERO);
|
||||
self.visit_perforation_cylinders(placement, |center, volume, area, _| {
|
||||
removed += volume;
|
||||
area_delta += area;
|
||||
// Use distances from the measured centre, including far-away placements.
|
||||
moment_delta += (measured.centroid - center) * volume;
|
||||
})?;
|
||||
if removed == 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let volume = measured.volume - removed;
|
||||
let area = measured.area + area_delta;
|
||||
let centroid = measured.centroid + moment_delta / volume;
|
||||
if !(volume.is_finite() && volume > 0.0 && area.is_finite() && area >= 0.0 && centroid.is_finite()) {
|
||||
return Err(DocError::Invalid(format!("invalid perforation physical properties for `{}`", self.body)));
|
||||
}
|
||||
measured.volume = volume;
|
||||
measured.area = area;
|
||||
measured.centroid = centroid;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the same hole cylinders from the inertia spread as from volume and centroid.
|
||||
/// The input and output tensors are at unit density, about their respective centroids.
|
||||
pub fn correct_perforation_mass_properties(&self, measured: &mut solidcraft_geom::MassProperties, placement: &Mat) -> Result<()> {
|
||||
use solidcraft_geom::inertia;
|
||||
let (mut removed, mut moment_delta, mut removed_inertia) = (0.0, Vec3::ZERO, [[0.0; 3]; 3]);
|
||||
self.visit_perforation_cylinders(placement, |center, volume, _, own| {
|
||||
let delta = measured.centroid - center;
|
||||
removed += volume;
|
||||
moment_delta += delta * volume;
|
||||
let about_center = inertia::shifted(&own, volume, delta);
|
||||
for (row, hole) in removed_inertia.iter_mut().zip(about_center) {
|
||||
for (value, h) in row.iter_mut().zip(hole) {
|
||||
*value += h;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if removed == 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let volume = measured.volume - removed;
|
||||
let delta = moment_delta / volume;
|
||||
let centroid = measured.centroid + delta;
|
||||
let mut tensor = measured.inertia;
|
||||
for (row, hole) in tensor.iter_mut().zip(removed_inertia) {
|
||||
for (value, h) in row.iter_mut().zip(hole) {
|
||||
*value -= h;
|
||||
}
|
||||
}
|
||||
// Move from the old centre to the new one, reversing the parallel-axis term.
|
||||
let tensor = inertia::shifted(&tensor, -volume, delta);
|
||||
if !(volume.is_finite() && volume > 0.0 && centroid.is_finite() && tensor.iter().flatten().all(|v| v.is_finite())) {
|
||||
return Err(DocError::Invalid(format!("invalid perforation inertia for `{}`", self.body)));
|
||||
}
|
||||
measured.volume = volume;
|
||||
measured.centroid = centroid;
|
||||
measured.inertia = tensor;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Analytic cylinders at each panel's current fold, in the requested measurement frame.
|
||||
fn visit_perforation_cylinders(&self, placement: &Mat, mut visit: impl FnMut(Vec3, f64, f64, solidcraft_geom::inertia::Tensor3)) -> Result<()> {
|
||||
if self.perforations.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let invalid = || DocError::Invalid(format!("invalid perforation physical properties for `{}`", self.body));
|
||||
if !(self.t.is_finite() && self.t > 0.0) || placement.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let isometry = |m: &Mat| {
|
||||
let [x, y, z] = [0, 1, 2].map(|i| Vec3::new(m[i][0], m[i][1], m[i][2]));
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& m[0][3] == 0.0
|
||||
&& m[1][3] == 0.0
|
||||
&& m[2][3] == 0.0
|
||||
&& m[3][3] == 1.0
|
||||
&& [x, y, z].iter().all(|a| (a.dot(*a) - 1.0).abs() < 1e-9)
|
||||
&& x.dot(y).abs() < 1e-9
|
||||
&& x.dot(z).abs() < 1e-9
|
||||
&& y.dot(z).abs() < 1e-9
|
||||
};
|
||||
let panels: Vec<_> = self.panels().into_iter().map(|(i, region)| (mat_mul(placement, &self.world(i)), region)).collect();
|
||||
let flat = mat_mul(placement, &self.world(None));
|
||||
if !isometry(&flat) || panels.iter().any(|(world, _)| !isometry(world)) {
|
||||
return Err(DocError::Invalid("not supported yet: perforation physical properties under scale or shear".into()));
|
||||
}
|
||||
for p in &self.perforations {
|
||||
if !([p.diameter, p.pitch, p.margin].iter().all(|v| v.is_finite()) && p.diameter > 0.0 && p.pitch > p.diameter && p.margin >= 0.0) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let holes = self.perforation_holes(p)?;
|
||||
let disk = std::f64::consts::PI * (p.diameter / 2.0).powi(2);
|
||||
let volume = disk * self.t;
|
||||
let area_delta = std::f64::consts::PI * p.diameter * self.t - 2.0 * disk;
|
||||
let radius2 = (p.diameter / 2.0).powi(2);
|
||||
let transverse = volume * (3.0 * radius2 + self.t * self.t) / 12.0;
|
||||
let own = [[transverse, 0.0, 0.0], [0.0, transverse, 0.0], [0.0, 0.0, volume * radius2 / 2.0]];
|
||||
for hole in holes {
|
||||
let world = if self.flat || panels.len() == 1 {
|
||||
&flat
|
||||
} else {
|
||||
&panels.iter().find(|(_, region)| region.contains(hole)).ok_or_else(invalid)?.0
|
||||
};
|
||||
let center = apply_point(world, v3(hole) + Vec3::Z * (self.t / 2.0));
|
||||
let rotation = [0, 1, 2].map(|i| [0, 1, 2].map(|j| world[j][i]));
|
||||
visit(center, volume, area_delta, solidcraft_geom::inertia::transformed(&own, &rotation));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Topology counted by faces and loops: a round hole is one face bounded by two closed circular
|
||||
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
|
||||
/// the sewn (faces, edges, vertices).
|
||||
@@ -3537,7 +3661,11 @@ impl SheetBody {
|
||||
return Err("the gap would cut a panel away".into());
|
||||
}
|
||||
}
|
||||
// The flat pattern must stay one sheet without overlaps.
|
||||
// The flat pattern must stay one sheet without overlaps: closing must not make the two
|
||||
// overlap, nor add to what either already overlaps of another flange. A flange can lie
|
||||
// over another one in the flat away from this corner (the walls on either side of a
|
||||
// notch's inner corner both unfold into the notch, and a return on one of them lies over
|
||||
// the other), and the corner is still closed then (#8823).
|
||||
let [new_a, new_b] = new;
|
||||
let pieces = |s: &SheetBody, f: &SheetFlange| {
|
||||
let mut v = vec![corners_of(&s.zone(f))];
|
||||
@@ -3547,13 +3675,19 @@ impl SheetBody {
|
||||
v
|
||||
};
|
||||
let mine = [pieces(self, &new_a), pieces(self, &new_b)];
|
||||
let was = [pieces(self, &fa), pieces(self, &fb)];
|
||||
let overlap = |p: &[Vec<Vec2>], q: &[Vec<Vec2>]| p.iter().any(|a| q.iter().any(|b| pieces_overlap(a, b)));
|
||||
let shared = |p: &[Vec<Vec2>], q: &[Vec<Vec2>]| p.iter().map(|a| q.iter().map(|b| convex_overlap(a, b)).sum::<f64>()).sum::<f64>();
|
||||
let perimeter =
|
||||
|p: &[Vec<Vec2>]| p.iter().flat_map(|c| (0..c.len()).filter_map(move |i| Some(c.get(i)?.dist(*c.get((i + 1) % c.len())?)))).sum::<f64>();
|
||||
let [mine_a, mine_b] = &mine;
|
||||
let clash = overlap(mine_a, mine_b)
|
||||
|| self.flanges.iter().enumerate().any(|(j, g)| {
|
||||
j != k.a && j != k.b && g.shape.is_none() && {
|
||||
let other = pieces(self, g);
|
||||
mine.iter().any(|p| overlap(p, &other))
|
||||
mine.iter().zip(&was).any(|(p, q)| {
|
||||
overlap(p, &other) && shared(p, &other) > shared(q, &other) + FLAT_SLIVER * perimeter(p).max(perimeter(&other)).max(1.0)
|
||||
})
|
||||
}
|
||||
});
|
||||
if clash {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `solidcraft-cli mcp setup` and Help > Connect AI assistants: add a `solidcraft` MCP server entry
|
||||
//! to the AI clients installed on this machine, using the `solidcraft-cli` path given.
|
||||
//!
|
||||
//! The clients' own files are edited in place, not rewritten: only the `solidcraft` entries change,
|
||||
//! every other byte (other servers, other settings, comments, key order) stays as it was. The file
|
||||
//! is copied to `<name>.bak` first. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! Only the `solidcraft` entries change; every other byte (other servers, other settings, comments,
|
||||
//! key order) stays as it was. Updates replace the file atomically, keeping the previous version
|
||||
//! as `<name>.bak`. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! left alone, and so is an entry the user changed (its command is not a SolidCraft CLI, or its
|
||||
//! arguments differ); an entry that only has an old path to the CLI is updated.
|
||||
//! A client counts as installed when its configuration folder exists; no client program is run.
|
||||
@@ -606,11 +606,7 @@ fn toml_to_json(t: &toml_edit::Table) -> Value {
|
||||
/// One client's outcome, a line of the report.
|
||||
fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode) -> Result<(String, Vec<(&'static str, Change)>), String> {
|
||||
let fail = |e: String| format!("{}: {e}; left unchanged", t.file.display());
|
||||
let existing = match std::fs::read_to_string(&t.file) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(format!("{}: {e}", t.file.display())),
|
||||
};
|
||||
let existing = read_config(&t.file).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
let mut text = existing.clone().unwrap_or_default();
|
||||
let changes =
|
||||
if t.shape == Shape::Codex { edit_toml(&mut text, exe, live, mode) } else { edit_json(&mut text, t.shape, exe, live, mode) }.map_err(fail)?;
|
||||
@@ -633,20 +629,30 @@ fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode)
|
||||
if !changed {
|
||||
return done(said);
|
||||
}
|
||||
if existing.is_some() {
|
||||
let mut bak = t.file.clone().into_os_string();
|
||||
bak.push(".bak");
|
||||
// The first backup is the user's original: later runs keep it.
|
||||
if !Path::new(&bak).exists() {
|
||||
std::fs::copy(&t.file, &bak).map_err(|e| format!("{}: cannot back up: {e}", t.file.display()))?;
|
||||
}
|
||||
} else if let Some(dir) = t.file.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(&t.file, text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
write_config(&t.file, existing.as_deref(), &text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
done(format!("{said} ({})", t.file.display()))
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> std::io::Result<Option<String>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a stale edit before writing. Other clients do not share a lock with SolidCraft, so
|
||||
/// this narrows the race with their writes; it cannot exclude a write after the comparison.
|
||||
fn write_config(path: &Path, expected: Option<&str>, text: &str) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if read_config(path)?.as_deref() != expected {
|
||||
return Err(std::io::Error::other("configuration changed during setup; left unchanged, try again"));
|
||||
}
|
||||
solidcraft_io::safe_write::write_atomic(path, text.as_bytes(), true)
|
||||
}
|
||||
|
||||
/// Set up every detected client (or `--client NAME`; flags `--dry-run`, `--live`) for the
|
||||
/// `solidcraft-cli` at `exe`. Returns the result lines, one per client file, and whether every
|
||||
/// client succeeded.
|
||||
@@ -842,6 +848,96 @@ mod tests {
|
||||
assert!(toml.contains("[mcp_servers.other]") && !toml.contains("solidcraft"), "{toml}");
|
||||
}
|
||||
|
||||
fn config_dir(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-agent-config-{label}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_backup_tracks_the_file_before_each_change() {
|
||||
let dir = config_dir("backup");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let backup = dir.join("config.json.bak");
|
||||
let original = "{\n \"personal_setting\": true\n}\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
|
||||
let changed_by_client = std::fs::read_to_string(&target.file).unwrap().replace("true", "false");
|
||||
std::fs::write(&target.file, &changed_by_client).unwrap();
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
let current = std::fs::read_to_string(&target.file).unwrap();
|
||||
assert!(current.contains("false") && current.contains("/new/solidcraft-cli"));
|
||||
// A no-op or dry run must leave both the file and its backup alone.
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
configure_file(&target, "/future/solidcraft-cli", false, true, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Remove).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), current);
|
||||
assert!(!std::fs::read_to_string(&target.file).unwrap().contains("solidcraft"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_is_unchanged_when_its_backup_cannot_be_replaced() {
|
||||
let dir = config_dir("backup-failure");
|
||||
let target = Target { client: "codex", file: dir.join("config.toml"), shape: Shape::Codex };
|
||||
let original = "# personal setting\nmodel = \"custom\"\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
std::fs::create_dir(dir.join("config.toml.bak")).unwrap();
|
||||
assert!(configure_file(&target, EXE, false, false, Mode::Add).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), original);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_refuses_an_edit_based_on_an_outdated_read() {
|
||||
let dir = config_dir("concurrent");
|
||||
let path = dir.join("config.json");
|
||||
let backup = dir.join("config.json.bak");
|
||||
std::fs::write(&backup, "previous backup").unwrap();
|
||||
for (expected, current) in [(Some("old"), Some("new")), (Some("old"), None), (None, Some("new"))] {
|
||||
if let Some(text) = current {
|
||||
std::fs::write(&path, text).unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = write_config(&path, expected, "stale edit").unwrap_err();
|
||||
assert!(error.to_string().contains("configuration changed"), "{error}");
|
||||
assert_eq!(read_config(&path).unwrap().as_deref(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "previous backup");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn configuration_replaces_a_complete_file_and_keeps_links_and_permissions() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let dir = config_dir("atomic");
|
||||
let real = dir.join("real.json");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let original = "{\"personal_setting\": true}\n";
|
||||
std::fs::write(&real, original).unwrap();
|
||||
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink("real.json", &target.file).unwrap();
|
||||
let mut reader = std::fs::File::open(&target.file).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
let mut old = String::new();
|
||||
reader.read_to_string(&mut old).unwrap();
|
||||
assert_eq!(old, original, "an existing reader sees the complete old file");
|
||||
assert!(std::fs::read_to_string(&target.file).unwrap().contains(EXE));
|
||||
assert!(std::fs::symlink_metadata(&target.file).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("real.json.bak")).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_adds_only_the_plain_entry_and_names_new_clients_once() {
|
||||
let h = std::env::temp_dir().join(format!("solidcraft-quiet-{}", std::process::id()));
|
||||
|
||||
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
|
||||
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design and undone like any other change (the camera is not)",
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
|
||||
),
|
||||
CommandSpec::new("browser.expand_all", "Expand All", expand_all).icon("folder").noundo().params(
|
||||
"path?: [occurrence ids] (that placement and its descendants); component?: id (that component's own folders); \
|
||||
@@ -436,6 +436,89 @@ fn tree_folder_key(k: &str) -> bool {
|
||||
k.len() <= 2048 && scope_ok && last_ok
|
||||
}
|
||||
|
||||
/// Isolate bodies (#7896): the picked bodies stay, and so do the placements that hold them, down
|
||||
/// to their own; those placements' other bodies and the root's other bodies are hidden one by
|
||||
/// one, and every other placement is hidden as a placement, so its Browser row shows it is off and
|
||||
/// its eye brings it back.
|
||||
fn isolate_bodies(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
if ["folder", "features", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(cmd, "isolate takes `paths` or `items` (bodies)"));
|
||||
}
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "isolate needs `paths` or `items` (bodies)"));
|
||||
}
|
||||
let world = s.world_state()?;
|
||||
let keep: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| match shown_item(s, &world, k) {
|
||||
Some(Shown::Body(name)) => Ok(name),
|
||||
_ => Err(bad(cmd, format!("no body `{k}`"))),
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let mut hidden_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0 && !keep.contains(&b.name)).map(|b| b.name.clone()).collect();
|
||||
let mut hidden = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if s.occurrence_bodies_shared(&child.path, true)?.iter().any(|b| keep.contains(b)) {
|
||||
hidden_bodies.extend(child.bodies.into_iter().map(|b| b.world).filter(|b| !keep.contains(b)));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A hidden Bodies folder or group a picked body is in would keep it hidden: those show
|
||||
// again. Their other bodies are hidden one by one above, as the root's are.
|
||||
let shown_folders = folders_holding(s, &keep);
|
||||
s.visibility.hidden_folders.retain(|k| !shown_folders.contains(k));
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = hidden_bodies;
|
||||
s.refresh_display();
|
||||
Ok(json!({"visible":true,"isolated":keep,"visibility":s.visibility}))
|
||||
}
|
||||
|
||||
/// The tree keys of the Bodies folders and groups (with the groups they are in) that hold the
|
||||
/// bodies named `world` in some placement.
|
||||
fn folders_holding(s: &Session, world: &[String]) -> std::collections::BTreeSet<String> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
if s.visibility.hidden_folders.is_empty() {
|
||||
return keys;
|
||||
}
|
||||
let st = s.model.state();
|
||||
let names = solidcraft_doc::WorldNames::new(&s.doc, &st);
|
||||
// Each body's component, found once rather than once per placement.
|
||||
let owned: Vec<(u64, &str)> = s.doc.model_body_components(&st).into_iter().zip(st.bodies.iter().map(|b| b.name.as_str())).collect();
|
||||
for (component, path, _) in s.doc.placements() {
|
||||
let scope = Session::folder_scope(&path);
|
||||
for b in owned.iter().filter(|(c, _)| *c == component).map(|(_, name)| *name) {
|
||||
if !world.contains(&names.name(b, component, &path)) {
|
||||
continue;
|
||||
}
|
||||
keys.insert(format!("{scope}/bodies"));
|
||||
for g in s.doc.browser_groups.iter().filter(|g| g.component == component && g.folder == "bodies" && g.has_item(b)) {
|
||||
for id in std::iter::successors(Some(g.id), |id| s.doc.browser_group(*id).and_then(|g| g.parent))
|
||||
.take(solidcraft_doc::MAX_GROUP_NESTING + 1)
|
||||
{
|
||||
keys.insert(format!("{scope}/g{id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
@@ -525,6 +608,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.refresh_display();
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
return isolate_bodies(s, p, visible);
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
_ if p.get("features").is_some() => {
|
||||
if ["items", "folder", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_geom::mesh_distance::box_gap;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
@@ -17,9 +17,20 @@ use solidcraft_kernel::{BodyDistance, DistanceShape};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, string_list};
|
||||
use crate::schema::clearance::{
|
||||
Clearance as ClearanceReply, ClearanceParams, MAX_CLEARANCE, MAX_NEAREST, NearPair, Neighbour, Overlap, OverlapBounds, Unchecked,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().params(
|
||||
const CONTRACT: TypedContract<ClearanceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", crate::checked!("inspect.clearance", clearance))
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ClearanceReply>)
|
||||
.params(
|
||||
"bodies?: [names] (default: every solid body, in world placement, but those in `against`; mesh bodies are not measured); against?: [names] (only pairs with one body in `bodies` and the other here); \
|
||||
min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \
|
||||
nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \
|
||||
@@ -34,10 +45,6 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "C
|
||||
const MAX_BODIES: usize = 20_000;
|
||||
/// Most pairs whose grown boxes meet that one call measures.
|
||||
const MAX_PAIRS: usize = 200_000;
|
||||
/// Most neighbours a body's `nearest` list holds.
|
||||
const MAX_NEAREST: usize = 1000;
|
||||
/// Largest clearance (mm).
|
||||
const MAX_CLEARANCE: f64 = 1e6;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SkipWithin {
|
||||
@@ -261,48 +268,56 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in ["bodies", "against"] {
|
||||
asked.extend(string_list(cmd, p, k)?);
|
||||
}
|
||||
let mut unchecked: Vec<Value> = mesh_bodies
|
||||
let mut unchecked: Vec<Unchecked> = mesh_bodies
|
||||
.iter()
|
||||
.filter(|b| p.get("bodies").is_none_or(Value::is_null) || asked.contains(&b.name))
|
||||
.map(|b| json!({"a": b.name, "b": null, "reason": super::interference::MESH_NOT_CHECKED}))
|
||||
.map(|b| Unchecked { a: b.name.clone(), b: None, reason: super::interference::MESH_NOT_CHECKED.to_string() })
|
||||
.collect();
|
||||
let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str());
|
||||
let pair = |i: usize, j: usize, reason: String| Unchecked { a: name(i).to_string(), b: Some(name(j).to_string()), reason };
|
||||
for (&(i, j), r) in close.iter().zip(results) {
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
|
||||
unchecked.push(pair(i, j, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = r.unchecked {
|
||||
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
|
||||
unchecked.push(pair(i, j, reason));
|
||||
continue;
|
||||
}
|
||||
if let Some((volume, area, bbox, center)) = r.overlap
|
||||
&& volume >= min_volume
|
||||
{
|
||||
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
|
||||
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
|
||||
overlaps.push(Overlap {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
volume_mm3: volume,
|
||||
area_mm2: area,
|
||||
bbox: OverlapBounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
center: center.into(),
|
||||
});
|
||||
known.insert((i, j), 0.0);
|
||||
continue;
|
||||
}
|
||||
let Some(d) = r.distance else { continue };
|
||||
known.insert((i, j), d.distance);
|
||||
if d.distance < gap {
|
||||
let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to});
|
||||
if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) {
|
||||
e["overlap_mm3"] = json!(v);
|
||||
}
|
||||
if !d.exact {
|
||||
e["exact"] = json!(false);
|
||||
}
|
||||
near.push(e);
|
||||
near.push(NearPair {
|
||||
a: name(i).to_string(),
|
||||
b: name(j).to_string(),
|
||||
distance_mm: d.distance,
|
||||
from: d.from.into(),
|
||||
to: d.to.into(),
|
||||
overlap_mm3: r.overlap.filter(|o| o.0 > 0.0).map(|o| o.0),
|
||||
exact: (!d.exact).then_some(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0)));
|
||||
near.sort_by(|x, y| x.distance_mm.total_cmp(&y.distance_mm));
|
||||
|
||||
let mut nearest_out = serde_json::Map::new();
|
||||
let mut nearest_out = BTreeMap::new();
|
||||
if nearest > 0 {
|
||||
let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| {
|
||||
solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| {
|
||||
@@ -311,23 +326,25 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
});
|
||||
for (i, list) in lists {
|
||||
let rows: Vec<Value> = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect();
|
||||
nearest_out.insert(name(i).to_string(), Value::Array(rows));
|
||||
let rows = list.iter().map(|&(j, d)| Neighbour { body: name(j).to_string(), distance_mm: d }).collect();
|
||||
nearest_out.insert(name(i).to_string(), rows);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"overlaps": overlaps,
|
||||
"near": near,
|
||||
"nearest": nearest_out,
|
||||
"unchecked": unchecked,
|
||||
"bodies": subjects.len(),
|
||||
"pairs_checked": close.len(),
|
||||
"pairs_reused": reused,
|
||||
"pairs_pruned": total.saturating_sub(close.len()),
|
||||
"truncated": truncated > 0,
|
||||
"ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
let reply = ClearanceReply {
|
||||
overlaps,
|
||||
near,
|
||||
nearest: nearest_out,
|
||||
unchecked,
|
||||
bodies: subjects.len(),
|
||||
pairs_checked: close.len(),
|
||||
pairs_reused: reused,
|
||||
pairs_pruned: total.saturating_sub(close.len()),
|
||||
truncated: truncated > 0,
|
||||
ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! well as any; each place's volume, centre and box are then measured exactly, on the pieces of
|
||||
//! the two cuts a − b and b − a that lie there, as `inspect.measure` measures a body.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
@@ -13,9 +13,19 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::compare::{
|
||||
Bounds, CompareParams, CompareSide, Comparison, Deviation, Deviations, Difference, MeasuredBy, Region, Resolution, Sampling, SideTotal,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params(
|
||||
const CONTRACT: TypedContract<CompareParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", crate::checked!("inspect.compare", compare_bodies))
|
||||
.icon("compare")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<Comparison>)
|
||||
.params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
@@ -32,10 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Com
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
fn side_of(s: Side) -> CompareSide {
|
||||
match s {
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
Side::OnlyInA => CompareSide::OnlyInA,
|
||||
Side::OnlyInB => CompareSide::OnlyInB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,21 +115,21 @@ fn v3(v: &Value) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
fn difference(ma: &Value, mb: &Value) -> Difference {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| (b - a).into());
|
||||
Difference {
|
||||
volume_mm3: sub(va, vb),
|
||||
volume_percent: va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
area_mm2: sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
center_of_mass: com.map(Into::into),
|
||||
center_of_mass_distance_mm: com.map(Vec3::len),
|
||||
bbox_min: corner("min"),
|
||||
bbox_max: corner("max"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
@@ -245,8 +255,8 @@ fn exact_regions(
|
||||
(measured, notes)
|
||||
}
|
||||
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
fn deviation(d: &compare::Deviation) -> Deviation {
|
||||
Deviation { max_mm: d.max, mean_mm: d.mean, at: d.at.into(), area_beyond_mm2: d.beyond_area, area_mm2: d.area, points: d.points }
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -308,30 +318,36 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
exact_notes.iter().find(mine).or_else(|| exact_notes.last()).cloned().unwrap_or_else(|| "the exact cut gave no piece here".to_string())
|
||||
};
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Value> = order
|
||||
let regions: Vec<Region> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &k)| c.regions.get(k).map(|r| (i, k, r)))
|
||||
.map(|(i, k, r)| {
|
||||
let m = measured(k);
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": m.map(|m| m.volume),
|
||||
"measured_by": if m.is_some() { "boolean" } else { "not measured" },
|
||||
"not_measured": if m.is_some() { Value::Null } else { json!(reason(k)) },
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": m.map(|m| m.center),
|
||||
"bbox": m.map(|m| json!({"min": m.bbox.min, "max": m.bbox.max})).unwrap_or_else(|| json!({"min": r.bbox.min, "max": r.bbox.max})),
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
let bbox = m.map_or(r.bbox, |m| m.bbox);
|
||||
Region {
|
||||
index: i + 1,
|
||||
side: side_of(r.side),
|
||||
material_of: if r.side == Side::OnlyInA { na.clone() } else { nb.clone() },
|
||||
volume_mm3: m.map(|m| m.volume),
|
||||
measured_by: if m.is_some() { MeasuredBy::Boolean } else { MeasuredBy::NotMeasured },
|
||||
not_measured: if m.is_some() { None } else { Some(reason(k)) },
|
||||
area_mm2: r.area_a + r.area_b,
|
||||
area_a_mm2: r.area_a,
|
||||
area_b_mm2: r.area_b,
|
||||
max_deviation_mm: r.max_deviation,
|
||||
at: r.at.into(),
|
||||
center: m.map(|m| m.center.into()),
|
||||
bbox: Bounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
faces_a: r.faces_a.iter().map(|x| x.0).collect(),
|
||||
faces_b: r.faces_b.iter().map(|x| x.0).collect(),
|
||||
resolution: Resolution {
|
||||
along: axis(r.lines.axis).to_string(),
|
||||
line_spacing_mm: r.lines.spacing,
|
||||
lines: r.lines.count,
|
||||
skipped_lines: r.lines.skipped,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A side's total, or none when one of its regions is not measured.
|
||||
@@ -361,25 +377,28 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
let colored_faces = match colors {
|
||||
Some(colors) => Some(color_regions(s, &c, [&na, &nb], colors)?),
|
||||
None => None,
|
||||
};
|
||||
let out = Comparison {
|
||||
difference: difference(&measure_a, &measure_b),
|
||||
a: measure_a.as_object().cloned().unwrap_or_default(),
|
||||
b: measure_b.as_object().cloned().unwrap_or_default(),
|
||||
tolerance_mm: tolerance,
|
||||
deviation: Deviations { a_to_b: deviation(&c.a_to_b), b_to_a: deviation(&c.b_to_a), max_mm: worst },
|
||||
identical,
|
||||
verdict,
|
||||
regions,
|
||||
only_in_a: SideTotal { regions: ca, volume_mm3: va },
|
||||
only_in_b: SideTotal { regions: cb, volume_mm3: vb },
|
||||
unlisted_regions: c.unlisted,
|
||||
warnings,
|
||||
sampling: Sampling { spacing_mm: c.spacing, chord_mm: chord, triangles_a: mesh_a.triangles.len(), triangles_b: mesh_b.triangles.len() },
|
||||
colored_faces,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(out).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
|
||||
@@ -60,7 +60,7 @@ mod occurrence_tests;
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
|
||||
.icon("colored_components").doc_only()
|
||||
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
|
||||
.params("component: id or name; saves the next identification colour and turns Component Colors on; shared by every instance, separate from appearances"),
|
||||
CommandSpec::new("component.hide_history", "Hide Component History", hide_history)
|
||||
.icon("eye").doc_only()
|
||||
.params("component: id or name; keeps the component's features in the design but leaves them out of the History bar (#10393)"),
|
||||
@@ -175,7 +175,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; 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"),
|
||||
.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", drag::drag).icon("move").params(
|
||||
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
|
||||
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
|
||||
@@ -730,6 +730,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
x.transform = next;
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
return Ok(after_joints(s, p, id, next, cmd));
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
@@ -737,6 +740,46 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
}
|
||||
|
||||
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
|
||||
/// something fixed is put back by that solve, so the reply gives where it is now, says
|
||||
/// `reverted`, and names the joint and what to change instead.
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
|
||||
s.refresh();
|
||||
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
|
||||
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
|
||||
if same {
|
||||
return json!({"occurrence": id, "transform": now, "pending": false});
|
||||
}
|
||||
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
// The placement the caller named, else the first one: a part in a component placed more
|
||||
// than once has no path of its own, and its joints hold it alike in every placement.
|
||||
let path = if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd).ok()
|
||||
} else {
|
||||
let parent = s.doc.occurrences.iter().find(|o| o.id == id).map_or(0, |o| o.parent);
|
||||
let mut path = s.doc.first_path(parent);
|
||||
path.push(id);
|
||||
Some(path)
|
||||
};
|
||||
let why = path.and_then(|path| s.drag_group(&path).err()).map(|e| format!("{e}. ")).unwrap_or_else(|| {
|
||||
// No one joint to blame (a hinge's stored value, say): name the joints on the part.
|
||||
let on: Vec<&str> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.filter(|j| !j.suppressed && (j.a.occurrence == id || j.b.occurrence == id))
|
||||
.map(|j| j.name.as_str())
|
||||
.collect();
|
||||
if on.is_empty() { String::new() } else { format!("Joints on {name}: {}. ", on.join(", ")) }
|
||||
});
|
||||
let warning = format!(
|
||||
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
|
||||
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
|
||||
);
|
||||
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
|
||||
@@ -287,3 +287,66 @@ fn a_unique_part_can_drag_a_chain_through_a_shared_peer() {
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
/// #11723: Move Occurrence on a part that rigid joints hold to something grounded is put back by
|
||||
/// the joint solve after it. The move still succeeds, but its reply says it was put back, gives
|
||||
/// where the part is, and names the joint. A free chain follows the move, as before, and says
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_says_so() {
|
||||
let setups: [(&str, fn(&mut Session, u64, u64, u64)); 4] = [
|
||||
("free chain", |_, _, _, _| {}),
|
||||
("plate held to base", |s, base, plate, _| {
|
||||
run(s, "joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
}),
|
||||
("motor held to base", |s, base, _, motor| {
|
||||
run(s, "joint.as_built", json!({"type": "rigid", "a": base, "b": motor, "name": "Mount"}));
|
||||
}),
|
||||
("motor grounded", |s, _, _, motor| {
|
||||
run(s, "occurrence.ground", json!({"occurrence": motor, "grounded": true}));
|
||||
}),
|
||||
];
|
||||
for (case, setup) in setups {
|
||||
let mut s = Session::default();
|
||||
let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]);
|
||||
let (_, plate) = part(&mut s, "Plate", [20.0, 0.0, 0.0]);
|
||||
let (_, motor) = part(&mut s, "Motor", [20.0, 0.0, 10.0]);
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": plate, "b": motor, "name": "Bolted"}));
|
||||
setup(&mut s, base, plate, motor);
|
||||
let (plate_at, motor_at) = (origin(&s, &[plate]), origin(&s, &[motor]));
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": plate, "space": "world", "translate": [0, 0, 30]}));
|
||||
let reply_at = [r["transform"][3][0].as_f64().unwrap(), r["transform"][3][1].as_f64().unwrap(), r["transform"][3][2].as_f64().unwrap()];
|
||||
near(reply_at, origin(&s, &[plate]));
|
||||
if case == "free chain" {
|
||||
near(origin(&s, &[plate]), [plate_at[0], plate_at[1], plate_at[2] + 30.0]);
|
||||
near(origin(&s, &[motor]), [motor_at[0], motor_at[1], motor_at[2] + 30.0]);
|
||||
assert!(r.get("reverted").is_none() && r.get("warning").is_none(), "{case}: {r}");
|
||||
continue;
|
||||
}
|
||||
near(origin(&s, &[plate]), plate_at);
|
||||
assert_eq!(r["reverted"], json!(true), "{case}: {r}");
|
||||
let warning = r["warning"].as_str().unwrap_or_default();
|
||||
let holder = if case == "motor grounded" { "Bolted" } else { "Mount" };
|
||||
assert!(warning.contains(holder) && warning.contains("joint.reach") && warning.contains("joint.edit"), "{case}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #11723: a part inside an assembly placed twice has no path of its own. When its joints put it
|
||||
/// back, the warning still names the joint that holds it.
|
||||
#[test]
|
||||
fn a_move_its_joints_undo_names_the_joint_in_a_repeated_assembly() {
|
||||
let mut s = Session::default();
|
||||
let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]);
|
||||
let arm = run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
let (arm_component, arm_at) = (arm["component"].as_u64().unwrap(), arm["occurrence"].as_u64().unwrap());
|
||||
run(&mut s, "component.activate", json!({"component": arm_component}));
|
||||
let plate = run(&mut s, "component.create", json!({"name": "Plate"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
|
||||
run(&mut s, "occurrence.copy", json!({"occurrence": arm_at, "translate": [0, 50, 0]}));
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": plate, "translate": [0, 0, 30]}));
|
||||
assert_eq!(r["reverted"], json!(true), "{r}");
|
||||
let warning = r["warning"].as_str().unwrap_or_default();
|
||||
assert!(warning.contains("Mount"), "the warning names the joint: {warning}");
|
||||
}
|
||||
|
||||
@@ -234,7 +234,12 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
#[cfg(test)]
|
||||
work::count(|w| w.spreads += spread_bodies.len());
|
||||
let spreads = solidcraft_kernel::map_on_threads(&spread_bodies, 16, |&i| {
|
||||
state.bodies.get(i).and_then(|b| solidcraft_io::mass_properties(&b.body).ok().flatten()).and_then(|m| m.gyration())
|
||||
let body = state.bodies.get(i)?;
|
||||
let mut measured = solidcraft_io::mass_properties(&body.body).ok().flatten()?;
|
||||
if let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.definition_name()) {
|
||||
sheet.correct_perforation_mass_properties(&mut measured, &solidcraft_doc::IDENTITY).ok()?;
|
||||
}
|
||||
measured.gyration()
|
||||
});
|
||||
let spread: BTreeMap<usize, Tensor3> = spread_bodies.into_iter().zip(spreads).filter_map(|(i, g)| Some((i, g.flatten()?))).collect();
|
||||
// A window waiting for the result (the Properties window) sees how far it is and can stop it.
|
||||
@@ -254,7 +259,17 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
}
|
||||
done += 1;
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let measured = measure_placed(body, transform)?;
|
||||
let mut measured = measure_placed(body, transform)?;
|
||||
if let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.definition_name() && !sheet.perforations.is_empty()) {
|
||||
let mut corrected = body.measure()?;
|
||||
corrected.volume = measured.volume;
|
||||
corrected.area = measured.area;
|
||||
corrected.centroid = measured.centroid;
|
||||
sheet.correct_perforation_properties(&mut corrected, &transform)?;
|
||||
measured.volume = corrected.volume;
|
||||
measured.area = corrected.area;
|
||||
measured.centroid = corrected.centroid;
|
||||
}
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
let closed = measured.closed;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
@@ -89,6 +90,252 @@ fn body_reassignment_offsets_preserve_world_properties() {
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
}
|
||||
|
||||
fn panel_blank(s: &mut Session, width: f64, height: f64) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "Panel 0.8", "thickness": "0.8", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [width, height]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": "Blank", "body_name": "Panel"}));
|
||||
}
|
||||
|
||||
fn perforated_panel(s: &mut Session) {
|
||||
panel_blank(s, 20.0, 10.0);
|
||||
run(s, "sheet.perforate", json!({"diameter": "2", "pitch": "10", "margin": "1", "pattern": "square"}));
|
||||
run(s, "material.assign", json!({"bodies": ["Panel"], "material": "Steel"}));
|
||||
}
|
||||
|
||||
/// Two radius-1 holes centred at (2, 2) and (12, 2), through a 20 x 10 x 0.8 blank.
|
||||
fn perforated_panel_expected() -> (f64, f64, [f64; 3]) {
|
||||
let removed = 2.0 * std::f64::consts::PI * 0.8;
|
||||
let volume = 160.0 - removed;
|
||||
let area = 448.0 - 0.8 * std::f64::consts::PI;
|
||||
(volume, area, [(160.0 * 10.0 - removed * 7.0) / volume, (160.0 * 5.0 - removed * 2.0) / volume, 0.4])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_properties_include_holes_and_their_off_centre_volume_moment() {
|
||||
let mut s = Session::default();
|
||||
perforated_panel(&mut s);
|
||||
let (volume, area, center) = perforated_panel_expected();
|
||||
let density = s.doc.density("Panel");
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
for command in ["component.properties", "inspect.measure"] {
|
||||
let result = run(&mut s, command, json!({}));
|
||||
close(&result["total"]["volume_mm3"], volume);
|
||||
close(&result["total"]["mass_g"], volume * density / 1000.0);
|
||||
close(&result["total"]["area_mm2"], area);
|
||||
for (axis, expected) in center.iter().enumerate() {
|
||||
close(&result["bodies"][0]["center_of_mass"][axis], *expected);
|
||||
}
|
||||
}
|
||||
let inspected = run(&mut s, "document.inspect", json!({"measure": true}));
|
||||
close(&inspected["bodies"][0]["volume_mm3"], volume);
|
||||
close(&inspected["bodies"][0]["area_mm2"], area);
|
||||
for key in ["faces", "edges", "vertices"] {
|
||||
assert_eq!(inspected["bodies"][0][key], inspected["bodies"][0]["kernel"][key], "document.inspect retains kernel {key}");
|
||||
}
|
||||
assert!(inspected["bodies"][0].get("sheet_metal").is_none());
|
||||
let picked = run(&mut s, "inspect.measure", json!({"items": [{"type": "body", "name": "Panel"}]}));
|
||||
close(&picked["items"][0]["volume_mm3"], volume);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.undo.len(), undo, "measuring must not add an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_instances_keep_physical_properties_in_nested_world_placements() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name": "Subassembly", "grounded": false}));
|
||||
let child = run(&mut s, "component.create", json!({"name": "Sheet", "grounded": false}));
|
||||
perforated_panel(&mut s);
|
||||
let sheet_density = s.doc.density("Panel");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [40, 0, 0]}));
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence": parent["occurrence"], "translate": [20, 0, 10], "axis": [0, 0, 1], "angle": "90 deg", "capture": false}),
|
||||
);
|
||||
box_in(&mut s, "Ballast");
|
||||
let ballast_body = s.model.state().bodies.last().unwrap().name.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies": [ballast_body], "material": "Aluminum"}));
|
||||
let ballast_mass = s.doc.density(&ballast_body);
|
||||
let (volume, area, center) = perforated_panel_expected();
|
||||
let sheet_mass = volume * sheet_density / 1000.0;
|
||||
let first = [20.0 - center[1], center[0], 10.0 + center[2]];
|
||||
let second = [40.0 + center[0], center[1], center[2]];
|
||||
let doc = s.doc.clone();
|
||||
let moves = s.pending_moves.clone();
|
||||
let undo = s.undo.len();
|
||||
for (parent_occurrence, expected) in [(&parent["occurrence"], first), (©["occurrence"], second)] {
|
||||
let properties =
|
||||
run(&mut s, "occurrence.properties", json!({"occurrence": child["occurrence"], "path": [parent_occurrence, child["occurrence"]]}));
|
||||
close(&properties["total"]["volume_mm3"], volume);
|
||||
close(&properties["total"]["mass_g"], sheet_mass);
|
||||
close(&properties["total"]["area_mm2"], area);
|
||||
for (axis, coordinate) in expected.iter().enumerate() {
|
||||
close(&properties["total"]["center_of_mass"][axis], *coordinate);
|
||||
}
|
||||
}
|
||||
let properties = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
let mass = 2.0 * sheet_mass + ballast_mass;
|
||||
close(&properties["total"]["volume_mm3"], 2.0 * volume + 1000.0);
|
||||
close(&properties["total"]["mass_g"], mass);
|
||||
for axis in 0..3 {
|
||||
close(&properties["total"]["center_of_mass"][axis], (sheet_mass * (first[axis] + second[axis]) + ballast_mass * 5.0) / mass);
|
||||
}
|
||||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||||
close(&measured["total"]["volume_mm3"], 2.0 * volume + 1000.0);
|
||||
close(&measured["total"]["mass_g"], mass);
|
||||
assert_eq!(measured["bodies"].as_array().unwrap().iter().filter(|b| b["sheet_metal"] == true).count(), 2);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.pending_moves, moves);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_centroid_follows_a_folded_panel_and_unfolds() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let mut s = Session::default();
|
||||
panel_blank(&mut s, 30.0, 10.0);
|
||||
run(
|
||||
&mut s,
|
||||
"sheet.fold",
|
||||
json!({"points": [[10, 0, 0], [10, 10, 0]], "angle": "90 deg", "radius": "0.8", "position": "centerline", "fixed": [2, 5, 0]}),
|
||||
);
|
||||
let before = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
run(&mut s, "sheet.perforate", json!({"diameter": "2", "pitch": "10", "margin": "0", "pattern": "square"}));
|
||||
let sheet = &s.model.state().sheets[0];
|
||||
let holes = sheet.perforation_holes(&sheet.perforations[0]).unwrap();
|
||||
assert_eq!(holes.len(), 2);
|
||||
// Fold boundaries carry kernel roundoff; keep the analytic centres and the
|
||||
// same sub-micrometre tolerance used by the property checks below.
|
||||
for (hole, expected) in holes.iter().zip([Vec2::new(1.0, 1.0), Vec2::new(21.0, 1.0)]) {
|
||||
assert!(hole.dist(expected) < 1e-6, "{hole:?} != {expected:?}");
|
||||
}
|
||||
// The right panel turns up by 90 degrees about a y-parallel bend axis at
|
||||
// z = radius + thickness. Its mid-plane is radius + thickness/2 from that axis.
|
||||
let allowance = FRAC_PI_2 * (0.8 + 0.44 * 0.8);
|
||||
let bend_start = 10.0 - allowance / 2.0;
|
||||
let right_hole = [bend_start + 1.2, 1.0, 1.6 + 21.0 - bend_start - allowance];
|
||||
let old_volume = before["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let hole_volume = PI * 0.8;
|
||||
let volume = old_volume - 2.0 * hole_volume;
|
||||
let result = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
close(&result["total"]["volume_mm3"], volume);
|
||||
for (axis, left) in [1.0, 1.0, 0.4].iter().enumerate() {
|
||||
let old = before["total"]["center_of_mass"][axis].as_f64().unwrap();
|
||||
close(&result["total"]["center_of_mass"][axis], old + hole_volume * (2.0 * old - left - right_hole[axis]) / volume);
|
||||
}
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let result = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
let volume = 240.0 - 2.0 * hole_volume;
|
||||
close(&result["total"]["volume_mm3"], volume);
|
||||
for (axis, expected) in [(240.0 * 15.0 - 2.0 * hole_volume * 11.0) / volume, (240.0 * 5.0 - 2.0 * hole_volume) / volume, 0.4].iter().enumerate() {
|
||||
close(&result["total"]["center_of_mass"][axis], *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_correction_rejects_invalid_metadata_without_partial_changes() {
|
||||
let mut s = Session::default();
|
||||
perforated_panel(&mut s);
|
||||
let state = s.model.state();
|
||||
let measured = solidcraft_kernel::measure(&state.bodies[0].body).unwrap();
|
||||
for pitch in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
let mut sheet = state.sheets[0].clone();
|
||||
sheet.perforations[0].pitch = pitch;
|
||||
let mut actual = measured.clone();
|
||||
assert!(sheet.correct_perforation_properties(&mut actual, &solidcraft_doc::IDENTITY).is_err());
|
||||
assert_eq!(actual.volume, measured.volume);
|
||||
assert_eq!(actual.area, measured.area);
|
||||
assert_eq!(actual.centroid, measured.centroid);
|
||||
}
|
||||
let mut scaled = solidcraft_doc::IDENTITY;
|
||||
scaled[0][0] = 2.0;
|
||||
let mut sheared = solidcraft_doc::IDENTITY;
|
||||
sheared[0][1] = 0.5;
|
||||
for placement in [scaled, sheared] {
|
||||
let mut actual = measured.clone();
|
||||
assert!(state.sheets[0].correct_perforation_properties(&mut actual, &placement).is_err());
|
||||
assert_eq!((actual.volume, actual.area, actual.centroid), (measured.volume, measured.area, measured.centroid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_body_transfer_preserves_world_mass_and_centroid() {
|
||||
let mut s = Session::default();
|
||||
perforated_panel(&mut s);
|
||||
let moved = run(&mut s, "component.create", json!({"name": "MovedSheet", "grounded": false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": moved["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Panel"], "component": moved["component"]}));
|
||||
let (volume, _, center) = perforated_panel_expected();
|
||||
let mass = volume * s.doc.density("Panel") / 1000.0;
|
||||
for command in ["component.properties", "inspect.measure"] {
|
||||
let result = run(&mut s, command, json!({"component": "root"}));
|
||||
close(&result["total"]["volume_mm3"], volume);
|
||||
close(&result["total"]["mass_g"], mass);
|
||||
for (axis, expected) in center.iter().enumerate() {
|
||||
close(&result["bodies"][0]["center_of_mass"][axis], *expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perforated_sheet_inertia_removes_analytic_hole_cylinders_and_turns_with_the_sheet() {
|
||||
let mut s = Session::default();
|
||||
let c = run(&mut s, "component.create", json!({"name": "Sheet", "grounded": false}));
|
||||
perforated_panel(&mut s);
|
||||
let (volume, _, center) = perforated_panel_expected();
|
||||
let old_center = [10.0, 5.0, 0.4];
|
||||
let mut expected = [[0.0; 3]; 3];
|
||||
let dimensions: [f64; 3] = [20.0, 10.0, 0.8];
|
||||
for i in 0..3 {
|
||||
expected[i][i] = 160.0 * (dimensions[(i + 1) % 3].powi(2) + dimensions[(i + 2) % 3].powi(2)) / 12.0;
|
||||
}
|
||||
let hole_volume = std::f64::consts::PI * 0.8;
|
||||
let own = [hole_volume * (3.0 + 0.64) / 12.0, hole_volume * (3.0 + 0.64) / 12.0, hole_volume / 2.0];
|
||||
for hole in [[2.0, 2.0, 0.4], [12.0, 2.0, 0.4]] {
|
||||
let d = [0, 1, 2].map(|i| hole[i] - old_center[i]);
|
||||
let d2: f64 = d.iter().map(|v| v * v).sum();
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
expected[i][j] -= if i == j { own[i] + hole_volume * d2 } else { 0.0 } - hole_volume * d[i] * d[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = [0, 1, 2].map(|i| center[i] - old_center[i]);
|
||||
let d2: f64 = d.iter().map(|v| v * v).sum();
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
expected[i][j] = (expected[i][j] - volume * (if i == j { d2 } else { 0.0 } - d[i] * d[j])) * s.doc.density("Panel") / 1000.0;
|
||||
}
|
||||
}
|
||||
let first = run(&mut s, "component.properties", json!({}));
|
||||
tensor_close(&first["total"]["inertia_g_mm2"], expected, 1e-9);
|
||||
let verify_assembly = |s: &mut Session, tensor: [[f64; 3]; 3], center: [f64; 3]| {
|
||||
let assembly = run(s, "assembly.mass_properties", json!({}));
|
||||
let occurrence = assembly["occurrences"].as_array().unwrap().iter().find(|o| o["occurrence"] == c["occurrence"]).unwrap();
|
||||
let mass = &occurrence["mass"];
|
||||
assert!((mass["mass_kg"].as_f64().unwrap() - volume * s.doc.density("Panel") * 1e-6).abs() < 1e-12);
|
||||
for (axis, coordinate) in center.iter().enumerate() {
|
||||
close(&mass["center_of_mass_mm"][axis], *coordinate);
|
||||
}
|
||||
tensor_close(&mass["inertia_world_kg_mm2"], tensor.map(|row| row.map(|value| value / 1000.0)), 1e-9);
|
||||
};
|
||||
verify_assembly(&mut s, expected, center);
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence": c["occurrence"], "translate": [100, 40, 10], "axis": [0, 0, 1], "angle": "90 deg", "capture": false}),
|
||||
);
|
||||
let turned = run(&mut s, "component.properties", json!({}));
|
||||
let rotation = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let rotated = solidcraft_geom::inertia::transformed(&expected, &rotation);
|
||||
tensor_close(&turned["total"]["inertia_g_mm2"], rotated, 1e-9);
|
||||
verify_assembly(&mut s, rotated, [100.0 - center[1], 40.0 + center[0], 10.0 + center[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_reject_hostile_identifiers_and_options() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -4,6 +4,136 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn transferred_profile(revolve: bool) -> (Session, u64, u64, solidcraft_kernel::Body) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":if revolve { "XZ" } else { "XY" },"name":"Profile"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[30,5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
if revolve {
|
||||
run(&mut s, "solid.revolve", json!({"axis":"y","angle":"360 deg","body_name":"Part"}));
|
||||
} else {
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Part"}));
|
||||
}
|
||||
let original = s.world_state().unwrap().body("Part").unwrap().body.clone();
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Part"]}));
|
||||
let component = created["components"][0]["component"].as_u64().unwrap();
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.component == component).unwrap().id;
|
||||
run(&mut s, "component.move_sketches", json!({"sketches":["Profile"],"component":component}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":false}));
|
||||
(s, component, occurrence, original)
|
||||
}
|
||||
|
||||
fn check_captured_profile_transfer(revolve: bool) {
|
||||
let (mut s, _, occurrence, original) = transferred_profile(revolve);
|
||||
let before = s.doc.to_json();
|
||||
let matrix = solidcraft_doc::rigid(Vec3::new(40.0, 0.0, 0.0), Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_4);
|
||||
let expected = solidcraft_kernel::transform_matrix(&original, matrix).unwrap();
|
||||
let expected_bounds = expected.exact_bounds().unwrap();
|
||||
let assert_pose = |s: &Session| {
|
||||
let state = s.world_state().unwrap();
|
||||
let body = &state.body("Part").unwrap().body;
|
||||
let bounds = body.exact_bounds().unwrap();
|
||||
assert!(bounds.min.dist(expected_bounds.min) < 0.002, "minimum: {bounds:?}, expected {expected_bounds:?}");
|
||||
assert!(bounds.max.dist(expected_bounds.max) < 0.002, "maximum: {bounds:?}, expected {expected_bounds:?}");
|
||||
let measured = solidcraft_kernel::measure(body).unwrap();
|
||||
let expected_measure = solidcraft_kernel::measure(&expected).unwrap();
|
||||
assert!((measured.volume - expected_measure.volume).abs() < 1e-5);
|
||||
assert!(measured.centroid.dist(expected_measure.centroid) < 1e-5);
|
||||
};
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"axis":[1,0,0],"angle":"45 deg","capture":false}));
|
||||
assert_pose(&s);
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_pose(&s);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_pose(&reopened);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_pose(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_transferred_extrusion_with_its_source_sketch_moves_once() {
|
||||
check_captured_profile_transfer(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_transferred_revolve_with_its_source_sketch_moves_once() {
|
||||
check_captured_profile_transfer(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_profile_frame_follows_transfer_edits_suppression_and_rollback() {
|
||||
let (mut s, component, occurrence, _) = transferred_profile(false);
|
||||
let creator = s.model.state().body("Part").unwrap().feature;
|
||||
let transfer = s.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
|
||||
let assert_x = |s: &Session, local: f64, owner: u64| {
|
||||
let source = s.model.result(creator).unwrap().state.body("Part").unwrap().body.exact_bounds().unwrap();
|
||||
assert!((source.min.x - local).abs() < 1e-6, "source frame: {source:?}");
|
||||
let state = s.world_state().unwrap();
|
||||
let body = state.body("Part").unwrap();
|
||||
assert_eq!(body.component, owner);
|
||||
assert!((body.body.exact_bounds().unwrap().min.x - 60.0).abs() < 1e-6);
|
||||
};
|
||||
assert_x(&s, 20.0, component);
|
||||
s.doc_mut().feature_mut(transfer).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
assert_x(&s, 60.0, 0);
|
||||
s.doc_mut().feature_mut(transfer).unwrap().suppressed = false;
|
||||
s.refresh();
|
||||
assert_x(&s, 20.0, component);
|
||||
let boundary = s.doc.feature_index(transfer);
|
||||
s.doc_mut().marker = boundary;
|
||||
s.refresh();
|
||||
assert_x(&s, 60.0, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_x(&s, 20.0, component);
|
||||
if let FeatureKind::BodyTransfer { transform, .. } = &mut s.doc_mut().feature_mut(transfer).unwrap().kind {
|
||||
*transform = solidcraft_doc::rigid(Vec3::new(-7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0);
|
||||
}
|
||||
s.refresh();
|
||||
assert_x(&s, 27.0, component);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_x(&reopened, 27.0, component);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capturing_one_transferred_profile_instance_keeps_its_peer_in_place() {
|
||||
let (mut s, _, occurrence, _) = transferred_profile(false);
|
||||
run(&mut s, "occurrence.copy", json!({"occurrence":occurrence,"translate":[100,0,0]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"capture":false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
let state = s.world_state().unwrap();
|
||||
let mut xs: Vec<f64> = state.bodies.iter().map(|body| body.body.exact_bounds().unwrap().min.x).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs.len(), 2);
|
||||
assert!((xs[0] - 60.0).abs() < 1e-6 && (xs[1] - 120.0).abs() < 1e-6, "{xs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_body_transfer_keeps_its_live_component_frame() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source","grounded":false}));
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Profile"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Profile","distance":5,"body_name":"Part"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
|
||||
let state = s.world_state().unwrap();
|
||||
let body = state.body("Part").unwrap();
|
||||
assert_eq!(body.component, 0);
|
||||
assert!((body.body.exact_bounds().unwrap().min.x - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -7,15 +7,27 @@ use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::schema::config::{ConfigActivation, ConfigRowParams, ConfigTable, ConfigTableParams, ConfigTableReply, ConfigTableRow, ConfigTarget};
|
||||
use crate::schema::config::{
|
||||
ConfigActivation, ConfigColumnParams, ConfigConfigureParams, ConfigRowEditParams, ConfigRowParams, ConfigRuleParams, ConfigTable,
|
||||
ConfigTableParams, ConfigTableReply, ConfigTableRow, ConfigTarget,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const TABLE_CONTRACT: TypedContract<ConfigTableParams> = TypedContract::new();
|
||||
const ROW_CONTRACT: TypedContract<ConfigRowParams> = TypedContract::new();
|
||||
const CONFIGURE_CONTRACT: TypedContract<ConfigConfigureParams> = TypedContract::new();
|
||||
const COLUMN_CONTRACT: TypedContract<ConfigColumnParams> = TypedContract::new();
|
||||
const ROW_EDIT_CONTRACT: TypedContract<ConfigRowEditParams> = TypedContract::new();
|
||||
const RULE_CONTRACT: TypedContract<ConfigRuleParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
|
||||
CommandSpec::new("config.configure", "Configure", crate::checked!("config.configure", configure))
|
||||
.at("SOLID", "CONFIGURE")
|
||||
.icon("params")
|
||||
.contract(&CONFIGURE_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params(
|
||||
"params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
),
|
||||
CommandSpec::new("config.table", "Display Configuration Table", crate::checked!("config.table", show))
|
||||
@@ -25,8 +37,16 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&TABLE_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).icon("configuration_table").params("param? | feature? | material? | appearance? (target name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", row).icon("configuration_table").params(
|
||||
CommandSpec::new("config.column", "Configuration Column", crate::checked!("config.column", column))
|
||||
.icon("configuration_table")
|
||||
.contract(&COLUMN_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params("param? | feature? | material? | appearance? (target name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", crate::checked!("config.row", row))
|
||||
.icon("configuration_table")
|
||||
.contract(&ROW_EDIT_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params(
|
||||
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", crate::checked!("config.activate", activate))
|
||||
@@ -40,7 +60,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&ROW_CONTRACT)
|
||||
.output(output_schema_for::<ConfigActivation>)
|
||||
.params("row: name — validates a rebuild without changing the design or history"),
|
||||
CommandSpec::new("config.rule", "Configuration Rule", rule).icon("configuration_table").params(
|
||||
CommandSpec::new("config.rule", "Configuration Rule", crate::checked!("config.rule", rule))
|
||||
.icon("configuration_table")
|
||||
.contract(&RULE_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params(
|
||||
"rule?: {id,name,enabled,condition,effect}; rules?: [rule…] (added or replaced together, then the rows checked once); id? + delete?; id? + index? (ordered move)",
|
||||
),
|
||||
CommandSpec::new("config.rules", "Configuration Rules", crate::checked!("config.rules", show))
|
||||
@@ -331,6 +355,11 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
for (column, value) in s.doc.configs.columns.iter().zip(&cells) {
|
||||
if matches!(column, Column::Suppress(_)) {
|
||||
solidcraft_doc::config::suppression_cell(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = s
|
||||
.doc
|
||||
.configs
|
||||
@@ -743,6 +772,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_reject_invalid_suppression_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for value in [json!("banana"), json!(""), json!(2), json!(-1)] {
|
||||
for params in [
|
||||
json!({"name":"Bad", "values":{"suppress:TestSketch":value}}),
|
||||
json!({"name":"Copy", "from":"Default", "values":{"TestSketch":value}}),
|
||||
json!({"name":"Default", "rename":"Renamed", "values":{"TestSketch":value}}),
|
||||
] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("invalid suppression must be rejected when saving a row");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_inherited_suppression_and_allow_repair() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
// A row saved by an older reader may already contain invalid suppression text.
|
||||
s.doc_mut().configs.rows[0].cells[0] = "banana".into();
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for params in [json!({"name":"Copy", "from":"Default"}), json!({"name":"Default", "rename":"Renamed"})] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("inherited invalid suppression must also be checked");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
run(&mut s, "config.row", json!({"name":"Default", "values":{"TestSketch":false}}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "false");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(!s.doc.features[0].suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_keep_supported_suppression_spellings() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
for (value, suppressed) in [
|
||||
(json!(true), true),
|
||||
(json!(false), false),
|
||||
(json!(1), true),
|
||||
(json!(0), false),
|
||||
(json!("true"), true),
|
||||
(json!("false"), false),
|
||||
(json!(" YES "), true),
|
||||
(json!(" No "), false),
|
||||
(json!("1"), true),
|
||||
(json!("0"), false),
|
||||
] {
|
||||
let before = s.doc.features.clone();
|
||||
run(&mut s, "config.row", json!({"name":"Accepted", "values":{"suppress:TestSketch":value}}));
|
||||
assert_eq!(s.doc.features, before, "saving a row does not activate it");
|
||||
let text = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string());
|
||||
assert_eq!(s.doc.configs.rows[1].cells[0], text, "accepted spellings are preserved");
|
||||
run(&mut s, "config.activate", json!({"row":"Accepted"}));
|
||||
assert_eq!(s.doc.features[0].suppressed, suppressed, "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_the_final_parameter_graph_without_activating_it() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -105,7 +105,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -744,6 +744,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
names
|
||||
} else if pts.is_empty() {
|
||||
Vec::new()
|
||||
} else if matches!(kind, FeatureKind::Hole { .. }) {
|
||||
hole_face_names(s, p, &kind)?
|
||||
} else {
|
||||
solidcraft_doc::face_point_names(&s.doc, &s.model.state(), &kind, s.active_component)?
|
||||
}
|
||||
@@ -1082,6 +1084,22 @@ fn face_body_reached(s: &Session, sketch: u64) -> Result<Option<String>> {
|
||||
Ok(Some(body.name.clone()))
|
||||
}
|
||||
|
||||
/// A hole's face points, named on the bodies it drills where one of theirs holds the point
|
||||
/// (#7520): a face of another body in the same plane, a hidden one the hole leaves alone, would
|
||||
/// otherwise take the name of the face that was picked. Over every body, as before, elsewhere.
|
||||
fn hole_face_names(s: &Session, p: &Value, kind: &FeatureKind) -> Result<Vec<String>> {
|
||||
let st = s.model.state();
|
||||
let all = solidcraft_doc::face_point_names(&s.doc, &st, kind, s.active_component)?;
|
||||
let drilled = string_list_or_default(p, "participants");
|
||||
if drilled.is_empty() || drilled.iter().any(|b| b == "*") {
|
||||
return Ok(all);
|
||||
}
|
||||
let mut scoped = solidcraft_doc::ModelState::default();
|
||||
scoped.bodies = st.bodies.iter().filter(|b| drilled.contains(&b.name)).cloned().collect();
|
||||
let own = solidcraft_doc::face_point_names(&s.doc, &scoped, kind, s.active_component)?;
|
||||
Ok(own.into_iter().zip(all).map(|(own, any)| if own.is_empty() { any } else { own }).collect())
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
|
||||
/// it is one the active component works on ([`active_scope`]).
|
||||
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
@@ -2600,6 +2618,18 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Split { body: BodyName(body), plane, tool: None, sketch: None, line: None })
|
||||
}
|
||||
|
||||
/// The fits [`clearance_diameter`] knows (close, normal, loose, and their older names).
|
||||
const CLEARANCE_FITS: [&str; 6] = ["close", "fine", "normal", "medium", "loose", "coarse"];
|
||||
|
||||
/// A value typed by the user, cut short for a message.
|
||||
fn brief(text: &str) -> String {
|
||||
const MAX: usize = 32;
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((end, _)) => format!("{}…", &text[..end]),
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
|
||||
fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
|
||||
const TABLE: [(f64, f64, f64, f64); 21] = [
|
||||
@@ -2808,10 +2838,18 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let position = if references.is_empty() { position } else { referenced_centre(s, cmd, &references, position, direction)? };
|
||||
// The diameter: given, or a metric-estimate/Unified-table tap drill, or an ISO 273
|
||||
// clearance hole for a bolt size (close, normal or loose fit).
|
||||
// A diameter number out of range says so, rather than that none was given (#9625).
|
||||
if matches!(p.get("diameter"), Some(Value::Number(_))) && expr(p, "diameter").is_none() {
|
||||
return Err(bad(cmd, format!("`diameter` {}", expr_problem(p.get("diameter")))));
|
||||
}
|
||||
let diameter = match (expr(p, "diameter"), &thread, str_(p, "clearance")) {
|
||||
(Some(d), _, _) => d,
|
||||
(None, _, Some(c)) => {
|
||||
let fit = str_(p, "fit").unwrap_or("normal").to_ascii_lowercase();
|
||||
// An unknown fit is not an unknown bolt size (#9625).
|
||||
if !CLEARANCE_FITS.contains(&fit.as_str()) {
|
||||
return Err(bad(cmd, format!("`fit` must be close, normal or loose, not `{}`", brief(&fit))));
|
||||
}
|
||||
let d = clearance_diameter(c, &fit).ok_or_else(|| bad(cmd, format!("no clearance hole for `{c}` ({fit} fit): M1.6 to M64")))?;
|
||||
Expr::new(format!("{d} mm"))
|
||||
}
|
||||
@@ -2838,22 +2876,41 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e = req_expr(cmd, p, k)?;
|
||||
Ok(e)
|
||||
};
|
||||
// Given but unreadable (`true`, a list, an empty text): refused, never a plain counterbore.
|
||||
let bridge = match (expr(p, "bridge"), p.get("bridge")) {
|
||||
(Some(b), _) => Some(b),
|
||||
(None, None | Some(Value::Null)) => None,
|
||||
(None, v) => return Err(bad(cmd, format!("`bridge` {}", expr_problem(v)))),
|
||||
};
|
||||
let kind = match str_(p, "type").unwrap_or("simple") {
|
||||
"simple" => match expr(p, "tip_angle") {
|
||||
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
|
||||
_ => solidcraft_doc::HoleKind::Simple,
|
||||
},
|
||||
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
|
||||
"counterbore" => {
|
||||
solidcraft_doc::HoleKind::Counterbore { cb_diameter: get("cb_diameter")?, cb_depth: get("cb_depth")?, tip_angle: expr(p, "tip_angle") }
|
||||
}
|
||||
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
|
||||
cb_diameter: get("cb_diameter")?,
|
||||
cb_depth: get("cb_depth")?,
|
||||
tip_angle: expr(p, "tip_angle"),
|
||||
bridge: bridge.clone(),
|
||||
},
|
||||
"countersink" => solidcraft_doc::HoleKind::Countersink {
|
||||
cs_diameter: get("cs_diameter")?,
|
||||
cs_angle: expr(p, "cs_angle").unwrap_or_else(|| "90 deg".into()),
|
||||
// The default only when no angle is given: a supplied one out of range is refused (#9625).
|
||||
cs_angle: match p.get("cs_angle") {
|
||||
None | Some(Value::Null) => "90 deg".into(),
|
||||
Some(Value::String(t)) if t.trim().is_empty() => "90 deg".into(),
|
||||
Some(_) => req_expr(cmd, p, "cs_angle")?,
|
||||
},
|
||||
tip_angle: expr(p, "tip_angle"),
|
||||
},
|
||||
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
|
||||
};
|
||||
match (&kind, &bridge) {
|
||||
(solidcraft_doc::HoleKind::Counterbore { .. }, Some(b)) => check_expr(s, b, Kind::Length, cmd, "bridge")?,
|
||||
(_, Some(_)) => return Err(bad(cmd, "`bridge` needs `type: counterbore`")),
|
||||
_ => {}
|
||||
}
|
||||
let to = match p.get("to") {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
@@ -2876,6 +2933,20 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// placed) cuts that body: the pick is already in that body's frame, and the
|
||||
// component's own bodies alone would leave nothing to cut.
|
||||
p["participants"] = json!([body]);
|
||||
} else if let Some(mut shown) = shown_scope(s) {
|
||||
// Like a cut (#3961), a hole leaves hidden bodies alone (#7520): while some are
|
||||
// hidden, it drills the shown ones, stored so showing the others later changes
|
||||
// nothing. The body whose face was picked is drilled though hidden (picked through
|
||||
// Select Other, or hidden with the dialog open): it is the one the hole is for.
|
||||
if !shown.is_empty()
|
||||
&& points.is_none()
|
||||
&& let Some(body) = hidden_body_picked_at(s, position, &shown)
|
||||
{
|
||||
shown.push(body);
|
||||
}
|
||||
if !shown.is_empty() {
|
||||
p["participants"] = json!(shown);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_feature(
|
||||
@@ -2961,16 +3032,36 @@ fn hole_reference_params(s: &Session, p: &Value, cmd: &str) -> Result<Vec<solidc
|
||||
/// component than the active one.
|
||||
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
let (_, body) = st
|
||||
.bodies
|
||||
let body = body_with_face_at(&st, p, |_| true)?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
}
|
||||
|
||||
/// The hidden body whose face holds `p`, when no shown body has a face there: a face shared by
|
||||
/// a shown and a hidden body is the shown one's, as a pick in the view would take it (#7520).
|
||||
fn hidden_body_picked_at(s: &Session, p: Vec3, shown: &[String]) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
if body_with_face_at(&st, p, |b| shown.contains(&b.name)).is_some() {
|
||||
return None;
|
||||
}
|
||||
let scope: Vec<&str> = active_scope(s, &st).into_iter().map(|b| b.name.as_str()).collect();
|
||||
body_with_face_at(&st, p, |b| scope.contains(&b.name.as_str())).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// The body, among those `keep` takes, with the face nearest `p` within its pick tolerance.
|
||||
fn body_with_face_at(
|
||||
st: &solidcraft_doc::ModelState,
|
||||
p: Vec3,
|
||||
keep: impl Fn(&solidcraft_doc::ModelBody) -> bool,
|
||||
) -> Option<&solidcraft_doc::ModelBody> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.filter(|b| !b.body.is_mesh() && keep(b))
|
||||
.filter_map(|b| {
|
||||
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
|
||||
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, b)| b)
|
||||
}
|
||||
|
||||
/// The points of a sketch a hole drills at when only the sketch is given.
|
||||
|
||||
@@ -27,7 +27,7 @@ mod appearance_chain;
|
||||
pub(super) mod appearance_gestures;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
|
||||
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
@@ -128,7 +128,30 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
|
||||
}
|
||||
|
||||
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
|
||||
match b.measure() {
|
||||
measure_sheet_json(b, None)
|
||||
}
|
||||
|
||||
pub(super) fn measure_body(
|
||||
b: &solidcraft_doc::ModelBody,
|
||||
definitions: Option<&solidcraft_doc::ModelState>,
|
||||
) -> Result<solidcraft_kernel::BodyMeasure> {
|
||||
let mut measured = b.measure()?;
|
||||
if let Some(state) = definitions
|
||||
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == b.definition_name() && !sheet.perforations.is_empty())
|
||||
{
|
||||
let definition = state.body(b.definition_name()).ok_or_else(|| EngineError::Other(format!("missing sheet body `{}`", sheet.body)))?;
|
||||
// A body transfer moves the geometry and sheet metadata together. Apply only
|
||||
// the placement relative to the current definition, so holes move once.
|
||||
let inverse = solidcraft_doc::mat_inverse(&definition.source_transform)
|
||||
.ok_or_else(|| EngineError::Other(format!("invalid sheet placement for `{}`", sheet.body)))?;
|
||||
let placement = solidcraft_doc::mat_mul(&b.source_transform, &inverse);
|
||||
sheet.correct_perforation_properties(&mut measured, &placement)?;
|
||||
}
|
||||
Ok(measured)
|
||||
}
|
||||
|
||||
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
|
||||
match measure_body(b, definitions) {
|
||||
Ok(m) => {
|
||||
let closed = encloses_volume(&b.body);
|
||||
json!({
|
||||
@@ -157,6 +180,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::measure_sel::measure_items(s, items);
|
||||
}
|
||||
let want = string_list(cmd, p, "bodies")?;
|
||||
let definitions = s.model.state();
|
||||
let st = s.world_state()?;
|
||||
let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect();
|
||||
if !want.is_empty() && selected.len() != want.len() {
|
||||
@@ -167,7 +191,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
|
||||
}
|
||||
// Each body is measured on its own: on all cores, in body order.
|
||||
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_json(b))
|
||||
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet_json(b, Some(&definitions)))
|
||||
.into_iter()
|
||||
.zip(&selected)
|
||||
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
|
||||
@@ -182,35 +206,13 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
}
|
||||
// Sheet metal keeps the edges where bends meet flat faces (so bends stay visible): count its
|
||||
// B-rep faces as they are, without merging coplanar neighbours.
|
||||
let sheets = s.model.state().sheets.clone();
|
||||
for b in &mut bodies {
|
||||
if let Some(sh) = sheets.iter().find(|x| b["name"].as_str() == Some(x.body.as_str())) {
|
||||
let (df, de, dv) = sh.round_hole_correction();
|
||||
for (k, d) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d);
|
||||
// Only Measure exposes sheet boundary counts; document.inspect keeps kernel counts.
|
||||
if let Some(sheet) = definitions.sheets.iter().find(|sheet| sheet.body == source) {
|
||||
let (df, de, dv) = sheet.round_hole_correction();
|
||||
for (key, delta) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[key] = json!(b["kernel"][key].as_i64().unwrap_or(0) + delta);
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
// A perforation field (#732) is not cut into the B-rep: take its open area out of the volume, the mass
|
||||
// and the area (both faces minus the holes, plus the bores).
|
||||
let open = sh.perforated_area();
|
||||
if open > 0.0 {
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let nv = v - open * sh.t;
|
||||
if let Some(m) = b["mass_g"].as_f64().filter(|_| v > 0.0) {
|
||||
b["mass_g"] = json!(m * nv / v);
|
||||
}
|
||||
b["volume_mm3"] = json!(nv);
|
||||
}
|
||||
let bores: f64 = sh
|
||||
.perforations
|
||||
.iter()
|
||||
.map(|p| sh.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * p.diameter * sh.t)
|
||||
.sum();
|
||||
b["area_mm2"] = json!(b["area_mm2"].as_f64().unwrap_or(0.0) - 2.0 * open + bores);
|
||||
}
|
||||
}
|
||||
}
|
||||
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
|
||||
@@ -395,9 +397,12 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let definitions = s.model.state();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
|
||||
let mut measured = if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, measure_json) } else { Vec::new() }.into_iter();
|
||||
let mut measured =
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
|
||||
.into_iter();
|
||||
let bodies: Vec<Value> = st
|
||||
.bodies
|
||||
.iter()
|
||||
|
||||
@@ -620,12 +620,13 @@ pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64>
|
||||
/// A joint value: numbers are degrees for angles and mm for distances; text is an expression.
|
||||
pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result<f64> {
|
||||
let kind = if angle { Kind::Angle } else { Kind::Length };
|
||||
// A number out of range says so, rather than that it is not a number (#9625).
|
||||
let e = match v {
|
||||
Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && x.abs() < 1e9).map(|x| x.to_string()),
|
||||
Value::String(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, "values must be numbers or expressions"))?;
|
||||
.ok_or_else(|| bad(cmd, format!("a joint value {}", crate::params::expr_problem(Some(v)))))?;
|
||||
s.doc.eval(&e, kind).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -408,3 +408,24 @@ fn a_motion_link_takes_its_driven_joints_freedom_with_nothing_grounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling the marker back works a gear's ratio out from the faces as they were then, so the
|
||||
/// editor's pinned model and its source differ in the link's ratio, and the edit still goes
|
||||
/// through.
|
||||
#[test]
|
||||
fn an_editor_accepts_the_ratios_rolling_the_marker_works_out() {
|
||||
let mut s = gear_train();
|
||||
let before = s.doc.features.len();
|
||||
// G1's wall moves out to a radius of 12, so the link's ratio goes from -0.5 to -0.6.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 2.5]], "distance": 2}));
|
||||
let ratio = |s: &Session| s.doc.assembly.links.first().map(|l| l.ratio).unwrap_or(f64::NAN);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
let original = s.scratch();
|
||||
run(&mut s, "timeline.roll_to", json!({"position": before}));
|
||||
let source = s.scratch();
|
||||
assert!(close_to(ratio(&source), -0.5), "before the offset, the pitch radius is 10: {}", ratio(&source));
|
||||
let prepared = s.scratch().compute_editor_commands(&[], &source, original).unwrap();
|
||||
s.accept_commands(prepared).unwrap();
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
}
|
||||
|
||||
@@ -124,6 +124,27 @@ fn revolute_hinge_turns_about_the_knuckle() {
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
|
||||
}
|
||||
|
||||
/// A joint value too large says so, rather than that it is not a number (#9625).
|
||||
#[test]
|
||||
fn a_joint_value_out_of_range_says_so() {
|
||||
let mut s = Session::default();
|
||||
leaf(&mut s, "LeafA");
|
||||
leaf(&mut s, "LeafB");
|
||||
let o = occ(&s, "LeafA");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
let hinge = json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"});
|
||||
run(&mut s, "joint.create", hinge);
|
||||
for value in [json!(1e9), json!(-5e15)] {
|
||||
let e = s.execute("joint.drive", &json!({"joint": "Hinge", "value": value})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
e.contains("a joint value must be a number between -1e9 and 1e9") && !e.contains("a joint value must be a number or"),
|
||||
"{value}: {e}"
|
||||
);
|
||||
}
|
||||
let e = s.execute("joint.drive", &json!({"joint": "Hinge", "value": true})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("a joint value must be a number or an expression") && !e.contains("between"), "{e}");
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes in components A (grounded) and B.
|
||||
fn two_cubes() -> Session {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -242,9 +242,20 @@ pub fn assembly(doc: &Document, state: &ModelState) -> Result<AssemblyMassResult
|
||||
if !closed {
|
||||
return Ok(Shape { closed, volume: 0.0, centroid: Vec3::ZERO, gyration: None });
|
||||
}
|
||||
let m = solidcraft_kernel::measure_mass(&body.body)?;
|
||||
let gyration = solidcraft_io::mass_properties(&body.body)?.and_then(|p| p.gyration());
|
||||
Ok(Shape { closed, volume: m.volume, centroid: m.centroid, gyration })
|
||||
let sheet = state.sheets.iter().find(|sheet| sheet.body == body.definition_name() && !sheet.perforations.is_empty());
|
||||
let (volume, centroid) = if sheet.is_some() {
|
||||
let measured = super::inspect::measure_body(body, Some(state))?;
|
||||
(measured.volume, measured.centroid)
|
||||
} else {
|
||||
let measured = solidcraft_kernel::measure_mass(&body.body)?;
|
||||
(measured.volume, measured.centroid)
|
||||
};
|
||||
let mut moments = solidcraft_io::mass_properties(&body.body)?;
|
||||
if let (Some(sheet), Some(moments)) = (sheet, moments.as_mut()) {
|
||||
sheet.correct_perforation_mass_properties(moments, &IDENTITY)?;
|
||||
}
|
||||
let gyration = moments.and_then(|p| p.gyration());
|
||||
Ok(Shape { closed, volume, centroid, gyration })
|
||||
});
|
||||
let mut shape: BTreeMap<usize, Shape> = BTreeMap::new();
|
||||
for (i, s) in measured.into_iter().zip(shapes) {
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
//! Read-only validation of candidate engineering data; no assignment or simulation.
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::material_properties::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate)
|
||||
const VALIDATE_CONTRACT: TypedContract<ValidateCardParams> = TypedContract::new();
|
||||
const DIRECTIONAL_CONTRACT: TypedContract<DirectionalModulusParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", crate::checked!("material.properties.validate", validate))
|
||||
.icon("material")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&VALIDATE_CONTRACT)
|
||||
.output(output_schema_for::<CardValidated>)
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", crate::checked!("material.properties.directional", directional)).icon("material").noundo().doc_only()
|
||||
.contract(&DIRECTIONAL_CONTRACT)
|
||||
.output(output_schema_for::<CardModulus>)
|
||||
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
|
||||
|
||||
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -28,7 +37,7 @@ fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let direction = [component(0)?, component(1)?, component(2)?];
|
||||
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
|
||||
serde_json::to_value(CardModulus::from(response)).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
@@ -38,13 +47,15 @@ fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?;
|
||||
let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?;
|
||||
let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?;
|
||||
Ok(json!({"valid":true,"validation":result}))
|
||||
let reply = CardValidated { valid: true, validation: result.into(), param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| bad("material.properties.validate", e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::doc::Document;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn card() -> Value {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! What a face measures on the surface behind it (#9086).
|
||||
//!
|
||||
//! A curved face's area is the area of the surface it lies on: a cylinder's band is
|
||||
//! r·θ·h and a whole sphere 4πr². The display mesh's triangles run inside a curve, so
|
||||
//! their sum falls short of it.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcore_record::brep::Curve;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Mesh, UnitVec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface};
|
||||
|
||||
/// The area of the patch of faces `patch` of `body`, worked out on the surface behind
|
||||
/// them: a cylinder's band, or a sphere. `None` where the surface does not give one, and
|
||||
/// the caller keeps the measure of the mesh.
|
||||
pub(crate) fn patch_area(body: &Body, mesh: &Mesh, patch: &[usize]) -> Option<f64> {
|
||||
match solidcraft_kernel::face_surface(body, *patch.first()?)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius } => cylinder_band(body, mesh, patch, origin, axis, radius),
|
||||
FaceSurface::Sphere { radius, .. } => sphere(body, patch, radius),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges on the border of the patch: the ones a face of the patch shares with a face
|
||||
/// outside it, and the ones it keeps to itself.
|
||||
fn border_edges(mesh: &Mesh, patch: &[usize]) -> Vec<usize> {
|
||||
mesh.edge_faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, faces)| {
|
||||
let inside = faces.iter().filter(|f| patch.contains(&(**f as usize))).count();
|
||||
inside > 0 && (faces.len() == 1 || inside < faces.len())
|
||||
})
|
||||
.map(|(edge, _)| edge)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether edge `edge` is a straight line parallel to `axis`.
|
||||
fn straight_along(body: &Body, edge: usize, axis: UnitVec3) -> bool {
|
||||
let Some(e) = body.record().edges.get(edge) else { return false };
|
||||
if !matches!(e.curve, Curve::Line { .. }) {
|
||||
return false;
|
||||
}
|
||||
let Ok(c) = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()) else { return false };
|
||||
let [t0, t1] = e.interval;
|
||||
let (Some(a), Some(b)) = (c.point(t0), c.point(t1)) else { return false };
|
||||
(b - a).normalized().is_some_and(|d| d.dot(axis.vec()).abs() >= 1.0 - 1e-9)
|
||||
}
|
||||
|
||||
/// The area of a cylindrical band: the radius of the surface, the turn the band covers
|
||||
/// and the distance between its two rims.
|
||||
fn cylinder_band(body: &Body, mesh: &Mesh, patch: &[usize], origin: Vec3, axis: UnitVec3, radius: f64) -> Option<f64> {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol = radius * 1e-6;
|
||||
// The whole patch lies on one cylinder.
|
||||
for f in patch {
|
||||
match solidcraft_kernel::face_surface(body, *f)? {
|
||||
FaceSurface::Cylinder { origin: o, axis: a, radius: r } => {
|
||||
if (r - radius).abs() > tol || o.dist(origin) > tol || a.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// The rims of the band: the circles that bound it, each across the axis.
|
||||
let mut rims: Vec<(f64, f64)> = Vec::new();
|
||||
for edge in border_edges(mesh, patch) {
|
||||
// An edge that is not a circle but a straight line along the axis (a seam, a straight
|
||||
// cut along the band) leaves the turn of the band to its rims alone. Any other edge
|
||||
// (the saddle rim of a bore through the wall) cuts the band: not ours to measure.
|
||||
let Some(circle) = solidcraft_kernel::edge_circle(body, edge) else {
|
||||
if straight_along(body, edge, axis) {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if circle.axis.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 || (circle.radius - radius).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let d = circle.center - origin;
|
||||
let height = d.dot(axis.vec());
|
||||
if (d - axis.vec() * height).len() > tol {
|
||||
return None;
|
||||
}
|
||||
rims.push((height, circle.angle));
|
||||
}
|
||||
// The two rims: the band runs between them, and both cover the same turn.
|
||||
let mut levels: Vec<(f64, f64)> = Vec::new();
|
||||
for (height, angle) in rims {
|
||||
match levels.iter_mut().find(|(h, _)| (*h - height).abs() <= tol) {
|
||||
Some((_, a)) => *a += angle,
|
||||
None => levels.push((height, angle)),
|
||||
}
|
||||
}
|
||||
if levels.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
levels.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let (low, low_turn) = levels[0];
|
||||
let (high, high_turn) = levels[1];
|
||||
let height = high - low;
|
||||
if !(height > 0.0) || (low_turn - high_turn).abs() > low_turn.max(high_turn) * 1e-6 {
|
||||
return None;
|
||||
}
|
||||
Some(radius * low_turn * height)
|
||||
}
|
||||
|
||||
/// The area of a sphere: 4πr², when the body is the whole of it.
|
||||
fn sphere(body: &Body, patch: &[usize], radius: f64) -> Option<f64> {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
// The whole patch lies on one sphere.
|
||||
for f in patch {
|
||||
match solidcraft_kernel::face_surface(body, *f)? {
|
||||
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// The body is the whole sphere: every face of it lies on that sphere and the patch
|
||||
// holds them all. A body with a face of its own (a sphere that was cut) is a piece of
|
||||
// the sphere, whose area this does not work out: the measure of the mesh stands in
|
||||
// for it.
|
||||
for f in 0..body.record().faces.len() {
|
||||
match solidcraft_kernel::face_surface(body, f)? {
|
||||
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
|
||||
_ => return None,
|
||||
}
|
||||
if !patch.contains(&f) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(4.0 * PI * radius * radius)
|
||||
}
|
||||
@@ -350,7 +350,10 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
|
||||
let m = b.mesh();
|
||||
let patch = m.surface_patch(*index);
|
||||
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let on_mesh: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
// A curved face is measured on the surface it lies on, not on the triangles of
|
||||
// the display mesh, which run inside the curve.
|
||||
let area = super::measure_exact::patch_area(&b.body, &m, &patch).unwrap_or(on_mesh);
|
||||
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
|
||||
@@ -380,7 +383,12 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
|
||||
let closed = crate::cmd::inspect::encloses_volume(&body.body);
|
||||
let m = body.mesh();
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().filter_map(|t| m.tri(t)).collect();
|
||||
let v = m.measure().volume;
|
||||
let definitions = s.model.state();
|
||||
let v = if definitions.sheets.iter().any(|sheet| sheet.body == body.definition_name() && !sheet.perforations.is_empty()) {
|
||||
super::inspect::measure_body(body, Some(&definitions)).ok()?.volume
|
||||
} else {
|
||||
m.measure().volume
|
||||
};
|
||||
plain(Geo::Tris(tris), None, json!({"type": "body", "name": name, "closed": closed, "volume_mm3": closed.then_some(v)}))
|
||||
}
|
||||
Sel::SketchCurve { id, sketch } => sketch_curve(sketch_of(s, st, *sketch, |sk| sk.curve_index(id).is_some())?, id),
|
||||
|
||||
@@ -10,28 +10,52 @@ use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::metadata::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const READ_CONTRACT: TypedContract<MetadataParams> = TypedContract::new();
|
||||
const SET_CONTRACT: TypedContract<MetadataSetParams> = TypedContract::new();
|
||||
const REMOVE_CONTRACT: TypedContract<MetadataRemoveParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
CommandSpec::new("document.metadata", "Design Properties", crate::checked!("document.metadata", read))
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&READ_CONTRACT)
|
||||
.output(output_schema_for::<MetadataResult>)
|
||||
.params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", crate::checked!("document.metadata.set", set))
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.contract(&SET_CONTRACT)
|
||||
.output(output_schema_for::<MetadataSetResult>)
|
||||
.params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", crate::checked!("document.metadata.remove", remove))
|
||||
.icon("delete")
|
||||
.doc_only()
|
||||
.contract(&REMOVE_CONTRACT)
|
||||
.output(output_schema_for::<MetadataRemoveResult>)
|
||||
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
/// A reply built from its schema's type, so the two can't drift apart.
|
||||
fn reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
reply("document.metadata", MetadataResult { metadata: m.0.clone(), properties })
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -60,7 +84,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if next != s.doc.metadata {
|
||||
s.doc_mut().metadata = next;
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
|
||||
reply(cmd, MetadataSetResult { metadata: s.doc.metadata.0.clone(), changed, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -84,7 +108,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
|
||||
let removed = removed.into_iter().map(str::to_string).collect();
|
||||
reply(cmd, MetadataRemoveResult { metadata: s.doc.metadata.0.clone(), removed, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,6 +46,7 @@ pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
pub use parts::part_length_label;
|
||||
pub use sketch::dimension_roles;
|
||||
@@ -73,6 +74,7 @@ mod libraries;
|
||||
pub mod mass_properties;
|
||||
mod material_properties;
|
||||
mod materials;
|
||||
mod measure_exact;
|
||||
mod measure_sel;
|
||||
mod measure_shape;
|
||||
mod mesh;
|
||||
@@ -260,16 +262,12 @@ impl CommandSpec {
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
let (label, icon) = match self.id {
|
||||
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
|
||||
_ => (self.label, self.icon),
|
||||
};
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label,
|
||||
label: self.label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon,
|
||||
icon: self.icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
|
||||
@@ -541,6 +541,84 @@ fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7896: isolating a body hides its own component's other bodies one by one and every other
|
||||
/// placement as a placement, so the Browser row of a component shows it is off and its eye
|
||||
/// brings it back. A second placement of the body's own component is another placement.
|
||||
/// #7896: isolating a body inside a hidden Bodies folder or group shows that folder and group
|
||||
/// again, so the body is visible; the folder's other bodies stay hidden.
|
||||
#[test]
|
||||
fn isolate_shows_the_hidden_folder_and_group_a_picked_body_is_in() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Kept"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Other","operation":"new"}));
|
||||
let group = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["Kept"], "name": "Held"}));
|
||||
let group = group["group"].as_u64().or_else(|| group["id"].as_u64()).expect("the group's id");
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies", format!("c0/g{group}")], "visible": false}));
|
||||
assert!(!s.body_visible("Kept") && !s.body_visible("Other"));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Kept"], "visible": true, "isolate": true}));
|
||||
assert!(s.body_visible("Kept"), "the picked body shows: {:?}", s.visibility.hidden_folders);
|
||||
assert!(!s.body_visible("Other"), "the folder's other body stays hidden");
|
||||
assert!(s.visibility.hidden_folders.is_empty(), "{:?}", s.visibility.hidden_folders);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_bodies_hides_other_components_as_components() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[-20,0,0],"body_name":"Root"}));
|
||||
let comp = |s: &mut Session, name: &str| {
|
||||
let c = run(s, "component.create", json!({"name": name, "parent": "root", "grounded": false}));
|
||||
c["occurrence"].as_u64().unwrap()
|
||||
};
|
||||
let a = comp(&mut s, "CompA");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BoxA"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,20],"body_name":"BoxA2"}));
|
||||
let b = comp(&mut s, "CompB");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"BoxB1"}));
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0],"body_name":"BoxB2"}));
|
||||
let a2 = run(&mut s, "occurrence.copy", json!({"occurrence":a,"translate":[0,40,0]}))["occurrence"].as_u64().unwrap();
|
||||
let in_a = s.occurrence_bodies(&[a], false).unwrap();
|
||||
let (box_a, box_a2) = (in_a.iter().find(|n| !n.contains("BoxA2")).unwrap().clone(), in_a.iter().find(|n| n.contains("BoxA2")).unwrap().clone());
|
||||
|
||||
let out = run(&mut s, "browser.visibility", json!({"visible":true,"items":[box_a],"isolate":true}));
|
||||
assert_eq!(out["isolated"], json!([box_a]));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut others = vec![vec![b], vec![a2]];
|
||||
others.sort();
|
||||
assert_eq!(occurrences, others, "CompB and CompA's other placement go as placements");
|
||||
let mut bodies = s.visibility.hidden_bodies.clone();
|
||||
bodies.sort();
|
||||
assert_eq!(bodies, vec![box_a2.clone(), "Root".to_string()], "only bodies beside BoxA go one by one");
|
||||
assert!(s.body_visible(&box_a) && !s.body_visible(&box_a2) && !s.body_visible("Root"));
|
||||
for name in s.occurrence_bodies(&[b], false).unwrap().iter().chain(&s.occurrence_bodies(&[a2], false).unwrap()) {
|
||||
assert!(!s.body_visible(name), "{name}");
|
||||
}
|
||||
// CompB's eye brings it back whole.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[b]]}));
|
||||
assert!(s.occurrence_bodies(&[b], false).unwrap().iter().all(|n| s.body_visible(n)));
|
||||
|
||||
// Isolating a root body hides every placement as a placement and no body one by one.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"items":["Root"],"isolate":true}));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut all = vec![vec![a], vec![b], vec![a2]];
|
||||
all.sort();
|
||||
assert_eq!(occurrences, all);
|
||||
assert!(s.visibility.hidden_bodies.is_empty() && s.body_visible("Root"), "{:?}", s.visibility.hidden_bodies);
|
||||
|
||||
// Only bodies isolate, and only shown; a refusal changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"visible":false,"items":["Root"],"isolate":true}),
|
||||
json!({"visible":true,"items":["XY"],"isolate":true}),
|
||||
json!({"visible":true,"items":["Nope"],"isolate":true}),
|
||||
json!({"visible":true,"folder":"bodies","isolate":true}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -12,9 +12,21 @@ use solidcore_record::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_};
|
||||
use crate::schema::parameter_sweep::{
|
||||
FeatureFailed, FeatureWarned, JointFlipped, JointLost, JointSide, MAX_VALUES, ParameterSweep, ParameterSweepParams, SketchFailed, SketchFlipped,
|
||||
SweepChanges, SweepResult, SweepSummary,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.parameter_sweep", "Parameter Sweep", sweep).icon("params").noundo().params(
|
||||
const CONTRACT: TypedContract<ParameterSweepParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.parameter_sweep", "Parameter Sweep", crate::checked!("inspect.parameter_sweep", sweep))
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ParameterSweep>)
|
||||
.params(
|
||||
"parameter: name; values: [number | expression] (at most 200) | from, to: numbers, steps? (default 10, at most 199: steps + 1 values) → \
|
||||
for each value, in a copy of the design (the design itself is not changed): {value, ms (the rebuild), ok, failed: [{feature, name, error}], warned: [{feature, name, warning}] \
|
||||
(features that build at the current value and fail or warn at this one), joints_lost: [{joint, name, origin?: a|b, why}], joints_flipped: [{joint, name, origin: a|b, degrees}] \
|
||||
@@ -24,9 +36,6 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.parameter_swee
|
||||
summary: {values, ok, slowest_ms, failed, joints_lost, joints_flipped, sketches_flipped, sketches_failed} (how many values show each)",
|
||||
)];
|
||||
|
||||
/// Values swept by one command, at most.
|
||||
const MAX_VALUES: usize = 200;
|
||||
|
||||
/// A frame's origin and z axis, in its occurrence's coordinates.
|
||||
type Frame = (Vec3, Vec3);
|
||||
|
||||
@@ -85,29 +94,29 @@ fn degrees(a: Vec3, b: Vec3) -> Option<f64> {
|
||||
}
|
||||
|
||||
/// What changed from `base` (the design as it is) to `at` (at one value).
|
||||
fn compare(base: &Look, at: &Look) -> Value {
|
||||
let failed: Vec<Value> = at
|
||||
fn compare(base: &Look, at: &Look) -> SweepChanges {
|
||||
let failed = at
|
||||
.errors
|
||||
.iter()
|
||||
.filter(|(id, _)| !base.errors.contains_key(id))
|
||||
.map(|(id, (name, e))| json!({"feature": id, "name": name, "error": e}))
|
||||
.map(|(id, (name, e))| FeatureFailed { feature: *id, name: name.clone(), error: e.clone() })
|
||||
.collect();
|
||||
let warned: Vec<Value> = at
|
||||
let warned = at
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|(id, (_, w))| base.warnings.get(id).is_none_or(|(_, b)| b != w) && !at.errors.contains_key(id))
|
||||
.map(|(id, (name, w))| json!({"feature": id, "name": name, "warning": w}))
|
||||
.map(|(id, (name, w))| FeatureWarned { feature: *id, name: name.clone(), warning: w.clone() })
|
||||
.collect();
|
||||
let mut lost = Vec::new();
|
||||
let mut flipped = Vec::new();
|
||||
let mut joints_lost = Vec::new();
|
||||
let mut joints_flipped = Vec::new();
|
||||
for (id, (name, a, b)) in &at.joints {
|
||||
let Some((_, a0, b0)) = base.joints.get(id) else { continue };
|
||||
for (side, now, was) in [("a", a, a0), ("b", b, b0)] {
|
||||
for (side, now, was) in [(JointSide::A, a, a0), (JointSide::B, b, b0)] {
|
||||
match (now, was) {
|
||||
(Err(why), Ok(_)) => lost.push(json!({"joint": id, "name": name, "origin": side, "why": why})),
|
||||
(Err(why), Ok(_)) => joints_lost.push(JointLost { joint: *id, name: name.clone(), origin: Some(side), why: why.clone() }),
|
||||
(Ok(f), Ok(f0)) => {
|
||||
if let Some(d) = degrees(f.1, f0.1).filter(|d| *d > 90.0) {
|
||||
flipped.push(json!({"joint": id, "name": name, "origin": side, "degrees": (d * 10.0).round() / 10.0}));
|
||||
joints_flipped.push(JointFlipped { joint: *id, name: name.clone(), origin: side, degrees: (d * 10.0).round() / 10.0 });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -117,7 +126,7 @@ fn compare(base: &Look, at: &Look) -> Value {
|
||||
&& a.is_ok()
|
||||
&& b.is_ok()
|
||||
{
|
||||
lost.push(json!({"joint": id, "name": name, "why": why}));
|
||||
joints_lost.push(JointLost { joint: *id, name: name.clone(), origin: None, why: why.clone() });
|
||||
}
|
||||
}
|
||||
let mut sketches_flipped = Vec::new();
|
||||
@@ -125,15 +134,13 @@ fn compare(base: &Look, at: &Look) -> Value {
|
||||
for (id, (name, n, failing, folded)) in &at.sketches {
|
||||
let Some((_, n0, failing0, folded0)) = base.sketches.get(id) else { continue };
|
||||
if let Some(d) = degrees(*n, *n0).filter(|d| *d > 90.0) {
|
||||
sketches_flipped.push(json!({"sketch": id, "name": name, "degrees": (d * 10.0).round() / 10.0}));
|
||||
sketches_flipped.push(SketchFlipped { sketch: *id, name: name.clone(), degrees: (d * 10.0).round() / 10.0 });
|
||||
}
|
||||
if (!failing.is_empty() && failing0.is_empty()) || (!folded.is_empty() && folded0.is_empty()) {
|
||||
sketches_failed.push(json!({"sketch": id, "name": name, "failing": failing, "folded": folded}));
|
||||
sketches_failed.push(SketchFailed { sketch: *id, name: name.clone(), failing: failing.clone(), folded: folded.clone() });
|
||||
}
|
||||
}
|
||||
let ok = failed.is_empty() && lost.is_empty() && flipped.is_empty() && sketches_flipped.is_empty() && sketches_failed.is_empty();
|
||||
json!({"ok": ok, "failed": failed, "warned": warned, "joints_lost": lost, "joints_flipped": flipped,
|
||||
"sketches_flipped": sketches_flipped, "sketches_failed": sketches_failed})
|
||||
SweepChanges { failed, warned, joints_lost, joints_flipped, sketches_flipped, sketches_failed }
|
||||
}
|
||||
|
||||
/// The values to sweep.
|
||||
@@ -175,37 +182,36 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let values = values(p, cmd)?;
|
||||
let base = look(s);
|
||||
let mut results = Vec::new();
|
||||
let mut count: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut slowest: f64 = 0.0;
|
||||
for v in values {
|
||||
let mut summary =
|
||||
SweepSummary { values: 0, ok: 0, slowest_ms: 0.0, failed: 0, joints_lost: 0, joints_flipped: 0, sketches_flipped: 0, sketches_failed: 0 };
|
||||
for value in values {
|
||||
let mut x = s.scratch();
|
||||
let t0 = std::time::Instant::now();
|
||||
let changed = x.execute("parameters.change", &json!({"name": name, "expression": v}));
|
||||
let changed = x.execute("parameters.change", &json!({"name": name, "expression": value}));
|
||||
let ms = (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0;
|
||||
slowest = slowest.max(ms);
|
||||
let mut r = match changed {
|
||||
Ok(_) => compare(&base, &look(&x)),
|
||||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||||
};
|
||||
for key in ["failed", "joints_lost", "joints_flipped", "sketches_flipped", "sketches_failed"] {
|
||||
if r[key].as_array().is_some_and(|a| !a.is_empty()) {
|
||||
*count.entry(key).or_default() += 1;
|
||||
summary.slowest_ms = summary.slowest_ms.max(ms);
|
||||
let r = match changed {
|
||||
Ok(_) => {
|
||||
let c = compare(&base, &look(&x));
|
||||
summary.failed += usize::from(!c.failed.is_empty());
|
||||
summary.joints_lost += usize::from(!c.joints_lost.is_empty());
|
||||
summary.joints_flipped += usize::from(!c.joints_flipped.is_empty());
|
||||
summary.sketches_flipped += usize::from(!c.sketches_flipped.is_empty());
|
||||
summary.sketches_failed += usize::from(!c.sketches_failed.is_empty());
|
||||
let ok = c.failed.is_empty()
|
||||
&& c.joints_lost.is_empty()
|
||||
&& c.joints_flipped.is_empty()
|
||||
&& c.sketches_flipped.is_empty()
|
||||
&& c.sketches_failed.is_empty();
|
||||
SweepResult { value, ms, ok, error: None, changes: Some(c) }
|
||||
}
|
||||
}
|
||||
if r["ok"] == json!(true) {
|
||||
*count.entry("ok").or_default() += 1;
|
||||
}
|
||||
r["value"] = v;
|
||||
r["ms"] = json!(ms);
|
||||
Err(e) => SweepResult { value, ms, ok: false, error: Some(e.to_string()), changes: None },
|
||||
};
|
||||
summary.ok += usize::from(r.ok);
|
||||
results.push(r);
|
||||
}
|
||||
let get = |k: &str| count.get(k).copied().unwrap_or(0);
|
||||
Ok(json!({
|
||||
"parameter": name,
|
||||
"results": results,
|
||||
"summary": {"values": results.len(), "ok": get("ok"), "slowest_ms": slowest, "failed": get("failed"), "joints_lost": get("joints_lost"),
|
||||
"joints_flipped": get("joints_flipped"), "sketches_flipped": get("sketches_flipped"), "sketches_failed": get("sketches_failed")},
|
||||
}))
|
||||
summary.values = results.len();
|
||||
serde_json::to_value(ParameterSweep { parameter: name, results, summary, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,27 +15,43 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, str_, vec3};
|
||||
use crate::schema::parts::{InsertedPart, PartFamily, PartsInsertParams, PartsLibrary, PartsLibraryParams, PartsResizeParams, ResizedPart};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const LIBRARY_CONTRACT: TypedContract<PartsLibraryParams> = TypedContract::new();
|
||||
const INSERT_CONTRACT: TypedContract<PartsInsertParams> = TypedContract::new();
|
||||
const RESIZE_CONTRACT: TypedContract<PartsResizeParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parts.library", "Standard Parts", library)
|
||||
CommandSpec::new("parts.library", "Standard Parts", crate::checked!("parts.library", library))
|
||||
.icon("library")
|
||||
.noundo()
|
||||
.contract(&LIBRARY_CONTRACT)
|
||||
.output(output_schema_for::<PartsLibrary>)
|
||||
.params("family? → families (name, standard, system: metric|inch, sizes, lengths) and, with family, each size's dimensions (mm)"),
|
||||
CommandSpec::new("parts.fastener", "Insert Fastener", insert_atomic)
|
||||
CommandSpec::new("parts.fastener", "Insert Fastener", crate::checked!("parts.fastener", insert_atomic))
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<InsertedPart>)
|
||||
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?"),
|
||||
CommandSpec::new("parts.insert", "Insert Part", insert_atomic).icon("fastener").params(
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
|
||||
CommandSpec::new("parts.insert", "Insert Part", crate::checked!("parts.insert", insert_atomic))
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<InsertedPart>)
|
||||
.params(
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
|
||||
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
|
||||
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
|
||||
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
|
||||
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name? → component, occurrence, joint?, \
|
||||
and with at: occurrences, joints (one per hole, in pick order)",
|
||||
),
|
||||
CommandSpec::new("parts.resize", "Change Part Size", resize_atomic)
|
||||
),
|
||||
CommandSpec::new("parts.resize", "Change Part Size", crate::checked!("parts.resize", resize_atomic))
|
||||
.icon("scale")
|
||||
.contract(&RESIZE_CONTRACT)
|
||||
.output(output_schema_for::<ResizedPart>)
|
||||
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
|
||||
];
|
||||
|
||||
@@ -572,7 +588,7 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
|
||||
}
|
||||
|
||||
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fam = |id: &str| -> Value {
|
||||
let fam = |id: &str| -> PartFamily {
|
||||
let mut sizes: Vec<Value> = match id {
|
||||
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
@@ -612,15 +628,24 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let system = if is_inch(id) { "inch" } else { "metric" };
|
||||
json!({"family": id, "name": name, "standard": standard, "system": system, "sizes": sizes, "lengths": lengths})
|
||||
PartFamily { family: id.into(), name: name.into(), standard: standard.into(), system: system.into(), sizes, lengths }
|
||||
};
|
||||
match str_(p, "family") {
|
||||
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)),
|
||||
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => {
|
||||
reply("parts.library", PartsLibrary { families: None, family: Some(fam(f)), param_warnings: Vec::new() })
|
||||
}
|
||||
Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))),
|
||||
None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::<Vec<_>>()})),
|
||||
None => reply(
|
||||
"parts.library",
|
||||
PartsLibrary { families: Some(FAMILIES.iter().map(|f| fam(f.0)).collect()), family: None, param_warnings: Vec::new() },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn part_name(family: &str, size: &str, sh: &Shape) -> String {
|
||||
match sh {
|
||||
Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {}", label(family), size_with_length(family, size, *len)),
|
||||
@@ -747,14 +772,26 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().parts.insert(comp, solidcraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
|
||||
set_identity(s, comp, identity(&family, &size, &sh), None);
|
||||
run(s, "component.activate", json!({"component": active}))?;
|
||||
let mut out = json!({"component": comp, "occurrence": occ, "name": name});
|
||||
let mut out = InsertedPart {
|
||||
component: comp,
|
||||
occurrence: occ,
|
||||
name: name.clone(),
|
||||
joint: None,
|
||||
occurrences: None,
|
||||
joints: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if !holes.is_empty() {
|
||||
// The first hole has the placement the component came with; each further hole places the
|
||||
// same part again. All are made before any is seated, while each is still at the part's
|
||||
// own origin, where the joint below takes the part's seating point to be.
|
||||
let mut occurrences = vec![json!(occ)];
|
||||
let mut occurrences = vec![occ];
|
||||
for _ in 1..holes.len() {
|
||||
occurrences.push(run(s, "occurrence.copy", json!({"component": comp}))?["occurrence"].clone());
|
||||
occurrences.push(
|
||||
run(s, "occurrence.copy", json!({"component": comp}))?["occurrence"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| bad(cmd, "the copy has no occurrence"))?,
|
||||
);
|
||||
}
|
||||
let mut joints = Vec::new();
|
||||
for (i, ((a, h), o)) in holes.into_iter().zip(&occurrences).enumerate() {
|
||||
@@ -766,11 +803,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": seat}),
|
||||
)?;
|
||||
joints.push(j["joint"].clone());
|
||||
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
|
||||
}
|
||||
out["joint"] = joints.first().cloned().unwrap_or(Value::Null);
|
||||
out["occurrences"] = Value::Array(occurrences);
|
||||
out["joints"] = Value::Array(joints);
|
||||
out.joint = joints.first().copied();
|
||||
out.occurrences = Some(occurrences);
|
||||
out.joints = Some(joints);
|
||||
} else if let Some(pt) = p.get("point").and_then(vec3) {
|
||||
// Placed at a point, its +z along `direction`.
|
||||
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
|
||||
@@ -789,7 +826,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The most holes one insert seats a part in.
|
||||
@@ -887,7 +924,7 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)}))
|
||||
reply(cmd, ResizedPart { component: comp, name, size, length: shape_len(&sh), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -404,7 +404,7 @@ mod tests {
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let lip = json!({"body": "Plate", "face": [51.89, 45.2, 8.0], "width": "1.5 mm", "height": "3.5 mm", "side": "inside", "type": "lip"});
|
||||
let e = s.execute("plastic.lip", &lip).unwrap_err().to_string();
|
||||
assert!(e.contains("rim face") && e.contains("2 openings alike"), "{e}");
|
||||
assert!(e.contains("one clear opening") && e.contains("2 openings alike"), "{e}");
|
||||
// The boss alone, no holes: its foot is not an opening either.
|
||||
let mut b = Session::default();
|
||||
run(&mut b, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
|
||||
|
||||
@@ -81,21 +81,35 @@ CommandSpec::new("render.preset.apply", "Apply Native Studio", studio_presets::a
|
||||
.params("Same reviewed input as render.preset.resolve, expected_revision required, replace_lighting_and_environment:true and replace_stage:true when an existing stage would change. Re-resolves the complete candidate and publishes one undo or no mutation; camera, optical bindings, geometry and engineering materials are preserved."),
|
||||
CommandSpec::new("render.quality.prepare", "Review Fixed Sampling Budget", studio_presets::quality).icon("settings").noundo().doc_only()
|
||||
.params("id: Q-*; variant:fixed_samples_png; acknowledge_omissions:true. Read-only bounded GPU still-budget projection, not full adaptive/convergence/linear-master quality acceptance. Returns dimensions/samples/depth/seed/denoise and explicit omissions. Never switches executor or mutates a shot."),
|
||||
CommandSpec::new("render.shot.duplicate", "Duplicate Render Shot", shots::duplicate).icon("copy").doc_only()
|
||||
CommandSpec::new("render.shot.duplicate", "Duplicate Render Shot", crate::checked!("render.shot.duplicate", shots::duplicate)).icon("copy").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, new_name, expected_revision:u64. Copies exact saved setup and resource references in one undo. Existing destinations refuse; captured jobs and camera navigation are unchanged."),
|
||||
CommandSpec::new("render.shot.rename", "Rename Render Shot", shots::rename).icon("settings").doc_only()
|
||||
CommandSpec::new("render.shot.rename", "Rename Render Shot", crate::checked!("render.shot.rename", shots::rename)).icon("settings").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, new_name, expected_revision:u64. Renames exact saved setup in one undo; an existing destination or stale review refuses."),
|
||||
CommandSpec::new("render.shot.remove", "Remove Render Shot", shots::remove).icon("delete").doc_only()
|
||||
CommandSpec::new("render.shot.remove", "Remove Render Shot", crate::checked!("render.shot.remove", shots::remove)).icon("delete").doc_only()
|
||||
.contract(&shots::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_shots::ShotChange>)
|
||||
.params("name, expected_revision:u64. Removes only that saved setup in one undo; retains resources, geometry and captured results."),
|
||||
CommandSpec::new("render.library.preview", "Inspect Original Surface Map", library::preview).icon("appearance").noundo().doc_only()
|
||||
CommandSpec::new("render.library.preview", "Inspect Original Surface Map", crate::checked!("render.library.preview", library::preview)).icon("appearance").noundo().doc_only()
|
||||
.contract(&library::PREVIEW_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPreview>)
|
||||
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
|
||||
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
|
||||
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", staging::ground).icon("render_scene")
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
|
||||
.contract(&staging::GROUND_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: typed raytrace setup, set?: setup patch, margin_fraction?:0.25, gap_mm?:0.1, reflectance_linear?:[0.18,0.18,0.18], replace_stage?:false. Fits one finite passive ground below the visible product once and saves its physical transforms in one undo. Product AABB separation is rechecked on every render."),
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", staging::backdrop).icon("render_scene").doc_only()
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", crate::checked!("render.stage.backdrop", staging::backdrop)).icon("render_scene").doc_only()
|
||||
.contract(&staging::BACKDROP_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: typed raytrace setup, set?: setup patch, kind: floor_wall or cyclorama, reflectance_linear?: [0.18,0.18,0.18], expected_revision: reviewed revision, replace_stage?:false. Fits a finite floor/wall or a 24-strip polygonal cyclorama behind the visible subject using the saved camera azimuth; world +Z up and a minimum one-mm stage envelope. Preserves subject geometry, lighting, output, pose and engineering materials; one undo and rechecked product separation. Not an infinite/analytically smooth/terrain background."),
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", staging::clear).icon("render_scene").doc_only()
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", crate::checked!("render.stage.clear", staging::clear)).icon("render_scene").doc_only()
|
||||
.contract(&staging::CLEAR_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
.params("name: setup, set?: setup patch. Removes saved render-only panels atomically; CAD geometry and lighting are unchanged."),
|
||||
CommandSpec::new("render.library.surface.bind", "Save Surface Binding", surface_bindings::bind).icon("appearance")
|
||||
.params("name: GPU setup; set?: setup patch; surface: same input as render.library.surface.prepare; replace?: false. Pins exact source hashes and definition/feature/occurrence identity with local-mm mapping in one undo. No paths, mesh ordinals or physical material changes."),
|
||||
@@ -103,11 +117,17 @@ CommandSpec::new("render.library.surface.unbind", "Remove Surface Binding", surf
|
||||
.params("name: setup, target: saved definition/feature/component/occurrence_path, set?: setup patch. Removes only that explicit binding in one undo; no device or current geometry required."),
|
||||
CommandSpec::new("render.result.pin", "Retain Displayed Render Result", jobs::pin).icon("inspect").noundo().doc_only()
|
||||
.params("job?: completed JobId (default selected result), or null to clear selection. Selects within the eight retained completed results; oldest is evicted first. Preserves exact export bytes and original host memory accounting. No design/undo change; preview or foreign jobs refuse."),
|
||||
CommandSpec::new("render.library.targets", "Find Render Library Targets", library::targets).icon("list").noundo()
|
||||
CommandSpec::new("render.library.targets", "Find Render Library Targets", crate::checked!("render.library.targets", library::targets)).icon("list").noundo()
|
||||
.contract(&library::TARGETS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryTargets>)
|
||||
.params("query?: exact-name substring; offset?:0; limit?:128 (1..128). Returns a bounded page of captured body ordinals/names, revision and scene bounds. Settles the model through ordinary command handling; never returns a large mesh payload."),
|
||||
CommandSpec::new("render.library.list", "Browse Render Library", library::list).icon("list").noundo().doc_only()
|
||||
CommandSpec::new("render.library.list", "Browse Render Library", crate::checked!("render.library.list", library::list)).icon("list").noundo().doc_only()
|
||||
.contract(&library::LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPage>)
|
||||
.params("kind?: surfaces, selene_appearances, hdr, rigs, scenes, materials, lighting, environments, quality, native_scenes, selene_shots or capabilities; query?: bounded search; availability?: all/usable/specification/blocked; offset?:0; limit?:48 (1..128). Returns source status and explicit implemented projections separately. No device, file IO or design change."),
|
||||
CommandSpec::new("render.library.describe", "Inspect Render Library Resource", library::describe).icon("inspect").noundo().doc_only()
|
||||
CommandSpec::new("render.library.describe", "Inspect Render Library Resource", crate::checked!("render.library.describe", library::describe)).icon("inspect").noundo().doc_only()
|
||||
.contract(&library::DESCRIBE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryDescription>)
|
||||
.params("kind, id: exact authored resource. Returns original specification, runtime projection variants and explicit omissions. A concept is not an executable preset."),
|
||||
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", library::apply_appearance).icon("appearance")
|
||||
.params("id: SURF-* or SELENE-AP-*; variant: uniform_opaque (required); bodies: 1..256 exact body names. Applies the original scalar baseline through ordinary appearance assignment in one undo, preserving physical properties. Spatial maps and advanced target effects are explicitly excluded."),
|
||||
|
||||
@@ -16,8 +16,22 @@ use solidcraft_render::{
|
||||
texture_input::MapRole,
|
||||
};
|
||||
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{
|
||||
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds,
|
||||
};
|
||||
use crate::{Result, Session, params::bad};
|
||||
|
||||
pub(super) const LIST_CONTRACT: TypedContract<LibraryListParams> = TypedContract::new();
|
||||
pub(super) const DESCRIBE_CONTRACT: TypedContract<LibraryDescribeParams> = TypedContract::new();
|
||||
pub(super) const TARGETS_CONTRACT: TypedContract<LibraryTargetsParams> = TypedContract::new();
|
||||
pub(super) const PREVIEW_CONTRACT: TypedContract<LibraryPreviewParams> = TypedContract::new();
|
||||
|
||||
fn reply(command: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
|
||||
const KINDS: &[&str] = &[
|
||||
"materials",
|
||||
"lighting",
|
||||
@@ -162,28 +176,47 @@ pub(super) fn list(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
};
|
||||
let values = catalog(&input.kind, command)?;
|
||||
let count = values.iter().filter(matches).count();
|
||||
let items: Vec<_> = values.iter().filter(matches).skip(input.offset).take(input.limit).map(|v| {
|
||||
json!({"id":v["id"],"name":v["name"],"domains":v["domains"],"family":v["family"],"source_status":v.get("status").or_else(|| v.get("state")),"projection":projection(&input.kind,v)})
|
||||
}).collect();
|
||||
let items = values
|
||||
.iter()
|
||||
.filter(matches)
|
||||
.skip(input.offset)
|
||||
.take(input.limit)
|
||||
.map(|v| {
|
||||
let listed = Listed::deserialize(v).map_err(|e| bad(command, format!("built-in catalog {}: {e}", input.kind)))?;
|
||||
Ok(ResourceSummary {
|
||||
id: listed.id,
|
||||
name: listed.name,
|
||||
domains: listed.domains,
|
||||
family: listed.family,
|
||||
source_status: listed.status.or(listed.state),
|
||||
projection: projection(&input.kind, v),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let end = input.offset.saturating_add(items.len());
|
||||
Ok(json!({"kind":input.kind,"total":count,"offset":input.offset,"next_offset":(end < count).then_some(end),"items":items}))
|
||||
let next_offset = (end < count).then_some(end);
|
||||
reply(command, LibraryPage { kind: input.kind, total: count, offset: input.offset, next_offset, items, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The fields of a catalog's resource a page lists. Every catalog gives an id and a name; the
|
||||
/// source's status is `status`, or a rig's `state`.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct TargetQuery {
|
||||
query: String,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
}
|
||||
impl Default for TargetQuery {
|
||||
fn default() -> Self {
|
||||
Self { query: String::new(), offset: 0, limit: 128 }
|
||||
}
|
||||
struct Listed {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
domains: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
family: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.targets";
|
||||
let input: TargetQuery = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryTargetsParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.query.len() > 256
|
||||
|| input.query.chars().any(char::is_control)
|
||||
|| input.limit == 0
|
||||
@@ -202,49 +235,70 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
.filter(|(_, body)| body.name.to_lowercase().contains(&query))
|
||||
.skip(input.offset)
|
||||
.take(input.limit)
|
||||
.map(|(ordinal, body)| {
|
||||
json!({"ordinal":ordinal,"name":body.name,"color":body.color,"opacity":body.opacity,
|
||||
"pbr":body.surface.map(|v|json!({"metallic":v.metallic(),"roughness":v.roughness(),"dielectric_ior":v.dielectric_ior()}))})
|
||||
.map(|(ordinal, body)| RenderTarget {
|
||||
ordinal,
|
||||
name: body.name.clone(),
|
||||
color: body.color,
|
||||
opacity: body.opacity,
|
||||
pbr: body.surface.map(|v| Pbr { metallic: v.metallic(), roughness: v.roughness(), dielectric_ior: v.dielectric_ior() }),
|
||||
})
|
||||
.collect();
|
||||
let end = input.offset.saturating_add(items.len());
|
||||
Ok(json!({"items":items,"total":count,"offset":input.offset,"next_offset":(end<count).then_some(end),"revision":s.revision,
|
||||
"bounds":if scene.bounds.is_empty(){Value::Null}else{json!({"min":scene.bounds.min,"max":scene.bounds.max})}}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Identify {
|
||||
kind: String,
|
||||
id: String,
|
||||
let bounds = (!scene.bounds.is_empty()).then(|| SceneBounds { min: scene.bounds.min.into(), max: scene.bounds.max.into() });
|
||||
reply(
|
||||
command,
|
||||
LibraryTargets {
|
||||
items,
|
||||
total: count,
|
||||
offset: input.offset,
|
||||
next_offset: (end < count).then_some(end),
|
||||
revision: s.revision,
|
||||
bounds,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn describe(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.describe";
|
||||
let input: Identify = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryDescribeParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.kind == "physical_profiles" {
|
||||
return super::physical_library::describe(&input.id);
|
||||
}
|
||||
let value = item(&input.kind, &input.id, command)?;
|
||||
let mut reply = json!({"kind":input.kind,"resource":value,"projection":projection(&input.kind,value),"source_manifest_unchanged":true});
|
||||
if matches!(input.kind.as_str(), "materials" | "surfaces") {
|
||||
reply["physical_references"] = super::physical_library::associations(&input.id)?;
|
||||
}
|
||||
Ok(reply)
|
||||
let physical_references =
|
||||
if matches!(input.kind.as_str(), "materials" | "surfaces") { Some(super::physical_library::associations(&input.id)?) } else { None };
|
||||
let projection = projection(&input.kind, value);
|
||||
reply(
|
||||
command,
|
||||
LibraryResource {
|
||||
kind: input.kind,
|
||||
resource: value.as_object().cloned().unwrap_or_default(),
|
||||
projection,
|
||||
source_manifest_unchanged: true,
|
||||
physical_references,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Preview {
|
||||
id: String,
|
||||
}
|
||||
pub(super) fn preview(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.preview";
|
||||
let input: Preview = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: LibraryPreviewParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
item("surfaces", &input.id, command)?;
|
||||
let bytes = asset_bytes(&format!("{}/base_color", input.id), 2 * 1024 * 1024, command)?;
|
||||
Ok(json!({"id":input.id,"mime":"image/png","sha256":HdrId::of(&bytes),"bytes":bytes,
|
||||
"fidelity":"authored_base_color_source_map_not_a_render","document_changed":false}))
|
||||
reply(
|
||||
command,
|
||||
LibraryPreview {
|
||||
id: input.id,
|
||||
mime: "image/png".into(),
|
||||
sha256: HdrId::of(&bytes).to_string(),
|
||||
bytes,
|
||||
fidelity: "authored_base_color_source_map_not_a_render".into(),
|
||||
document_changed: false,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The key is matched against generated constants, never joined to a filesystem path.
|
||||
|
||||
@@ -1,61 +1,65 @@
|
||||
//! Named saved-shot management through the existing RenderSettings authority.
|
||||
//! No captured jobs, camera navigation, external files or engineering data are edited.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_shots::{ShotChange, ShotOperation, ShotParams};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::render_settings::RenderSettings;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Named {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
new_name: Option<String>,
|
||||
expected_revision: u64,
|
||||
}
|
||||
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
pub(super) const CONTRACT: TypedContract<ShotParams> = TypedContract::new();
|
||||
|
||||
fn change(s: &mut Session, params: &Value, operation: ShotOperation) -> Result<Value> {
|
||||
let command = match operation {
|
||||
"duplicate" => "render.shot.duplicate",
|
||||
"rename" => "render.shot.rename",
|
||||
_ => "render.shot.remove",
|
||||
ShotOperation::Duplicate => "render.shot.duplicate",
|
||||
ShotOperation::Rename => "render.shot.rename",
|
||||
ShotOperation::Remove => "render.shot.remove",
|
||||
};
|
||||
let input: Named = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: ShotParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "saved shot changed after review; refresh before editing"));
|
||||
}
|
||||
RenderSettings::validate_name(&input.name).map_err(|e| bad(command, e.to_string()))?;
|
||||
let source = s.doc.render.setups.get(&input.name).cloned().ok_or_else(|| bad(command, "no saved shot with that name"))?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let output = if operation == "remove" {
|
||||
let output = if operation == ShotOperation::Remove {
|
||||
if input.new_name.is_some() {
|
||||
return Err(bad(command, "remove does not accept a destination name"));
|
||||
}
|
||||
settings.setups.remove(&input.name);
|
||||
Value::Null
|
||||
None
|
||||
} else {
|
||||
let destination = input.new_name.as_ref().ok_or_else(|| bad(command, "new_name is required"))?;
|
||||
RenderSettings::validate_name(destination).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.setups.contains_key(destination) {
|
||||
let destination = input.new_name.ok_or_else(|| bad(command, "new_name is required"))?;
|
||||
RenderSettings::validate_name(&destination).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.setups.contains_key(&destination) {
|
||||
return Err(bad(command, "destination already exists; saved shots are never overwritten"));
|
||||
}
|
||||
settings.setups.insert(destination.clone(), source);
|
||||
if operation == "rename" {
|
||||
if operation == ShotOperation::Rename {
|
||||
settings.setups.remove(&input.name);
|
||||
}
|
||||
json!(destination)
|
||||
Some(destination)
|
||||
};
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
Ok(json!({"operation":operation,"source":input.name,"name":output,"resources_preserved":true,
|
||||
"camera_navigation_unchanged":true,"engineering_properties_unchanged":true,"captured_jobs_unchanged":true}))
|
||||
let reply = ShotChange {
|
||||
operation,
|
||||
source: input.name,
|
||||
name: output,
|
||||
resources_preserved: true,
|
||||
camera_navigation_unchanged: true,
|
||||
engineering_properties_unchanged: true,
|
||||
captured_jobs_unchanged: true,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
pub(super) fn duplicate(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "duplicate")
|
||||
change(s, params, ShotOperation::Duplicate)
|
||||
}
|
||||
pub(super) fn rename(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "rename")
|
||||
change(s, params, ShotOperation::Rename)
|
||||
}
|
||||
pub(super) fn remove(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
change(s, params, "remove")
|
||||
change(s, params, ShotOperation::Remove)
|
||||
}
|
||||
|
||||