mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f19974d6a | |||
| f4d9519bb9 | |||
| 9890abae36 | |||
| 28cfed5e07 | |||
| 01042ca81e | |||
| 42cd362748 | |||
| bfee9bcc19 | |||
| 1210ac5080 | |||
| 6e27cfa7ff | |||
| 5c95fb027c | |||
| 1dcb1066e5 | |||
| 94cb149856 | |||
| 9e3e441a2d | |||
| 3d78129af6 | |||
| 3fb1040164 | |||
| 746b43878e | |||
| 674e72ee8d | |||
| d0da8047a7 | |||
| 72a22b3338 | |||
| f0f0b58a94 | |||
| cfb0240869 | |||
| 16204d4c96 | |||
| 419bbcda0e | |||
| 28efdb6681 | |||
| 8c197e4d18 | |||
| b76f681f01 | |||
| 82660d4ee2 | |||
| fcd3186798 | |||
| 8706d623fb | |||
| ae1d85e5d4 | |||
| 0d350e7b38 | |||
| e69dfe126c | |||
| 3c4b336080 | |||
| 1ec5eda955 | |||
| 7bb016e5e4 | |||
| 98fdb6ef3c | |||
| 95fece4c7b | |||
| f959c0168d | |||
| a2d42fdaec | |||
| eaf8f27a7f | |||
| 8f1143b940 | |||
| 778e7465e9 | |||
| fa127b3e29 | |||
| 970eb715f3 | |||
| a74e78fba8 | |||
| 2171393816 | |||
| 3eacb3722e | |||
| 2c82e702f5 | |||
| f256bef34b | |||
| 83cb509f81 | |||
| c18aaf7cc3 | |||
| 1e6b1ec20f | |||
| 4d96c780e1 | |||
| 52afe212a8 | |||
| 0bb09d0b13 | |||
| 11abdba1ba | |||
| 7c9c1699de | |||
| cf9ae4d25e | |||
| 1f29f432ee | |||
| dc679cae34 | |||
| dbbfa437a5 | |||
| ba43d2798f | |||
| 6a1fe74683 |
@@ -1,6 +1,9 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
|
||||
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
|
||||
*.wgsl text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
|
||||
@@ -505,9 +505,15 @@ jobs:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -528,7 +534,7 @@ jobs:
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
@@ -576,9 +582,15 @@ jobs:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -619,7 +631,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -692,9 +704,15 @@ jobs:
|
||||
key: native-gpu
|
||||
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -715,7 +733,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -848,9 +866,15 @@ jobs:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
# nextest does not run doc tests.
|
||||
- name: Doc tests
|
||||
env:
|
||||
@@ -906,9 +930,15 @@ jobs:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the command line
|
||||
run: cargo build -p solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
|
||||
@@ -85,9 +85,15 @@ jobs:
|
||||
key: fillet-rig
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the CLI (release)
|
||||
run: cargo build --release --locked -p solidcraft-cli
|
||||
- name: Run the fillet cases against the known-failing list
|
||||
|
||||
@@ -149,9 +149,15 @@ jobs:
|
||||
key: kernel-rig
|
||||
save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }}
|
||||
- name: Set up compiler cache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# A failed download of sccache (GitHub release outages answer 504) must not fail the build.
|
||||
continue-on-error: true
|
||||
- name: Build without the compiler cache if it could not be set up
|
||||
if: steps.sccache.outcome == 'failure'
|
||||
run: echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV"
|
||||
- name: Build the CLI (release) and the rig's runner
|
||||
run: |
|
||||
cargo build --release --locked -p solidcraft-cli
|
||||
@@ -174,7 +180,7 @@ jobs:
|
||||
retention-days: 1
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
|
||||
shard:
|
||||
name: Kernel rig (${{ matrix.shard }})
|
||||
|
||||
@@ -13,7 +13,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- 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`.
|
||||
- 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`.
|
||||
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
|
||||
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
|
||||
|
||||
+2
-1
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
@@ -842,6 +841,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
Generated
+76
-3
@@ -4075,7 +4075,6 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"keyring",
|
||||
"libc",
|
||||
@@ -4093,10 +4092,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
@@ -4123,6 +4125,29 @@ dependencies = [
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-color"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4141,6 +4166,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4173,6 +4199,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4207,6 +4235,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4234,6 +4263,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4268,8 +4298,17 @@ version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-mcp-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp-server"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4293,6 +4332,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"web-time",
|
||||
"weezl",
|
||||
@@ -4345,6 +4385,16 @@ dependencies = [
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-theme"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-color",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4363,20 +4413,41 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-control-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-registry",
|
||||
"solidcraft-ui-text",
|
||||
"syn 2.0.119",
|
||||
"sys-locale",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
"winreg",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-registry"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-text"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.3"
|
||||
@@ -4387,8 +4458,10 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
@@ -28,8 +28,15 @@ solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-ui-registry = { path = "crates/ui-registry" }
|
||||
solidcraft-color = { path = "crates/color" }
|
||||
solidcraft-ui-text = { path = "crates/ui-text" }
|
||||
solidcraft-theme = { path = "crates/theme" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-mcp-server = { path = "crates/mcp-server" }
|
||||
solidcraft-control = { path = "crates/control" }
|
||||
solidcraft-control-egui = { path = "crates/control-egui" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
|
||||
|
||||
@@ -68,7 +68,8 @@ impl Control {
|
||||
if !addr.ip().is_loopback() || addr.port() == 0 {
|
||||
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
|
||||
}
|
||||
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
|
||||
let token = solidcraft_engine::control_auth::APP
|
||||
.client_token(addr.port(), token)
|
||||
.map(|t| t.token)
|
||||
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
|
||||
if token.len() > 4096 {
|
||||
|
||||
@@ -916,7 +916,7 @@ fn serve_mcp(
|
||||
/// The port of the running desktop app: the one its discovery file names, if something listens
|
||||
/// there (a file left by an app that crashed names a closed port).
|
||||
fn discover_app() -> Option<u16> {
|
||||
let port = solidcraft_engine::control_auth::read_current_port()?;
|
||||
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
|
||||
}
|
||||
|
||||
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use solidcraft_control::ControlRequest;
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
|
||||
const MAX_PENDING: usize = 256;
|
||||
|
||||
@@ -48,15 +48,15 @@ impl Download {
|
||||
}
|
||||
if solidcraft_ui_egui::documents::any_dirty(app) {
|
||||
let mut answer = None;
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
|
||||
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -810,7 +810,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -864,7 +864,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -1016,7 +1016,7 @@ mod web {
|
||||
|
||||
/// Whether the shell already has the on-demand face the live language needs.
|
||||
fn script_font_ready(&self) -> bool {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
|
||||
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
|
||||
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
|
||||
Some("ja") => self.cjk_bytes.is_some(),
|
||||
@@ -1058,7 +1058,7 @@ mod web {
|
||||
false
|
||||
}
|
||||
};
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
|
||||
log::warn!("script font unavailable; showing English for this session");
|
||||
}
|
||||
@@ -1067,13 +1067,13 @@ mod web {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let ready = self.script_font_ready();
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.font.tick(&mut self.app.preferences.language, ready) {
|
||||
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1081,7 +1081,7 @@ mod web {
|
||||
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
|
||||
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(ctx);
|
||||
} else {
|
||||
@@ -1149,7 +1149,7 @@ mod web {
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
if self.launch.embed {
|
||||
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
|
||||
{
|
||||
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
|
||||
@@ -1225,6 +1225,7 @@ mod web {
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let launch = crate::Launch::parse(&query());
|
||||
|
||||
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
# The D3D12 shader compiler the window starts with (#6756).
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
solidcraft-theme = { workspace = true }
|
||||
solidcraft-space-mouse = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
@@ -45,7 +48,6 @@ rfd = { workspace = true }
|
||||
ron = "0.12"
|
||||
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
|
||||
pollster = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let content = ui.ctx().content_rect();
|
||||
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
let mut everything = io::formats::open_extensions(true);
|
||||
everything.extend(["dxf", "svg"]);
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
@@ -945,6 +945,7 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
mark("main");
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
|
||||
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
|
||||
// macOS version than the GPU's Metal compiler supports. Re-launching with
|
||||
@@ -1221,7 +1222,7 @@ fn main() -> eframe::Result {
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port; otherwise the preference (on by default)
|
||||
// opens the default one (docs/control-protocol.md).
|
||||
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
|
||||
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ assets/
|
||||
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
|
||||
materials/library.rs # physical defaults and colours
|
||||
|
||||
@@ -38,7 +38,7 @@ 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`) |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/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 |
|
||||
|
||||
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
|
||||
err(d + '/palette.toml', message)
|
||||
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
|
||||
def app_tokens():
|
||||
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
|
||||
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
|
||||
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
|
||||
light_src, dark_src = src[lo:hi], src[hi:]
|
||||
def pick(block, name, dark):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
|
||||
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
|
||||
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
}
|
||||
|
||||
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
|
||||
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
|
||||
}
|
||||
|
||||
/// The same original mark as a single quiet silhouette, without face seams or outlines.
|
||||
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
|
||||
};
|
||||
// The faces are separated by gaps that show the application bar, not by white lines.
|
||||
let [light, mid, dark] = colours;
|
||||
let edge_col = crate::theme::Tokens::get().app_bar;
|
||||
let edge_col = solidcraft_theme::Tokens::get().app_bar;
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Constraint, assembly snap, hole placement and dimension marker artwork.
|
||||
use crate::dialogs_assembly::SnapKind;
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, Pos2, Stroke, vec2};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
// What it snaps to (hovered), or which component it is (picked): a small label.
|
||||
if let Some(tag) = tag {
|
||||
let t = Tokens::get();
|
||||
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
|
||||
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
|
||||
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
|
||||
let at = c + vec2(12.0, 10.0);
|
||||
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::geom::Ring;
|
||||
use solidcraft_theme::Tokens;
|
||||
pub(crate) struct Facet {
|
||||
pub(crate) direction: Vec3,
|
||||
pub(crate) points: Ring<Pos2>,
|
||||
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
|
||||
// On a hovered (white) disc the letter takes the axis colour.
|
||||
let letter = if hovered { col } else { t.on_accent };
|
||||
let label = name.to_uppercase();
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let origin = at - galley.size() / 2.0;
|
||||
let ppp = painter.ctx().pixels_per_point().max(0.5);
|
||||
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
|
||||
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
|
||||
pub(crate) fn agent() -> Color32 {
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
solidcraft_theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = crate::theme::Tokens::get().icons;
|
||||
let ic = solidcraft_theme::Tokens::get().icons;
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, ic.agent);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
|
||||
|
||||
+27
-10
@@ -977,7 +977,7 @@
|
||||
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_title": "معايرة: {name}",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
|
||||
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
|
||||
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
|
||||
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
|
||||
"cmd.panel.float": "تعويم اللوحة",
|
||||
"cmd.panel.focus": "تركيز اللوحة",
|
||||
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
|
||||
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
|
||||
"cmd.panel.list": "سرد اللوحات",
|
||||
"cmd.panel.move": "نقل اللوحة",
|
||||
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
|
||||
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
|
||||
"cmd.panel.open": "فتح اللوحة",
|
||||
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
|
||||
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1501,7 +1510,7 @@
|
||||
"cmd.timeline_suppress": "تعطيل الميزة",
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
|
||||
"cmd.view.navigator": "متصفح العرض المخصص",
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_browser": "مستعرض التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
|
||||
@@ -1865,7 +1874,7 @@
|
||||
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
|
||||
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
|
||||
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
|
||||
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
|
||||
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "المظهر المحفوظ",
|
||||
"material.editor.public_reference": "مرجع عام",
|
||||
"material.editor.reference": "مرجع",
|
||||
"material.editor.remove": "إزالة",
|
||||
"material.editor.save_or_discard": "احفظ مسودة المادة أو تجاهلها قبل تعيين مادة.",
|
||||
"material.editor.saved": "حُفظت المادة. تعيينات الأجسام لم تتغير.",
|
||||
"material.editor.scope": "الحفظ يخزّن هذه المادة دون تعيينها للأجسام. الخصائص والشروط الفارغة تبقى غير معروفة. كثافة البطاقة لا تغيّر الكثافة المعيّنة ولا الكتلة.",
|
||||
@@ -2520,7 +2530,7 @@
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_body_features": "الميزات تحت الأجسام",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات المخطط الزمني التي بنته، بترتيب المخطط الزمني.",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
|
||||
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
|
||||
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -2862,8 +2876,8 @@
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -3384,7 +3398,7 @@
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
@@ -3600,7 +3614,7 @@
|
||||
"render.flare_streak": "خط ضوئي بأسلوب أنامورفي",
|
||||
"render.flare_strength": "شدة وهج الإضاءات العالية",
|
||||
"render.flare_threshold": "عتبة الوهج الخطية للمشهد",
|
||||
"render.fold_browser": "طيّ المتصفح",
|
||||
"render.fold_browser": "طيّ المستعرض",
|
||||
"render.frame_aspect": "النسبة",
|
||||
"render.frame_centre_mark": "علامة المركز",
|
||||
"render.frame_copy_limit": "لا يوجد اسم متاح للقطة مكررة",
|
||||
@@ -3830,7 +3844,7 @@
|
||||
"render.light_gpu_punctual": "الأضواء النقطية والكشّافة متاحة مع تتبّع الأشعة على المعالج.",
|
||||
"render.light_hard_shadow": "لهذا الضوء ظلال حادة؛ نعومة المصدر غير متاحة.",
|
||||
"render.light_intensity": "الشدة",
|
||||
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
|
||||
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المستعرض.",
|
||||
"render.light_place_highlight": "وضع بالتمييز",
|
||||
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
|
||||
"render.light_position": "الموضع",
|
||||
@@ -4443,7 +4457,7 @@
|
||||
"render.transmission_strata_help": "القيمة 1 تحافظ على التدفق الأصلي. 2 أو 4 مسارات فرعية تقسّم أول اختيار انعكاس أو نفاذ في الزجاج إلى طبقات مع بقاء قانوني سنل والامتصاص دون تغيير. المزيد من المسارات يكلف عملًا أكثر؛ وهذا ليس إزالة تشويش ولا دعمًا للأوساط المتداخلة ولا وعدًا بتصيير أسرع.",
|
||||
"render.tree_add_light": "إضافة ضوء",
|
||||
"render.tree_add_shot": "إضافة لقطة",
|
||||
"render.tree_browser": "متصفح التصيير",
|
||||
"render.tree_browser": "مستعرض التصيير",
|
||||
"render.tree_by_look": "حسب المظهر",
|
||||
"render.tree_by_object": "حسب الكائن",
|
||||
"render.tree_constant": "بيئة ثابتة",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "البلاستيك",
|
||||
"ui.heading_position": "الموضع",
|
||||
"ui.heading_prepare": "تحضير",
|
||||
"ui.heading_press_pull": "ضغط وسحب",
|
||||
"ui.heading_rectangular_pattern": "نمط مستطيلي",
|
||||
"ui.heading_revolve": "دوران",
|
||||
"ui.heading_right": "يمين",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "المقاطع",
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
|
||||
@@ -1395,6 +1395,7 @@
|
||||
"material.editor.preserved_appearance": "Preserved appearance",
|
||||
"material.editor.public_reference": "Public reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Remove",
|
||||
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
|
||||
"material.editor.saved": "Material saved. Body assignments are unchanged.",
|
||||
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Sačuvani izgled",
|
||||
"material.editor.public_reference": "Javna referenca",
|
||||
"material.editor.reference": "Referenca",
|
||||
"material.editor.remove": "Ukloni",
|
||||
"material.editor.save_or_discard": "Sačuvajte ili odbacite nacrt materijala prije dodjele materijala.",
|
||||
"material.editor.saved": "Materijal je sačuvan. Dodjele tijelima ostaju nepromijenjene.",
|
||||
"material.editor.scope": "Sačuvaj sprema ovaj materijal bez dodjele tijelima. Prazna svojstva i uslovi ostaju nepoznati. Gustina s kartice ne mijenja dodijeljenu gustinu ni masu.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Aparença conservada",
|
||||
"material.editor.public_reference": "Referència pública",
|
||||
"material.editor.reference": "Referència",
|
||||
"material.editor.remove": "Eliminar",
|
||||
"material.editor.save_or_discard": "Deseu o descarteu l'esborrany de material abans d'assignar un material.",
|
||||
"material.editor.saved": "Material desat. Les assignacions als cossos no han canviat.",
|
||||
"material.editor.scope": "Desar emmagatzema aquest material sense assignar-lo a cap cos. Les propietats i condicions buides continuen sent desconegudes. La densitat de la fitxa no canvia la densitat assignada ni la massa.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Zachovaný vzhled",
|
||||
"material.editor.public_reference": "Veřejná reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Odebrat",
|
||||
"material.editor.save_or_discard": "Před přiřazením materiálu uložte nebo zahoďte koncept materiálu.",
|
||||
"material.editor.saved": "Materiál byl uložen. Přiřazení k tělesům se nezměnila.",
|
||||
"material.editor.scope": "Uložením se materiál uloží bez přiřazení k tělesům. Prázdné vlastnosti a podmínky zůstávají neznámé. Hustota z karty nemění přiřazenou hustotu ani hmotnost.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Bevaret udseende",
|
||||
"material.editor.public_reference": "Offentlig reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Fjern",
|
||||
"material.editor.save_or_discard": "Gem eller kassér materialeudkastet, før du tildeler et materiale.",
|
||||
"material.editor.saved": "Materialet er gemt. Tildelinger til emner er uændrede.",
|
||||
"material.editor.scope": "Gem gemmer materialet uden at tildele det til emner. Tomme egenskaber og betingelser forbliver ukendte. Kortets densitet ændrer ikke den tildelte densitet eller massen.",
|
||||
|
||||
@@ -1047,8 +1047,11 @@
|
||||
"cmd.panel.dismiss": "Panel-Vorschau schließen",
|
||||
"cmd.panel.float": "Panel frei platzieren",
|
||||
"cmd.panel.focus": "Panel fokussieren",
|
||||
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
|
||||
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
|
||||
"cmd.panel.list": "Panels auflisten",
|
||||
"cmd.panel.move": "Panel verschieben",
|
||||
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
|
||||
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
|
||||
"cmd.panel.open": "Panel öffnen",
|
||||
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
|
||||
@@ -1056,6 +1059,12 @@
|
||||
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
|
||||
"cmd.panel.resize": "Panel-Bereich skalieren",
|
||||
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
|
||||
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
|
||||
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
|
||||
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
|
||||
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
|
||||
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
|
||||
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
|
||||
"cmd.parameters_add": "Benutzerparameter hinzufügen",
|
||||
"cmd.parameters_change": "Parameter ändern",
|
||||
"cmd.parameters_comment": "Parameter kommentieren",
|
||||
@@ -2001,6 +2010,7 @@
|
||||
"material.editor.preserved_appearance": "Beibehaltenes Erscheinungsbild",
|
||||
"material.editor.public_reference": "Öffentliche Quelle",
|
||||
"material.editor.reference": "Referenz",
|
||||
"material.editor.remove": "Entfernen",
|
||||
"material.editor.save_or_discard": "Speichern oder verwerfen Sie den Materialentwurf, bevor Sie ein Material zuweisen.",
|
||||
"material.editor.saved": "Material gespeichert. Körperzuweisungen bleiben unverändert.",
|
||||
"material.editor.scope": "Speichern legt dieses Material ab, ohne es Körpern zuzuweisen. Leere Eigenschaften und Bedingungen bleiben unbekannt. Die Dichte im Datenblatt ändert weder die zugewiesene Dichte noch die Masse.",
|
||||
@@ -2348,6 +2358,8 @@
|
||||
"prefs.design_tools.degrees": "Grad",
|
||||
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
|
||||
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
|
||||
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
|
||||
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
|
||||
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
|
||||
@@ -2600,6 +2612,8 @@
|
||||
"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.",
|
||||
"prefs.selecting": "Auswahl",
|
||||
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
|
||||
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
|
||||
"prefs.shift": "Umschalt",
|
||||
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
|
||||
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
|
||||
@@ -5250,6 +5264,7 @@
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "VORBEREITEN",
|
||||
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
@@ -5589,6 +5604,8 @@
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
|
||||
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
|
||||
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Dismiss Panel Peek",
|
||||
"cmd.panel.float": "Float Panel",
|
||||
"cmd.panel.focus": "Focus Panel",
|
||||
"cmd.panel.focus.next": "Focus Next Panel",
|
||||
"cmd.panel.focus.previous": "Focus Previous Panel",
|
||||
"cmd.panel.list": "List Panels",
|
||||
"cmd.panel.move": "Move Panel",
|
||||
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
|
||||
"cmd.panel.native_close": "Redock Native Panel Section",
|
||||
"cmd.panel.open": "Open Panel",
|
||||
"cmd.panel.peek": "Peek Collapsed Panel",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Reset Panel Sash",
|
||||
"cmd.panel.resize": "Resize Panel Section",
|
||||
"cmd.panel.resize_pair": "Resize Panel Sash",
|
||||
"cmd.panel.sash.down": "Resize Panel Sash Down",
|
||||
"cmd.panel.sash.left": "Resize Panel Sash Left",
|
||||
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
|
||||
"cmd.panel.sash.right": "Resize Panel Sash Right",
|
||||
"cmd.panel.sash.up": "Resize Panel Sash Up",
|
||||
"cmd.panel.toggle": "Toggle Focused Panel",
|
||||
"cmd.parameters_add": "Add User Parameter",
|
||||
"cmd.parameters_change": "Change Parameters",
|
||||
"cmd.parameters_comment": "Comment Parameter",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "Preserved appearance",
|
||||
"material.editor.public_reference": "Public reference",
|
||||
"material.editor.reference": "Reference",
|
||||
"material.editor.remove": "Remove",
|
||||
"material.editor.save_or_discard": "Save or discard the material draft before assigning a material.",
|
||||
"material.editor.saved": "Material saved. Body assignments are unchanged.",
|
||||
"material.editor.scope": "Save stores this material without assigning it to bodies. Blank properties and conditions remain unknown. Card density does not change assigned density or mass.",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "Degrees",
|
||||
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
|
||||
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
|
||||
"prefs.design_tools.hole_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
|
||||
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_several": "Several holes on one face",
|
||||
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"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.",
|
||||
"prefs.selecting": "Selecting",
|
||||
"prefs.shared_panel_layout": "Use shared panel layout",
|
||||
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "PLASTIC",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "PREPARE",
|
||||
"ui.heading_press_pull": "PRESS PULL",
|
||||
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
|
||||
"ui.heading_revolve": "REVOLVE",
|
||||
"ui.heading_right": "RIGHT",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.project_references_construction": "Project references as construction geometry",
|
||||
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
|
||||
"ui.properties": "Properties",
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Apariencia conservada",
|
||||
"material.editor.public_reference": "Referencia pública",
|
||||
"material.editor.reference": "Referencia",
|
||||
"material.editor.remove": "Eliminar",
|
||||
"material.editor.save_or_discard": "Guarda o descarta el borrador antes de asignar un material.",
|
||||
"material.editor.saved": "Material guardado. Las asignaciones a cuerpos no han cambiado.",
|
||||
"material.editor.scope": "Guardar almacena este material sin asignarlo a cuerpos. Las propiedades y condiciones vacías siguen siendo desconocidas. La densidad de la ficha no cambia la densidad asignada ni la masa.",
|
||||
|
||||
@@ -1155,7 +1155,7 @@
|
||||
"cmd.sheet_perforate": "Rei'itä",
|
||||
"cmd.sheet_refold": "Taita uudelleen",
|
||||
"cmd.sheet_rules": "Luettele ohutlevysäännöt",
|
||||
"cmd.sheet_unfold": "Avaa",
|
||||
"cmd.sheet_unfold": "Levitä",
|
||||
"cmd.sketch.construction_line": "Apuviiva",
|
||||
"cmd.sketch.construction_line.angle": "Apuviiva kulmassa",
|
||||
"cmd.sketch.construction_line.bisector": "Kulman puolittaja",
|
||||
@@ -1692,7 +1692,7 @@
|
||||
"help.sketch.move": "Siirtää tai kiertää luonnoskäyriä ja -pisteitä tai niiden kopiota. Käytä sitä geometrian uudelleensijoittamiseen niin, että sen muoto ja rajoitteet säilyvät.",
|
||||
"help.sketch.offset": "Luo käyräketjusta kopion annetulle etäisyydelle toiselle tai molemmille puolille. Käytä sitä seinämiin, ääriviivoihin ja välyksiin; etäisyys säilyy muokattavana mittana.",
|
||||
"help.sketch.paste": "Liittää kopioimasi tai leikkaamasi luonnosgeometrian aktiiviseen luonnokseen. Sijoita se pisteeseen tai siirrä sitä siirtymän verran.",
|
||||
"help.sketch.pattern.circular": "Kopioi luonnosgeometriaa keskipisteen ympäri koko ympyrän tai annetun kulman matkalle. Käytä sitä pulttijakoympyröihin, puolisiin ja hampaisiin.",
|
||||
"help.sketch.pattern.circular": "Kopioi luonnosgeometriaa keskipisteen ympäri koko ympyrän tai annetun kulman matkalle. Käytä sitä pulttijakoympyröihin, puoliin ja hampaisiin.",
|
||||
"help.sketch.pattern.rectangular": "Kopioi luonnosgeometriaa riveihin ja sarakkeisiin annetuin välein. Käytä sitä reikien, urien tai tuuletusaukkojen ruudukkoihin.",
|
||||
"help.sketch.point": "Sijoittaa luonnospisteen. Käytä sitä mittojen, rajoitteiden, reikien keskipisteiden tai myöhempien piirteiden viitteenä.",
|
||||
"help.sketch.point3d": "Sijoittaa pisteen avaruuteen, kun 3D-luonnos on käytössä. Käytä sitä 3D-käyrien ja polkujen viitteenä.",
|
||||
@@ -1869,7 +1869,7 @@
|
||||
"import_report.details": "Tiedot:",
|
||||
"import_report.file_one": "Tiedostossa oli 1 muu ongelma.",
|
||||
"import_report.file_other": "Tiedostossa oli {count} muuta ongelmaa.",
|
||||
"import_report.hint": "Näiden osien vieminen uudelleen ohjelmasta, jolla ne on tehty, voi korjata tämän. Kopioi varoitusilmoitus sisältää yksityiskohdat.",
|
||||
"import_report.hint": "Näiden osien vieminen uudelleen ohjelmasta, jolla ne on tehty, voi korjata tämän. Näet yksityiskohdat toiminnolla Kopioi varoitusilmoitus.",
|
||||
"import_report.left_out_one": "1 pintaa ei voitu korjata, ja se puuttuu.",
|
||||
"import_report.left_out_other": "{count} pintaa ei voitu korjata, ja ne puuttuvat.",
|
||||
"import_report.needs_repair_one": "1 osa on korjattava ennen kuin sitä voi mallintaa edelleen.",
|
||||
@@ -1984,6 +1984,7 @@
|
||||
"material.editor.preserved_appearance": "Säilytetty ulkoasu",
|
||||
"material.editor.public_reference": "Julkinen lähde",
|
||||
"material.editor.reference": "Viite",
|
||||
"material.editor.remove": "Poista",
|
||||
"material.editor.save_or_discard": "Tallenna tai hylkää materiaaliluonnos ennen materiaalin määrittämistä.",
|
||||
"material.editor.saved": "Materiaali tallennettu. Kappaleiden määritykset eivät muuttuneet.",
|
||||
"material.editor.scope": "Tallennus tallentaa tämän materiaalin määrittämättä sitä kappaleille. Tyhjät ominaisuudet ja olosuhteet jäävät tuntemattomiksi. Kortin tiheys ei muuta määritettyä tiheyttä tai massaa.",
|
||||
@@ -2321,7 +2322,7 @@
|
||||
"prefs.design_note": "Millä uudet mallit alkavat. Avoimet mallit säilyttävät omansa.",
|
||||
"prefs.design_tools.ask": "Kysy",
|
||||
"prefs.design_tools.degrees": "Astetta",
|
||||
"prefs.design_tools.double_click_help": "Tai muokataksesi linkitettyä paikallaan.",
|
||||
"prefs.design_tools.double_click_help": "Tai muokataksesi linkitettyä komponenttia paikallaan.",
|
||||
"prefs.design_tools.ghost": "Näytä siirrettyjen osien vanha paikka haamukuvana",
|
||||
"prefs.design_tools.hole_moves_help": "Pinnan valitseminen uudelleen siirtää reikää sen poistamisen sijaan.",
|
||||
"prefs.design_tools.hole_snaps_help": "Pinnan keskikohta ja sen pyöreiden särmien keskipisteet.",
|
||||
@@ -2380,7 +2381,7 @@
|
||||
"prefs.graphics_note": "Vaihda nopeutta sulavuuteen. Kunkin valinnan vaikutus näkyy näytteessä.",
|
||||
"prefs.graphics_page.current_view": "Nykyinen näkymä",
|
||||
"prefs.graphics_page.fastest": "Nopein",
|
||||
"prefs.graphics_page.fold_items": "Kokoa kohteet",
|
||||
"prefs.graphics_page.fold_items": "Kutista kohteet",
|
||||
"prefs.graphics_page.in_view": "Näkymässä",
|
||||
"prefs.graphics_page.live_details": "Reaaliaikaiset tiedot",
|
||||
"prefs.graphics_page.own_mix": "Oma yhdistelmä",
|
||||
@@ -2588,7 +2589,7 @@
|
||||
"prefs.sketch.prefer_free_points": "Suosi siirrettäviä pisteitä päällekkäisissä kohdissa",
|
||||
"prefs.sketch.project_options": "Kysy projisoinnin asetukset ensin",
|
||||
"prefs.sketch.recent_snap": "Tartu kohtaan, jossa kaksi ohjainta risteää",
|
||||
"prefs.sketch.select_other_hidden": "Valitse muu ulottuu piilotettuun geometriaan",
|
||||
"prefs.sketch.select_other_hidden": "Valitse muu -toiminto ulottuu piilotettuun geometriaan",
|
||||
"prefs.sketch.shortcut_cycles_modes": "Pikanäppäin uudelleen vaihtaa tilaa",
|
||||
"prefs.sketch.shortcut_cycles_modes_tip": "Pois: se käynnistää työkalun uudelleen.",
|
||||
"prefs.sketch.snapping_guides": "Tartunta ja ohjaimet",
|
||||
|
||||
@@ -1899,6 +1899,7 @@
|
||||
"material.editor.preserved_appearance": "Apparence conservée",
|
||||
"material.editor.public_reference": "Référence publique",
|
||||
"material.editor.reference": "Référence",
|
||||
"material.editor.remove": "Retirer",
|
||||
"material.editor.save_or_discard": "Enregistrez ou abandonnez le brouillon de matériau avant d'affecter un matériau.",
|
||||
"material.editor.saved": "Matériau enregistré. Les affectations des corps sont inchangées.",
|
||||
"material.editor.scope": "Enregistrer stocke ce matériau sans l'affecter à des corps. Les propriétés et conditions vides restent inconnues. La masse volumique de la fiche ne modifie ni la masse volumique affectée ni la masse.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Aparencia conservada",
|
||||
"material.editor.public_reference": "Referencia pública",
|
||||
"material.editor.reference": "Referencia",
|
||||
"material.editor.remove": "Quitar",
|
||||
"material.editor.save_or_discard": "Garda ou descarta o borrador do material antes de asignar un material.",
|
||||
"material.editor.saved": "Material gardado. As asignacións aos corpos non cambian.",
|
||||
"material.editor.scope": "Gardar almacena este material sen asignalo a corpos. As propiedades e condicións en branco seguen sendo descoñecidas. A densidade da ficha non cambia a densidade asignada nin a masa.",
|
||||
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
|
||||
"cmd.panel.float": "הצפת חלונית",
|
||||
"cmd.panel.focus": "מיקוד חלונית",
|
||||
"cmd.panel.focus.next": "מיקוד בלוח הבא",
|
||||
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
|
||||
"cmd.panel.list": "הצגת רשימת חלוניות",
|
||||
"cmd.panel.move": "העברת חלונית",
|
||||
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
|
||||
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
|
||||
"cmd.panel.open": "פתיחת חלונית",
|
||||
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
|
||||
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
|
||||
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
|
||||
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
|
||||
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
|
||||
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
|
||||
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
|
||||
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
|
||||
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
|
||||
"cmd.parameters_add": "הוספת פרמטר משתמש",
|
||||
"cmd.parameters_change": "שינוי פרמטרים",
|
||||
"cmd.parameters_comment": "הערה לפרמטר",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "מראה שנשמר",
|
||||
"material.editor.public_reference": "ייחוס ציבורי",
|
||||
"material.editor.reference": "ייחוס",
|
||||
"material.editor.remove": "הסרה",
|
||||
"material.editor.save_or_discard": "יש לשמור או לבטל את טיוטת החומר לפני הקצאת חומר.",
|
||||
"material.editor.saved": "החומר נשמר. הקצאות הגופים לא השתנו.",
|
||||
"material.editor.scope": "השמירה מאחסנת את החומר הזה בלי להקצות אותו לגופים. תכונות ותנאים ריקים נשארים לא ידועים. צפיפות הכרטיס אינה משנה את הצפיפות המוקצית או את המסה.",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
"prefs.selecting": "בחירה",
|
||||
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
|
||||
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "פלסטיק",
|
||||
"ui.heading_position": "מיקום",
|
||||
"ui.heading_prepare": "הכנה",
|
||||
"ui.heading_press_pull": "דחיפה/משיכה",
|
||||
"ui.heading_rectangular_pattern": "מערך מלבני",
|
||||
"ui.heading_revolve": "סיבוב",
|
||||
"ui.heading_right": "ימין",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "פרופילים",
|
||||
"ui.project": "הטלה",
|
||||
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
|
||||
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
|
||||
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
|
||||
"ui.properties": "מאפיינים",
|
||||
"ui.properties.copy_matrix": "העתקת המטריצה",
|
||||
"ui.properties.first_moments": "מומנטים ראשונים",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Očuvani izgled",
|
||||
"material.editor.public_reference": "Javna referenca",
|
||||
"material.editor.reference": "Referenca",
|
||||
"material.editor.remove": "Ukloni",
|
||||
"material.editor.save_or_discard": "Spremite ili odbacite nacrt materijala prije dodjele materijala.",
|
||||
"material.editor.saved": "Materijal je spremljen. Dodjele tijelima ostaju nepromijenjene.",
|
||||
"material.editor.scope": "Spremi pohranjuje ovaj materijal bez dodjele tijelima. Prazna svojstva i uvjeti ostaju nepoznati. Gustoća s kartice ne mijenja dodijeljenu gustoću ni masu.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Megőrzött megjelenés",
|
||||
"material.editor.public_reference": "Nyilvános hivatkozás",
|
||||
"material.editor.reference": "Hivatkozás",
|
||||
"material.editor.remove": "Eltávolítás",
|
||||
"material.editor.save_or_discard": "Anyag hozzárendelése előtt mentse vagy vesse el az anyagvázlatot.",
|
||||
"material.editor.saved": "Az anyag mentve. A testekhez rendelt anyagok nem változtak.",
|
||||
"material.editor.scope": "A Mentés az anyagot a testekhez való hozzárendelés nélkül tárolja. Az üres tulajdonságok és feltételek ismeretlenek maradnak. A kártyán szereplő sűrűség nem változtatja meg a hozzárendelt sűrűséget vagy a tömeget.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Tampilan yang dipertahankan",
|
||||
"material.editor.public_reference": "Referensi publik",
|
||||
"material.editor.reference": "Referensi",
|
||||
"material.editor.remove": "Hapus",
|
||||
"material.editor.save_or_discard": "Simpan atau buang draf material sebelum menetapkan material.",
|
||||
"material.editor.saved": "Material disimpan. Penetapan ke bodi tidak berubah.",
|
||||
"material.editor.scope": "Simpan menyimpan material ini tanpa menetapkannya ke bodi. Properti dan kondisi yang dikosongkan tetap dianggap tidak diketahui. Massa jenis pada kartu tidak mengubah massa jenis atau massa yang sudah ditetapkan.",
|
||||
|
||||
@@ -1896,6 +1896,7 @@
|
||||
"material.editor.preserved_appearance": "Aspetto conservato",
|
||||
"material.editor.public_reference": "Riferimento pubblico",
|
||||
"material.editor.reference": "Riferimento",
|
||||
"material.editor.remove": "Rimuovi",
|
||||
"material.editor.save_or_discard": "Salva o scarta la bozza del materiale prima di assegnare un materiale.",
|
||||
"material.editor.saved": "Materiale salvato. Le assegnazioni ai corpi non sono cambiate.",
|
||||
"material.editor.scope": "Salva memorizza questo materiale senza assegnarlo ai corpi. Le proprietà e le condizioni lasciate vuote restano sconosciute. La densità della scheda non cambia la densità assegnata né la massa.",
|
||||
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
|
||||
"cmd.panel.float": "パネルをフロート表示",
|
||||
"cmd.panel.focus": "パネルにフォーカス",
|
||||
"cmd.panel.focus.next": "次のパネルにフォーカス",
|
||||
"cmd.panel.focus.previous": "前のパネルにフォーカス",
|
||||
"cmd.panel.list": "パネルを一覧表示",
|
||||
"cmd.panel.move": "パネルを移動",
|
||||
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
|
||||
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
|
||||
"cmd.panel.open": "パネルを開く",
|
||||
"cmd.panel.peek": "折りたたんだパネルを一時表示",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "パネルの区切りをリセット",
|
||||
"cmd.panel.resize": "パネルのセクションのサイズを変更",
|
||||
"cmd.panel.resize_pair": "パネルの区切りを移動",
|
||||
"cmd.panel.sash.down": "パネルの区切りを下に移動",
|
||||
"cmd.panel.sash.left": "パネルの区切りを左に移動",
|
||||
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
|
||||
"cmd.panel.sash.right": "パネルの区切りを右に移動",
|
||||
"cmd.panel.sash.up": "パネルの区切りを上に移動",
|
||||
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
|
||||
"cmd.parameters_add": "ユーザー パラメータを追加",
|
||||
"cmd.parameters_change": "パラメータを変更",
|
||||
"cmd.parameters_comment": "パラメータにコメントを付与",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "保持される外観",
|
||||
"material.editor.public_reference": "公開資料",
|
||||
"material.editor.reference": "参照",
|
||||
"material.editor.remove": "削除",
|
||||
"material.editor.save_or_discard": "材料を割り当てる前に下書きを保存または破棄してください。",
|
||||
"material.editor.saved": "材料を保存しました。ボディへの割り当ては変わりません。",
|
||||
"material.editor.scope": "保存してもボディには割り当てません。空欄の特性と条件は不明のままです。カードの密度は割り当て密度や質量を変更しません。",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
"prefs.selecting": "選択",
|
||||
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
|
||||
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "プラスチック",
|
||||
"ui.heading_position": "配置",
|
||||
"ui.heading_prepare": "準備",
|
||||
"ui.heading_press_pull": "プレス プル",
|
||||
"ui.heading_rectangular_pattern": "矩形パターン",
|
||||
"ui.heading_revolve": "回転",
|
||||
"ui.heading_right": "右側面",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
|
||||
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
|
||||
@@ -1939,6 +1939,7 @@
|
||||
"material.editor.preserved_appearance": "შენარჩუნებული იერსახე",
|
||||
"material.editor.public_reference": "საჯარო წყარო",
|
||||
"material.editor.reference": "საცნობარო",
|
||||
"material.editor.remove": "წაშლა",
|
||||
"material.editor.save_or_discard": "მასალის მინიჭებამდე შეინახეთ ან გააუქმეთ მასალის მონახაზი.",
|
||||
"material.editor.saved": "მასალა შენახულია. სხეულებზე მინიჭებები არ შეცვლილა.",
|
||||
"material.editor.scope": "შენახვა ამ მასალას ინახავს სხეულებზე მინიჭების გარეშე. ცარიელი თვისებები და პირობები უცნობად რჩება. ბარათის სიმკვრივე არ ცვლის მინიჭებულ სიმკვრივეს ან მასას.",
|
||||
|
||||
@@ -992,8 +992,11 @@
|
||||
"cmd.panel.dismiss": "패널 임시 보기 닫기",
|
||||
"cmd.panel.float": "패널 띄우기",
|
||||
"cmd.panel.focus": "패널에 초점 맞추기",
|
||||
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
|
||||
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
|
||||
"cmd.panel.list": "패널 목록",
|
||||
"cmd.panel.move": "패널 이동",
|
||||
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
|
||||
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
|
||||
"cmd.panel.open": "패널 열기",
|
||||
"cmd.panel.peek": "접힌 패널 임시 보기",
|
||||
@@ -1001,6 +1004,12 @@
|
||||
"cmd.panel.reset_pair": "패널 구분선 초기화",
|
||||
"cmd.panel.resize": "패널 구역 크기 조정",
|
||||
"cmd.panel.resize_pair": "패널 구분선 이동",
|
||||
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
|
||||
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
|
||||
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
|
||||
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
|
||||
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
|
||||
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
|
||||
"cmd.parameters_add": "사용자 매개 변수 추가",
|
||||
"cmd.parameters_change": "매개 변수 변경",
|
||||
"cmd.parameters_comment": "매개 변수에 설명 추가",
|
||||
@@ -1929,6 +1938,8 @@
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
@@ -2135,6 +2146,8 @@
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
"prefs.selecting": "선택",
|
||||
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
|
||||
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
@@ -4719,6 +4732,7 @@
|
||||
"ui.heading_plastic": "플라스틱",
|
||||
"ui.heading_position": "배치",
|
||||
"ui.heading_prepare": "준비",
|
||||
"ui.heading_press_pull": "밀기/당기기",
|
||||
"ui.heading_rectangular_pattern": "직사각형 패턴",
|
||||
"ui.heading_revolve": "회전",
|
||||
"ui.heading_right": "우측면",
|
||||
@@ -5040,6 +5054,8 @@
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
|
||||
"ui.project_references_construction": "참조를 구성선으로 투영",
|
||||
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
|
||||
"ui.properties": "속성",
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Penampilan dikekalkan",
|
||||
"material.editor.public_reference": "Rujukan awam",
|
||||
"material.editor.reference": "Rujukan",
|
||||
"material.editor.remove": "Buang",
|
||||
"material.editor.save_or_discard": "Simpan atau buang draf bahan sebelum menetapkan bahan.",
|
||||
"material.editor.saved": "Bahan disimpan. Penetapan jasad tidak berubah.",
|
||||
"material.editor.scope": "Simpan menyimpan bahan ini tanpa menetapkannya pada jasad. Sifat dan keadaan yang dibiarkan kosong kekal tidak diketahui. Ketumpatan kad tidak mengubah ketumpatan yang ditetapkan atau jisim.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Bevart utseende",
|
||||
"material.editor.public_reference": "Offentlig referanse",
|
||||
"material.editor.reference": "Referanse",
|
||||
"material.editor.remove": "Fjern",
|
||||
"material.editor.save_or_discard": "Lagre eller forkast materialutkastet før du tildeler et materiale.",
|
||||
"material.editor.saved": "Materialet er lagret. Kroppstildelinger er uendret.",
|
||||
"material.editor.scope": "Ved lagring lagres materialet uten at det tildeles kropper. Tomme egenskaper og betingelser forblir ukjente. Tettheten på kortet endrer ikke tildelt tetthet eller masse.",
|
||||
|
||||
@@ -1893,6 +1893,7 @@
|
||||
"material.editor.preserved_appearance": "Behouden uiterlijk",
|
||||
"material.editor.public_reference": "Openbare referentie",
|
||||
"material.editor.reference": "Referentie",
|
||||
"material.editor.remove": "Verwijderen",
|
||||
"material.editor.save_or_discard": "Sla het materiaalconcept op of verwerp het voordat je een materiaal toewijst.",
|
||||
"material.editor.saved": "Materiaal opgeslagen. Toewijzingen aan lichamen zijn ongewijzigd.",
|
||||
"material.editor.scope": "Opslaan bewaart dit materiaal zonder het aan lichamen toe te wijzen. Lege eigenschappen en voorwaarden blijven onbekend. De dichtheid op de kaart wijzigt de toegewezen dichtheid of massa niet.",
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
{
|
||||
"fi": {
|
||||
" Keys": " Avainruudut",
|
||||
" Maximum": " Enimmäisarvo",
|
||||
" Minimum": " Vähimmäisarvo",
|
||||
" Of": " Vapausaste",
|
||||
" Value": " Arvo",
|
||||
"ABS - Black": "ABS - musta",
|
||||
"ABS - Grey": "ABS - harmaa",
|
||||
"ABS - White": "ABS - valkoinen",
|
||||
"ALIGN": "KOHDISTA",
|
||||
"AS-BUILT JOINT": "NYKYASENNON MUKAINEN NIVEL",
|
||||
"ASSIGN PLASTIC RULE": "MÄÄRITÄ MUOVISÄÄNTÖ",
|
||||
"AXIS PERPENDICULAR TO FACE": "AKSELI KOHTISUORAAN PINTAAN",
|
||||
"AXIS THROUGH CYLINDER": "AKSELI SYLINTERIN KAUTTA",
|
||||
"AXIS THROUGH EDGE": "AKSELI SÄRMÄN KAUTTA",
|
||||
"AXIS THROUGH TWO PLANES": "AKSELI KAHDEN TASON KAUTTA",
|
||||
"AXIS THROUGH TWO POINTS": "AKSELI KAHDEN PISTEEN KAUTTA",
|
||||
"Add shortcut": "Lisää pikanäppäin",
|
||||
"Allowed values": "Sallitut arvot",
|
||||
"Aluminum - Satin": "Alumiini - satiini",
|
||||
"Anodized Black": "Anodisoitu musta",
|
||||
"Any": "Mikä tahansa",
|
||||
"BOSS": "TAPPI",
|
||||
"BOUNDARY FILL": "RAJATÄYTTÖ",
|
||||
"Brass": "Messinki",
|
||||
"Built-in": "Sisäänrakennettu",
|
||||
"By definition": "Määrittelyn mukaan",
|
||||
"By part number": "Osanumeron mukaan",
|
||||
"CAM": "CAM",
|
||||
"CONTACT SET": "KOSKETUSJOUKKO",
|
||||
"CONVERT MESH": "MUUNNA VERKKO",
|
||||
"CONVERT TO SHEET METAL": "MUUNNA OHUTLEVYKSI",
|
||||
"Chord length": "Jänteen pituus",
|
||||
"Chrome": "Kromi",
|
||||
"Close All Tabs": "Sulje kaikki välilehdet",
|
||||
"Close Other Tabs": "Sulje muut välilehdet",
|
||||
"Connect AI assistants…": "Yhdistä tekoälyavustajat…",
|
||||
"Copper": "Kupari",
|
||||
"Crash Reports…": "Kaatumisraportit…",
|
||||
"Curvature continuous (G2)": "Kaarevuusjatkuva (G2)",
|
||||
"DELETE FACE": "POISTA PINTA",
|
||||
"DELETE FEATURE": "POISTA PIIRRE",
|
||||
"DRIVE JOINTS": "OHJAA NIVELIÄ",
|
||||
"Default material": "Oletusmateriaali",
|
||||
"Design Properties…": "Mallin ominaisuudet…",
|
||||
"Direct interaction": "Suora vuorovaikutus",
|
||||
"Distance X": "Etäisyys X",
|
||||
"Distance Y": "Etäisyys Y",
|
||||
"Distance and Angle": "Etäisyys ja kulma",
|
||||
"Download design": "Lataa malli",
|
||||
"Drive parameter": "Ohjaa parametria",
|
||||
"Drive suppression": "Ohjaa ohitusta",
|
||||
"EDIT DIMENSION": "MUOKKAA MITTAA",
|
||||
"EDIT JOINT": "MUOKKAA NIVELTÄ",
|
||||
"EDIT MOTION STUDY": "MUOKKAA LIIKETUTKIMUSTA",
|
||||
"EDIT TEXT": "MUOKKAA TEKSTIÄ",
|
||||
"EMBOSS": "KOHOKUVIO",
|
||||
"EXPLODED VIEW": "RÄJÄYTYSKUVA",
|
||||
"EXTEND": "JATKA",
|
||||
"Edit Holes": "Muokkaa reikiä",
|
||||
"Exact": "Tarkka",
|
||||
"Export 3MF…": "Vie 3MF…",
|
||||
"Export Design as JSON…": "Vie malli JSON-muodossa…",
|
||||
"Export OBJ…": "Vie OBJ…",
|
||||
"Export STEP…": "Vie STEP…",
|
||||
"Export STL…": "Vie STL…",
|
||||
"Export URDF…": "Vie URDF…",
|
||||
"FLANGE": "LAIPPA",
|
||||
"FLAT PATTERN": "LEVITYSKUVA",
|
||||
"FOLD": "TAITA",
|
||||
"FRAME MEMBER": "RUNKOPALKKI",
|
||||
"Faces on Block": "Lohkon pinnat",
|
||||
"Faces on Ring": "Renkaan pinnat",
|
||||
"Flipped": "Käännetty",
|
||||
"Fold Features": "Kutista piirteet",
|
||||
"Follow inherited appearance": "Käytä perittyä ulkoasua",
|
||||
"Follow material": "Käytä materiaalin ulkoasua",
|
||||
"From Object": "Kohteesta",
|
||||
"GEAR": "HAMMASPYÖRÄ",
|
||||
"GROOVE": "URA",
|
||||
"Glass - Clear": "Lasi - kirkas",
|
||||
"Glass - Smoked": "Lasi - savustettu",
|
||||
"Groove": "Ura",
|
||||
"Group Features": "Ryhmitä piirteet",
|
||||
"HDR file changed since selection; inspect and select it again before saving": "HDR-tiedosto on muuttunut valinnan jälkeen; tarkastele ja valitse se uudelleen ennen tallennusta",
|
||||
"Hold click": "Pidä painettuna",
|
||||
"INSERT PART": "LISÄÄ OSA",
|
||||
"INSERT SVG": "LISÄÄ SVG",
|
||||
"Insert Mesh…": "Lisää verkko…",
|
||||
"Insert STEP…": "Lisää STEP…",
|
||||
"JOINT": "NIVEL",
|
||||
"JOINT ORIGIN": "NIVELEN ORIGO",
|
||||
"MANAGE PLASTIC RULES": "HALLITSE MUOVISÄÄNTÖJÄ",
|
||||
"MIDPLANE": "KESKITASO",
|
||||
"MOTION LINK": "LIIKELINKKI",
|
||||
"MOTION STUDY": "LIIKETUTKIMUS",
|
||||
"MOTION TRACE": "LIIKERATA",
|
||||
"MOVE/COPY TEXT": "SIIRRÄ/KOPIOI TEKSTI",
|
||||
"Mesh": "Verkko",
|
||||
"NEW COMPONENT": "UUSI KOMPONENTTI",
|
||||
"Not": "Ei",
|
||||
"Nylon - Natural": "Nailon - luonnonvärinen",
|
||||
"OFFSET FACE": "PINNAN SIIRTYMÄ",
|
||||
"OFFSET SURFACE": "SIIRTYMÄPINTA",
|
||||
"Oak": "Tammi",
|
||||
"Offset X": "Siirtymä X",
|
||||
"Offset Y": "Siirtymä Y",
|
||||
"Offset Z": "Siirtymä Z",
|
||||
"PATCH": "PAIKKAPINTA",
|
||||
"PERPENDICULAR PLANE": "KOHTISUORA TASO",
|
||||
"PHYSICAL MATERIAL": "FYSIKAALINEN MATERIAALI",
|
||||
"PICK CONTEXT": "VALITSE KONTEKSTI",
|
||||
"PLANE ALONG PATH": "TASO POLKUA PITKIN",
|
||||
"PLANE THROUGH THREE POINTS": "TASO KOLMEN PISTEEN KAUTTA",
|
||||
"PLANE THROUGH TWO EDGES": "TASO KAHDEN SÄRMÄN KAUTTA",
|
||||
"POINT ALONG PATH": "PISTE POLKUA PITKIN",
|
||||
"POINT AT CENTER": "PISTE KESKIPISTEESEEN",
|
||||
"POINT AT EDGE AND PLANE": "PISTE SÄRMÄN JA TASON LEIKKAUKSEEN",
|
||||
"POINT AT VERTEX": "PISTE KÄRKIPISTEESEEN",
|
||||
"POINT THROUGH THREE PLANES": "PISTE KOLMEN TASON KAUTTA",
|
||||
"POINT THROUGH TWO EDGES": "PISTE KAHDEN SÄRMÄN KAUTTA",
|
||||
"Paint - Blue": "Maali - sininen",
|
||||
"Paint - Green": "Maali - vihreä",
|
||||
"Paint - Orange": "Maali - oranssi",
|
||||
"Paint - Purple": "Maali - violetti",
|
||||
"Paint - Red": "Maali - punainen",
|
||||
"Paint - Yellow": "Maali - keltainen",
|
||||
"Parts only": "Vain osat",
|
||||
"Play": "Toista",
|
||||
"Point to Point": "Pisteestä pisteeseen",
|
||||
"Point to Position": "Pisteestä sijaintiin",
|
||||
"Polycarbonate - Clear": "Polykarbonaatti - kirkas",
|
||||
"Preferences…": "Asetukset…",
|
||||
"RACK AND PINION": "HAMMASTANKO JA -PYÖRÄ",
|
||||
"REFOLD": "TAITA UUDELLEEN",
|
||||
"REMOVE": "POISTA",
|
||||
"RENAME": "NIMEÄ UUDELLEEN",
|
||||
"REPLACE FACE": "KORVAA PINTA",
|
||||
"REST": "TUKI",
|
||||
"REVERSE NORMAL": "KÄÄNNÄ NORMAALI",
|
||||
"RIGID GROUP": "JÄYKKÄ RYHMÄ",
|
||||
"Range": "Alue",
|
||||
"Reduce to Fit": "Pienennä sopivaksi",
|
||||
"Report an Issue…": "Ilmoita ongelmasta…",
|
||||
"Rolling": "Vierivä",
|
||||
"Rotate": "Kierrä",
|
||||
"Rubber - Black": "Kumi - musta",
|
||||
"Rules": "Säännöt",
|
||||
"SCREW": "RUUVI",
|
||||
"SHEET METAL RULES": "OHUTLEVYSÄÄNNÖT",
|
||||
"SNAP FIT": "NAPSAUTUSLIITOS",
|
||||
"SPLIT FACE": "JAA PINTA",
|
||||
"STITCH": "OMPELE",
|
||||
"STOCK": "AIHIO",
|
||||
"SURFACE EXTRUDE": "PINTAPURSOTUS",
|
||||
"SURFACE LOFT": "PINTALOFTTAUS",
|
||||
"SURFACE REVOLVE": "PINTAPYÖRÄYTYS",
|
||||
"SURFACE SWEEP": "PINTAPYYHKÄISY",
|
||||
"Save As JSON…": "Tallenna JSON-muodossa…",
|
||||
"Save As…": "Tallenna nimellä…",
|
||||
"Screenshot captured locally. Review it before attaching it on GitHub.": "Kuvakaappaus otettiin paikallisesti. Tarkista se ennen kuin liität sen GitHubissa.",
|
||||
"Scroll left": "Vieritä vasemmalle",
|
||||
"Scroll right": "Vieritä oikealle",
|
||||
"Select Features": "Valitse piirteet",
|
||||
"Select the curves to divide first": "Valitse ensin jaettavat käyrät",
|
||||
"Sheet Metal": "Ohutlevy",
|
||||
"Show Each Feature": "Näytä jokainen piirre",
|
||||
"Stainless Steel": "Ruostumaton teräs",
|
||||
"Steel": "Teräs",
|
||||
"Steel - Brushed": "Teräs - harjattu",
|
||||
"Suppress Features": "Ohita piirteet",
|
||||
"Suppression": "Ohitus",
|
||||
"Surface or face": "Pintakappale tai pinta",
|
||||
"Surface or faces": "Pintakappale tai pinnat",
|
||||
"TANGENT PLANE": "TANGENTTITASO",
|
||||
"THICKEN": "PAKSUNNA",
|
||||
"TRANSFORM": "MUUNNA",
|
||||
"Taper to Fit": "Kavenna sopivaksi",
|
||||
"Target edges": "Kohdesärmät",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Origon akseli on kohtisuorassa luonnostasoon nähden, joten sillä ei ole viivaa tasossa",
|
||||
"Top level": "Ylin taso",
|
||||
"Two Distances": "Kaksi etäisyyttä",
|
||||
"UNFOLD": "LEVITÄ",
|
||||
"UNSTITCH": "PURA OMMEL",
|
||||
"Use axis direction only": "Käytä vain akselin suuntaa",
|
||||
"Variable radius": "Muuttuva säde",
|
||||
"View": "Näkymä",
|
||||
"Walnut": "Pähkinäpuu",
|
||||
"ai.http.allow": "Salli suojaamaton HTTP",
|
||||
"ai.http.confirm": "Salli tälle päätepisteelle",
|
||||
"ai.http.warning": "API-avain ja keskustelu lähetetään salaamattomina. Kuka tahansa, jolla on pääsy verkkoon, voi lukea tai muuttaa niitä. Käytä tätä vain verkossa, johon luotat.",
|
||||
"ai.prefs.instructions": "Omat ohjeet",
|
||||
"ai.prefs.instructions_note": "Lähetetään agentille jokaisen keskustelun alussa SolidCraftin omien ohjeiden jälkeen: miten haluat työskennellä ja mitä sääntöjä noudatetaan. Muutos tulee voimaan seuraavasta keskustelusta (Yhdistä tai Uusi keskustelu).",
|
||||
"browser.canvas_locked": "Pohjakuva on lukittu. Avaa lukitus ensin.",
|
||||
"cmd.inspect_colored_components": "Komponenttien värit",
|
||||
"cmd.model_body_features": "Luettele kappaleen piirteet",
|
||||
"cmd.panel.action": "Suorita paneelin otsikon toiminto",
|
||||
"cmd.panel.focus.next": "Kohdista seuraavaan paneeliin",
|
||||
"cmd.panel.focus.previous": "Kohdista edelliseen paneeliin",
|
||||
"cmd.panel.move.next_zone": "Siirrä paneeli seuraavalle alueelle",
|
||||
"cmd.panel.sash.down": "Siirrä paneelien jakajaa alas",
|
||||
"cmd.panel.sash.left": "Siirrä paneelien jakajaa vasemmalle",
|
||||
"cmd.panel.sash.reset": "Palauta kohdistetun paneelin jakaja",
|
||||
"cmd.panel.sash.right": "Siirrä paneelien jakajaa oikealle",
|
||||
"cmd.panel.sash.up": "Siirrä paneelien jakajaa ylös",
|
||||
"cmd.panel.toggle": "Kohdistettu paneeli päälle/pois",
|
||||
"cmd.pointcloud_reload": "Lataa pistepilvi uudelleen",
|
||||
"cmd.render.coat.bind": "Tallenna kirkaslakka",
|
||||
"cmd.render.coat.unbind": "Poista kirkaslakka",
|
||||
"cmd.render.surface.import": "Tuo pintakartat",
|
||||
"cmd.render.texture.list": "Luettele upotetut pintakuvat",
|
||||
"cmd.render.texture.remove": "Poista upotettu pintakuva",
|
||||
"cmd.sketch.replace": "Korvaa luonnos",
|
||||
"cmd.sketch_move_points": "Siirrä luonnospisteitä",
|
||||
"cmd.sketch_point_divide": "Jaa käyrä",
|
||||
"cmd.view.navigator": "Mukautettu näkymänavigaattori",
|
||||
"cmd.view.render_live": "Keskeytä tai jatka reaaliaikaista renderöintiä",
|
||||
"cmd.view_ribbon_tabs_only": "Vain valintanauhan välilehdet",
|
||||
"cmd.view_tool_zoom": "Zoomaa vetämällä",
|
||||
"dialog.cylinder_face_tip": "Mallin pinta tai kärkipiste, jonka päällä sylinteri seisoo: napsauta tätä ja napsauta sitten sitä näkymässä. Jos tämä jätetään tyhjäksi, sijainnin määräävät X, Y ja Z.",
|
||||
"dialog.primitive_point_tip": "Piste, joka täyttää X:n, Y:n ja Z:n: napsauta tätä ja napsauta sitten mallin kärkipistettä, särmää tai pintaa, luonnoksen pistettä tai origoa.",
|
||||
"dialog.reference_edge_tip": "Valitun pinnan suora särmä, josta alla oleva etäisyys mitataan: napsauta tätä ja napsauta sitten särmää.",
|
||||
"document.newer_file": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, tämä versio lukee enintään muodon {reads}). Jos jotain puuttuu, päivitä.",
|
||||
"document.newer_file_change": "Tallennettu uudemmalla SolidCraftilla (muoto {format}, jossa `{change}`, jota tämä versio ei tunne). Jos jotain puuttuu, päivitä.",
|
||||
"document.newer_file_not_read": "Ei luettu: {what}. Tallennus säilyttää sen.",
|
||||
"file.open.portal_fallback.no_portal_service": "Avautunut tiedostonvalitsin ei ole työpöydän oma: työpöydän portaalipalvelu ei ole käytettävissä.",
|
||||
"file.open.portal_fallback.no_session_bus": "Avautunut tiedostonvalitsin ei ole työpöydän oma: D-Bus-istuntoväylää ei ole.",
|
||||
"file.open.portal_fallback.xwayland": "Avautunut tiedostonvalitsin ei ole työpöydän oma: XWaylandissa portaali hylkää yläikkunan.",
|
||||
"file_load.inserted": "Lisätty {name}",
|
||||
"file_load.opened": "Avattu {name}",
|
||||
"file_load.opened_in_tab": "{name} avattu uudessa välilehdessä",
|
||||
"file_load.reloading_cloud": "Ladataan pistepilveä uudelleen",
|
||||
"help.sketch.point.divide": "Sijoittaa pisteitä, jotka jakavat valitut käyrät yhtä pitkiin osiin. Pisteet pysyvät kullakin käyrällä ja säilyttävät välinsä, kun käyrä muuttuu.",
|
||||
"help.view.ribbon_tabs_only": "Kutistaa valintanauhan välilehtiriviksi, jolloin näkymä saa paneelien viemän tilan. Napsauta välilehteä, niin sen paneelit näkyvät näkymän päällä; valitse tämä uudelleen, niin koko valintanauha pysyy näkyvissä.",
|
||||
"hole_folder.applies_to": "Muutokset koskevat kansion {folder} {count} reikää; kukin pysyy paikallaan.",
|
||||
"nav.drag_keeps_mode.help": "Keski- tai oikean painikkeen veto jatkaa kiertoa, panorointia tai zoomausta sellaisena kuin se alkoi, kunnes painike vapautetaan, riippumatta siitä, mitä näppäimiä sillä välin painetaan tai vapautetaan. Pois: painettuina olevat näppäimet ratkaisevat vedon joka hetkellä.",
|
||||
"nav.drag_keeps_mode.label": "Veto säilyttää navigointitavan, jolla se alkoi",
|
||||
"nav.orbit_cursor_wrap.help": "Kun kiertoveto saavuttaa näkymän reunan, kohdistin siirtyy vastakkaiseen reunaan ja kierto jatkuu. Pois: veto pysähtyy näytön reunaan. Ei vaikutusta Waylandissa eikä selaimessa.",
|
||||
"nav.orbit_cursor_wrap.label": "Siirrä kohdistin vastakkaiseen reunaan kierrettäessä",
|
||||
"navbar.zoom_hold_tip": "Zoomaa (pidä pikanäppäin painettuna)",
|
||||
"prefs.background_choose": "Valitse kuva…",
|
||||
"prefs.background_image": "Kuva",
|
||||
"prefs.background_unreadable": "Taustakuvaa ei voitu lukea",
|
||||
"prefs.browser_body_features": "Piirteet kappaleiden alla",
|
||||
"prefs.browser_body_features_note": "Kunkin kappaleen alle selaimessa avautuvat aikajanan piirteet, joilla se on rakennettu, aikajanan järjestyksessä.",
|
||||
"prefs.browser_rows_start_closed": "Selaimen rivit ovat aluksi suljettuina",
|
||||
"prefs.browser_rows_start_closed_note": "Selaimen sijoitukset ja kansiot ovat aluksi suljettuina, joten mallista, jossa on paljon komponentteja, näkyvät ylimmän tason rivit. Kukin avautuu nuolestaan, Laajenna kaikki -toiminnolla tai Etsi selaimesta -toiminnolla, joka avaa vain rivit kappaleeseen asti ja sulkee taas ne, jotka edellinen haku avasi. 3D-näkymässä valittu kappale jättää selaimen ennalleen. Kun tämä on pois päältä, kansiot ovat aluksi auki, sijoitukset noudattavat Komponentit alkavat avoimina -asetusta, ja 3D-näkymässä tehty valinta avaa selaimen kappaleeseen asti kuten ennenkin.",
|
||||
"prefs.command_dialog_values": "Komentoikkunat",
|
||||
"prefs.design_tools.hole_group": "Ryhmitä yhdessä tehdyt reiät",
|
||||
"prefs.design_tools.hole_group_help": "Kerralla tehdyt reiät ovat historiassa samassa kansiossa, joka on aluksi suljettu; avaa kansio, jos haluat muokata reikää erikseen.",
|
||||
"prefs.design_tools.hole_several": "Useita reikiä yhdelle pinnalle",
|
||||
"prefs.design_tools.hole_several_help": "Pinnan muun kohdan valitseminen lisää uuden reiän; reiän valitseminen poistaa sen.",
|
||||
"prefs.extrude_arrow_face_stops": "Pursotusnuoli pysähtyy pintoihin",
|
||||
"prefs.extrude_arrow_face_stops_note": "Pursotuksen etäisyysnuolen vetäminen pysähtyy profiilin suuntaisiin tasopintoihin, kuten leikattavan kappaleen vastakkaiseen pintaan, ja korostaa pinnan ääriviivan. Pois: nuoli liikkuu vain tasavälein.",
|
||||
"prefs.ground_shadow_follows_model": "Maavarjo seuraa mallia",
|
||||
"prefs.ground_shadow_follows_model_help": "Sijoita varjo alimman näkyvän kappaleen alle. Kun tämä on pois päältä, korotettujen mallien varjo pysyy origon maatasolla.",
|
||||
"prefs.history_bar_groups": "Kokoa historiapalkki ryhmiksi",
|
||||
"prefs.history_bar_groups_note": "Pitkässä historiassa yhden komponentin peräkkäiset piirteet kootaan yhdeksi merkiksi, ja piirrekansiot ovat aluksi kutistettuina. Lisätty STEP-kokoonpano sijoitetaan ryhmäkansioon. Pois: jokainen piirre näytetään, kansiot ovat aluksi auki eikä kansiota luoda.",
|
||||
"prefs.history_folder_kind_icon": "Kansion kuvake sen piirteistä",
|
||||
"prefs.history_folder_kind_icon_note": "Historiakansio, jonka kaikki piirteet ovat samaa lajia, näyttää niiden kuvakkeen.",
|
||||
"prefs.history_group_joints": "Ryhmitä nivelet historiassa",
|
||||
"prefs.history_group_joints_note": "Kaksi tai useampi nivel näkyy yhtenä painikkeena lukumäärineen, ja napsautus näyttää jokaisen. Pois: jokainen nivel näytetään.",
|
||||
"prefs.keep_unsaved_also_asks": "Kysy silti, kun välilehti suljetaan",
|
||||
"prefs.keep_unsaved_also_asks_tip": "Kun tämä on pois päältä, välilehti sulkeutuu ilman selaimen kyselyikkunaa, kun jokaisen tallentamattoman mallin kopio on ajan tasalla.",
|
||||
"prefs.keep_unsaved_in_browser": "Säilytä tallentamaton työ tässä selaimessa",
|
||||
"prefs.keep_unsaved_in_browser_tip": "Jokaisen avoimen mallin tallentamattomat muutokset pidetään ajan tasalla tämän selaimen yksityisessä tallennustilassa. Seuraavalla käynnillä niitä tarjotaan palautettaviksi.",
|
||||
"prefs.layout_page.hover_panel_menus": "Vaihda paneelivalikkoa osoittimen ollessa päällä",
|
||||
"prefs.layout_page.hover_panel_menus_help": "Avaa valintanauhan paneelin valikko napsauttamalla sen nimeä. Vaihda valikkoa viemällä osoitin toisen paneelin nimen päälle. Sulje valikko siirtämällä osoitin valintanauhan ja avoimen valikon ulkopuolelle.",
|
||||
"prefs.move_drag_highlights": "Korosta osoittimen alla oleva kohde, kun Siirrä-toiminnon nuolilla vedetään komponenttia",
|
||||
"prefs.move_drag_highlights_tip": "Pois: kun Siirrä-toiminnon nuolilla vedetään komponenttia, osoittimen alla olevaa kohdetta ei korosteta, mikä pitää suuret kokoonpanot sulavina. Kun leikkaus tai analyysi on näkyvissä, kohde korostetaan silti, eikä niveliään pitkin vedetty osa koskaan korosta sitä.",
|
||||
"prefs.panel_layout.recovery": "Tallennettuja paneeliasetteluja ei voitu palauttaa. Tallennetut tiedot säilytetään; Perinteinen on edelleen käytettävissä.",
|
||||
"prefs.picker.carousel": "Karuselli",
|
||||
"prefs.picker.change": "Vaihda",
|
||||
"prefs.picker.clear": "Tyhjennä suodattimet",
|
||||
"prefs.picker.day": "Päiväteema",
|
||||
"prefs.picker.empty": "Mikään teema ei vastaa näitä suodattimia.",
|
||||
"prefs.picker.follow": "Seuraa järjestelmää",
|
||||
"prefs.picker.grid": "Ruudukko",
|
||||
"prefs.picker.high": "Suuri kontrasti",
|
||||
"prefs.picker.high_note": "{source} · Suuri kontrasti",
|
||||
"prefs.picker.hint": "Vaihtuu käyttöjärjestelmän mukana.",
|
||||
"prefs.picker.import": "Tuo teematiedosto…",
|
||||
"prefs.picker.imported": "Tuotu",
|
||||
"prefs.picker.night": "Yöteema",
|
||||
"prefs.picker.one": "Yksi teema",
|
||||
"prefs.picker.pick": "Valitse alta",
|
||||
"prefs.picker.standard": "Vakio",
|
||||
"prefs.remember_dialog_values": "Muista komentoikkunoiden arvot",
|
||||
"prefs.remember_dialog_values.help": "Oletuksena pois päältä. Kun tämä on käytössä, onnistuneen komennon turvalliset arvot ja asetukset otetaan uudelleen käyttöön, kun avaat saman komennon seuraavan kerran, kunnes SolidCraft suljetaan.",
|
||||
"prefs.render_legacy_image_defaults": "Käytä aiempia renderöintikuvan oletuksia",
|
||||
"prefs.render_legacy_image_defaults_help": "Säilytä aiempi manuaalinen työnkulku: uudet asetelmat käyttävät CPU-rasterointia, CPU-säteenjäljitys säilyttää kiinteän näytteistyksen ja avaat renderöidyt tulokset itse. Sovita näkymä uudelleen Renderöintiin siirryttäessä, vaihda kuvakulmia palauttamatta niiden kameroita ja käytä aiempaa rajausta ja navigointia. Poista automaattinen reaaliaikainen kuvapalaute käytöstä. Tallennetut asetelmat eivät muutu.",
|
||||
"prefs.ribbon_tabs_only": "Näytä vain valintanauhan välilehdet",
|
||||
"prefs.ribbon_tabs_only_note": "Valintanauha kutistuu välilehtiriviksi. Välilehden napsauttaminen näyttää sen paneelit näkymän päällä, kunnes komento alkaa tai osoitin poistuu niiden päältä. Näkymä-välilehden Vain valintanauhan välilehdet -komento tai sen pikanäppäin vaihtaa tämän.",
|
||||
"prefs.select_hidden_edges": "Valitse piilosärmiä tilassa Varjostettu, piilosärmät näkyvissä",
|
||||
"prefs.select_hidden_edges_help": "Valitse särmiä pintojen läpi, kun Varjostettu, piilosärmät näkyvissä on käytössä, myös pyöristyksessä. Pois: säilyttää version v0.30.1 valintatoiminnan. Piilotettuja kappaleita ei voi valita.",
|
||||
"prefs.shared_panel_layout": "Käytä jaettua paneeliasettelua",
|
||||
"prefs.shared_panel_layout_help": "Näytä nykyiset Selain, Historia ja Avustaja siirrettävissä paneeleissa. Muutos tulee voimaan, kun asetukset otetaan käyttöön.",
|
||||
"prefs.sketch.ellipse_axes": "Piirrä ellipsit akseleineen",
|
||||
"prefs.sketch.ellipse_axes_note": "Iso- ja pikkuakseli apuviivoina, jotta puoliakseleille ja akseleille voi lisätä mitat. Pois: pelkkä ellipsi, ja D sen päällä antaa sen pienemmän säteen riippumatta siitä, mistä arvo vedetään, piirrettynä ja R-merkittynä kuten ennenkin.",
|
||||
"prefs.sketch.escape_twice_finishes": "Kaksi Esc-painallusta lopettaa luonnoksen",
|
||||
"prefs.sketch.escape_twice_finishes_note": "Kaksi nopeaa Esc-painallusta lopettaa luonnoksen. Pois: Esc vain peruuttaa työkalun tai valinnan, ja Lopeta luonnos lopettaa luonnoksen.",
|
||||
"prefs.sketch_mirror_auto_line": "Käytä ennalta valittua apuviivaa peilaukseen",
|
||||
"prefs.tab_double_click_renames": "Nimeä mallin välilehti uudelleen kaksoisnapsauttamalla",
|
||||
"prefs.tab_double_click_renames_note": "Kirjoita uusi nimi ja säilytä se painamalla Enter tai napsauttamalla muualla, tai peruuta painamalla Esc. Pois: välilehden kaksoisnapsautus suurentaa ikkunan kuten palkin tyhjän osan kaksoisnapsautus.",
|
||||
"recovery.open_saved": "Avaa tallennettu versio",
|
||||
"recovery.restore": "Palauta",
|
||||
"recovery.restore_intro": "Muutokset, joita ei ollut tallennettu, kun tämä sivu viimeksi suljettiin, on säilytetty tässä selaimessa.",
|
||||
"recovery.restore_row": "{name}, muutettu {when}",
|
||||
"recovery.restore_title": "Palautetaanko tallentamattomat mallit?",
|
||||
"recovery.restore_title_one": "Palautetaanko tallentamaton malli: {name}, muutettu {when}?",
|
||||
"render.adaptive.pilot_progress": "Mukautuvat pilotit · {current}/{target} kierrosta",
|
||||
"render.adaptive.preview_help": "Pilottikuvat ohjaavat jakoa, eikä niitä sisällytetä lopulliseen kuvaan. Tuotantoiteraatiot käyttävät erillistä budjettia kullekin pikselille; iteraatioiden määrä ei vastaa tasaista näytemäärää pikseliä kohden.",
|
||||
"render.adaptive.production_progress": "Mukautuva tuotanto · {current}/{target} iteraatiota",
|
||||
"render.aging_raytrace_help": "Vanhentaminen tehdään CPU-säteenjäljityksellä ja GPU-polunjäljityksellä. Rasterointi säilyttää kaavamaisen renderöinnin.",
|
||||
"render.appearance_editor.discard_saved_edit": "Hylkää tallentamattomat optiset muokkaukset",
|
||||
"render.batch_current_settings_help": "Käyttää nykyisiä kuva-asetuksia kunkin tallennetun kuvakulman kameran kanssa, yksi kuvakulma kerrallaan.",
|
||||
"render.batch_owner_limit": "Enintään 32 asiakirjan kuvakulmajonoa voidaan säilyttää. Viimeistele tai peruuta ensin jokin olemassa oleva jono.",
|
||||
"render.batch_preparing": "Valmistellaan seuraavaa kuvakulmaa",
|
||||
"render.batch_waiting_worker": "Odotetaan renderöinnin valmistelua",
|
||||
"render.caustics_depth": "Kaustiikkapolun syvyys",
|
||||
"render.caustics_enable": "Lasin kaustiikka piste- ja suuntavaloista",
|
||||
"render.caustics_help": "Fotonien määrä vaikuttaa kohinaan ja laskennan määrään. Keruusäde vaihtaa terävät yksityiskohdat tasaisempaan arvioon. CPU vaatii epäsuoran valaistuksen.",
|
||||
"render.caustics_path_required": "CPU-kaustiikka vaatii epäsuoran valaistuksen.",
|
||||
"render.caustics_photons": "Fotonit",
|
||||
"render.caustics_radius": "Keruusäde",
|
||||
"render.check_glass_cpu": "CPU tukee varmennettuja tasomaisia lasilevyjä ja akselien suuntaisia laatikoita. Yleiset suljetut verkot, sisäkkäisyys ja sisäinen sironta vaativat epäsuoran valaistuksen. Suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta.",
|
||||
"render.check_glass_gpu": "GPU tukee varmennettuja erillisiä lasikappaleita tai nimenomaista varmennettua sisäkkäisyyttä. Sisäkkäisyys vaatii kuperat ulommat kappaleet, kokonaan niiden sisällä olevat sisemmät kappaleet eikä toisiaan koskettavia rajapintoja.",
|
||||
"render.check_media_remaining": "Tiheysruudukot, siirtymäkartoitus, täysi spektri-integrointi ja polarisaation eteneminen eivät edelleenkään ole käytettävissä.",
|
||||
"render.check_media_scope": "Aktiivinen sisäinen sironta vaatii suljetun lasiväliaineen, suorien varjojen läpäisyn pois päältä ja kohinanpoiston pois päältä. GPU vaatii lisäksi yhden Fresnel-kerrostuman. Aktiivista ulkoista ilmakehää ei voi yhdistää lasiin eikä pintojen kohinanpoistoon.",
|
||||
"render.coat_discard": "Hylkää lakan muokkaus",
|
||||
"render.coat_pending_edit": "Tätä lakan muokkausta ei ole tallennettu. Tallenna se kuvakulman mukana tai hylkää se.",
|
||||
"render.coat_remove": "Poista tallennettu lakka",
|
||||
"render.coat_remove_note": "Poistaa tämän kohteen lakan kuvakulmasta. Geometria, ulkoasut ja fysikaaliset materiaalit pysyvät ennallaan.",
|
||||
"render.coat_remove_title": "Poistetaanko tallennettu kirkaslakka?",
|
||||
"render.coat_revalidate_saved": "Tallennettu lakka on muuttunut. Hylkää tämä muokkaus ja avaa kohde uudelleen ennen kuin muokkaat sitä taas.",
|
||||
"render.coat_save": "Tallenna lakka kuvakulmaan",
|
||||
"render.coat_saved_help": "Kirkaslakka lisää kiiltävän kerroksen läpinäkymättömille pinnoille. Tallenna se kuvakulman mukana. Lasi ei voi jakaa tätä kohdetta. Myöskään CPU:n mukautetut heijastus- ja anisotropiaohitukset eivät voi jakaa sitä.",
|
||||
"render.cpu_celestial_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella · polun syvyys {depth} · {spp} SPP · jäljitetyt varjot · siemen {seed} · 1 ensisijainen näyte",
|
||||
"render.cpu_celestial_adopt_label": "CPU-säteenjäljitys epäsuoralla valaistuksella",
|
||||
"render.cpu_celestial_path_required": "Äärellinen aurinko ja liukuväritaivas käyttävät CPU:n epäsuoraa valaistusta.",
|
||||
"render.cpu_celestial_switch": "Säilytä tämä aurinko tai taivas valitsemalla CPU-säteenjäljitys epäsuoralla valaistuksella.",
|
||||
"render.cpu_celestial_unportable": "Säilytä GPU-polunjäljitys nykyisillä läpäisynäytteiden asetuksilla.",
|
||||
"render.cpu_constant_direct_help": "Ilman epäsuoraa valaistusta tämä vakiolähde voi näkyä heijastuksissa ja lasin läpi. Se ei valaise läpinäkymättömiä pintoja. Ota epäsuora valaistus käyttöön, jos haluat käyttää sitä näkymän valaistukseen.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Polun syvyys {depth}; progressiivinen näytteistys {spp} näytettä/pikseli (siemen {seed}); yksi ensisijainen säde näytettä kohden; jäljitetyt varjot. Kuvan koko, CPU-budjetti ja muut asetukset säilyvät.",
|
||||
"render.cpu_denoise_help": "Vähentää kohinaa jokaisessa kuvan päivityksessä. Se voi pehmentää hienoja yksityiskohtia ja syväterävyyden reunoja. Raaka-EXR pysyy muuttumattomana. Valmiin kuvan säätäminen käyttää sen raakakuvaa eikä tee kohinanpoistoa uudelleen.",
|
||||
"render.cpu_fixed_retained": "Kiinteä näytteistys säilytetty",
|
||||
"render.cpu_glass_enable_path": "Ota epäsuora valaistus käyttöön",
|
||||
"render.cpu_indirect_method_required": "Valitse CPU-säteenjäljitys ottaaksesi tämän epäsuoran valaistuksen tilan käyttöön.",
|
||||
"render.cpu_maps_saved_help": "CPU-renderöinnit voivat käyttää tallennettuja läpinäkymättömiä karttoja: valitse pinta tästä ja valitse sitten Tallenna kartat kuvakulmaan. Epäsuoralla valaistuksella lasi voi olla toisessa kappaleessa; kartoitettu kappale ei voi käyttää lasia. Valittujen heijastusten, anisotropian ja proseduraalisten pintatehosteiden on oltava pois päältä. Väliaikaiset karttatiedostopolut käyttävät GPU:ta.",
|
||||
"render.cpu_material_certification": "CPU:n tarkistus varmentaa tuetun lasigeometrian ennen renderöintiä. Suljettu lasi käyttää geometrista etäisyyttä.",
|
||||
"render.cpu_opaque_hint": "CPU-säteenjäljitys käyttää läpinäkymätöntä taustaa.",
|
||||
"render.cpu_path_depth": "Polun syvyys",
|
||||
"render.cpu_path_help": "Lisää valon heijastelun läpinäkymättömien pintojen välillä ja tuettujen lasimuotojen läpi. Vaatii progressiivisen renderöinnin ja jäljitetyt varjot. Polun syvyys laskee varjostetut pinnat, lasin rajapinnat ja sirontatapahtumat ensimmäisestä osumasta alkaen (1–16). Syvyys 1 antaa suoran valaistuksen läpinäkymättömille pinnoille; suuremmat arvot sallivat useampia heijastuksia tai lasin läpäisyjä ja voivat kestää kauemmin. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
|
||||
"render.cpu_path_lighting": "Epäsuora valaistus",
|
||||
"render.cpu_path_optics_reason": "Asetuksesi säilytetään. Poista epäsuora valaistus käytöstä, jos haluat käyttää mukautettuja heijastuksia tai anisotropiaa.",
|
||||
"render.cpu_progressive_adopt": "Käytä progressiivista näytteistystä",
|
||||
"render.cpu_progressive_adopt_help": "Progressiivinen näytteistys käyttää yhtä kameranäytettä kierrosta kohden. Kuvan koko, valot ja heijastusasetukset pysyvät ennallaan.",
|
||||
"render.cpu_progressive_always": "Progressiivinen näytteistys on aina käytössä",
|
||||
"render.cpu_progressive_reflection_adopt": "Aseta valittujen heijastusten yrityskerroiksi 1 ennen progressiivisen näytteistyksen käyttöönottoa.",
|
||||
"render.cpu_quality_budget_help": "Muuttaa koon, tuotantonäytteiden ylärajan, siemenen ja CPU-resurssiprofiilin. Kamera, objektiivi, valaistus, GI-syvyys ja tehosteet säilyvät.",
|
||||
"render.cpu_quality_progressive_required": "Ota alla progressiivinen näytteistys käyttöön ennen CPU-budjetin valitsemista.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · näytteitä enintään {samples} · siemen {seed} · {profile}",
|
||||
"render.cpu_sky_bounds": "Taivaan kierron on oltava äärellinen ja välillä ±1 000 000 radiaania; voimakkuuden on oltava 0–100.",
|
||||
"render.cpu_studio_enable": "Ota epäsuora valaistus käyttöön tälle asetelmalle",
|
||||
"render.cpu_studio_enable_apply": "Ota epäsuora valaistus käyttöön ja käytä",
|
||||
"render.cpu_studio_indirect_help": "Tämä aurinkoasetelma vaatii epäsuoran valaistuksen syvyydellä 8, jäljitetyt varjot ja yhden kameranäytteen kierrosta kohden. Jos progressiivinen renderöinti on pois päältä, se aloitetaan 16 näytteellä pikseliä kohden siemenellä 0. Nykyiset progressiiviset asetukset ja resurssiprofiili säilyvät. Yhteensopimattomat asetukset keskeyttävät käyttöönoton muuttamatta niitä.",
|
||||
"render.cpu_studio_radiance_limit": "Tämän asetelman pintasäteily on {radiance}, mikä ylittää CPU:n rajan 64. Laadittu valaistus säilytetään; valitse toinen asetelma tai GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU:n pinnanalainen sironta vaatii kohinanpoiston pois päältä. Asetuksesi säilytetään.",
|
||||
"render.cpu_subsurface_help": "Sironta ohjaa valoa uudelleen suljetun lasin sisällä. Positiiviset tiheydet vaativat epäsuoran valaistuksen ja kohinanpoiston pois päältä. Tiheys ilmoitetaan törmäyksinä millimetriä kohden; 0 ei lisää sirontaa. Positiivinen vaihepainotus suosii eteenpäin suuntautuvia suuntia, negatiivinen taaksepäin suuntautuvia.",
|
||||
"render.cpu_transparent_help": "Läpinäkyvä PNG poistaa taustan ja säilyttää reunojen peittävyyden. Kappaleiden ja pintojen peittävyyden on pysyttävä 100 %:ssa.",
|
||||
"render.cpu_volume_enable_path": "Ota epäsuora valaistus käyttöön",
|
||||
"render.cpu_volume_enable_path_plan": "CPU-säteenjäljitys · polun syvyys {depth} · {spp} näytettä/pikseli · siemen {seed} · jäljitetyt varjot. Laadittu ilmakehä ja kuvabudjetti säilyvät.",
|
||||
"render.cpu_volume_help": "CPU:n ilmakehä käyttää epäsuoraa valaistusta. Nollaekstinktio säilyttää tallennetun laatikon ilman sumua.",
|
||||
"render.cpu_volume_method_adopt": "CPU-säteenjäljitys epäsuoralla valaistuksella",
|
||||
"render.cpu_volume_method_incompatible": "Näitä valo-, näytteistys- tai ilmakehäasetuksia ei voi siirtää tähän menetelmään. Asetuksesi säilytetään.",
|
||||
"render.cpu_volume_path_required": "Ota epäsuora valaistus käyttöön, jos haluat käyttää CPU:n ilmakehää.",
|
||||
"render.directional_sun_limit": "GPU-valaistus tukee yhteensä kahdeksaa suuntavalolähdettä, joista enintään yksi voi olla pehmeä aurinko. Lisää uusi kaukainen valonlähde monistamalla kova suuntavalo.",
|
||||
"render.glass_abbe": "Abben luku",
|
||||
"render.glass_dispersion": "Kromaattinen dispersio",
|
||||
"render.glass_film": "Ohutkalvointerferenssi",
|
||||
"render.glass_film_ior": "Kalvon IOR",
|
||||
"render.glass_film_thickness": "Kalvon paksuus",
|
||||
"render.glass_model_enable": "Edistynyt lasimalli",
|
||||
"render.glass_model_help": "Lasin karheus sirottaa läpäiseviä säteitä. Dispersio ja kalvon väri käyttävät kolmea aallonpituuskaistaa. Karheat levyt vaativat suorien läpäisevien varjojen olevan pois päältä.",
|
||||
"render.glass_roughness": "Lasin karheus",
|
||||
"render.gpu_job_shader_wait": "Varjostimien valmistelu voi kestää minuutteja",
|
||||
"render.gpu_path_crashed": "SolidCraft sulkeutui viimeksi, kun tätä valmisteltiin tällä näytönohjaimella, todennäköisesti näytönohjaimen ajurin sisällä, joten valmistelua ei käynnistetä uudelleen automaattisesti. Valitse sen sijaan CPU-säteenjäljitys tai yritä uudelleen (myös ajurin päivitys yrittää uudelleen).",
|
||||
"render.gpu_path_not_started": "GPU-polunjäljittimen valmistelu ei ole alkanut.",
|
||||
"render.gpu_path_retry": "Yritä uudelleen",
|
||||
"render.light_space_world": "Maailmakoordinaatiston valot",
|
||||
"render.light_space_world_help": "Valojen sijainnit ja suunnat käyttävät maailmakoordinaatteja. Kameran kiertäminen ei siirrä niitä.",
|
||||
"render.live.adaptive": "Reaaliaikainen · {width} × {height} · {progress}",
|
||||
"render.live.final_changed": "Renderöintipyyntö muuttui reaaliaikaisen renderöinnin pysähtyessä. Aloita lopullinen kuva uudelleen.",
|
||||
"render.live.final_waiting": "Valmistellaan lopullista kuvaa · vapautetaan reaaliaikaisen renderöinnin resursseja",
|
||||
"render.live.invalid_size": "Reaaliaikainen renderöinti vaatii positiivisen tulosteen leveyden ja korkeuden.",
|
||||
"render.live.pause": "Keskeytä reaaliaikainen",
|
||||
"render.live.paused": "Reaaliaikainen · Keskeytetty",
|
||||
"render.live.paused_outdated": "Reaaliaikainen · Keskeytetty · Vanhentunut",
|
||||
"render.live.quality_help": "Vuorovaikutteinen laatu: enintään 512 pikseliä pisimmällä sivulla. Käyttää valittua renderöijää ja sen tukemia tehosteita. Lopullinen tarkkuus, asetukset ja vienti pysyvät erillään.",
|
||||
"render.live.refused": "Reaaliaikainen ei käytettävissä: {reason}",
|
||||
"render.live.resume": "Jatka reaaliaikaista",
|
||||
"render.live.samples": "Reaaliaikainen · {width} × {height} · {current}/{target} näytettä",
|
||||
"render.live.source_current": "{method} · Reaaliaikainen",
|
||||
"render.live.source_outdated": "{method} · Edellinen kuva",
|
||||
"render.live.source_outdated_help": "Näyttää viimeksi muodostetun kuvan, kun reaaliaikainen renderöinti päivittyy tai ei ole käytettävissä. Se voi poiketa nykyisestä näkymästä tai kuva-asetuksista.",
|
||||
"render.live.source_settings_help": "Avaa Tuloste muokataksesi renderöijän ja kuvan asetuksia.",
|
||||
"render.live.stopped": "Reaaliaikainen renderöinti pysäytetty",
|
||||
"render.live.updating": "Reaaliaikainen · Vuorovaikutteinen laatu (enintään 512 px) · Päivitetään",
|
||||
"render.live.waiting_canvas": "Odotetaan renderöinnin piirtoaluetta",
|
||||
"render.live.worker_stopped": "Reaaliaikaisen renderöinnin kaappausprosessi pysähtyi ennen tuloksen palauttamista",
|
||||
"render.material_cpu_glass_only": "Väliaikaiset kuvatiedostokartat vaativat GPU:n. CPU voi käyttää tallennettuja sisäänrakennettuja pintakarttoja.",
|
||||
"render.material_cpu_optics_limits": "Ota epäsuora valaistus käyttöön tuettuja suljettuja lasimuotoja, varmennettua sisäkkäisyyttä ja pinnanalaista sirontaa varten. Ohuet levyt voivat päästää varjot läpi. Muussa tapauksessa käytä tasomaista levyä tai akselien suuntaista laatikkoa. Toisiaan koskettavia muotoja ja suljetun lasin läpi kulkevia varjoja ei edelleenkään tueta. Arvosi säilytetään.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Tallennettua CPU-optiikkaa ei voi yhdistää vanhanmallisiin väliaikaisiin optisiin sidontoihin. Tallenna tarkistettu optiikka tai poista ristiriitainen väliaikainen sidonta.",
|
||||
"render.material_raytrace_required": "Fysikaalinen lasi vaatii CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
|
||||
"render.material_saved_edit_pending": "Näitä optisia muokkauksia ei ole tallennettu. Tallenna tarkistetut arvot, poista tallennettu määritys tai hylkää muokkaukset. Vanhanmalliset väliaikaiset sidonnat ja tukemattomat tehosteet eivät voi ohittaa tallennettua kohdetta.",
|
||||
"render.output_master_unavailable": "Valitse valmis kuva, jossa on raaka-EXR-data, viedäksesi EXR:n. Ota raaka-EXR käyttöön ennen uusien kuvien renderöintiä.",
|
||||
"render.output_presentation_limits": "CPU:n kuvatehosteet ja EXR-tuloste tukevat kuvia, joiden leveys tai korkeus on enintään 8192 pikseliä ja joissa on yhteensä enintään 16 777 216 pikseliä.",
|
||||
"render.output_raytrace_only": "Kuvatehosteet vaativat CPU-säteenjäljityksen tai GPU-polunjäljityksen.",
|
||||
"render.preparation_cancelled": "Renderöinnin valmistelu peruutettu",
|
||||
"render.preparation_checking": "Tarkistetaan renderöintiä",
|
||||
"render.preparation_retirement_failed": "Renderöinnin valmistelun siivous epäonnistui. Tämä ikkuna ei voi valmistella uutta kuvaa.",
|
||||
"render.preparation_retiring": "Viimeistellään renderöinnin valmistelua",
|
||||
"render.preparation_unavailable": "Renderöinnin valmistelu ei ole käytettävissä",
|
||||
"render.presentation_banner": "Esitystehosteet toimivat CPU- ja GPU-säteenjäljityksen kanssa. Tämän sivun avaaminen ei koskaan muuta menetelmää.",
|
||||
"render.quality_budget_help": "Asettaa koon, näytteet, syvyyden, siemenen ja kohinanpoiston. Muut kuva-asetukset säilyvät.",
|
||||
"render.quality_denoise_off": "Kohinanpoisto: Pois",
|
||||
"render.quality_denoise_on": "Kohinanpoisto: Päällä",
|
||||
"render.quality_summary": "{width} × {height} · {samples} näytettä · syvyys {depth} · siemen {seed} · {denoise}",
|
||||
"render.shot_lens_cpu_progressive_reason": "Ota progressiivinen näytteistys käyttöön Näytteistys-kohdassa ennen CPU:n syväterävyyden käyttöönottoa.",
|
||||
"render.shot_lens_raytrace_reason": "Syväterävyys on käytettävissä CPU-säteenjäljityksessä ja GPU-polunjäljityksessä.",
|
||||
"render.spot_axis": "Säteilysuunta",
|
||||
"render.spot_gpu_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä. Hyvin kapeat himmenemisalueet vaativat CPU-renderöinnin.",
|
||||
"render.spot_help": "Kulmat mitataan säteilysuunnasta. Valo himmenee sisemmän ja ulomman kulman välillä.",
|
||||
"render.spot_inner": "Sisempi puolikulma",
|
||||
"render.spot_outer": "Ulompi puolikulma",
|
||||
"render.stage_panels.grain_angle": "Syykulma",
|
||||
"render.surface_import.author": "Tekijä",
|
||||
"render.surface_import.busy": "Tuodaan kuvaa…",
|
||||
"render.surface_import.complete": "Valitse kuva ja anna sen tekijä, lähde ja lisenssi.",
|
||||
"render.surface_import.embedded": "Tuotu kuva",
|
||||
"render.surface_import.failed": "Kuvan tuonti ei valmistunut.",
|
||||
"render.surface_import.license": "Lisenssi / omistus",
|
||||
"render.surface_import.raytrace_required": "Kuvat vaativat CPU- tai GPU-säteenjäljityksen. Valitse se kohdassa Tuloste.",
|
||||
"render.surface_import.repeat_u": "U-toisto",
|
||||
"render.surface_import.repeat_v": "V-toisto",
|
||||
"render.surface_import.save": "Tallenna kuva kappaleeseen",
|
||||
"render.surface_import.saved_cpu": "Tallenna kartat, jotta voit käyttää niitä CPU-renderöinnissä.",
|
||||
"render.surface_import.source": "Lähde",
|
||||
"render.surface_import.title": "Tuo kuva",
|
||||
"render.volume_denoise_required": "Poista kohinanpoisto käytöstä, jos haluat käyttää ilmakehää.",
|
||||
"render.volume_glass_required": "Ilmakehää ei voi yhdistää lasiin tässä asetelmassa.",
|
||||
"render.volume_lighting_required": "Valitse ympäristö tai nimenomainen valoasetelma, jotta ilmakehä voidaan renderöidä.",
|
||||
"render.volume_single_stratum_required": "Käytä yhtä läpäisynäytettä ilmakehän renderöintiin.",
|
||||
"sketch.divide_hint": "Pisteet jakavat kunkin käyrän yhtä pitkiin osiin ja säilyttävät jaon, kun käyrä muuttuu.",
|
||||
"sketch.error.closed_curve_two_crossings": "suljettu käyrä tarvitsee kaksi leikkauskohtaa katkaisua varten",
|
||||
"sketch.error.nothing_to_break": "tässä ei ole katkaistavaa",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` on siirtymä, ja sen kulmat seuraavat sen lähdekäyriä: pyöristä tai viistä lähdekäyrien kulmat ennen siirtymää tai katkaise ensin siirtymän yhteys",
|
||||
"sketch.error.offset_trim": "`{id}` on siirtymä, ja sen päät seuraavat sen lähdekäyriä: muokkaa sen sijaan lähdekäyriä tai katkaise ensin siirtymän yhteys",
|
||||
"sketch.hint.arc.center_point": "Kaari: napsauta keskipistettä ja alkupistettä, kierrä kumpaan suuntaan tahansa ja napsauta loppupistettä tai kirjoita etumerkillinen kaarikulma",
|
||||
"sketch.hint.arc.three_point": "Kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä",
|
||||
"sketch.hint.arc3d": "3D-kaari: napsauta alkupistettä, loppupistettä ja sitten kaaren pistettä (pisteet voivat olla luonnostason ulkopuolella)",
|
||||
"sketch.hint.break": "Katko: napsauta käyrää kohdasta, josta se jaetaan",
|
||||
"sketch.hint.circle.center": "Ympyrä: napsauta keskipistettä ja sitten ympyrän pistettä",
|
||||
"sketch.hint.constraint.generic": "Rajoite: valitse luonnoksen entiteetit",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Vaakasuora/pystysuora: valitse viiva, kaksi pistettä tai piste ja viiva; valitse viivan keskipiste pitämällä Shift painettuna",
|
||||
"sketch.hint.constraint.midpoint": "Keskipiste: valitse piste ja viiva tai kaksi viivaa, jolloin jälkimmäinen keskitetään ensimmäiseen",
|
||||
"sketch.hint.dimension": "Mitta: valitse yksi tai kaksi entiteettiä ja kirjoita arvo (tai sijoita mitta ensin napsauttamalla); muokkaa mittaa napsauttamalla sitä; Enter käyttää",
|
||||
"sketch.hint.extend": "Jatka: napsauta käyrää jatkettavan pään läheltä (Shift: napsauta kahta käyrää jatkaaksesi molempia niiden kohtaamispisteeseen)",
|
||||
"sketch.hint.fallback": "Napsauta luonnoksessa",
|
||||
"sketch.hint.line": "Viiva: napsauta pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
|
||||
"sketch.hint.line3d": "3D-viiva: napsauta maailman pisteitä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä (pisteet voivat olla luonnostason ulkopuolella)",
|
||||
"sketch.hint.open_done": "Avoin valmis",
|
||||
"sketch.hint.point3d": "3D-piste: napsauta 3D-pisteen paikkaa",
|
||||
"sketch.hint.rectangle.center": "Keskipistesuorakulmio: napsauta keskipistettä ja sitten kulmaa",
|
||||
"sketch.hint.rectangle.rounded": "Pyöristetty suorakulmio: napsauta kahta vastakkaista kulmaa (siirry Tab-näppäimellä säderuutuun kirjoittaaksesi kulmasäteen)",
|
||||
"sketch.hint.rectangle.three_point": "Kolmen pisteen suorakulmio: napsauta yhden sivun kahta pistettä ja sitten leveyttä",
|
||||
"sketch.hint.rectangle.two_point": "Suorakulmio: napsauta kahta vastakkaista kulmaa",
|
||||
"sketch.hint.slot": "Ura: napsauta molempia päitä ja sitten leveyttä",
|
||||
"sketch.hint.spline3d": "3D-splini: napsauta sovituspisteitä 3D:ssä; lopeta hiiren kakkospainikkeella tai Esc-näppäimellä",
|
||||
"sketch.hint.trim": "Katkaise: napsauta poistettavaa käyrän osaa tai vedä poistettavien osien yli",
|
||||
"ui.addToQuickAccess": "Lisää pikakäyttöön",
|
||||
"ui.close_tab": "Sulje välilehti",
|
||||
"ui.fair_iterations": "Iteraatiot",
|
||||
"ui.fair_keep_edges": "Säilytä särmät",
|
||||
"ui.fair_strength": "Voimakkuus",
|
||||
"ui.fair_surface_title": "SILOTA PINTA",
|
||||
"ui.fair_tolerance_default": "0.001 kappaleen koosta",
|
||||
"ui.first_dimension_scales": "Ensimmäinen mitta skaalaa piirustuksen",
|
||||
"ui.first_dimension_scales_tip": "Päällä: luonnoksen ensimmäinen pituusmitta skaalaa mittaamaansa piirustusta, joten sen muoto pysyy samana. Pois: mitta muuttaa vain mittaamansa kohteen kokoa.",
|
||||
"ui.heading_custom_view_navigator": "MUKAUTETTU NÄKYMÄNAVIGAATTORI",
|
||||
"ui.heading_divide_curve": "JAA KÄYRÄ",
|
||||
"ui.heading_press_pull": "TYÖNNÄ/VEDÄ",
|
||||
"ui.history_scope_all": "Näytä koko mallin piirteet",
|
||||
"ui.history_scope_all_shown": "Koko mallin piirteet",
|
||||
"ui.history_scope_pick": "Muokkaa komponenttia tai valitse komponentti selaimessa, niin näet vain sen piirteet",
|
||||
"ui.history_scope_show": "Näytä vain komponentin {name} ja sen sisältämien komponenttien piirteet",
|
||||
"ui.history_scope_shown": "Komponentin {name} ja sen sisältämien komponenttien piirteet",
|
||||
"ui.intersection_curve_second_pick": "Leikkauskäyrä: napsauta toista pintaa tai käyrää (aloita alusta hiiren kakkospainikkeella)",
|
||||
"ui.match_surface_title": "SOVITA PINTA",
|
||||
"ui.mcp_agents": "Agentit MCP:n kautta",
|
||||
"ui.mcp_compact": "Tiivis tuloste",
|
||||
"ui.mcp_compact_note": "MCP-palvelimen (solidcraft-cli mcp) kautta yhdistetyille agenteille: inspect_design tiivistää luonnokset, eikä työkalujen tuloksia muotoilla luettaviksi, joten ne käyttävät vähemmän tokeneita. Koskee muutoksen jälkeen aloitettuja agenttiistuntoja; myös --compact tai SOLIDCRAFT_MCP_COMPACT=1 ottaa sen käyttöön.",
|
||||
"ui.mesh_import_origin": "Siirrä origoon",
|
||||
"ui.mesh_import_origin_tip": "Keskitä verkko X- ja Y-suunnassa niin, että sen pohja on tasolla Z = 0. Kun asetus on pois päältä, verkko säilyttää tiedostoon tallennetut koordinaatit.",
|
||||
"ui.organize": "Järjestä",
|
||||
"ui.panel_fixture.apply": "Käytä",
|
||||
"ui.panel_fixture.cancel": "Peruuta",
|
||||
"ui.panel_fixture.caption": "Synteettinen paneelinäyte",
|
||||
"ui.panel_fixture.draft": "Säilytetty luonnos",
|
||||
"ui.panel_fixture.editor": "Näyte-editori",
|
||||
"ui.panel_fixture.explanation": "Tämä synteettinen sisältö säilyttää luonnoksensa, kun se on piilotettu tai siirretty.",
|
||||
"ui.panel_fixture.fill": "Täytä näyte",
|
||||
"ui.panel_fixture.finish": "Valmis",
|
||||
"ui.panel_fixture.finished": "Synteettinen tapahtuma päättyi",
|
||||
"ui.panel_fixture.footer": "Synteettiset toiminnot eivät muuta mallia.",
|
||||
"ui.panel_fixture.guard": "Estä peruutus, kun tapahtuma on käynnissä",
|
||||
"ui.panel_fixture.inline": "Upotettu näyte",
|
||||
"ui.panel_fixture.live": "Synteettinen tapahtuma käynnissä",
|
||||
"ui.panel_fixture.log": "Näyteloki",
|
||||
"ui.panel_fixture.pin": "Kiinnitetty näyte",
|
||||
"ui.panel_fixture.title": "{title} · {id}",
|
||||
"ui.panel_fixture.updates": "Palvelupäivitykset: {count}",
|
||||
"ui.panel_host.attention": "Vaatii huomiota",
|
||||
"ui.panel_host.bottom": "Alas",
|
||||
"ui.panel_host.close": "Sulje paneeli",
|
||||
"ui.panel_host.collapse": "Kutista paneeli",
|
||||
"ui.panel_host.expand": "Laajenna paneeli",
|
||||
"ui.panel_host.floating": "Kelluva",
|
||||
"ui.panel_host.focus": "Kohdista paneeliin",
|
||||
"ui.panel_host.focus_target": "Kohdista: {title}",
|
||||
"ui.panel_host.header": "Paneelin otsikko",
|
||||
"ui.panel_host.join": "Yhdistä välilehdet",
|
||||
"ui.panel_host.join_target": "Yhdistä välilehdet: {title}",
|
||||
"ui.panel_host.left": "Vasemmalle",
|
||||
"ui.panel_host.menu": "Paneelin toiminnot",
|
||||
"ui.panel_host.move": "Siirrä",
|
||||
"ui.panel_host.overflow": "Lisää paneeleja",
|
||||
"ui.panel_host.popout": "Avaa ikkunassa",
|
||||
"ui.panel_host.resize": "Muuta paneelin kokoa",
|
||||
"ui.panel_host.right": "Oikealle",
|
||||
"ui.panel_host.rule.availability": "Tämä paneeli ei ole käytettävissä.",
|
||||
"ui.panel_host.rule.bounds": "Paneelit eivät mahdu kokorajoihinsa.",
|
||||
"ui.panel_host.rule.capability": "Tämä isäntä ei tue kyseistä toimintoa.",
|
||||
"ui.panel_host.rule.collection_limit": "Paneeliasettelu on saavuttanut rajansa.",
|
||||
"ui.panel_host.rule.duplicate_id": "Tämä paneelin tunniste on jo käytössä.",
|
||||
"ui.panel_host.rule.exactly_once": "Jokaisen paneelin on oltava mukana kerran.",
|
||||
"ui.panel_host.rule.feature_disabled": "Tämä toiminto on poistettu käytöstä tässä paneelissa.",
|
||||
"ui.panel_host.rule.id": "Paneelin tunniste on virheellinen.",
|
||||
"ui.panel_host.rule.incompatible_tabs": "Nämä paneelit eivät voi jakaa välilehtiryhmää.",
|
||||
"ui.panel_host.rule.lifecycle": "Paneeli ei voinut suorittaa tätä toimintoa loppuun.",
|
||||
"ui.panel_host.rule.missing_target": "Tämä paneelin kohde ei ole enää käytettävissä.",
|
||||
"ui.panel_host.rule.no_space": "Tälle paneeliasettelulle ei ole tilaa.",
|
||||
"ui.panel_host.rule.pin_conflict": "Toinen paneeli on jo kiinnitetty tähän reunaan.",
|
||||
"ui.panel_host.rule.pin_order": "Kiinnitetyn paneelin on pysyttävä reunansa ensimmäisenä.",
|
||||
"ui.panel_host.rule.placement": "Tätä paneelia ei voi siirtää kyseiseen paikkaan.",
|
||||
"ui.panel_host.rule.revision": "Asettelu muuttui. Yritä toimintoa uudelleen.",
|
||||
"ui.panel_host.rule.schema": "Paneelipyyntö on virheellinen.",
|
||||
"ui.panel_host.rule.version": "Tätä paneeliasettelun versiota ei tueta.",
|
||||
"ui.panel_host.sash": "Muuta viereisten paneelien kokoa",
|
||||
"ui.panel_host.scroll_horizontal": "Vieritä paneelisarakkeita",
|
||||
"ui.panel_host.scroll_vertical": "Vieritä paneelipinoja",
|
||||
"ui.panel_host.surface": "Paneeli",
|
||||
"ui.panel_host.tab": "Paneelin välilehti",
|
||||
"ui.panel_host.tabs": "Kaikki välilehdet",
|
||||
"ui.panel_layout.applied": "Asettelu {name} otettu käyttöön työtilassa {workspace}",
|
||||
"ui.panel_layout.cancel": "Peruuta",
|
||||
"ui.panel_layout.confirm_replace": "Korvataanko ”{name}”?",
|
||||
"ui.panel_layout.copy": "Kopioi",
|
||||
"ui.panel_layout.current": "Nykyinen asettelu",
|
||||
"ui.panel_layout.custom": "Mukautettu",
|
||||
"ui.panel_layout.custom_hint": "Paneeleja on siirretty asettelun {name} jälkeen.",
|
||||
"ui.panel_layout.details": "Tiedot",
|
||||
"ui.panel_layout.layout_workspace": "Asettelun työtila",
|
||||
"ui.panel_layout.page_defaults_hint": "Palauttaa tämän sivun asetukset. Paneelien järjestys ja tallennetut asettelut säilyvät.",
|
||||
"ui.panel_layout.panel_layout": "Paneeliasettelu",
|
||||
"ui.panel_layout.panel_style": "Paneelityyli",
|
||||
"ui.panel_layout.replace": "Korvaa asettelu",
|
||||
"ui.panel_layout.replace_current": "Korvaa nykyisellä",
|
||||
"ui.panel_layout.replace_short": "Korvaa",
|
||||
"ui.panel_layout.reset": "Palauta työtila",
|
||||
"ui.panel_layout.reset_hint": "Palauta työtilan {workspace} oletusjärjestys. Tallennetut asettelut säilyvät.",
|
||||
"ui.panel_layout.reset_short": "Palauta",
|
||||
"ui.panel_layout.restore_failed": "Asettelua ei voitu palauttaa",
|
||||
"ui.panel_layout.save": "Tallenna asettelu",
|
||||
"ui.panel_layout.save_as": "Tallenna nimellä",
|
||||
"ui.panel_layout.save_as_tile": "Tallenna nimellä…",
|
||||
"ui.panel_layout.selected": "valittu",
|
||||
"ui.panel_layout.suggested_name": "Asettelu {n}",
|
||||
"ui.panel_layout.try_again": "Yritä uudelleen",
|
||||
"ui.panel_layout.validation.builtin": "Sisäänrakennettuja asetteluja ei voi muuttaa",
|
||||
"ui.panel_layout.validation.duplicate": "Tämä nimi on jo käytössä",
|
||||
"ui.panel_layout.validation.empty": "Anna nimi",
|
||||
"ui.panel_layout.validation.full": "64/64 tallennettu. Valitse korvattava asettelu.",
|
||||
"ui.panel_layout.validation.invalid": "Käytä 1–64 merkkiä, ei ohjausmerkkejä",
|
||||
"ui.panel_layout.workspace.classic": "Klassinen",
|
||||
"ui.panel_layout.workspace.modelling": "Mallinnus",
|
||||
"ui.panel_layout.workspace.render": "Renderöinti",
|
||||
"ui.panel_layout.workspace.review": "Tarkastelu",
|
||||
"ui.panel_layout.workspace.sketch": "Luonnos",
|
||||
"ui.panel_layout.your_layout": "oma asettelusi",
|
||||
"ui.point_cloud_dots": "Pisteet",
|
||||
"ui.point_cloud_reload": "Lataa uudelleen tiedostosta",
|
||||
"ui.project_references_construction": "Projisoi viitteet apugeometriaksi",
|
||||
"ui.project_references_construction_note": "Projisoi, Leikkaa, Sisällytä 3D-geometria ja pinnalle luodun uuden luonnoksen pinnan särmät tuottavat apugeometriaa: viitteitä, joita vasten piirretään ja jotka jätetään profiilien ulkopuolelle. Ne pysyvät linkitettyinä malliin.",
|
||||
"ui.quick_access_full": "Pikakäyttöpalkkiin mahtuu {count} komentoa; poista yksi lisätäksesi toisen",
|
||||
"ui.removeFromQuickAccess": "Poista pikakäytöstä",
|
||||
"ui.replace_sketch.from_file": "Mallitiedostosta…",
|
||||
"ui.replace_sketch.no_sketches": "Mallissa ei ole luonnosta, jonka voisi ottaa",
|
||||
"ui.report_characters_left_one": "{count} merkki jäljellä",
|
||||
"ui.report_characters_left_other": "{count} merkkiä jäljellä",
|
||||
"ui.segments": "Segmentit",
|
||||
"view_gizmo.arrows": "Kiertonuolet",
|
||||
"view_gizmo.home": "Aloitusnäkymäpainike",
|
||||
"view_gizmo.no_such_body": "Tässä mallissa ei ole sen nimistä kappaletta.",
|
||||
"view_gizmo.note": "Valitse kappale näkymästä. Se tallennetaan asetuksiisi ja asetusprofiileihisi, ja se näytetään jokaisessa mallissa. Napsauta jotakin sen pinnoista katsoaksesi sitä suoraan.",
|
||||
"view_gizmo.texture": "Tekstuuri",
|
||||
"view_gizmo.themed": "Teeman mukainen",
|
||||
"view_gizmo.too_detailed": "Liian yksityiskohtainen näkymänavigaattoriin (yli 10 000 kolmiota)",
|
||||
"view_gizmo.too_thin": "Liian pitkä ja ohut näkymänavigaattoriin: sen ohuimman sivun on oltava vähintään viidesosa pisimmästä"
|
||||
}
|
||||
}
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Zachowany wygląd",
|
||||
"material.editor.public_reference": "Publiczne źródło",
|
||||
"material.editor.reference": "Źródło",
|
||||
"material.editor.remove": "Usuń",
|
||||
"material.editor.save_or_discard": "Zapisz lub odrzuć szkic materiału przed przypisaniem materiału.",
|
||||
"material.editor.saved": "Zapisano materiał. Przypisania do brył pozostają bez zmian.",
|
||||
"material.editor.scope": "Zapisz przechowuje ten materiał bez przypisywania go do brył. Puste właściwości i warunki pozostają nieznane. Gęstość z karty nie zmienia przypisanej gęstości ani masy.",
|
||||
|
||||
@@ -2204,6 +2204,7 @@
|
||||
"material.editor.preserved_appearance": "Aparência preservada",
|
||||
"material.editor.public_reference": "Referência pública",
|
||||
"material.editor.reference": "Referência",
|
||||
"material.editor.remove": "Remover",
|
||||
"material.editor.save_or_discard": "Salve ou descarte o rascunho do material antes de atribuir um material.",
|
||||
"material.editor.saved": "Material salvo. As atribuições dos corpos não mudam.",
|
||||
"material.editor.scope": "Salvar armazena este material sem atribuí-lo a corpos. Propriedades e condições em branco permanecem desconhecidas. A densidade da ficha não altera a densidade atribuída nem a massa.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Aspect păstrat",
|
||||
"material.editor.public_reference": "Referință publică",
|
||||
"material.editor.reference": "Referință",
|
||||
"material.editor.remove": "Elimină",
|
||||
"material.editor.save_or_discard": "Salvați sau renunțați la ciorna materialului înainte de a atribui un material.",
|
||||
"material.editor.saved": "Material salvat. Atribuirile pe corpuri rămân neschimbate.",
|
||||
"material.editor.scope": "Salvarea stochează acest material fără a-l atribui corpurilor. Proprietățile și condițiile lăsate necompletate rămân necunoscute. Densitatea din fișă nu modifică densitatea atribuită și nici masa.",
|
||||
|
||||
@@ -1882,6 +1882,7 @@
|
||||
"material.editor.preserved_appearance": "Сохранённый внешний вид",
|
||||
"material.editor.public_reference": "Общедоступный справочник",
|
||||
"material.editor.reference": "Ссылка",
|
||||
"material.editor.remove": "Убрать",
|
||||
"material.editor.save_or_discard": "Сохраните или отмените черновик материала, прежде чем назначать материал.",
|
||||
"material.editor.saved": "Материал сохранён. Назначения телам не изменились.",
|
||||
"material.editor.scope": "Сохранение записывает этот материал, не назначая его телам. Пустые свойства и условия остаются неизвестными. Плотность в карточке не меняет назначенную плотность и массу.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Сачувани изглед",
|
||||
"material.editor.public_reference": "Јавна референца",
|
||||
"material.editor.reference": "Референца",
|
||||
"material.editor.remove": "Уклони",
|
||||
"material.editor.save_or_discard": "Сачувајте или одбаците нацрт материјала пре доделе материјала.",
|
||||
"material.editor.saved": "Материјал је сачуван. Доделе телима остају непромењене.",
|
||||
"material.editor.scope": "Сачувај чува овај материјал без доделе телима. Празна својства и услови остају непознати. Густина са картице не мења додељену густину ни масу.",
|
||||
|
||||
@@ -1890,6 +1890,7 @@
|
||||
"material.editor.preserved_appearance": "Bevarat utseende",
|
||||
"material.editor.public_reference": "Offentlig referens",
|
||||
"material.editor.reference": "Referens",
|
||||
"material.editor.remove": "Ta bort",
|
||||
"material.editor.save_or_discard": "Spara eller förkasta materialutkastet innan du tilldelar ett material.",
|
||||
"material.editor.saved": "Materialet sparades. Kropparnas tilldelningar är oförändrade.",
|
||||
"material.editor.scope": "Spara lagrar materialet utan att tilldela det till kroppar. Tomma egenskaper och förhållanden förblir okända. Kortets densitet ändrar inte tilldelad densitet eller massa.",
|
||||
|
||||
@@ -1889,6 +1889,7 @@
|
||||
"material.editor.preserved_appearance": "Korunan görünüm",
|
||||
"material.editor.public_reference": "Herkese açık referans",
|
||||
"material.editor.reference": "Referans",
|
||||
"material.editor.remove": "Kaldır",
|
||||
"material.editor.save_or_discard": "Malzeme atamadan önce malzeme taslağını kaydedin veya atın.",
|
||||
"material.editor.saved": "Malzeme kaydedildi. Gövde atamaları değişmedi.",
|
||||
"material.editor.scope": "Kaydetme, bu malzemeyi gövdelere atamadan saklar. Boş özellikler ve koşullar bilinmez kalır. Kart yoğunluğu, atanan yoğunluğu veya kütleyi değiştirmez.",
|
||||
|
||||
@@ -2199,6 +2199,7 @@
|
||||
"material.editor.preserved_appearance": "Збережений вигляд",
|
||||
"material.editor.public_reference": "Публічний довідник",
|
||||
"material.editor.reference": "Посилання",
|
||||
"material.editor.remove": "Вилучити",
|
||||
"material.editor.save_or_discard": "Збережіть або відхиліть чернетку матеріалу перед призначенням матеріалу.",
|
||||
"material.editor.saved": "Матеріал збережено. Призначення тілам не змінилися.",
|
||||
"material.editor.scope": "Збереження записує цей матеріал, не призначаючи його тілам. Порожні властивості й умови лишаються невідомими. Густина в картці не змінює призначену густину чи масу.",
|
||||
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
|
||||
"cmd.panel.float": "Thả nổi bảng",
|
||||
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
|
||||
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
|
||||
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
|
||||
"cmd.panel.list": "Liệt kê các bảng",
|
||||
"cmd.panel.move": "Di chuyển bảng",
|
||||
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
|
||||
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
|
||||
"cmd.panel.open": "Mở bảng",
|
||||
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
|
||||
"cmd.panel.resize": "Đổi kích thước vùng bảng",
|
||||
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
|
||||
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
|
||||
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
|
||||
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
|
||||
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
|
||||
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
|
||||
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
|
||||
"cmd.parameters_add": "Thêm tham số người dùng",
|
||||
"cmd.parameters_change": "Thay đổi tham số",
|
||||
"cmd.parameters_comment": "Thêm chú thích tham số",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "Bề mặt hiển thị giữ lại",
|
||||
"material.editor.public_reference": "Tài liệu tham khảo công khai",
|
||||
"material.editor.reference": "Tham khảo",
|
||||
"material.editor.remove": "Xóa",
|
||||
"material.editor.save_or_discard": "Lưu hoặc hủy bản nháp vật liệu trước khi gán vật liệu.",
|
||||
"material.editor.saved": "Đã lưu vật liệu. Gán vật liệu cho khối không đổi.",
|
||||
"material.editor.scope": "Lưu sẽ lưu trữ vật liệu này mà không gán cho khối. Các thuộc tính và điều kiện để trống vẫn chưa xác định. Khối lượng riêng trên thẻ không làm thay đổi khối lượng riêng hay khối lượng đã gán.",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "Độ",
|
||||
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
|
||||
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
|
||||
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
|
||||
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
|
||||
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"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ỏ.",
|
||||
"prefs.selecting": "Đang chọn",
|
||||
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
|
||||
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
|
||||
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "CHI TIẾT NHỰA",
|
||||
"ui.heading_position": "VỊ TRÍ",
|
||||
"ui.heading_prepare": "CHUẨN BỊ",
|
||||
"ui.heading_press_pull": "KÉO/ĐẨY",
|
||||
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
|
||||
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
|
||||
"ui.heading_right": "HÌNH CHIẾU CẠNH",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "Các biên dạng",
|
||||
"ui.project": "Chiếu hình học",
|
||||
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
|
||||
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
|
||||
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
|
||||
"ui.properties": "Thuộc tính cơ lý",
|
||||
"ui.properties.copy_matrix": "Sao chép ma trận",
|
||||
"ui.properties.first_moments": "Mômen bậc nhất",
|
||||
|
||||
@@ -1896,6 +1896,7 @@
|
||||
"material.editor.preserved_appearance": "保留的外觀",
|
||||
"material.editor.public_reference": "公開參考資料",
|
||||
"material.editor.reference": "參考",
|
||||
"material.editor.remove": "刪除",
|
||||
"material.editor.save_or_discard": "指定材料前,請儲存或放棄材料草稿。",
|
||||
"material.editor.saved": "材料已儲存。實體的材料指定未變。",
|
||||
"material.editor.scope": "儲存材料不會將其指定給實體。空白屬性和條件仍為未知。屬性卡密度不會改變指定密度或質量。",
|
||||
|
||||
@@ -1236,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "关闭面板临时预览",
|
||||
"cmd.panel.float": "浮动面板",
|
||||
"cmd.panel.focus": "聚焦面板",
|
||||
"cmd.panel.focus.next": "聚焦下一个面板",
|
||||
"cmd.panel.focus.previous": "聚焦上一个面板",
|
||||
"cmd.panel.list": "列出面板",
|
||||
"cmd.panel.move": "移动面板",
|
||||
"cmd.panel.move.next_zone": "将面板移至下一个区域",
|
||||
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
|
||||
"cmd.panel.open": "打开面板",
|
||||
"cmd.panel.peek": "临时预览折叠面板",
|
||||
@@ -1245,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "重置面板分隔条",
|
||||
"cmd.panel.resize": "调整面板分区大小",
|
||||
"cmd.panel.resize_pair": "移动面板分隔条",
|
||||
"cmd.panel.sash.down": "向下移动面板分隔条",
|
||||
"cmd.panel.sash.left": "向左移动面板分隔条",
|
||||
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
|
||||
"cmd.panel.sash.right": "向右移动面板分隔条",
|
||||
"cmd.panel.sash.up": "向上移动面板分隔条",
|
||||
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
|
||||
"cmd.parameters_add": "添加用户参数",
|
||||
"cmd.parameters_change": "更改参数",
|
||||
"cmd.parameters_comment": "注释参数",
|
||||
@@ -2209,6 +2218,7 @@
|
||||
"material.editor.preserved_appearance": "保留的外观",
|
||||
"material.editor.public_reference": "公开参考资料",
|
||||
"material.editor.reference": "参考",
|
||||
"material.editor.remove": "删除",
|
||||
"material.editor.save_or_discard": "指定材料前,请保存或放弃材料草稿。",
|
||||
"material.editor.saved": "材料已保存。实体的材料指定未变。",
|
||||
"material.editor.scope": "保存材料不会将其指定给实体。空白属性和条件仍为未知。属性卡密度不会改变指定密度或质量。",
|
||||
@@ -2561,6 +2571,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
@@ -2840,6 +2852,8 @@
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
"prefs.selecting": "选择",
|
||||
"prefs.shared_panel_layout": "使用共享面板布局",
|
||||
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
@@ -5530,6 +5544,7 @@
|
||||
"ui.heading_plastic": "塑料",
|
||||
"ui.heading_position": "位置",
|
||||
"ui.heading_prepare": "准备",
|
||||
"ui.heading_press_pull": "按压拖动",
|
||||
"ui.heading_rectangular_pattern": "矩形阵列",
|
||||
"ui.heading_revolve": "旋转",
|
||||
"ui.heading_right": "右视",
|
||||
@@ -5873,6 +5888,8 @@
|
||||
"ui.profiles": "轮廓",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "将面的边投影到新草图",
|
||||
"ui.project_references_construction": "将参考投影为构造几何",
|
||||
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
|
||||
"ui.properties": "属性",
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
|
||||
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
if u.product_lights_meta.x>0.0 {
|
||||
var radiance=vec3<f32>(0.0);
|
||||
let albedo=srgb_decode(base.rgb);
|
||||
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
let sample=product_light_sample(k,i.wp);
|
||||
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
}
|
||||
let mapped=radiance/(vec3<f32>(1.0)+radiance);
|
||||
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
|
||||
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
|
||||
if (u.back.w > 0.5) {
|
||||
return vec4<f32>(mapped,base.a);
|
||||
}
|
||||
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
|
||||
return out_color(vec4<f32>(encoded,base.a));
|
||||
return vec4<f32>(encoded,base.a);
|
||||
}
|
||||
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
|
||||
let diff = max(dot(n, key), 0.0);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "solidcraft-color"
|
||||
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,20 @@
|
||||
# solidcraft-color
|
||||
|
||||
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
|
||||
so the renderer, the document, the importers and the UI theme all use the same rule and the same
|
||||
colours instead of keeping copies.
|
||||
|
||||
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
|
||||
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
|
||||
copies.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it is |
|
||||
|---|---|
|
||||
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
|
||||
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
|
||||
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
|
||||
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
|
||||
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
|
||||
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The colours a surface analysis is shaded in.
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
|
||||
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
|
||||
/// reads with hue lost too (#975).
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
|
||||
impl AnalysisColors {
|
||||
/// The light theme's colours (no colour vision mode): what headless pictures use.
|
||||
pub const DEFAULT: AnalysisColors = AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
|
||||
hatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
impl Default for AnalysisColors {
|
||||
fn default() -> Self {
|
||||
AnalysisColors::DEFAULT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
|
||||
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
|
||||
//! rule and the same colours.
|
||||
|
||||
mod analysis;
|
||||
pub mod srgb;
|
||||
|
||||
pub use analysis::AnalysisColors;
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "solidcraft-control-egui"
|
||||
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-control = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
|
||||
egui = { workspace = true }
|
||||
# Screenshots are saved in the format their path names.
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,14 @@
|
||||
# solidcraft-control-egui
|
||||
|
||||
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
|
||||
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
|
||||
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
|
||||
control channel without compiling egui. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
|
||||
| `screenshot::encode` | A frame in the format its path names |
|
||||
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
|
||||
@@ -0,0 +1,8 @@
|
||||
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
|
||||
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
|
||||
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod screenshot;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,288 @@
|
||||
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
|
||||
//! window asks egui for a frame a few frames after the request (a view that just moved settles
|
||||
//! first), saves it where the request says and answers; a hidden window, a request whose agent
|
||||
//! chat turn ended, or no frame within 8 s is answered at once with why.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use egui::ColorImage;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
|
||||
|
||||
use crate::window::hidden;
|
||||
|
||||
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
|
||||
/// since it was asked for.
|
||||
pub type Queued = (u64, f64, u32);
|
||||
|
||||
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
|
||||
/// (ms) after which no frame is coming.
|
||||
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
|
||||
|
||||
/// A frame egui handed back, by its token.
|
||||
pub type Shot = (u64, Arc<ColorImage>);
|
||||
|
||||
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
|
||||
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
|
||||
pub struct Shots {
|
||||
/// The error a request gets when the window presents no frame: the app's words, which may
|
||||
/// name its other ways to a picture.
|
||||
no_frame: String,
|
||||
/// The last token given out.
|
||||
pub token: u64,
|
||||
/// Frames asked for and not yet requested from egui.
|
||||
pub queued: Vec<Queued>,
|
||||
/// Control requests waiting for their frame.
|
||||
pub pending: Vec<Pending>,
|
||||
/// The agent chat turn of each pending request that belongs to one.
|
||||
turns: HashMap<u64, Arc<AtomicBool>>,
|
||||
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
|
||||
tx: Sender<Shot>,
|
||||
rx: Receiver<Shot>,
|
||||
}
|
||||
|
||||
impl Shots {
|
||||
/// No screenshots in flight; a request the window presents no frame for is answered with the
|
||||
/// error `no_frame`.
|
||||
pub fn new(no_frame: impl Into<String>) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
|
||||
}
|
||||
|
||||
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
|
||||
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
|
||||
/// (another macOS Space) presents no frame: that is said at once rather than after the
|
||||
/// deadline (#1677).
|
||||
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
|
||||
if hidden(ctx) {
|
||||
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
|
||||
return;
|
||||
}
|
||||
self.token += 1;
|
||||
let token = self.token;
|
||||
if let Some(turn) = turn {
|
||||
self.turns.insert(token, turn);
|
||||
}
|
||||
self.queued.push((token, now + 150.0, 0));
|
||||
self.pending.push((token, path, reply, now + 8000.0));
|
||||
}
|
||||
|
||||
/// Each frame: ask egui for the frames that are due, three frames after their request at the
|
||||
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
|
||||
/// as it is half a second later (#7165).
|
||||
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
|
||||
let shot_tx = &self.tx;
|
||||
self.queued.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
|
||||
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
|
||||
ctx.request_screenshot(move |image| {
|
||||
let _ = tx.send((token, image));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued.is_empty() || !self.pending.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
|
||||
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
|
||||
/// then every frame that came this frame is handed back, for the app to take its own from.
|
||||
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
|
||||
// A screenshot requested in one tab must never return or save a frame of another.
|
||||
let turns = &mut self.turns;
|
||||
let queued = &mut self.queued;
|
||||
self.pending.retain(|(token, _, reply, _)| {
|
||||
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
|
||||
turns.remove(token);
|
||||
queued.retain(|(waiting, ..)| waiting != token);
|
||||
let _ = reply.send(cancelled_chat_response());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Drained every frame: a shot that comes after its request was cancelled or timed out
|
||||
// is dropped here instead of waiting in the channel.
|
||||
let shots: Vec<Shot> = self.rx.try_iter().collect();
|
||||
if self.pending.is_empty() && !others {
|
||||
return Vec::new();
|
||||
}
|
||||
for (token, image) in &shots {
|
||||
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
|
||||
let (_, path, reply, _) = self.pending.remove(i);
|
||||
self.turns.remove(token);
|
||||
let _ = reply.send(save(image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
// Hidden while waiting: no frame is coming.
|
||||
let hidden = hidden(ctx);
|
||||
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
|
||||
self.pending.retain(|(token, _, reply, deadline)| {
|
||||
if now < *deadline && !hidden {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": no_frame}));
|
||||
turns.remove(token);
|
||||
false
|
||||
});
|
||||
shots
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` in the format `path`'s extension names (a PNG for `.png`).
|
||||
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
|
||||
let [w, h] = image.size;
|
||||
let mut buf = Vec::with_capacity(w * h * 4);
|
||||
for c in &image.pixels {
|
||||
buf.extend_from_slice(&c.to_array());
|
||||
}
|
||||
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
|
||||
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
|
||||
Ok(bytes.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The app's reply for no frame.
|
||||
const NO_FRAME: &str = "no frame here";
|
||||
|
||||
fn pixel_image() -> Arc<ColorImage> {
|
||||
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
|
||||
}
|
||||
|
||||
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
|
||||
/// a frame of the app's own is handed back to it; a request whose frame never comes is
|
||||
/// answered after its deadline.
|
||||
#[test]
|
||||
fn a_request_is_answered_with_its_frame_or_its_deadline() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
|
||||
// The frame egui hands back, and one the app asked for itself.
|
||||
shots.queued.clear();
|
||||
let _ = shots.tx.send((1, pixel_image()));
|
||||
let _ = shots.tx.send((2, pixel_image()));
|
||||
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
|
||||
let back = shots.collect(&ctx, 1.0, true, save);
|
||||
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
|
||||
assert!(shots.pending.is_empty());
|
||||
|
||||
let (reply, late) = channel();
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
|
||||
assert!(late.try_recv().is_err(), "still waiting");
|
||||
shots.collect(&ctx, 8000.0, false, save);
|
||||
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
}
|
||||
|
||||
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
|
||||
#[test]
|
||||
fn a_request_whose_turn_ended_is_refused() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
let turn = Arc::new(AtomicBool::new(true));
|
||||
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
|
||||
turn.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
|
||||
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
|
||||
assert!(shots.queued.is_empty() && shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
|
||||
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
|
||||
let mut raw = egui::RawInput::default();
|
||||
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
|
||||
raw.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
// No painter takes the frame's textures here.
|
||||
ctx.run_pass(raw, f).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
|
||||
/// a view that keeps moving is taken as it is 500 ms after that.
|
||||
#[test]
|
||||
fn a_frame_is_taken_once_the_view_settles() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, _answer) = channel();
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert_eq!(shots.queued.len(), 1, "two frames drawn");
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert!(shots.queued.is_empty(), "the third frame is taken");
|
||||
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, true, 149.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "too soon");
|
||||
shots.issue(&ctx, true, 150.0);
|
||||
assert!(shots.queued.is_empty());
|
||||
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, false, 649.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "still moving");
|
||||
shots.issue(&ctx, false, 650.0);
|
||||
assert!(shots.queued.is_empty(), "taken as it is");
|
||||
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
|
||||
}
|
||||
|
||||
/// A hidden window refuses a request at once, and answers one that waits when it hides.
|
||||
#[test]
|
||||
fn a_hidden_window_answers_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
|
||||
|
||||
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
frame(&ctx, false, |ctx| {
|
||||
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert!(answer.try_recv().is_err(), "shown: still waiting");
|
||||
frame(&ctx, true, |ctx| {
|
||||
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
assert!(shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
|
||||
#[test]
|
||||
fn frames_nobody_waits_for_are_dropped() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let _ = shots.tx.send((5, pixel_image()));
|
||||
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
|
||||
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
|
||||
}
|
||||
|
||||
/// The saved bytes are the format the path names.
|
||||
#[test]
|
||||
fn a_png_path_gets_a_png() {
|
||||
let bytes = encode(&pixel_image(), "shot.png").unwrap();
|
||||
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
|
||||
assert!(encode(&pixel_image(), "shot.nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The window's own control requests over `egui::Context`, which any app answers the same way.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
|
||||
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
|
||||
/// never comes.
|
||||
pub fn hidden(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().visible()) == Some(false)
|
||||
}
|
||||
|
||||
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
|
||||
/// 8192×8192.
|
||||
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
|
||||
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "solidcraft-control"
|
||||
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# The loopback server is for desktop apps; the web app is driven through the page instead.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
|
||||
getrandom = "0.3"
|
||||
@@ -0,0 +1,23 @@
|
||||
# solidcraft-control
|
||||
|
||||
The control channel any app may share (docs/control-protocol.md). An app that listens for
|
||||
control makes a random token per launch, writes it to a file only the user can read and refuses
|
||||
every connection whose first request doesn't carry it; a client finds the token the same way.
|
||||
Which app the files belong to, its environment, its name in messages and its default port come
|
||||
from an `App` value the app defines. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
|
||||
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
|
||||
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
|
||||
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
|
||||
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
|
||||
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
|
||||
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
|
||||
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
|
||||
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
|
||||
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
|
||||
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
|
||||
@@ -0,0 +1,665 @@
|
||||
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
|
||||
//! control makes a random token, writes it to a file only the user can read
|
||||
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
|
||||
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
|
||||
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
|
||||
//! names the process that wrote it, so a client refused with a leftover token can say the file is
|
||||
//! stale.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Error, ErrorKind, Read, Result, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
|
||||
pub const MIN_TOKEN_LEN: usize = 32;
|
||||
|
||||
/// Ports tried from the app's default port upward when it is taken (a second window, another
|
||||
/// program).
|
||||
pub const PORT_TRIES: u16 = 16;
|
||||
|
||||
/// Which app a control channel belongs to: where its token files live, its environment and its
|
||||
/// name in messages.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct App {
|
||||
/// The app's name in messages ("restart SolidCraft").
|
||||
pub name: &'static str,
|
||||
/// The per-user data folder; the token files go in its `control` folder.
|
||||
pub data_dir: fn() -> Option<PathBuf>,
|
||||
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
|
||||
pub env: fn(&str) -> Option<OsString>,
|
||||
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
|
||||
pub utc: fn(u64) -> String,
|
||||
/// The port the control channel tries first when the launch gives none. Below the ephemeral
|
||||
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
|
||||
/// outgoing connection.
|
||||
pub default_port: u16,
|
||||
}
|
||||
|
||||
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
|
||||
/// allow-agents setting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Listen {
|
||||
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
|
||||
Exact(u16),
|
||||
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
|
||||
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
|
||||
/// nothing listens.
|
||||
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
|
||||
match explicit {
|
||||
Some(port) => Some(Listen::Exact(port)),
|
||||
None if enabled => Some(Listen::Default),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
|
||||
/// `tries` ports in all.
|
||||
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
|
||||
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
|
||||
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => return Ok(l),
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
|
||||
fn control_dir(&self) -> Option<PathBuf> {
|
||||
match self.custom_control_dir() {
|
||||
Some(d) => Some(PathBuf::from(d)),
|
||||
None => Some((self.data_dir)()?.join("control")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the app's `CONTROL_DIR` names in place of the default one.
|
||||
fn custom_control_dir(&self) -> Option<OsString> {
|
||||
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
|
||||
/// (the app's `CONTROL_DIR` replaces the folder).
|
||||
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join(format!("{port}.token")))
|
||||
}
|
||||
|
||||
/// The discovery file: the port of the most recently started app, so a client with no port
|
||||
/// finds it. It holds only the port; the token stays in its own file.
|
||||
pub fn current_port_path(&self) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join("current.port"))
|
||||
}
|
||||
|
||||
/// Record `port` as the running app's, readable only by the user like the token files.
|
||||
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
|
||||
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
|
||||
self.write_token_file(&path, &port.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The port the discovery file names, if there is one.
|
||||
pub fn read_current_port(&self) -> Option<u16> {
|
||||
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
|
||||
}
|
||||
|
||||
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
|
||||
/// over: it names another port, or another launch now holds this port's token file. Under
|
||||
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
|
||||
/// number and write the same file names; the one closing first must leave the other's (#8794).
|
||||
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
|
||||
pub fn remove_current_port(&self, port: u16, token: &str) {
|
||||
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
|
||||
remove_discovery(¤t, &file, port, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
|
||||
/// whose data folder the client can't see), if it is long enough.
|
||||
pub fn env_token(&self) -> Option<String> {
|
||||
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
|
||||
}
|
||||
|
||||
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
|
||||
/// `CONTROL_TOKEN`, else the app's token file.
|
||||
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
|
||||
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
|
||||
}
|
||||
if let Some(t) = self.env_token() {
|
||||
return Some(ClientToken { token: t, file: None, app: *self });
|
||||
}
|
||||
// Read directly, not through any agent's file scope: the app's own file.
|
||||
read_token_file(&self.token_path(port)?, *self)
|
||||
}
|
||||
|
||||
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
|
||||
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
|
||||
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
|
||||
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
|
||||
/// file beside it ([`pid_path`]) records this process, best effort.
|
||||
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
|
||||
#[cfg(not(windows))]
|
||||
let restrict = |_: &Path| Ok(());
|
||||
write_token_file_with(path, token, restrict)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(text: &str) -> Option<u16> {
|
||||
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
|
||||
/// file of `port`.
|
||||
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
|
||||
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
|
||||
if named != Some(port) || held_by_another(file, token) {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::remove_file(current);
|
||||
let _ = std::fs::remove_file(pid_path(current));
|
||||
}
|
||||
|
||||
/// A client token and, when it came from a token file, where that file is.
|
||||
pub struct ClientToken {
|
||||
pub token: String,
|
||||
pub file: Option<TokenSource>,
|
||||
/// The app it is for, named in an explanation.
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
/// The token file a client read: its path, when it was written and the process that wrote it.
|
||||
pub struct TokenSource {
|
||||
pub path: PathBuf,
|
||||
pub modified: Option<SystemTime>,
|
||||
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
|
||||
pub writer: Option<(u32, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl ClientToken {
|
||||
/// The app's `error`, and for a refused token read from a file, which file it was, when it
|
||||
/// was written and whether the instance that wrote it has exited. Never the token itself.
|
||||
pub fn explain_error(&self, error: &str) -> String {
|
||||
let Some(src) = &self.file else { return error.to_string() };
|
||||
if !error.starts_with("unauthorized") {
|
||||
return error.to_string();
|
||||
}
|
||||
let written = src
|
||||
.modified
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|t| (self.app.utc)(t.as_secs()))
|
||||
.unwrap_or_else(|| "at an unknown time".into());
|
||||
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
|
||||
let path = src.path.display();
|
||||
let name = self.app.name;
|
||||
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
|
||||
Some((pid, _)) => format!(
|
||||
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
|
||||
),
|
||||
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files bigger than this are not token files.
|
||||
const MAX_TOKEN_FILE: u64 = 4096;
|
||||
|
||||
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let mut text = String::new();
|
||||
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
if text.len() as u64 > MAX_TOKEN_FILE {
|
||||
return None;
|
||||
}
|
||||
let token = text.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let writer = read_pid_file(&pid_path(&path));
|
||||
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
|
||||
}
|
||||
|
||||
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
|
||||
pub fn pid_path(path: &Path) -> PathBuf {
|
||||
path.with_extension("pid")
|
||||
}
|
||||
|
||||
/// `<pid> [<start time>]`; anything else (or no file) is no information.
|
||||
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
|
||||
let mut fields = text.split_whitespace();
|
||||
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
|
||||
Some((pid, fields.next().and_then(|s| s.parse().ok())))
|
||||
}
|
||||
|
||||
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
|
||||
/// belongs to a process that started at another time. Unknown counts as running.
|
||||
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
|
||||
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
|
||||
}
|
||||
|
||||
/// Whether process `pid` runs; `None` when that can't be found out.
|
||||
fn process_running(pid: u32) -> Option<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !Path::new("/proc/self").exists() {
|
||||
return None;
|
||||
}
|
||||
match std::fs::metadata(format!("/proc/{pid}")) {
|
||||
Ok(_) => Some(true),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
|
||||
if output.status.success() {
|
||||
return Some(true);
|
||||
}
|
||||
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `"image","pid",…` per process; with no match, a message line without a pid.
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
|
||||
fn process_start(pid: u32) -> Option<u64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut stat = String::new();
|
||||
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
|
||||
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `a == b`, taking the same time wherever they differ.
|
||||
pub fn tokens_match(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
|
||||
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let opened = opts.open(path)?;
|
||||
// On a refusal the file goes with the closure, so it is closed before it is removed.
|
||||
let mut f = match restrict(path).map(|()| opened) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
f.sync_all()?;
|
||||
let pid = std::process::id();
|
||||
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
|
||||
let _ = std::fs::write(pid_path(path), line);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
|
||||
/// Another launch may have written the same file since (a second window on the same port,
|
||||
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
|
||||
/// has the same one, but each launch's token is its own.
|
||||
pub fn remove_token_file(path: &Path, token: &str) {
|
||||
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(pid_path(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token a token file holds, trimmed; `None` when there is no readable token file.
|
||||
fn read_token(path: &Path) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
let token = text.trim();
|
||||
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
/// Whether the token file at `path` holds a token other than `token`: another launch's.
|
||||
fn held_by_another(path: &Path, token: &str) -> bool {
|
||||
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
|
||||
}
|
||||
|
||||
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
|
||||
/// is left suspended (security software holding it), and waiting on it without a limit froze
|
||||
/// the app.
|
||||
#[cfg(windows)]
|
||||
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How a run of `icacls` ended.
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IcaclsEnd {
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
|
||||
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
|
||||
/// the user's own local app data, which other users can't read by default; nothing is known about
|
||||
/// the permissions of any other folder.
|
||||
#[cfg(any(windows, test))]
|
||||
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
|
||||
match end {
|
||||
IcaclsEnd::Succeeded => Ok(()),
|
||||
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
|
||||
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
|
||||
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
|
||||
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
|
||||
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
|
||||
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
|
||||
let child = std::process::Command::new("icacls")
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.spawn()?;
|
||||
let end = match wait_within(child, ICACLS_LIMIT)? {
|
||||
Some(status) if status.success() => IcaclsEnd::Succeeded,
|
||||
Some(_) => IcaclsEnd::Failed,
|
||||
None => IcaclsEnd::TimedOut,
|
||||
};
|
||||
restriction_result(end, custom_folder, path)
|
||||
}
|
||||
|
||||
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
|
||||
/// hasn't finished by then.
|
||||
#[cfg(any(windows, test))]
|
||||
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
if start.elapsed() >= limit {
|
||||
// Not waited for afterwards: a suspended process may not end at once.
|
||||
let _ = child.kill();
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the token file (path, token) when the app ends, while it still holds that token.
|
||||
pub struct TokenFile(pub PathBuf, pub String);
|
||||
|
||||
impl Drop for TokenFile {
|
||||
fn drop(&mut self) {
|
||||
remove_token_file(&self.0, &self.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An app with no data folder and no environment, whose times read `t<seconds>`.
|
||||
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
|
||||
|
||||
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_slow_child_is_waited_for_only_so_long() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let quick = std::process::Command::new("true").spawn().unwrap();
|
||||
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
|
||||
let failing = std::process::Command::new("false").spawn().unwrap();
|
||||
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
|
||||
}
|
||||
|
||||
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
|
||||
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
|
||||
#[test]
|
||||
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
|
||||
let path = Path::new("control/7878.token");
|
||||
for custom in [false, true] {
|
||||
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
|
||||
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
|
||||
}
|
||||
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
|
||||
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(e.to_string().contains("7878.token"), "{e}");
|
||||
}
|
||||
|
||||
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
|
||||
/// the error goes to the caller. A restricted one gets its token.
|
||||
#[test]
|
||||
fn a_token_file_that_could_not_be_restricted_is_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
|
||||
let path = dir.join("7879.token");
|
||||
let e = write_token_file_with(&path, "secret-token", |p| {
|
||||
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
|
||||
restriction_result(IcaclsEnd::TimedOut, true, p)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
|
||||
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_compare_whole() {
|
||||
assert!(tokens_match("abc123", "abc123"));
|
||||
assert!(!tokens_match("abc123", "abc124"));
|
||||
assert!(!tokens_match("abc123", "abc12"));
|
||||
assert!(!tokens_match("", "a"));
|
||||
}
|
||||
|
||||
/// The preference starts the listener by default, off it doesn't, and a port given on the
|
||||
/// command line or in the environment wins over it either way.
|
||||
#[test]
|
||||
fn the_preference_decides_unless_a_port_is_given() {
|
||||
assert_eq!(plan(None, true), Some(Listen::Default));
|
||||
assert_eq!(plan(None, false), None);
|
||||
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
|
||||
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
|
||||
}
|
||||
|
||||
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
|
||||
/// is an error, not a hang.
|
||||
#[test]
|
||||
fn a_taken_port_falls_back_to_the_next_free_one() {
|
||||
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
|
||||
let a = first.local_addr().unwrap();
|
||||
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
|
||||
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
|
||||
assert!(second.local_addr().unwrap().port() > a.port());
|
||||
assert!(bind_with_fallback(a.port(), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_discovery_file_holds_a_port() {
|
||||
assert_eq!(parse_port("27879\n"), Some(27879));
|
||||
assert_eq!(parse_port("0"), None);
|
||||
assert_eq!(parse_port("70000"), None);
|
||||
assert_eq!(parse_port("nope"), None);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
|
||||
let path = dir.join("current.port");
|
||||
TEST.write_token_file(&path, "27881").unwrap();
|
||||
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A one-line token file (and one with no process file) still reads.
|
||||
#[test]
|
||||
fn a_token_file_reads_with_and_without_its_process_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("7879.token");
|
||||
std::fs::write(&path, "legacy-token\n").unwrap();
|
||||
let client = read_token_file(&path, TEST).unwrap();
|
||||
assert_eq!(client.token, "legacy-token");
|
||||
let src = client.file.as_ref().unwrap();
|
||||
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
|
||||
assert!(src.modified.is_some() && src.writer.is_none());
|
||||
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
|
||||
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
|
||||
std::fs::write(&path, "x".repeat(4097)).unwrap();
|
||||
assert!(read_token_file(&path, TEST).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A refused token's explanation names the app and writes the time as the app does.
|
||||
#[test]
|
||||
fn an_explanation_names_the_app() {
|
||||
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
|
||||
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
|
||||
let error = client.explain_error("unauthorized");
|
||||
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
|
||||
assert!(!error.contains("secret-token"), "{error}");
|
||||
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
|
||||
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
|
||||
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
|
||||
}
|
||||
|
||||
/// A process file naming a process that has exited marks the token file stale.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn a_token_file_from_an_exited_process_is_stale() {
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "auth::tests::tokens_compare_whole"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
|
||||
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
|
||||
let error = stale.explain_error("unauthorized");
|
||||
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
|
||||
assert!(!error.contains("secret-token"));
|
||||
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
|
||||
assert!(!live.explain_error("unauthorized").contains("stale"));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let reused = process_start(std::process::id()).map(|s| s + 1);
|
||||
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
|
||||
assert!(reused.explain_error("unauthorized").contains("stale token file"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token file is the user's alone, and a client reads it back.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_token_file_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("control/7878.token");
|
||||
std::fs::create_dir_all(dir.join("control")).unwrap();
|
||||
std::fs::write(&path, "stale").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(mode(path.parent().unwrap()), 0o700);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
|
||||
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
|
||||
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
|
||||
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
|
||||
/// loopback) and write the same files. The one closing first leaves the other's token, process
|
||||
/// and discovery files; the last one takes them all away, `current.pid` too.
|
||||
#[test]
|
||||
fn a_closing_launch_leaves_another_launchs_files() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
|
||||
let (first, second) = ("a".repeat(64), "b".repeat(64));
|
||||
// The first window, then the second on the same port, each writing both files.
|
||||
for token in [&first, &second] {
|
||||
TEST.write_token_file(&file, token).unwrap();
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
}
|
||||
// The first closes: the second's files stay.
|
||||
remove_token_file(&file, &first);
|
||||
remove_discovery(¤t, &file, 27878, &first);
|
||||
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
|
||||
assert!(pid_path(&file).exists() && current.exists() && pid_path(¤t).exists());
|
||||
// Another port named in the discovery file is another app's too.
|
||||
remove_discovery(¤t, &file, 27879, &second);
|
||||
assert!(current.exists());
|
||||
// The second closes: nothing of either is left.
|
||||
remove_token_file(&file, &format!(" {second}\n"));
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(¤t).exists());
|
||||
// With no token file left (removed by hand, or never written), the discovery file of the
|
||||
// port still goes; a missing file is no error.
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
remove_token_file(&file, &second);
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!current.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
|
||||
//! #1618): the app's control server and a bridged client both say what the request still does when
|
||||
//! it finishes. Which requests do what is the app's to say.
|
||||
|
||||
/// What a request still running does when it finishes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// It only reads (the document, the window or the command list): nothing changes.
|
||||
ReadsOnly,
|
||||
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
|
||||
WritesFile,
|
||||
/// It changes the document (or may: anything not known to read or only write a file).
|
||||
Changes,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
|
||||
/// "it was not cancelled and will still write the file when it finishes; the design does not
|
||||
/// change", … `document` is what the app calls its document ("design").
|
||||
pub fn when_done(self, document: &str) -> String {
|
||||
match self {
|
||||
Effect::ReadsOnly => "it only reads: nothing changes".into(),
|
||||
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
|
||||
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
|
||||
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
|
||||
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
|
||||
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
|
||||
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
|
||||
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
|
||||
pub use effect::Effect;
|
||||
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
|
||||
@@ -0,0 +1,118 @@
|
||||
//! A control request as the window gets it, and the design watch subscribed connections wait on.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
|
||||
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
|
||||
/// validity follows them through queues, computation and cursor animation.
|
||||
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
|
||||
}
|
||||
|
||||
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
|
||||
pub fn refuse_cancelled_chat(&self) -> bool {
|
||||
if chat_turn_cancelled(self.chat_turn.as_deref()) {
|
||||
let _ = self.reply.send(cancelled_chat_response());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
|
||||
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
|
||||
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The reply to a request whose agent chat turn has ended.
|
||||
pub fn cancelled_chat_response() -> Value {
|
||||
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
|
||||
}
|
||||
|
||||
/// The active design's id and revision for control connections that sent `control.subscribe`
|
||||
/// (#6985): the window publishes them each frame while one is subscribed, and each such
|
||||
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
|
||||
#[derive(Default)]
|
||||
pub struct DocumentWatch {
|
||||
subscribers: AtomicUsize,
|
||||
now: Mutex<Option<(String, u64)>>,
|
||||
moved: Condvar,
|
||||
}
|
||||
|
||||
/// The app's [`DocumentWatch`].
|
||||
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
|
||||
|
||||
impl DocumentWatch {
|
||||
pub const fn new() -> Self {
|
||||
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
|
||||
}
|
||||
|
||||
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
|
||||
self.now.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The design as last published (`None` before anything was).
|
||||
pub fn current(&self) -> Option<(String, u64)> {
|
||||
self.now().clone()
|
||||
}
|
||||
|
||||
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
|
||||
/// changes made faster than a connection sends them go out as one.
|
||||
pub fn publish(&self, document_id: &str, revision: u64) {
|
||||
let mut now = self.now();
|
||||
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
|
||||
return;
|
||||
}
|
||||
*now = Some((document_id.to_string(), revision));
|
||||
self.moved.notify_all();
|
||||
}
|
||||
|
||||
/// Whether a connection is subscribed: the window publishes only then.
|
||||
pub fn subscribed(&self) -> bool {
|
||||
self.subscribers.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
/// Count a subscribed connection until the returned guard is dropped.
|
||||
pub fn join(&'static self) -> Subscriber {
|
||||
self.subscribers.fetch_add(1, Ordering::Relaxed);
|
||||
Subscriber(self)
|
||||
}
|
||||
|
||||
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
|
||||
/// (`None` before anything was published).
|
||||
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
|
||||
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
|
||||
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
|
||||
now.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
|
||||
pub struct Subscriber(&'static DocumentWatch);
|
||||
|
||||
impl Drop for Subscriber {
|
||||
fn drop(&mut self) {
|
||||
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
|
||||
// Under the lock, so a connection about to wait sees its `stop` set before it does.
|
||||
let _now = self.0.now();
|
||||
self.0.moved.notify_all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -154,7 +154,7 @@ impl VisualPreset {
|
||||
match self.recipe {
|
||||
Recipe::Opaque(r) => r.color,
|
||||
// A UI swatch of the transmission values, not rendered glass or absorption.
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::persistent_name::{Base, FaceName, FeatureId};
|
||||
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
|
||||
use crate::{FeatureKind, PlaneRef};
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
@@ -18,9 +18,20 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
}
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
let body_face = name.rsplit_once(":face:").and_then(|(body, suffix)| Some((bodies.get(body)?, body, suffix)));
|
||||
// An edge name (`A|B`) is remapped one face at a time, so a body face on one side doesn't
|
||||
// stop the other side's feature or body from being remapped (#13097). A body whose own
|
||||
// name holds a `|` is a single face, not an edge.
|
||||
if !body_face.is_some_and(|(_, body, _)| body.contains('|'))
|
||||
&& let Ok(edge) = EdgeName::parse(name)
|
||||
&& edge.faces.len() > 1
|
||||
&& edge.to_string() == name
|
||||
{
|
||||
let faces: Vec<String> = edge.faces.iter().map(|face| remap_topology(&face.to_string(), ids, bodies)).collect();
|
||||
let tie = edge.tie.map(|k| format!("~{k}")).unwrap_or_default();
|
||||
return format!("{}{tie}", faces.join("|"));
|
||||
}
|
||||
if let Some((new, _, suffix)) = body_face {
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
// A name that reads is remapped by its structure: every feature it names, and a body
|
||||
@@ -410,6 +421,19 @@ mod clone_tests {
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn an_edge_name_with_a_body_face_maps_every_face() {
|
||||
// #13097: a body face on either side of an edge must not stop the other side's
|
||||
// feature or body from being remapped.
|
||||
let ids: BTreeMap<u64, u64> = [(2, 20)].into_iter().collect();
|
||||
let bodies: BTreeMap<String, String> =
|
||||
[("Plate".to_string(), "Plate (copy)".to_string()), ("A|B".to_string(), "A|B (copy)".to_string())].into_iter().collect();
|
||||
assert_eq!(remap_topology("Plate:face:3|F2:top", &ids, &bodies), "Plate (copy):face:3|F20:top");
|
||||
assert_eq!(remap_topology("F2:top|Plate:face:3", &ids, &bodies), "F20:top|Plate (copy):face:3");
|
||||
assert_eq!(remap_topology("Plate:face:3|F2:top~1", &ids, &bodies), "Plate (copy):face:3|F20:top~1");
|
||||
// A body whose name holds a `|` is still one face.
|
||||
assert_eq!(remap_topology("A|B:face:0", &ids, &bodies), "A|B (copy):face:0");
|
||||
}
|
||||
#[test]
|
||||
fn a_sheet_face_follows_its_body_and_its_flange() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
|
||||
|
||||
@@ -176,7 +176,12 @@ mod tests {
|
||||
let sag = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.map(|i| mesh.positions[i as usize]))
|
||||
.zip(&mesh.tri_face)
|
||||
// The side wall's own triangles: the flat caps have corners on the rim too.
|
||||
.filter(|(_, f)| {
|
||||
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
|
||||
})
|
||||
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
|
||||
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
|
||||
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
|
||||
.fold(0.0, f64::max);
|
||||
|
||||
+97
-26
@@ -4754,9 +4754,13 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
|
||||
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
|
||||
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
|
||||
// pocket and keeps metadata attached to the narrow cylindrical bore.
|
||||
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
|
||||
//
|
||||
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
|
||||
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
|
||||
// first, each as (its start and end from the entry along `inward`, a point on it).
|
||||
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
|
||||
let tol = (radius * 1e-6).max(1e-6);
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
|
||||
for body in &st.bodies {
|
||||
// A wall found lies on the line through `entry` along `inward`, give or take its radius
|
||||
// (and the slant its axis may have off `inward` over the body's distance), and its
|
||||
@@ -4783,12 +4787,15 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64)
|
||||
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
|
||||
let lo = a.min(b).max(0.0);
|
||||
let hi = a.max(b);
|
||||
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
|
||||
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
|
||||
// One piece of wall made of several faces (a cylinder split at its seams) is one.
|
||||
let mid = (lo + hi) * 0.5;
|
||||
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
|
||||
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, wall)| wall)
|
||||
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
walls
|
||||
}
|
||||
|
||||
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
|
||||
@@ -7021,6 +7028,22 @@ fn replay(
|
||||
instances.push(start..copies.len());
|
||||
}
|
||||
}
|
||||
// A cut or intersect copy with no named targets, made where the pattern's own component
|
||||
// has no body for it, works on the bodies of its source's component, as the source did
|
||||
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
|
||||
// counts misses, takes sheet cuts and applies the copies.
|
||||
let scoped;
|
||||
let f = if matches!(op, Operation::Cut | Operation::Intersect)
|
||||
&& targets.is_empty()
|
||||
&& f.participants.is_empty()
|
||||
&& src.component != f.component
|
||||
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
|
||||
{
|
||||
scoped = Feature { component: src.component, ..f.clone() };
|
||||
&scoped
|
||||
} else {
|
||||
f
|
||||
};
|
||||
if op == Operation::Cut {
|
||||
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
|
||||
missed += m;
|
||||
@@ -7500,7 +7523,7 @@ fn eval_feature(
|
||||
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
|
||||
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
|
||||
}
|
||||
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
|
||||
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
|
||||
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
|
||||
if thread_depth.is_some() && thread.is_none() {
|
||||
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
|
||||
@@ -7515,6 +7538,19 @@ fn eval_feature(
|
||||
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
|
||||
}
|
||||
let (spots, dir) = hole_spots(vals, st, f)?;
|
||||
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
|
||||
// the pieces of a wall the thread runs along lie within it.
|
||||
let reaches: Vec<f64> = if thread.is_some() {
|
||||
spots
|
||||
.iter()
|
||||
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
|
||||
Some(e) => val(vals, &e, Kind::Length),
|
||||
None => Ok(f64::INFINITY),
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let tools = hole_feature_tools(vals, st, f)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools, warning)?;
|
||||
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
|
||||
@@ -7550,42 +7586,77 @@ fn eval_feature(
|
||||
)
|
||||
});
|
||||
let ease = thread::runout(vals, ease_out)?;
|
||||
for p in spots {
|
||||
let wall = if pipe {
|
||||
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
|
||||
} else {
|
||||
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
|
||||
for (p, reach) in spots.into_iter().zip(reaches) {
|
||||
let walls = parallel_tapped_wall(st, p, dir, r, reach);
|
||||
if pipe && walls.is_empty() {
|
||||
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
|
||||
}
|
||||
// A pipe thread is one taper from the entry: its first wall only.
|
||||
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
|
||||
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
|
||||
Some(first) if pipe => vec![*first],
|
||||
Some(_) => walls,
|
||||
};
|
||||
// The thread depth runs from where the first wall starts, through every piece
|
||||
// of the wall it reaches (#13505).
|
||||
let first = walls.first().map_or(0.0, |w| w.0);
|
||||
let stop = thread_depth.map(|d| first + d);
|
||||
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
|
||||
if *modeled {
|
||||
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
|
||||
// at a blind hole's bottom the groove stops clear of it, or fades out
|
||||
// before it with a runout.
|
||||
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
|
||||
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
|
||||
let on = thread_on(st, f.id, wall, &ask)?;
|
||||
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
|
||||
// before it with a runout. Every piece is found before any is cut.
|
||||
let mut on = Vec::new();
|
||||
for (lo, hi, wall) in &walls {
|
||||
let extent = match stop {
|
||||
None => thread::Extent::Full,
|
||||
Some(s) if s >= *hi => thread::Extent::Full,
|
||||
Some(s) if s > *lo => thread::Extent::Length(s - lo),
|
||||
Some(_) => break,
|
||||
};
|
||||
// A piece past the first starts at its end nearest the entry.
|
||||
let from = if on.is_empty() { p } else { p + dir * *lo };
|
||||
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
|
||||
on.push(thread_on(st, f.id, *wall, &ask)?);
|
||||
}
|
||||
for o in on {
|
||||
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut t = thread_on(st, f.id, wall, &ask)?.info;
|
||||
let mut threads = Vec::new();
|
||||
for (lo, _, wall) in &walls {
|
||||
if stop.is_some_and(|s| s <= *lo) {
|
||||
break;
|
||||
}
|
||||
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
|
||||
}
|
||||
if let Some(depth) = thread_depth {
|
||||
let available = t.end - t.start;
|
||||
if depth > available {
|
||||
let useful =
|
||||
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
|
||||
let available = threads.last().map_or(0.0, |t| useful(t) - first);
|
||||
if depth > available + 1e-9 {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
|
||||
show_mm(depth),
|
||||
show_mm(available)
|
||||
)));
|
||||
}
|
||||
// Start at the entry in the drilling direction, independently of the
|
||||
// wall probe's position and the cylinder surface's axis orientation.
|
||||
if dir.dot(t.axis) > 0.0 {
|
||||
t.end = t.start + depth;
|
||||
} else {
|
||||
t.start = t.end - depth;
|
||||
// Stop `depth` past the first wall's start in the drilling direction,
|
||||
// independently of the wall probe's position and the cylinder surface's
|
||||
// axis orientation.
|
||||
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
|
||||
let past = useful(t) - s;
|
||||
if past > 0.0 {
|
||||
if dir.dot(t.axis) > 0.0 {
|
||||
t.end -= past;
|
||||
} else {
|
||||
t.start += past;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.threads.push(t);
|
||||
st.threads.extend(threads);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1831,6 +1831,26 @@ pub(super) fn line_beyond_reach(mb: &ModelBody, at: Vec3, dir: Vec3, reach: f64)
|
||||
lo > hi
|
||||
}
|
||||
|
||||
/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a face
|
||||
/// only when its mesh of the body, made to a chord tolerance of `(size * 1e-3).max(1e-3)`, comes
|
||||
/// within a twentieth of the body's size of the point. That mesh lies within its tolerance of
|
||||
/// the exact faces, so of the body's exact box ([`exact_box`]): a box farther from the point
|
||||
/// than a twentieth of the size and twice that tolerance holds no face for it. Working that out
|
||||
/// from the exact box spares meshing every body in the design for each tap marking (#10227). A
|
||||
/// mesh body, and a body whose box is unknown, are searched as before.
|
||||
pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool {
|
||||
if mb.body.is_mesh() {
|
||||
return false;
|
||||
}
|
||||
let Some(b) = exact_box(&mb.body) else { return false };
|
||||
let size = mb.body.size();
|
||||
let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3);
|
||||
let off = |at: f64, lo: f64, hi: f64| (lo - at).max(at - hi).max(0.0);
|
||||
let (dx, dy, dz) = (off(p.x, b.min.x, b.max.x), off(p.y, b.min.y, b.max.y), off(p.z, b.min.z, b.max.z));
|
||||
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
dist.is_finite() && reach.is_finite() && dist > reach
|
||||
}
|
||||
|
||||
/// Whether two boxes are known to be apart (an unknown box is never apart).
|
||||
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> bool {
|
||||
let (Some(a), Some(b)) = (a, b) else { return false };
|
||||
|
||||
@@ -192,6 +192,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
||||
.bodies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !super::more::beyond_cylinder_reach(b, p))
|
||||
.filter_map(|(i, b)| {
|
||||
#[cfg(test)]
|
||||
super::cost_tests::count(|c| c.cylinder_search += 1);
|
||||
|
||||
@@ -571,9 +571,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
// The named piece, told apart by the faces around it: they still bound this piece. Or the
|
||||
// face it was a piece of, whole again since, which they still bound: the piece is all of it
|
||||
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
|
||||
// says nothing about which part of the whole face it was.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
|
||||
@@ -171,3 +171,21 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
|
||||
let _ = role(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
|
||||
/// is all of it, when the face still touches the faces that told the piece apart.
|
||||
#[test]
|
||||
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
|
||||
let faces = named(&["top", "left", "right", "start"]);
|
||||
let names = qualify(faces, &[(0, 1), (0, 3)]);
|
||||
assert_eq!(names.faces[0], "top");
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
|
||||
// It no longer touches a face the qualifier names: some other piece, gone since.
|
||||
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
|
||||
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
|
||||
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
|
||||
}
|
||||
|
||||
@@ -243,3 +243,57 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
|
||||
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
|
||||
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
|
||||
}
|
||||
|
||||
/// An edge picked when its side face was split in two keeps the piece in its name
|
||||
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
|
||||
#[test]
|
||||
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
|
||||
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": "p1", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p2", "pos": [10., 0.]},
|
||||
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
|
||||
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
|
||||
],
|
||||
"curves": [
|
||||
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
|
||||
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
|
||||
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
|
||||
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {"k": 0, "l": 4, "p": 4}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
|
||||
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
|
||||
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
|
||||
}
|
||||
]))
|
||||
.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();
|
||||
// The side along x = 0 meets the start cap in the edge under the pick.
|
||||
let near = Vec3::new(0., 8.05, 0.);
|
||||
let names = crate::naming::edge_names(body);
|
||||
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
|
||||
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
|
||||
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
|
||||
assert!(hit.on_point, "the edge under the saved pick");
|
||||
assert_eq!(hit.point, near);
|
||||
assert_eq!(names.get(hit.edge), Some(whole));
|
||||
// A piece told apart by a face the side never touches (the opposite side) is still refused.
|
||||
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
|
||||
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ required-features = ["solver-profiling"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -1552,3 +1552,57 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
|
||||
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
|
||||
assert!(combine["warning"].is_null(), "{combine}");
|
||||
}
|
||||
|
||||
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
|
||||
/// component active, cuts the bodies of the cut's own component, as the cut did.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
|
||||
}
|
||||
|
||||
/// #12996: when the component active for the pattern has a body of its own, the copies of an
|
||||
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
|
||||
let plate = volume(&s, "Plate2");
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
|
||||
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
|
||||
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
ActivateParams, ActivateResult, ChildEntry, CompactPlacement, ComponentNode, CreateParams, CreateResult, DeleteParams, DeleteResult,
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, ListParams, ListResult,
|
||||
MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams,
|
||||
OccurrenceAlignResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams,
|
||||
ReparentItemsResult, UnlockParams, UnlockResult,
|
||||
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
|
||||
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
|
||||
FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult, ListParams, ListResult, MoveBodiesParams,
|
||||
MoveBodiesResult, MoveFeaturesParams, MoveFeaturesResult, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
|
||||
MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams,
|
||||
OccurrencePropertiesResult, PasteNewParams, PasteNewResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams,
|
||||
RenameResult, ReparentItemsParams, ReparentItemsResult, RevertPositionParams, RevertPositionResult, UnlockParams, UnlockResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
@@ -35,6 +37,16 @@ const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_TO_PARTS_CONTRACT: TypedContract<MoveSketchesToPartsParams> = TypedContract::new();
|
||||
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
|
||||
const CAPTURE_POSITION_CONTRACT: TypedContract<CapturePositionParams> = TypedContract::new();
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
|
||||
const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
|
||||
const MOVE_BODIES_CONTRACT: TypedContract<MoveBodiesParams> = TypedContract::new();
|
||||
const MOVE_SKETCHES_CONTRACT: TypedContract<MoveSketchesParams> = TypedContract::new();
|
||||
const OCCURRENCE_PROPERTIES_CONTRACT: TypedContract<OccurrencePropertiesParams> = TypedContract::new();
|
||||
const PASTE_NEW_CONTRACT: TypedContract<PasteNewParams> = TypedContract::new();
|
||||
const REVERT_POSITION_CONTRACT: TypedContract<RevertPositionParams> = TypedContract::new();
|
||||
|
||||
#[path = "component_align.rs"]
|
||||
mod align;
|
||||
@@ -133,8 +145,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<PropertiesResult>)
|
||||
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", crate::checked!("occurrence.properties", properties::occurrence))
|
||||
.icon("measure").noundo()
|
||||
.contract(&OCCURRENCE_PROPERTIES_CONTRACT)
|
||||
.output(output_schema_for::<OccurrencePropertiesResult>)
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
|
||||
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
|
||||
.icon("rename")
|
||||
@@ -154,7 +168,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
|
||||
move and take joints one by one → joints (the ids removed)",
|
||||
),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", crate::checked!("component.move_bodies", move_bodies))
|
||||
.icon("move")
|
||||
.contract(&MOVE_BODIES_CONTRACT)
|
||||
.output(output_schema_for::<MoveBodiesResult>)
|
||||
.params(
|
||||
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
|
||||
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
|
||||
move from (default: its only one, else its first, where its bodies have their own names)",
|
||||
@@ -173,8 +191,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
|
||||
`bodies` and `feature`, or the pasted components' `paths`",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", crate::checked!("component.move_sketches", move_sketches))
|
||||
.icon("move")
|
||||
.contract(&MOVE_SKETCHES_CONTRACT)
|
||||
.output(output_schema_for::<MoveSketchesResult>)
|
||||
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
|
||||
CommandSpec::new(
|
||||
"component.move_sketches.to_parts",
|
||||
@@ -210,12 +230,26 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
|
||||
still elsewhere (a body transfer, which leaves its features behind)",
|
||||
),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", crate::checked!("occurrence.copy", copy_occurrence))
|
||||
.icon("copy")
|
||||
.contract(©_CONTRACT)
|
||||
.output(output_schema_for::<CopyResult>)
|
||||
.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")
|
||||
CommandSpec::new("component.paste_new", "Paste New", crate::checked!("component.paste_new", paste_new))
|
||||
.icon("paste")
|
||||
.contract(&PASTE_NEW_CONTRACT)
|
||||
.output(output_schema_for::<PasteNewResult>)
|
||||
.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", crate::checked!("occurrence.move", move_occurrence))
|
||||
.icon("move")
|
||||
.contract(&MOVE_CONTRACT)
|
||||
.output(output_schema_for::<MoveResult>)
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; 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(
|
||||
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
|
||||
.icon("move")
|
||||
.contract(&DRAG_CONTRACT)
|
||||
.output(output_schema_for::<DragResult>)
|
||||
.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 \
|
||||
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
|
||||
@@ -230,14 +264,29 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
|
||||
every placement of it, and the reply's `shared` lists those paths",
|
||||
),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", crate::checked!("occurrence.ground", ground))
|
||||
.icon("ground")
|
||||
.contract(&GROUND_CONTRACT)
|
||||
.output(output_schema_for::<GroundResult>)
|
||||
.params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
|
||||
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
|
||||
placement to place it by (default: its only one, else the active one; every instance of the parent gets it). The placement \
|
||||
keeps its world position; a move that would break a feature is refused",
|
||||
),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", crate::checked!("component.capture_position", capture))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("capture")
|
||||
.contract(&CAPTURE_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<CapturePositionResult>)
|
||||
.params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("revert")
|
||||
.noundo()
|
||||
.contract(&REVERT_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<RevertPositionResult>)
|
||||
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
@@ -269,7 +318,7 @@ fn translation(t: Vec3) -> Mat {
|
||||
solidcraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
pub(crate) fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
@@ -621,7 +670,7 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.component = to;
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketches": ids, "component": to}))
|
||||
reply(MoveSketchesResult { sketches: ids, component: to, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -655,7 +704,7 @@ pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
reply(CopyResult { occurrence: id, component: comp, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -785,17 +834,7 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
base_transform(value).map_err(|message| bad(cmd, message))?
|
||||
} else {
|
||||
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
@@ -809,24 +848,47 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.transform = next;
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
return Ok(after_joints(s, p, id, next, cmd));
|
||||
return reply(after_joints(s, p, id, next, cmd));
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
}
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
reply(MoveResult {
|
||||
occurrence: id,
|
||||
transform: next,
|
||||
pending: !bool_(p, "capture").unwrap_or(true),
|
||||
reverted: false,
|
||||
warning: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A move's `base_transform`: a 4×4 matrix that is finite, affine and invertible, or what is
|
||||
/// wrong with it. The parameters contract reads it the same way.
|
||||
pub(crate) fn base_transform(value: &Value) -> std::result::Result<Mat, &'static str> {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| "base_transform must be a 4x4 affine matrix")?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solidcraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err("base_transform must be finite, affine and invertible");
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> MoveResult {
|
||||
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});
|
||||
return MoveResult { occurrence: id, transform: now, pending: false, reverted: false, warning: None, param_warnings: Vec::new() };
|
||||
}
|
||||
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
|
||||
@@ -855,7 +917,7 @@ fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> V
|
||||
"{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})
|
||||
MoveResult { occurrence: id, transform: now, pending: false, reverted: true, warning: Some(warning), param_warnings: Vec::new() }
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -868,7 +930,7 @@ fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
let g = o.grounded;
|
||||
Ok(json!({"occurrence": id, "grounded": g}))
|
||||
reply(GroundResult { occurrence: id, grounded: g, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -882,7 +944,7 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "captured": n }))
|
||||
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -904,7 +966,7 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The active component's frame from world coordinates (for new features' geometry).
|
||||
|
||||
@@ -4,8 +4,9 @@ use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::PasteNewResult;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
@@ -793,7 +794,16 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
|
||||
let out = super::component::reply(PasteNewResult {
|
||||
component: root,
|
||||
occurrence: root_occurrence,
|
||||
features: new_ids,
|
||||
components: components.len(),
|
||||
bodies: expected_count,
|
||||
frozen_bodies: frozen,
|
||||
notices,
|
||||
param_warnings: Vec::new(),
|
||||
})?;
|
||||
Ok((scratch, out))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing
|
||||
//! fixed moves as one, so its joints stay as they are.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{mat_inverse, mat_mul};
|
||||
|
||||
use super::{move_vector, occurrence, occurrence_path, translation, valid_position};
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::DragResult;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -158,5 +159,12 @@ pub(super) fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false);
|
||||
Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous}))
|
||||
super::reply(DragResult {
|
||||
occurrence: path.last().copied().unwrap_or_default(),
|
||||
path,
|
||||
moved,
|
||||
translate: [t.x, t.y, t.z],
|
||||
continuous,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{Document, Mat};
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::inertia::{self, Tensor3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
|
||||
use crate::schema::component::{
|
||||
BodyProperties, BoundingBox, InertiaAbout, OccurrencePropertiesResult, PhysicalProperties, PropertiesResult, TotalProperties,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -104,10 +106,9 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
|
||||
let name = doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone());
|
||||
super::reply(OccurrencePropertiesResult { occurrence: id, name, properties, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding.
|
||||
@@ -199,11 +200,6 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
|
||||
})
|
||||
}
|
||||
|
||||
/// [`measure`] as the JSON `occurrence.properties` adds its own keys to.
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
|
||||
super::reply(measure(s, doc, selected, descendants, about, cmd)?)
|
||||
}
|
||||
|
||||
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult};
|
||||
use crate::schema::component::{BodyComponent, FromBodiesResult, MoveBodiesResult};
|
||||
use solidcraft_doc::BodyName;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -188,7 +188,7 @@ fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
super::reply(MoveBodiesResult { bodies, component: to, features, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+102
-60
@@ -8,13 +8,17 @@ use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, name_or_id_values, num, str_};
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::edit::{
|
||||
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
|
||||
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
|
||||
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
BrokenFeature, FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams,
|
||||
TimelineComputeAllResult, TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams,
|
||||
TimelineEditResult, TimelineGroupMade, TimelineGroupParams, TimelineGroupRenamed, TimelineInspectParams, TimelineInspectResult,
|
||||
TimelineMarkerResult, TimelineMoveManyParams, TimelineMoveToGroupParams, TimelineMoved, TimelineMovedToGroup, TimelineRedefineParams,
|
||||
TimelineRedefined, TimelineRenameGroupParams, TimelineRenameParams, TimelineRenameResult, TimelineReorderManyParams, TimelineReorderParams,
|
||||
TimelineReordered, TimelineReorderedMany, TimelineRollToParams, TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
TimelineUngroupParams, TimelineUngrouped,
|
||||
};
|
||||
use crate::schema::select::{PartialPick, SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -87,18 +91,35 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
|
||||
.params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", crate::checked!("timeline.group", group))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupMade>)
|
||||
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", crate::checked!("timeline.ungroup", ungroup))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&UNGROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineUngrouped>)
|
||||
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", crate::checked!("timeline.rename_group", rename_group))
|
||||
.icon("rename")
|
||||
.doc_only()
|
||||
.contract(&RENAME_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineGroupRenamed>)
|
||||
.params("group: id, name (renames the folder holding features and joints)"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", crate::checked!("timeline.move_to_group", move_to_group))
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.contract(&MOVE_TO_GROUP_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMovedToGroup>)
|
||||
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", crate::checked!("timeline.move_many", move_many))
|
||||
.icon("reorder")
|
||||
.contract(&MOVE_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineMoved>)
|
||||
.params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
|
||||
@@ -111,12 +132,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&ROLL_TO_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", crate::checked!("timeline.reorder", reorder))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReordered>)
|
||||
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", crate::checked!("timeline.reorder_many", reorder_many))
|
||||
.icon("reorder")
|
||||
.contract(&REORDER_MANY_CONTRACT)
|
||||
.output(output_schema_for::<TimelineReorderedMany>)
|
||||
.params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", crate::checked!("timeline.redefine", redefine))
|
||||
.icon("edit")
|
||||
.contract(&REDEFINE_CONTRACT)
|
||||
.output(output_schema_for::<TimelineRedefined>)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
|
||||
.icon("graph")
|
||||
@@ -152,16 +183,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set).icon("select")
|
||||
CommandSpec::new("select.set", "Select", crate::checked!("select.set", select_set)).icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_SET_CONTRACT)
|
||||
.output(output_schema_for::<SelectSetResult>)
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
|
||||
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
|
||||
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
|
||||
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
|
||||
CommandSpec::new("select.clear", "Clear Selection", crate::checked!("select.clear", select_clear))
|
||||
.icon("select")
|
||||
.noundo()
|
||||
.contract(&SELECT_CLEAR_CONTRACT)
|
||||
.output(output_schema_for::<SelectClearResult>),
|
||||
CommandSpec::new("select.same_look", "Select Same Look", select_same_look).icon("select").noundo().doc_only()
|
||||
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
|
||||
];
|
||||
|
||||
const SELECT_SET_CONTRACT: TypedContract<SelectSetParams> = TypedContract::new();
|
||||
const SELECT_CLEAR_CONTRACT: TypedContract<SelectClearParams> = TypedContract::new();
|
||||
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
|
||||
@@ -171,6 +210,14 @@ const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContra
|
||||
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
|
||||
const GROUP_CONTRACT: TypedContract<TimelineGroupParams> = TypedContract::new();
|
||||
const UNGROUP_CONTRACT: TypedContract<TimelineUngroupParams> = TypedContract::new();
|
||||
const RENAME_GROUP_CONTRACT: TypedContract<TimelineRenameGroupParams> = TypedContract::new();
|
||||
const MOVE_TO_GROUP_CONTRACT: TypedContract<TimelineMoveToGroupParams> = TypedContract::new();
|
||||
const MOVE_MANY_CONTRACT: TypedContract<TimelineMoveManyParams> = TypedContract::new();
|
||||
const REORDER_CONTRACT: TypedContract<TimelineReorderParams> = TypedContract::new();
|
||||
const REORDER_MANY_CONTRACT: TypedContract<TimelineReorderManyParams> = TypedContract::new();
|
||||
const REDEFINE_CONTRACT: TypedContract<TimelineRedefineParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
|
||||
|
||||
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -608,7 +655,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(not_contiguous(&folders, broken, cmd));
|
||||
}
|
||||
s.doc_mut().timeline_groups = folders;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
reply(cmd, TimelineGroupMade { group: id, name, parent, groups, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -665,7 +712,7 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"groups": ids}))
|
||||
reply(cmd, TimelineUngrouped { groups: ids, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -678,7 +725,7 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
reply(cmd, TimelineGroupRenamed { group: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -715,7 +762,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
|
||||
reply(cmd, TimelineMovedToGroup { features, joints, groups, group: target, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -761,7 +808,7 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
reply(cmd, TimelineMoved { features: ordered, groups, group: target, position: slot, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1408,20 +1455,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
|
||||
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
|
||||
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
let kind = value.get("type").and_then(Value::as_str);
|
||||
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
|
||||
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
|
||||
}
|
||||
let face = kind == Some("face");
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Pick {
|
||||
body: String,
|
||||
index: Option<usize>,
|
||||
point: Option<Vec3>,
|
||||
}
|
||||
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
let (face, PartialPick { body, index, point }) = match read_item(value).map_err(|e| bad("select.set", e))? {
|
||||
SelectItem::Exact(sel) => return Ok(sel),
|
||||
SelectItem::Pick { face, pick } => (face, pick),
|
||||
};
|
||||
let state = s.world_state()?;
|
||||
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
|
||||
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
|
||||
@@ -1496,7 +1533,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.refresh_display();
|
||||
Ok(json!({"selected": s.selection.len()}))
|
||||
reply("select.set", SelectSetResult { selected: s.selection.len(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn select_filter(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1553,7 +1590,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.selection.clear();
|
||||
s.refresh_display();
|
||||
Ok(json!({"selected": 0}))
|
||||
reply("select.clear", SelectClearResult { selected: 0, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Features that worked in `before` and fail in `after`: (name, error).
|
||||
@@ -1660,7 +1697,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let active = active_param(p, cmd)?;
|
||||
if from == to && active.is_none() {
|
||||
let why = capped.map_or_else(|| format!("it is already at position {to}"), |c| format!("{c}, where it already is"));
|
||||
return Ok(json!({"feature": id, "position": to, "from": from, "moved": false, "note": format!("nothing moved: {why}")}));
|
||||
let note = Some(format!("nothing moved: {why}"));
|
||||
return reply(cmd, TimelineReordered { feature: id, position: to, from, moved: false, note, broken: None, param_warnings: Vec::new() });
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
let f = next.features.remove(from);
|
||||
@@ -1705,20 +1743,22 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"feature": id, "position": now, "from": from, "moved": now != from});
|
||||
// With `active`, a feature left in place may still cross the history marker: say which.
|
||||
let stayed = (now == from).then(|| {
|
||||
let what = if s.doc.marker == marker { "nothing moved" } else { "only the history marker moved" };
|
||||
format!("{what}: {name} is already at position {now}")
|
||||
});
|
||||
let note: Vec<String> = [capped, stayed].into_iter().flatten().collect();
|
||||
if !note.is_empty() {
|
||||
out["note"] = json!(note.join("; "));
|
||||
}
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
let out = TimelineReordered {
|
||||
feature: id,
|
||||
position: now,
|
||||
from,
|
||||
moved: now != from,
|
||||
note: (!note.is_empty()).then(|| note.join("; ")),
|
||||
broken: allow.then(|| broken_features(&broken)),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The optional `allow_errors` of a move: make it even when it breaks features that worked.
|
||||
@@ -1731,8 +1771,8 @@ fn allow_errors(p: &Value, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// The features a move broke, as `[{feature, error}]`.
|
||||
fn broken_json(broken: &[(String, String)]) -> Value {
|
||||
broken.iter().map(|(feature, error)| json!({"feature": feature, "error": error})).collect()
|
||||
fn broken_features(broken: &[(String, String)]) -> Vec<BrokenFeature> {
|
||||
broken.iter().map(|(feature, error)| BrokenFeature { feature: feature.clone(), error: error.clone() }).collect()
|
||||
}
|
||||
|
||||
/// The optional `active` of a move: whether the moved features end up before the history marker.
|
||||
@@ -1778,11 +1818,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
|
||||
if allow {
|
||||
out["broken"] = broken_json(&broken);
|
||||
}
|
||||
Ok(out)
|
||||
let position = slot.min(s.doc.features.len());
|
||||
reply(cmd, TimelineReorderedMany { features: ordered, position, broken: allow.then(|| broken_features(&broken)), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The reordered design, the moved features in order, and the model the move was checked on
|
||||
@@ -2093,15 +2130,20 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
|
||||
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
|
||||
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
|
||||
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
|
||||
if let Some(warning) = warning {
|
||||
reply["warning"] = json!(warning);
|
||||
}
|
||||
if rolled_back {
|
||||
let note = rolled_back.then(|| {
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
|
||||
}
|
||||
Ok(reply)
|
||||
format!("{name} is after the history marker, so it is not built: move the marker past it to build it")
|
||||
});
|
||||
let out = TimelineRedefined {
|
||||
feature: id,
|
||||
recomputed: rebuilt + s.model.last_recomputed,
|
||||
restored: reused,
|
||||
rolled_back,
|
||||
warning,
|
||||
note,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -13,9 +13,9 @@ use solidcraft_geom::prelude::*;
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
||||
use crate::schema::features::{
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice,
|
||||
FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams,
|
||||
TorusParams,
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
SphereParams, TorusParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -33,6 +33,7 @@ const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
|
||||
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
|
||||
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
||||
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||
@@ -87,8 +88,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("F")
|
||||
.contract(&FILLET_CONTRACT)
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", crate::checked!("solid.chamfer", chamfer))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("chamfer")
|
||||
.contract(&CHAMFER_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
|
||||
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("combine")
|
||||
@@ -112,7 +118,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; where faces of several bodies meet there, the face of a body in `targets`; 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)"),
|
||||
.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; where faces of several bodies meet there, the face of a body in `targets`; 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); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); 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")
|
||||
@@ -177,7 +183,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet); body?: the body the face or edges are on (for edges, as for solid.fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"),
|
||||
];
|
||||
|
||||
@@ -1554,20 +1560,131 @@ fn prim_cone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Cone { base, axis: Dir(axis), radius, top_radius, height, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
/// What [`edge_points`] found: a point on each edge, in its body's frame, and the body the
|
||||
/// feature keeps (`None`: each edge is looked up on its own).
|
||||
struct EdgePicks {
|
||||
points: Vec<Vec3>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
/// A body as a pick is looked up on: the model body, the name it is shown by, and the frame it
|
||||
/// is placed by and its inverse.
|
||||
struct PickRow {
|
||||
body: usize,
|
||||
name: String,
|
||||
frame: solidcraft_doc::Mat,
|
||||
inverse: solidcraft_doc::Mat,
|
||||
}
|
||||
|
||||
/// What one body's edges near a pick are: the point in its frame, its distance from the pick on
|
||||
/// the exact curves and the edge there, whether any edge within reach is not a seam, and the
|
||||
/// edges within reach (polyline distance, seam, the direction leaving).
|
||||
struct RowHit {
|
||||
row: usize,
|
||||
q: Vec3,
|
||||
d: f64,
|
||||
edge: Option<usize>,
|
||||
edge_like: bool,
|
||||
near: Vec<(f64, bool, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
/// How many bodies a refusal names before "and N more".
|
||||
const NAMED_BODIES: usize = 8;
|
||||
|
||||
/// A body's size, its edges' polylines and which of them are seams.
|
||||
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
|
||||
|
||||
/// How far a polyline is from `q`.
|
||||
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
|
||||
points.windows(2).filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// How far from an edge of a body this big a pick still means it.
|
||||
fn edge_reach(size: f64) -> f64 {
|
||||
size * 0.05 + 1e-3
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges, each in the frame of the body it lies on.
|
||||
///
|
||||
/// A point is looked up where it was picked, on every placed body, shown or hidden: a part
|
||||
/// whose edge is in the same place only in its own frame is not in the way, and two bodies
|
||||
/// with an edge there make it ambiguous. The polylines short-list the edges; which body a pick
|
||||
/// means is decided on the exact curves.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "select 1…1000 edges"));
|
||||
}
|
||||
let verb = if cmd == "solid.chamfer" { "chamfer" } else { "round" };
|
||||
let st = s.model.state();
|
||||
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
|
||||
// of the body the feature names, or of any body: a pick far from every edge would round
|
||||
// whichever edge is nearest (#783). Each body's edges are worked out once.
|
||||
let named = str_(p, "body").filter(|n| st.body(n).is_some());
|
||||
// Per body: its size, its edges' polylines and which of them are seams.
|
||||
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
|
||||
// whichever edge is nearest (#783).
|
||||
let given = str_(p, "body");
|
||||
let named = given.and_then(|n| st.bodies.iter().position(|b| b.name == n));
|
||||
// A pick's own body is the feature's, which keeps one.
|
||||
let agrees = |i: usize| -> Result<usize> {
|
||||
match given {
|
||||
Some(g) if named != Some(i) => {
|
||||
let n = st.bodies.get(i).map_or("", |b| b.name.as_str());
|
||||
Err(bad(cmd, format!("an edge names `{n}`, but `body` is `{g}`: give one")))
|
||||
}
|
||||
_ => Ok(i),
|
||||
}
|
||||
};
|
||||
let identity = |i: usize, b: &solidcraft_doc::ModelBody| PickRow {
|
||||
body: i,
|
||||
name: b.name.clone(),
|
||||
frame: solidcraft_doc::IDENTITY,
|
||||
inverse: solidcraft_doc::IDENTITY,
|
||||
};
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
let local: Vec<PickRow> = st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()).map(|(i, b)| identity(i, b)).collect();
|
||||
let placed = crate::frames::placed(s);
|
||||
// Worked out for the first pick looked up where it was picked.
|
||||
let world: std::cell::OnceCell<Vec<PickRow>> = std::cell::OnceCell::new();
|
||||
let world_rows = || {
|
||||
world.get_or_init(|| {
|
||||
let index: std::collections::BTreeMap<&str, usize> = st.bodies.iter().enumerate().map(|(i, b)| (b.definition_name(), i)).collect();
|
||||
s.body_frames()
|
||||
.iter()
|
||||
.filter_map(|(name, own, frame)| {
|
||||
let body = *index.get(own.as_str())?;
|
||||
let inverse = solidcraft_doc::mat_inverse(frame)?;
|
||||
(!st.bodies.get(body)?.body.is_mesh()).then(|| PickRow { body, name: name.clone(), frame: *frame, inverse })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
// Per body, worked out once.
|
||||
let mut shapes: std::collections::BTreeMap<usize, EdgeShape> = std::collections::BTreeMap::new();
|
||||
let shape_of = |i: usize, shapes: &mut std::collections::BTreeMap<usize, EdgeShape>| {
|
||||
if shapes.contains_key(&i) {
|
||||
return;
|
||||
}
|
||||
let Some(b) = st.bodies.get(i) else { return };
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
let shape = match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
};
|
||||
if let Some(shape) = shape {
|
||||
shapes.insert(i, shape);
|
||||
}
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// Per pick: the body it was found on, where it was picked, and how far from that body's edge.
|
||||
let mut found: Vec<(usize, String, Vec3, Vec3, f64)> = Vec::new();
|
||||
let mut seen: Vec<Vec3> = Vec::new();
|
||||
for e in a {
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
EdgeRefForm::Point(pt) => pt,
|
||||
@@ -1576,57 +1693,139 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
let mid = edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid;
|
||||
if let Some(i) = st.bodies.iter().position(|m| m.name == b.name) {
|
||||
found.push((agrees(i)?, b.name.clone(), mid, mid, 0.0));
|
||||
}
|
||||
out.push(mid);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
// The nth pick seen at this point is the nth remembered there.
|
||||
let nth = seen.iter().filter(|q| **q == pt).count();
|
||||
seen.push(pt);
|
||||
let picked = crate::frames::picked_world(pt, nth).filter(|_| placed);
|
||||
let shown = picked.unwrap_or(pt);
|
||||
let own = match e.get("body").and_then(Value::as_str) {
|
||||
Some(n) => Some(agrees(st.bodies.iter().position(|b| b.name == n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))?)?),
|
||||
None => named,
|
||||
};
|
||||
// Named: on that body only, in its frame. Else where it was picked, on every placed body;
|
||||
// a command run by another without the world point, or a design with no placement to look
|
||||
// through, sees the bodies in their own frames.
|
||||
let (w, rows): (Vec3, Vec<&PickRow>) = match (own, picked) {
|
||||
(Some(i), _) => (pt, local.iter().filter(|r| r.body == i).collect()),
|
||||
(None, Some(w)) if !world_rows().is_empty() => (w, world_rows().iter().collect()),
|
||||
_ => (pt, local.iter().collect()),
|
||||
};
|
||||
let q_of = |r: &PickRow| {
|
||||
let q = solidcraft_doc::apply_point(&r.inverse, w);
|
||||
(q.x.is_finite() && q.y.is_finite() && q.z.is_finite()).then_some(q)
|
||||
};
|
||||
// Only bodies whose box (grown by the reach) holds the pick: an edge lies in its body's box.
|
||||
let culled: Vec<(usize, Vec3)> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, r)| {
|
||||
let b = st.bodies.get(r.body)?;
|
||||
let q = q_of(r)?;
|
||||
b.outline_bounds().inflated(edge_reach(b.body.size())).contains(q).then_some((k, q))
|
||||
})
|
||||
.collect();
|
||||
for (k, _) in &culled {
|
||||
if let Some(r) = rows.get(*k) {
|
||||
shape_of(r.body, &mut shapes);
|
||||
}
|
||||
}
|
||||
let mut hits: Vec<RowHit> = Vec::new();
|
||||
for (k, q) in culled {
|
||||
let Some(r) = rows.get(k) else { continue };
|
||||
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let mut near = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = polyline_dist(points, q);
|
||||
if d <= edge_reach(*size) {
|
||||
let seam = seams.get(i).copied().unwrap_or(false);
|
||||
near.push((d, seam, leaving(points, q)));
|
||||
candidates.push((i, d, seam));
|
||||
}
|
||||
}
|
||||
if near.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Seams count only where nothing else is near.
|
||||
let edge_like = candidates.iter().any(|(_, _, seam)| !seam);
|
||||
let candidates: Vec<(usize, f64)> = candidates.into_iter().filter(|(_, _, seam)| !edge_like || !seam).map(|(i, d, _)| (i, d)).collect();
|
||||
let polyline = candidates.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
|
||||
let (d, edge) = candidates
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
// The display mesh flags seams (a smooth surface meeting itself, as down the side
|
||||
// of a cylinder), which nobody means by a pick where they end.
|
||||
match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
|
||||
// An edge where the faces meet tangentially (the spans of a spline wall
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
m.seams
|
||||
.iter()
|
||||
.zip(&m.smooth_edges)
|
||||
.map(|(s, t)| *s || *t)
|
||||
.chain(m.seams.iter().skip(m.smooth_edges.len()).copied())
|
||||
.collect(),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
.filter(|(_, d)| *d <= polyline + 2.0 * chord)
|
||||
.map(|(i, d)| (edge_foot(&b.body, *i, q).map_or(*d, |(_, foot)| solidcraft_doc::apply_point(&r.frame, foot).dist(w)), *i))
|
||||
.fold((f64::INFINITY, None), |best, (d, i)| if d < best.0 { (d, Some(i)) } else { best });
|
||||
hits.push(RowHit { row: k, q, d, edge, edge_like, near });
|
||||
}
|
||||
if hits.is_empty() {
|
||||
if rows.is_empty() {
|
||||
out.push(pt);
|
||||
continue;
|
||||
}
|
||||
// How far the nearest edge is, on every body the pick was looked up on.
|
||||
let mut nearest = f64::INFINITY;
|
||||
for r in &rows {
|
||||
let Some(q) = q_of(r) else { continue };
|
||||
shape_of(r.body, &mut shapes);
|
||||
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
|
||||
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
|
||||
}
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", shown.x, shown.y, shown.z)));
|
||||
}
|
||||
// A body with only a seam there has no edge to blend, where another body has one.
|
||||
if hits.iter().any(|h| h.edge_like) {
|
||||
hits.retain(|h| h.edge_like);
|
||||
}
|
||||
// Bodies with an edge as near as the nearest one's: the pick is on both. A pick off its
|
||||
// curve by e widens the window to about 2e. The same body placed twice, at the same
|
||||
// edge or point of its frame, is one edge.
|
||||
let d_min = hits.iter().map(|h| h.d).fold(f64::INFINITY, f64::min);
|
||||
let mut groups: Vec<&RowHit> = Vec::new();
|
||||
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
|
||||
let Some(r) = rows.get(h.row) else { continue };
|
||||
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
|
||||
let same = |g: &&RowHit| {
|
||||
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
|
||||
};
|
||||
if !groups.iter().any(same) {
|
||||
groups.push(h);
|
||||
}
|
||||
}
|
||||
if groups.len() > 1 {
|
||||
let names: Vec<String> = groups.iter().filter_map(|g| rows.get(g.row).map(|r| r.name.clone())).collect();
|
||||
let hidden: String = names.iter().filter(|n| !s.body_visible(n)).map(|n| format!(" ({n} is hidden)")).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"[{}, {}, {}] lies on edges of {} bodies, {}: name the one to {verb} with `body`{hidden}",
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES)
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(hit) = hits.iter().filter(|h| h.d <= d_min).chain(hits.iter()).next() else { continue };
|
||||
let Some(row) = rows.get(hit.row) else { continue };
|
||||
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
|
||||
// them, picked silently (#5173). Edges that carry straight on through the point (the two
|
||||
// halves of a circle) are one edge to whoever picked it, and seams and tangent joins don't
|
||||
// count. Directions
|
||||
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
|
||||
// of opposite counts as carrying on (edges at a real corner are far from opposite).
|
||||
let mut nearest = f64::INFINITY;
|
||||
let mut near = Vec::new();
|
||||
for (size, edges, seams) in bodies.iter() {
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
if d <= size * 0.05 + 1e-3 {
|
||||
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let matches = near.len();
|
||||
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
// count. Directions come from the polylines' end chords, which lean off a curve's
|
||||
// tangent: within about 45° of opposite counts as carrying on (edges at a real corner are
|
||||
// far from opposite).
|
||||
let nearest = hit.near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min);
|
||||
let mut meeting: Vec<Option<Vec3>> = hit.near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
|
||||
let mut chains = 0usize;
|
||||
while let Some(dir) = meeting.pop() {
|
||||
chains += 1;
|
||||
@@ -1639,16 +1838,107 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
if chains > 1 {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
|
||||
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", shown.x, shown.y, shown.z),
|
||||
));
|
||||
}
|
||||
if matches == 0 && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
out.push(pt);
|
||||
found.push((row.body, row.name.clone(), hit.q, shown, hit.d));
|
||||
out.push(hit.q);
|
||||
}
|
||||
Ok(out)
|
||||
if given.is_some() {
|
||||
return Ok(EdgePicks { points: out, body: given.map(str::to_string) });
|
||||
}
|
||||
let mut bodies: Vec<(usize, String)> = Vec::new();
|
||||
for (i, name, ..) in &found {
|
||||
if !bodies.iter().any(|(b, _)| b == i) {
|
||||
bodies.push((*i, name.clone()));
|
||||
}
|
||||
}
|
||||
let body = match bodies.as_slice() {
|
||||
// One body of several: the feature keeps it, so the model never looks the edges up on
|
||||
// another body that has one in the same place in its own frame.
|
||||
[(i, _)] => st.bodies.get(*i).filter(|_| local.len() > 1).map(|b| b.name.clone()),
|
||||
[] => None,
|
||||
_ => {
|
||||
// Several: each edge is looked up on its own when the model is built, on whichever
|
||||
// body has an edge there in its own frame, so another body's edge there is refused.
|
||||
for (i, _, q, shown, d) in &found {
|
||||
for other in local.iter().filter(|r| r.body != *i) {
|
||||
let Some(b) = st.bodies.get(other.body) else { continue };
|
||||
let reach = edge_reach(b.body.size());
|
||||
if !b.outline_bounds().inflated(reach).contains(*q) {
|
||||
continue;
|
||||
}
|
||||
shape_of(other.body, &mut shapes);
|
||||
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
|
||||
// The exact curve is no nearer than the polyline less its chord tolerance.
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
|
||||
let poly = polyline_dist(points, *q);
|
||||
poly <= reach && poly - chord <= 2.0 * d + 1e-3 && edge_foot(&b.body, k, *q).map_or(poly, |(d, _)| d) <= 2.0 * d + 1e-3
|
||||
});
|
||||
if ghost {
|
||||
let names: Vec<String> = bodies.iter().map(|(_, n)| n.clone()).collect();
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the picks lie on {} bodies, {}, and at [{}, {}, {}] {} has an edge in the same place in its own component: {verb} each body in a command of its own, naming it with `body`",
|
||||
names.len(),
|
||||
and_list(&names, NAMED_BODIES),
|
||||
shown.x,
|
||||
shown.y,
|
||||
shown.z,
|
||||
b.name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(EdgePicks { points: out, body })
|
||||
}
|
||||
|
||||
/// "A", "A and B", "A, B and C", and past `most` names "A, B and 3 more".
|
||||
fn and_list(names: &[String], most: usize) -> String {
|
||||
let mut shown: Vec<String> = names.iter().take(most).cloned().collect();
|
||||
if names.len() > most {
|
||||
shown.push(format!("{} more", names.len() - most));
|
||||
}
|
||||
match shown.split_last() {
|
||||
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
|
||||
Some((last, _)) => last.clone(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance from `q` to edge `index` of `body` on its exact curve, and the point there:
|
||||
/// samples, then a golden-section search between the best one's neighbours.
|
||||
fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f64, Vec3)> {
|
||||
const SAMPLES: usize = 32;
|
||||
const STEPS: usize = 40;
|
||||
let e = body.record().edges.get(index)?;
|
||||
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
|
||||
let [t0, t1] = e.interval;
|
||||
if !(t0.is_finite() && t1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = |t: f64| c.point(t).map(|p| p.dist(q)).filter(|d| d.is_finite());
|
||||
let step = (t1 - t0) / SAMPLES as f64;
|
||||
let (k, _) = (0..=SAMPLES).filter_map(|k| Some((k, at(t0 + step * k as f64)?))).min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let (mut lo, mut hi) = (t0 + step * k.saturating_sub(1) as f64, t0 + step * (k + 1).min(SAMPLES) as f64);
|
||||
let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
|
||||
for _ in 0..STEPS {
|
||||
let (a, b) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
|
||||
if at(a).unwrap_or(f64::INFINITY) <= at(b).unwrap_or(f64::INFINITY) {
|
||||
hi = b;
|
||||
} else {
|
||||
lo = a;
|
||||
}
|
||||
}
|
||||
let foot = c.point((lo + hi) / 2.0)?;
|
||||
let d = foot.dist(q);
|
||||
d.is_finite().then_some((d, foot))
|
||||
}
|
||||
|
||||
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
|
||||
@@ -1687,7 +1977,11 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.press_pull";
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
if p.get("edges").is_some() {
|
||||
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
|
||||
let mut q = json!({"edges": p.get("edges"), "radius": distance});
|
||||
if let Some(body) = p.get("body").filter(|b| b.is_string()) {
|
||||
q["body"] = body.clone();
|
||||
}
|
||||
return fillet(s, &q);
|
||||
}
|
||||
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
|
||||
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
|
||||
@@ -1737,7 +2031,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.fillet";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let radius = req_expr(cmd, p, "radius")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
@@ -1840,9 +2134,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Fillet {
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
radius,
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
body: picks.body.map(BodyName::from),
|
||||
style,
|
||||
fit,
|
||||
corner,
|
||||
@@ -1866,7 +2160,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.chamfer";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let picks = edge_points(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
|
||||
@@ -1884,12 +2178,12 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Chamfer {
|
||||
edges: edges.into_iter().map(Position).collect(),
|
||||
edges: picks.points.into_iter().map(Position).collect(),
|
||||
distance,
|
||||
distance2,
|
||||
angle,
|
||||
flip,
|
||||
body: str_(p, "body").map(BodyName::from),
|
||||
body: picks.body.map(BodyName::from),
|
||||
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
||||
},
|
||||
)
|
||||
@@ -2653,7 +2947,7 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 {
|
||||
pub(super) fn brief(text: &str) -> String {
|
||||
const MAX: usize = 32;
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((end, _)) => format!("{}…", &text[..end]),
|
||||
@@ -2697,6 +2991,35 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The counterbore a cap screw's head sits in (`head` with `clearance`): its diameter and depth.
|
||||
/// The head's dk and k come from the standard's table (ISO 4762 socket heads, ISO 7380-1 button
|
||||
/// heads, in `parts`), with `head_gap` (default 0.5 mm) added each side and below. A gap that
|
||||
/// names parameters is kept as an expression, so the recess follows them. `None` without `head`.
|
||||
fn head_recess(s: &Session, cmd: &str, p: &Value) -> Result<Option<(Expr, Expr)>> {
|
||||
let head = match p.get("head") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(h)) => h.trim(),
|
||||
Some(_) => return Err(bad(cmd, "`head` must be a text: `ISO 4762` (socket head) or `ISO 7380-1` (button head)")),
|
||||
};
|
||||
let size = str_(p, "clearance").ok_or_else(|| bad(cmd, "`head` needs `clearance`, the screw size the head belongs to (e.g. \"M4\")"))?;
|
||||
let (dk, k) = super::parts::cap_screw_head(head, size).map_err(|e| bad(cmd, e))?;
|
||||
let gap = match (expr(p, "head_gap"), p.get("head_gap")) {
|
||||
(Some(g), _) => g,
|
||||
(None, None | Some(Value::Null)) => Expr::new("0.5 mm"),
|
||||
(None, v) => return Err(bad(cmd, format!("`head_gap` {}", expr_problem(v)))),
|
||||
};
|
||||
let g = s.doc.eval(gap.as_str(), Kind::Length).map_err(|e| bad(cmd, format!("head_gap: {e}")))?;
|
||||
if !(0.0..=dk).contains(&g) {
|
||||
return Err(bad(cmd, format!("`head_gap` must be 0 to {dk} mm (the head's diameter), not {g}")));
|
||||
}
|
||||
let mm = |x: f64| format!("{} mm", (x * 1e6).round() / 1e6);
|
||||
Ok(Some(if solidcraft_doc::expr::references(gap.as_str()).is_empty() {
|
||||
(Expr::new(mm(dk + 2.0 * g)), Expr::new(mm(k + g)))
|
||||
} else {
|
||||
(Expr::new(format!("{dk} mm + 2 * ({})", gap.as_str())), Expr::new(format!("{k} mm + ({})", gap.as_str())))
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pitches to try for a plain `M<d>` that has no ISO coarse pitch, `None` for anything else.
|
||||
fn fine_only_hint(t: &str) -> Option<String> {
|
||||
let diameter = t.trim().strip_prefix(['M', 'm']).and_then(|d| d.trim().parse::<f64>().ok());
|
||||
@@ -2920,18 +3243,28 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(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") {
|
||||
// A counterbore sized to the screw's head: `head` with `clearance`.
|
||||
let head = head_recess(s, cmd, p)?;
|
||||
if head.is_some() && str_(p, "type").is_some_and(|t| t != "counterbore") {
|
||||
return Err(bad(cmd, "`head` needs `type: counterbore` (the default with `head`); a countersink for a flat head is not supported yet"));
|
||||
}
|
||||
let kind = match str_(p, "type").unwrap_or(if head.is_some() { "counterbore" } else { "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"),
|
||||
bridge: bridge.clone(),
|
||||
},
|
||||
"counterbore" => {
|
||||
// Sized to the screw's head unless given; a value given, even a bad one, is checked.
|
||||
let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null));
|
||||
let (cb_diameter, cb_depth) = match &head {
|
||||
Some((d, h)) => {
|
||||
(if given("cb_diameter") { get("cb_diameter")? } else { d.clone() }, if given("cb_depth") { get("cb_depth")? } else { h.clone() })
|
||||
}
|
||||
None => (get("cb_diameter")?, get("cb_depth")?),
|
||||
};
|
||||
solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle: expr(p, "tip_angle"), bridge: bridge.clone() }
|
||||
}
|
||||
"countersink" => solidcraft_doc::HoleKind::Countersink {
|
||||
cs_diameter: get("cs_diameter")?,
|
||||
// The default only when no angle is given: a supplied one out of range is refused (#9625).
|
||||
|
||||
@@ -23,6 +23,8 @@ use solidcraft_io::Format;
|
||||
use super::CommandSpec;
|
||||
use super::print_face;
|
||||
use crate::params::{bad, bool_, num, str_, string_list};
|
||||
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -40,15 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
.noundo()
|
||||
.contract(&SAVE_CONTRACT)
|
||||
.output(output_schema_for::<SaveResult>)
|
||||
.params("path? (default: current file; a name that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
CommandSpec::new("file.save_as", "Save As", crate::checked!("file.save_as", save_as))
|
||||
.icon("save")
|
||||
.key("Ctrl+Shift+S")
|
||||
.noundo()
|
||||
.contract(&SAVE_AS_CONTRACT)
|
||||
.output(output_schema_for::<SaveResult>)
|
||||
.params("path (a design file name, .sc as a rule; one that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
|
||||
.noundo()
|
||||
@@ -122,6 +128,8 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
|
||||
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
|
||||
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
|
||||
@@ -682,7 +690,15 @@ fn write_bytes(
|
||||
s.disk = solidcraft_io::design_lock::stamp(path);
|
||||
s.read_only = false;
|
||||
s.mark_saved();
|
||||
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
|
||||
let mut out = SaveResult {
|
||||
path: path.to_string(),
|
||||
bytes: bytes.len(),
|
||||
name: s.doc.name.clone(),
|
||||
kept_format: None,
|
||||
warnings: None,
|
||||
not_kept: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
|
||||
// couldn't go back, because the design was changed around it, is said.
|
||||
if s.doc.newer_file.is_some() {
|
||||
@@ -690,13 +706,13 @@ fn write_bytes(
|
||||
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
|
||||
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
|
||||
}
|
||||
out["kept_format"] = json!(s.doc.format);
|
||||
out.kept_format = Some(s.doc.format.clone());
|
||||
if !lost.is_empty() {
|
||||
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
|
||||
out["not_kept"] = json!(lost);
|
||||
out.warnings = Some(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect());
|
||||
out.not_kept = Some(lost);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
serde_json::to_value(out).map_err(|e| bad("file.save", e.to_string()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -1393,13 +1409,17 @@ mod tests {
|
||||
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64), "{cmd}");
|
||||
assert!(bytes.starts_with(signature), "{cmd}");
|
||||
}
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name): a box is
|
||||
// 12 triangles, 84 + 50 bytes each.
|
||||
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
|
||||
// bytes each. A planar face is exact for any triangulation of its outline, so the mesher
|
||||
// gives each of the box's six faces two triangles (kernel test `a_box_is_two_triangles_a_face`).
|
||||
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
|
||||
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
|
||||
assert_eq!(std::fs::metadata(at("box.stl")).unwrap().len(), 84 + 50 * 12);
|
||||
let triangles = 6 * 2;
|
||||
let stl = std::fs::read(at("box.stl")).unwrap();
|
||||
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
|
||||
assert_eq!(stl.len(), 84 + 50 * triangles);
|
||||
let obj = std::fs::read_to_string(at("Obj.txt")).unwrap();
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12, "{obj}");
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), triangles, "{obj}");
|
||||
let r = s.execute("file.export_urdf", &json!({"path": at("robot.urdf")})).unwrap();
|
||||
assert_eq!(r["format"], "Urdf");
|
||||
assert!(std::fs::read_to_string(at("robot.urdf")).unwrap().contains("<robot"));
|
||||
|
||||
+315
-140
@@ -12,8 +12,12 @@ use solidcraft_sketch::CurveKind;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::schema::inspect::{
|
||||
BodyFeaturesParams, BodyListParams, FaceCylinder, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces, ModelThread, ModelThreads,
|
||||
ModelThreadsParams,
|
||||
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
|
||||
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
|
||||
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
|
||||
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
|
||||
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
|
||||
SketchWire, TimelineEntry, WireArcInfo,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -21,6 +25,12 @@ use crate::{EngineError, Result, Session};
|
||||
const BODY_LIST_CONTRACT: TypedContract<BodyListParams> = TypedContract::new();
|
||||
const THREADS_CONTRACT: TypedContract<ModelThreadsParams> = TypedContract::new();
|
||||
const BODY_FEATURES_CONTRACT: TypedContract<BodyFeaturesParams> = TypedContract::new();
|
||||
const MEASURE_CONTRACT: TypedContract<MeasureParams> = TypedContract::new();
|
||||
const DOCUMENT_INSPECT_CONTRACT: TypedContract<DocumentInspectParams> = TypedContract::new();
|
||||
const SKETCH_INSPECT_CONTRACT: TypedContract<SketchInspectParams> = TypedContract::new();
|
||||
const MATERIAL_ASSIGN_CONTRACT: TypedContract<MaterialAssignParams> = TypedContract::new();
|
||||
const MATERIAL_LIST_CONTRACT: TypedContract<MaterialListParams> = TypedContract::new();
|
||||
const COMMANDS_CONTRACT: TypedContract<CommandsParams> = TypedContract::new();
|
||||
|
||||
#[path = "appearance_chain.rs"]
|
||||
mod appearance_chain;
|
||||
@@ -35,19 +45,32 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
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)
|
||||
CommandSpec::new("inspect.measure", "Measure", crate::checked!("inspect.measure", measure))
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
.key("I")
|
||||
.noundo()
|
||||
.contract(&MEASURE_CONTRACT)
|
||||
.output(output_schema_for::<MeasureResult>)
|
||||
.params("bodies?: [names] (default all); names_only?: bool (each world body's name and its definition's, nothing measured) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", crate::checked!("document.inspect", inspect))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&DOCUMENT_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<DocumentInspectResult>)
|
||||
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
|
||||
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
|
||||
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
|
||||
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
|
||||
),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", crate::checked!("sketch.inspect", sketch_inspect))
|
||||
.at("SKETCH", "INSPECT")
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&SKETCH_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<SketchInspected>)
|
||||
.params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", crate::checked!("model.edges", model_edges))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
@@ -79,9 +102,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"body?: name (a placed copy's name means the body it shows; default every body) → bodies: [{body, features: [{id, name}]}], \
|
||||
in timeline order the feature that created each body, every later one that changed it, and those of bodies consumed into it. Read-only",
|
||||
),
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
CommandSpec::new("material.assign", "Physical Material", crate::checked!("material.assign", physical_material))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.contract(&MATERIAL_ASSIGN_CONTRACT)
|
||||
.output(output_schema_for::<MaterialAssignResult>)
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -118,11 +143,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<AppearanceListResult>)
|
||||
.params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
|
||||
CommandSpec::new("material.list", "List Materials", crate::checked!("material.list", material_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&MATERIAL_LIST_CONTRACT)
|
||||
.output(output_schema_for::<MaterialListResult>)
|
||||
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", crate::checked!("engine.commands", commands))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&COMMANDS_CONTRACT)
|
||||
.output(output_schema_for::<Vec<CommandEntry>>),
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
@@ -142,7 +174,7 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
|
||||
}
|
||||
|
||||
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
|
||||
measure_sheet_json(b, None)
|
||||
serde_json::to_value(measure_sheet(b, None)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn measure_body(
|
||||
@@ -164,27 +196,53 @@ pub(super) fn measure_body(
|
||||
Ok(measured)
|
||||
}
|
||||
|
||||
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
|
||||
fn measure_sheet(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> BodyMeasurement {
|
||||
let name = b.name.clone();
|
||||
match measure_body(b, definitions) {
|
||||
Ok(m) => {
|
||||
let closed = encloses_volume(&b.body);
|
||||
json!({
|
||||
"name": b.name,
|
||||
"closed": closed,
|
||||
"volume_mm3": closed.then_some(m.volume),
|
||||
"area_mm2": m.area,
|
||||
"center_of_mass": closed.then_some(m.centroid),
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
|
||||
"faces": m.merged.faces,
|
||||
"edges": m.merged.edges,
|
||||
"vertices": m.merged.vertices,
|
||||
"face_types": m.merged.face_types,
|
||||
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
|
||||
"measured_by": m.measured_by,
|
||||
"notes": m.notes,
|
||||
})
|
||||
let outcome = BodyOutcome::Measured(BodyFigures {
|
||||
closed,
|
||||
volume_mm3: closed.then_some(m.volume),
|
||||
area_mm2: m.area,
|
||||
center_of_mass: closed.then(|| m.centroid.into()),
|
||||
bbox: BoundingBox { min: m.bbox.min.into(), max: m.bbox.max.into() },
|
||||
faces: count(m.merged.faces),
|
||||
edges: count(m.merged.edges),
|
||||
vertices: count(m.merged.vertices),
|
||||
face_types: m.merged.face_types,
|
||||
kernel: KernelCounts { faces: m.faces, edges: m.edges, vertices: m.vertices, shells: m.shells },
|
||||
measured_by: m.measured_by.to_string(),
|
||||
notes: m.notes,
|
||||
});
|
||||
BodyMeasurement { name, outcome }
|
||||
}
|
||||
Err(e) => measure_failed(name, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_failed(name: String, error: String) -> BodyMeasurement {
|
||||
BodyMeasurement { name, outcome: BodyOutcome::Failed(BodyFailure { error, faces: None, edges: None, vertices: None }) }
|
||||
}
|
||||
|
||||
/// A topology count as the replies give it.
|
||||
fn count(n: usize) -> i64 {
|
||||
i64::try_from(n).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A reply as JSON: the commands build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
impl BodyMeasurement {
|
||||
/// The volume, when the body has one that is a number.
|
||||
fn volume(&self) -> Option<f64> {
|
||||
match &self.outcome {
|
||||
BodyOutcome::Measured(f) => f.volume_mm3.filter(|v| v.is_finite()),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
Err(e) => json!({"name": b.name, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,138 +259,217 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "unknown body name"));
|
||||
}
|
||||
if bool_(p, "names_only").unwrap_or(false) {
|
||||
let bodies: Vec<_> = selected.iter().map(|b| json!({"name": b.name, "definition": b.definition_name()})).collect();
|
||||
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
|
||||
let bodies: Vec<_> = selected.iter().map(|b| BodyName { name: b.name.clone(), definition: b.definition_name().to_string() }).collect();
|
||||
let measured = Measured::Names(BodyNames { body_count: bodies.len(), bodies });
|
||||
return typed_reply(cmd, MeasureResult { measured, param_warnings: Vec::new() });
|
||||
}
|
||||
// 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_sheet_json(b, Some(&definitions)))
|
||||
let measurements: Vec<BodyMeasurement> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet(b, Some(&definitions)))
|
||||
.into_iter()
|
||||
.zip(&selected)
|
||||
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
|
||||
.map(|(m, b)| m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into())))
|
||||
.collect();
|
||||
// Mass from each body's material.
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
let mut bodies: Vec<MeasuredBody> = Vec::with_capacity(measurements.len());
|
||||
for (measurement, body) in measurements.into_iter().zip(selected) {
|
||||
let source = body.definition_name();
|
||||
// Every body's material, measured or not: naming it takes no measuring.
|
||||
b["material"] = json!(s.doc.body_material(source));
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let mut b = MeasuredBody { measurement, material: s.doc.body_material(source), density_g_cm3: None, mass_g: None, sheet_metal: None };
|
||||
if let Some(v) = b.measurement.volume() {
|
||||
let rho = s.doc.density(source);
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
b.density_g_cm3 = Some(rho);
|
||||
b.mass_g = Some(v * rho / 1000.0);
|
||||
}
|
||||
// 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);
|
||||
match &mut b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => {
|
||||
f.faces = count(f.kernel.faces) + df;
|
||||
f.edges = count(f.kernel.edges) + de;
|
||||
f.vertices = count(f.kernel.vertices) + dv;
|
||||
}
|
||||
BodyOutcome::Failed(x) => (x.faces, x.edges, x.vertices) = (Some(df), Some(de), Some(dv)),
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
b.sheet_metal = Some(true);
|
||||
}
|
||||
bodies.push(b);
|
||||
}
|
||||
fn figures(b: &MeasuredBody) -> Option<&BodyFigures> {
|
||||
match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => Some(f),
|
||||
BodyOutcome::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
|
||||
Ok(
|
||||
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"open_bodies_skipped": skipped, "volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
|
||||
let counted = |n: Option<i64>| n.and_then(|n| u64::try_from(n).ok());
|
||||
let faces = |b: &MeasuredBody| match &b.measurement.outcome {
|
||||
BodyOutcome::Measured(f) => (counted(Some(f.faces)), counted(Some(f.edges)), counted(Some(f.vertices))),
|
||||
BodyOutcome::Failed(x) => (counted(x.faces), counted(x.edges), counted(x.vertices)),
|
||||
};
|
||||
let total = MeasureTotal {
|
||||
open_bodies_skipped: bodies.iter().filter(|b| figures(b).is_some_and(|f| !f.closed)).count(),
|
||||
volume_mm3: bodies.iter().filter_map(|b| b.measurement.volume()).sum(),
|
||||
area_mm2: bodies.iter().filter_map(|b| figures(b)).map(|f| f.area_mm2).filter(|a| a.is_finite()).sum(),
|
||||
mass_g: bodies.iter().filter_map(|b| b.mass_g).filter(|m| m.is_finite()).sum(),
|
||||
faces: bodies.iter().filter_map(|b| faces(b).0).sum(),
|
||||
edges: bodies.iter().filter_map(|b| faces(b).1).sum(),
|
||||
vertices: bodies.iter().filter_map(|b| faces(b).2).sum(),
|
||||
};
|
||||
typed_reply(
|
||||
cmd,
|
||||
MeasureResult { measured: Measured::Bodies(MeasuredBodies { body_count: bodies.len(), bodies, total }), param_warnings: Vec::new() },
|
||||
)
|
||||
}
|
||||
|
||||
/// A 2D position as the replies give it.
|
||||
fn at2(p: solidcore_record::Vec2) -> [f64; 2] {
|
||||
[p.x, p.y]
|
||||
}
|
||||
|
||||
/// Sketch `id`, placed where the placed model `world` has it (its component's placement).
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<Value> {
|
||||
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<SketchInspected> {
|
||||
let state = s.model.state();
|
||||
let ss = state.sketch(id)?;
|
||||
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
|
||||
let sk = &ss.sketch;
|
||||
let curves: Vec<Value> = sk
|
||||
let curves: Vec<SketchCurve> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => {
|
||||
let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det});
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) {
|
||||
o.insert("knots".into(), json!(n.knots));
|
||||
if !n.weights.is_empty() {
|
||||
o.insert("weights".into(), json!(n.weights));
|
||||
}
|
||||
}
|
||||
v
|
||||
let at = |k: usize| sk.point(k).map(at2);
|
||||
let shape = match c.kind {
|
||||
CurveKind::Line { a, b } => CurveShape::Line { start: pid(a), end: pid(b), start_at: at(a), end_at: at(b) },
|
||||
CurveKind::Circle { c: cc, r } => CurveShape::Circle { center: pid(cc), center_at: at(cc), radius: r },
|
||||
CurveKind::Arc { c: cc, a, b } => CurveShape::Arc {
|
||||
center: pid(cc),
|
||||
start: pid(a),
|
||||
end: pid(b),
|
||||
center_at: at(cc),
|
||||
start_at: at(a),
|
||||
end_at: at(b),
|
||||
radius: sk.radius(i),
|
||||
},
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
|
||||
points: pts.iter().map(|q| pid(*q)).collect(),
|
||||
points_at: pts.iter().map(|q| at(*q)).collect(),
|
||||
control,
|
||||
degree,
|
||||
periodic,
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
knots: nurbs.as_ref().map(|n| n.knots.clone()),
|
||||
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
|
||||
},
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
CurveShape::Conic { start: pid(a), end: pid(b), apex: pid(apex), start_at: at(a), end_at: at(b), apex_at: at(apex), rho }
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
SketchCurve {
|
||||
id: c.id.clone(),
|
||||
shape,
|
||||
construction: c.construction,
|
||||
fixed: sk.curve_locked(i),
|
||||
fully_constrained: det,
|
||||
link: c.link.clone(),
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let locked = sk.locked_points();
|
||||
let points: Vec<Value> = sk
|
||||
let points: Vec<SketchPoint> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.map(|(i, p)| SketchPoint {
|
||||
id: p.id.clone(),
|
||||
at: at2(p.pos),
|
||||
world: plane.to_world(p.pos).into(),
|
||||
fixed: locked.get(i).copied().unwrap_or(false),
|
||||
link: p.link.clone(),
|
||||
fully_constrained: ss.report.point_determined.get(i).copied().unwrap_or(false),
|
||||
})
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
let constraints: Vec<SketchConstraint> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("name".into(), json!(c.kind.name()));
|
||||
if let Some(p) = &c.param {
|
||||
o.insert("param".into(), json!(p));
|
||||
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
|
||||
}
|
||||
let mut kind = match serde_json::to_value(&c.kind) {
|
||||
Ok(Value::Object(kind)) => kind,
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The constraint's own keys win over any of the kind's.
|
||||
for key in ["id", "name", "param", "expression"] {
|
||||
kind.remove(key);
|
||||
}
|
||||
SketchConstraint {
|
||||
id: c.id.clone(),
|
||||
name: c.kind.name().to_string(),
|
||||
param: c.param.clone(),
|
||||
expression: c.param.as_ref().map(|p| s.doc.param(p).map(|x| x.expr.clone())),
|
||||
kind,
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let profiles: Vec<Value> = ss
|
||||
let profiles: Vec<SketchProfile> = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// A loop records one entry per piece it walks, and a conic or spline is walked as many
|
||||
// short pieces: each loop names a curve once, in loop order.
|
||||
.map(|(i, p)| {
|
||||
let holes: Vec<Vec<String>> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect();
|
||||
json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes})
|
||||
.map(|(i, p)| SketchProfile {
|
||||
index: i,
|
||||
area_mm2: p.area,
|
||||
centroid_sketch: at2(p.centroid),
|
||||
curves: solidcraft_sketch::distinct_curves(&p.outer_curves),
|
||||
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"name": ss.name,
|
||||
"plane": ss.plane,
|
||||
"status": ss.report.status,
|
||||
"dof": ss.report.dof,
|
||||
"fully_constrained": ss.report.fully_constrained(),
|
||||
"failing": ss.report.failing,
|
||||
"curves": curves,
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
let wires = sk
|
||||
.wires
|
||||
.iter()
|
||||
.map(|w| SketchWire {
|
||||
id: w.id.clone(),
|
||||
points: w.pts.len(),
|
||||
start: w.pts.first().map(|&p| p.into()),
|
||||
end: w.pts.last().map(|&p| p.into()),
|
||||
link: w.link.clone(),
|
||||
arc: w.arc.map(|a| WireArcInfo {
|
||||
center: a.center.into(),
|
||||
radius: a.radius,
|
||||
start: a.start.into(),
|
||||
end: a.end.into(),
|
||||
sweep_deg: solidcraft_sketch::arc_sweep(&a).to_degrees(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Some(SketchInspected {
|
||||
id,
|
||||
name: ss.name.clone(),
|
||||
plane: serde_json::to_value(ss.plane).unwrap_or_default(),
|
||||
status: ss.report.status.into(),
|
||||
dof: ss.report.dof,
|
||||
fully_constrained: ss.report.fully_constrained(),
|
||||
failing: ss.report.failing.clone(),
|
||||
curves,
|
||||
points,
|
||||
constraints,
|
||||
profiles,
|
||||
links: serde_json::to_value(&sk.links).unwrap_or_default(),
|
||||
wires,
|
||||
visible: crate::view::sketch_shown(s, id),
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
|
||||
sketch_json(s, &*s.world_state()?, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
let inspected = sketch_json(s, &*s.world_state()?, id)
|
||||
.ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))?;
|
||||
typed_reply("sketch.inspect", inspected)
|
||||
}
|
||||
|
||||
/// Revisions of every design of this run (#6646).
|
||||
@@ -391,24 +528,38 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<Value> = s
|
||||
let params: Vec<DocumentParam> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
|
||||
.map(|p| DocumentParam {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: vals.get(&p.name).map(|v| v.v),
|
||||
model: p.model,
|
||||
comment: p.comment.clone(),
|
||||
error: perr.get(&p.name).cloned(),
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<Value> = s
|
||||
let timeline: Vec<TimelineEntry> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
})
|
||||
TimelineEntry {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back: i >= marker,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
ms: r.map(|r| r.ms),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let definitions = s.model.state();
|
||||
@@ -416,46 +567,69 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let with_counts = bool_(p, "sketch_counts").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, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
|
||||
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet(b, Some(&definitions))) } else { Vec::new() }
|
||||
.into_iter();
|
||||
let bodies: Vec<Value> = st
|
||||
let bodies: Vec<DocumentBody> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut v = match measured.next() {
|
||||
Some(m) => m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})),
|
||||
None => json!({"name": b.name, "feature": b.feature}),
|
||||
};
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
if s.body_removed_later(&b.name) {
|
||||
// Editing a sketch rolls the timeline back; this body is deleted further on.
|
||||
o.insert("removed_later".into(), json!(true));
|
||||
let detail = match measured.next() {
|
||||
Some(m) => {
|
||||
DocumentBodyDetail::Measured(Box::new(m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into()))))
|
||||
}
|
||||
}
|
||||
v
|
||||
None => DocumentBodyDetail::Listed { name: b.name.clone(), feature: b.feature },
|
||||
};
|
||||
// Editing a sketch rolls the timeline back; a body deleted further on is `removed_later`.
|
||||
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
|
||||
})
|
||||
.collect();
|
||||
let sketches: Vec<Value> = st
|
||||
let sketches: Vec<DocumentSketch> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| {
|
||||
let mut v = json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)});
|
||||
let mut v = DocumentSketch {
|
||||
id: ss.feature,
|
||||
name: ss.name.clone(),
|
||||
dof: ss.report.dof,
|
||||
status: ss.report.status.into(),
|
||||
curves: ss.sketch.curves.len(),
|
||||
profiles: ss.profiles.len(),
|
||||
visible: crate::view::sketch_shown(s, ss.feature),
|
||||
plane: None,
|
||||
points: None,
|
||||
constraints: None,
|
||||
};
|
||||
// A summary that stands in for the sketch's contents (the compact MCP inspect_design).
|
||||
if with_counts && let Some(o) = v.as_object_mut() {
|
||||
o.insert("plane".into(), json!(ss.plane));
|
||||
o.insert("points".into(), json!(ss.sketch.points.len()));
|
||||
o.insert("constraints".into(), json!(ss.sketch.constraints.len()));
|
||||
if with_counts {
|
||||
v.plane = Some(serde_json::to_value(ss.plane).unwrap_or_default());
|
||||
v.points = Some(ss.sketch.points.len());
|
||||
v.constraints = Some(ss.sketch.constraints.len());
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
}))
|
||||
typed_reply(
|
||||
"document.inspect",
|
||||
DocumentInspectResult {
|
||||
name: s.doc.name.clone(),
|
||||
units: s.doc.units.clone(),
|
||||
metadata: s.doc.metadata.0.clone(),
|
||||
path: s.path.clone(),
|
||||
dirty: s.is_dirty(),
|
||||
revision: s.revision,
|
||||
active_sketch: s.active_sketch,
|
||||
marker,
|
||||
params,
|
||||
timeline,
|
||||
bodies,
|
||||
sketches,
|
||||
selection: serde_json::to_value(&s.selection).unwrap_or_default(),
|
||||
undo: s.undo.len(),
|
||||
redo: s.redo.len(),
|
||||
hidden_origin: s.visibility.hidden_origin.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn body_of<'a>(st: &'a solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solidcraft_doc::ModelBody> {
|
||||
@@ -544,7 +718,8 @@ fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
let list: Vec<CommandEntry> = crate::command_specs().iter().map(|c| c.info(s).into()).collect();
|
||||
typed_reply("engine.commands", list)
|
||||
}
|
||||
|
||||
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -568,7 +743,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc_mut().materials.insert(b.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "material": canonical}))
|
||||
typed_reply(cmd, MaterialAssignResult { bodies, material: canonical, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
@@ -915,18 +1090,18 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| ListedMaterial::BuiltIn { name: n.to_string(), density_g_cm3: *d });
|
||||
let user = s
|
||||
.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut out = super::materials::material_json(m)?;
|
||||
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
|
||||
Ok(out)
|
||||
let mut out = super::materials::listed_material(m)?;
|
||||
out.appearance = s.doc.material_look(&m.name).as_ref().map(LookReply::from);
|
||||
Ok(ListedMaterial::Design(Box::new(out)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
typed_reply("material.list", MaterialListResult { materials: built_in.chain(user).collect(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user