mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66bc1b7de2 | |||
| e8e4861933 | |||
| c56cb30444 |
@@ -505,15 +505,9 @@ 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
|
||||
@@ -534,7 +528,7 @@ jobs:
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
@@ -582,15 +576,9 @@ 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
|
||||
@@ -631,7 +619,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
run: sccache --show-stats
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -704,15 +692,9 @@ 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
|
||||
@@ -733,7 +715,7 @@ jobs:
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
run: sccache --show-stats
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -866,15 +848,9 @@ 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:
|
||||
@@ -930,15 +906,9 @@ 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,15 +85,9 @@ 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,15 +149,9 @@ 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
|
||||
@@ -180,7 +174,7 @@ jobs:
|
||||
retention-days: 1
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: if command -v sccache >/dev/null; then sccache --show-stats; else echo "sccache was not set up; built without it"; fi
|
||||
run: sccache --show-stats
|
||||
|
||||
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; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- 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`.
|
||||
|
||||
@@ -201,6 +201,7 @@ 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 |
|
||||
|
||||
Generated
+60
-76
@@ -2508,6 +2508,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -2773,6 +2782,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -4075,6 +4094,7 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"keyring",
|
||||
"libc",
|
||||
@@ -4092,13 +4112,10 @@ 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",
|
||||
@@ -4125,29 +4142,6 @@ 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"
|
||||
@@ -4166,7 +4160,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4199,8 +4192,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4235,7 +4226,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4263,7 +4253,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4298,17 +4287,8 @@ 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]]
|
||||
@@ -4327,13 +4307,14 @@ dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"sysinfo",
|
||||
"web-time",
|
||||
"weezl",
|
||||
]
|
||||
@@ -4385,16 +4366,6 @@ 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"
|
||||
@@ -4413,41 +4384,20 @@ 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"
|
||||
@@ -4458,10 +4408,8 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -4558,6 +4506,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5410,6 +5372,22 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5419,6 +5397,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
+2
-7
@@ -28,15 +28,8 @@ 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;
|
||||
@@ -69,6 +62,8 @@ semver = "1"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
|
||||
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -68,8 +68,7 @@ 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::APP
|
||||
.client_token(addr.port(), token)
|
||||
let token = solidcraft_engine::control_auth::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::APP.read_current_port()?;
|
||||
let port = solidcraft_engine::control_auth::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)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ 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_control::ControlRequest;
|
||||
use solidcraft_ui_egui::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_text::tr("web.open_link"))
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
|
||||
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::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_text::tr("web.files.insert_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::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_text::tr("web.files.insert_stored_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::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_text::effective_language(&self.app.preferences.language)) {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::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_text::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_egui::i18n::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_text::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_egui::i18n::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_text::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
if solidcraft_ui_egui::i18n::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_text::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_egui::i18n::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_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::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,7 +1225,6 @@ 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,12 +31,9 @@ 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 }
|
||||
@@ -48,6 +45,7 @@ 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_theme::Tokens::get().app_bar);
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::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_text::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::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,7 +945,6 @@ 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
|
||||
@@ -1222,7 +1221,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_control::auth::plan(control_port, app.preferences.agent_control) {
|
||||
if let Some(listen_on) = solidcraft_engine::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,6 +18,7 @@ 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 (`crates/theme/src/palettes.rs`) |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
@@ -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, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
|
||||
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', '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 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`. */
|
||||
/* 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`. */
|
||||
: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, solidcraft_theme::Tokens::get().logo, true);
|
||||
brand_mark_styled(p, c, 20.0, crate::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 = solidcraft_theme::Tokens::get().app_bar;
|
||||
let edge_col = crate::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(solidcraft_theme::Tokens::get().font_caption);
|
||||
let font = egui::FontId::proportional(crate::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(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::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 {
|
||||
solidcraft_theme::Tokens::get().icons.agent
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = solidcraft_theme::Tokens::get().icons;
|
||||
let ic = crate::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(solidcraft_theme::Tokens::get().font_small), ic.highlight);
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::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);
|
||||
|
||||
+9
-10
@@ -1250,10 +1250,10 @@
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
|
||||
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
|
||||
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1510,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": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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": "الحفظ يخزّن هذه المادة دون تعيينها للأجسام. الخصائص والشروط الفارغة تبقى غير معروفة. كثافة البطاقة لا تغيّر الكثافة المعيّنة ولا الكتلة.",
|
||||
@@ -2853,7 +2852,7 @@
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
|
||||
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -3398,7 +3397,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–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
@@ -3614,7 +3613,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": "لا يوجد اسم متاح للقطة مكررة",
|
||||
@@ -3844,7 +3843,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": "الموضع",
|
||||
@@ -4457,7 +4456,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": "بيئة ثابتة",
|
||||
@@ -5889,7 +5888,7 @@
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
|
||||
@@ -1395,7 +1395,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +2204,6 @@
|
||||
"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,7 +2204,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -2010,7 +2010,6 @@
|
||||
"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.",
|
||||
@@ -3152,7 +3151,7 @@
|
||||
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
|
||||
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
|
||||
"render.cpu_path_depth": "Pfadtiefe",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_lighting": "Indirekte Beleuchtung",
|
||||
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_point": "Punktlicht",
|
||||
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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.",
|
||||
@@ -3398,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
|
||||
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
|
||||
"render.cpu_path_depth": "Path depth",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_lighting": "Indirect lighting",
|
||||
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_point": "Point light",
|
||||
|
||||
@@ -2204,7 +2204,6 @@
|
||||
"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.",
|
||||
|
||||
+6
-625
File diff suppressed because it is too large
Load Diff
@@ -1899,7 +1899,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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": "השמירה מאחסנת את החומר הזה בלי להקצות אותו לגופים. תכונות ותנאים ריקים נשארים לא ידועים. צפיפות הכרטיס אינה משנה את הצפיפות המוקצית או את המסה.",
|
||||
@@ -3398,7 +3397,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–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_lighting": "תאורה עקיפה",
|
||||
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_point": "אור נקודתי",
|
||||
|
||||
@@ -1889,7 +1889,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +1896,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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": "保存してもボディには割り当てません。空欄の特性と条件は不明のままです。カードの密度は割り当て密度や質量を変更しません。",
|
||||
@@ -3398,7 +3397,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~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_lighting": "間接照明",
|
||||
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_point": "点光源",
|
||||
|
||||
@@ -1939,7 +1939,6 @@
|
||||
"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": "შენახვა ამ მასალას ინახავს სხეულებზე მინიჭების გარეშე. ცარიელი თვისებები და პირობები უცნობად რჩება. ბარათის სიმკვრივე არ ცვლის მინიჭებულ სიმკვრივეს ან მასას.",
|
||||
|
||||
@@ -2669,7 +2669,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–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_lighting": "간접 조명",
|
||||
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_point": "점 조명",
|
||||
|
||||
@@ -1889,7 +1889,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +1893,6 @@
|
||||
"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,66 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
|
||||
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
|
||||
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
|
||||
"render.texture_filtering": "ترشيح تفاصيل الخامات",
|
||||
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً."
|
||||
},
|
||||
"de": {
|
||||
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
|
||||
"render.caustics_environment": "Kaustiken aus der Umgebung",
|
||||
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
|
||||
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
|
||||
"render.texture_filtering": "Texturdetails filtern",
|
||||
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung."
|
||||
},
|
||||
"en": {
|
||||
"render.caustics_area_lights": "Area light caustics",
|
||||
"render.caustics_environment": "Environment caustics",
|
||||
"render.metal_energy_compensation": "Recover rough metal reflectance",
|
||||
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
|
||||
"render.texture_filtering": "Filter texture detail",
|
||||
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering."
|
||||
},
|
||||
"he": {
|
||||
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
|
||||
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
|
||||
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
|
||||
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
|
||||
"render.texture_filtering": "סינון פרטי מרקם",
|
||||
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם."
|
||||
},
|
||||
"ja": {
|
||||
"render.caustics_area_lights": "面光源のコースティクス",
|
||||
"render.caustics_environment": "環境光のコースティクス",
|
||||
"render.metal_energy_compensation": "粗い金属の反射を補正",
|
||||
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
|
||||
"render.texture_filtering": "テクスチャの細部をフィルタリング",
|
||||
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。"
|
||||
},
|
||||
"ko": {
|
||||
"render.caustics_area_lights": "면광원 집광 효과",
|
||||
"render.caustics_environment": "환경광 집광 효과",
|
||||
"render.metal_energy_compensation": "거친 금속의 반사 복원",
|
||||
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
|
||||
"render.texture_filtering": "텍스처 세부 필터링",
|
||||
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
|
||||
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
|
||||
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
|
||||
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
|
||||
"render.texture_filtering": "Lọc chi tiết kết cấu",
|
||||
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây."
|
||||
},
|
||||
"zh": {
|
||||
"render.caustics_area_lights": "面光源焦散",
|
||||
"render.caustics_environment": "环境光焦散",
|
||||
"render.metal_energy_compensation": "恢复粗糙金属的反射",
|
||||
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
|
||||
"render.texture_filtering": "过滤纹理细节",
|
||||
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"en": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
|
||||
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Off",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
|
||||
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
|
||||
"render.cpu_threads": "CPU workers",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Single",
|
||||
"render.cpu_threads_custom": "Custom",
|
||||
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
|
||||
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
|
||||
"render.denoise_passes": "Filter passes",
|
||||
"render.denoise_strength": "Denoising strength",
|
||||
"render.denoise_firefly": "Suppress isolated bright pixels",
|
||||
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
|
||||
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
|
||||
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
|
||||
"render.denoise_diagnostics_title": "Denoising diagnostics",
|
||||
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
|
||||
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
|
||||
"render.denoise_view_image": "Rendered image",
|
||||
"render.denoise_view_mask": "Denoising mask",
|
||||
"render.denoise_export_mask": "Export mask PNG",
|
||||
"render.denoise_format_mask": "Mask PNG",
|
||||
"render.denoise_class_unavailable": "Skipped: no guide",
|
||||
"render.denoise_class_surface": "Eligible: surface",
|
||||
"render.denoise_class_optical": "Eligible: refracted surface",
|
||||
"render.denoise_class_background": "Skipped: background",
|
||||
"render.denoise_class_emitter": "Skipped: light emitter",
|
||||
"render.denoise_class_material": "Skipped: unsupported material",
|
||||
"render.denoise_class_nested": "Skipped: nested medium",
|
||||
"render.denoise_class_lens": "Skipped: depth of field",
|
||||
"render.denoise_class_volume": "Skipped: volume scattering",
|
||||
"render.denoise_class_depth": "Skipped: path depth limit",
|
||||
"render.denoise_class_degenerate": "Skipped: degenerate path",
|
||||
"render.denoise_class_exit": "Skipped: incompatible exit",
|
||||
"render.denoise_class_tir": "Skipped: total internal reflection",
|
||||
"render.denoise_class_transmission": "Skipped: further transmission",
|
||||
"render.denoise_class_reflection": "Skipped: reflective endpoint",
|
||||
"render.denoise_class_identity": "Skipped: guide identity limit",
|
||||
"render.denoise_class_coverage": "Skipped: partial coverage"
|
||||
},
|
||||
"ar": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
|
||||
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
|
||||
"render.supersampling": "أخذ عينات فائقة",
|
||||
"render.supersampling_off": "إيقاف",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
|
||||
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
|
||||
"render.cpu_threads": "خيوط المعالجة",
|
||||
"render.cpu_threads_auto": "تلقائي",
|
||||
"render.cpu_threads_single": "واحد",
|
||||
"render.cpu_threads_custom": "مخصص",
|
||||
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
|
||||
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
|
||||
"render.denoise_passes": "مرات الترشيح",
|
||||
"render.denoise_strength": "قوة إزالة الضوضاء",
|
||||
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
|
||||
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_view_image": "الصورة المصيّرة",
|
||||
"render.denoise_view_mask": "قناع إزالة الضوضاء",
|
||||
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
|
||||
"render.denoise_format_mask": "قناع PNG",
|
||||
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
|
||||
"render.denoise_class_surface": "مؤهل: سطح",
|
||||
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
|
||||
"render.denoise_class_background": "متجاوز: خلفية",
|
||||
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
|
||||
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "متجاوز: وسط متداخل",
|
||||
"render.denoise_class_lens": "متجاوز: عمق المجال",
|
||||
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
|
||||
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
|
||||
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
|
||||
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
|
||||
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
|
||||
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
|
||||
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
|
||||
"render.denoise_class_coverage": "متجاوز: تغطية جزئية"
|
||||
},
|
||||
"he": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
|
||||
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
|
||||
"render.supersampling": "דגימת יתר",
|
||||
"render.supersampling_off": "כבוי",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
|
||||
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
|
||||
"render.cpu_threads": "תהליכוני CPU",
|
||||
"render.cpu_threads_auto": "אוטומטי",
|
||||
"render.cpu_threads_single": "יחיד",
|
||||
"render.cpu_threads_custom": "מותאם",
|
||||
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
|
||||
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
|
||||
"render.denoise_passes": "מעברי סינון",
|
||||
"render.denoise_strength": "עוצמת הפחתת רעש",
|
||||
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
|
||||
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
|
||||
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
|
||||
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
|
||||
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
|
||||
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
|
||||
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
|
||||
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
|
||||
"render.denoise_view_image": "תמונה מרונדרת",
|
||||
"render.denoise_view_mask": "מסכת הפחתת רעש",
|
||||
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
|
||||
"render.denoise_format_mask": "מסכה PNG",
|
||||
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
|
||||
"render.denoise_class_surface": "מתאים: משטח",
|
||||
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
|
||||
"render.denoise_class_background": "דולג: רקע",
|
||||
"render.denoise_class_emitter": "דולג: מקור אור",
|
||||
"render.denoise_class_material": "דולג: חומר לא נתמך",
|
||||
"render.denoise_class_nested": "דולג: תווך מקונן",
|
||||
"render.denoise_class_lens": "דולג: עומק שדה",
|
||||
"render.denoise_class_volume": "דולג: פיזור נפחי",
|
||||
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
|
||||
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
|
||||
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
|
||||
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
|
||||
"render.denoise_class_transmission": "דולג: העברה נוספת",
|
||||
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
|
||||
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
|
||||
"render.denoise_class_coverage": "דולג: כיסוי חלקי"
|
||||
},
|
||||
"ja": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
|
||||
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
|
||||
"render.supersampling": "スーパーサンプリング",
|
||||
"render.supersampling_off": "オフ",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
|
||||
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
|
||||
"render.cpu_threads": "CPU ワーカー",
|
||||
"render.cpu_threads_auto": "自動",
|
||||
"render.cpu_threads_single": "1 ワーカー",
|
||||
"render.cpu_threads_custom": "カスタム",
|
||||
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
|
||||
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
|
||||
"render.denoise_passes": "フィルターパス数",
|
||||
"render.denoise_strength": "ノイズ除去の強度",
|
||||
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
|
||||
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
|
||||
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
|
||||
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
|
||||
"render.denoise_diagnostics_title": "ノイズ除去の診断",
|
||||
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
|
||||
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
|
||||
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
|
||||
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
|
||||
"render.denoise_view_image": "レンダリング画像",
|
||||
"render.denoise_view_mask": "ノイズ除去マスク",
|
||||
"render.denoise_export_mask": "マスク PNG を書き出す",
|
||||
"render.denoise_format_mask": "マスク PNG",
|
||||
"render.denoise_class_unavailable": "対象外: ガイドなし",
|
||||
"render.denoise_class_surface": "対象: サーフェス",
|
||||
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
|
||||
"render.denoise_class_background": "対象外: 背景",
|
||||
"render.denoise_class_emitter": "対象外: 発光体",
|
||||
"render.denoise_class_material": "対象外: 未対応のマテリアル",
|
||||
"render.denoise_class_nested": "対象外: 入れ子の媒質",
|
||||
"render.denoise_class_lens": "対象外: 被写界深度",
|
||||
"render.denoise_class_volume": "対象外: ボリューム散乱",
|
||||
"render.denoise_class_depth": "対象外: 経路深度の上限",
|
||||
"render.denoise_class_degenerate": "対象外: 退化した経路",
|
||||
"render.denoise_class_exit": "対象外: 不整合な出射",
|
||||
"render.denoise_class_tir": "対象外: 全反射",
|
||||
"render.denoise_class_transmission": "対象外: 追加の透過",
|
||||
"render.denoise_class_reflection": "対象外: 反射する終点",
|
||||
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
|
||||
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ"
|
||||
},
|
||||
"vi": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
|
||||
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
|
||||
"render.supersampling": "Lấy mẫu siêu phân giải",
|
||||
"render.supersampling_off": "Tắt",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
|
||||
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
|
||||
"render.cpu_threads": "Luồng CPU",
|
||||
"render.cpu_threads_auto": "Tự động",
|
||||
"render.cpu_threads_single": "Một luồng",
|
||||
"render.cpu_threads_custom": "Tùy chỉnh",
|
||||
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
|
||||
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
|
||||
"render.denoise_passes": "Số lượt lọc",
|
||||
"render.denoise_strength": "Cường độ khử nhiễu",
|
||||
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
|
||||
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
|
||||
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
|
||||
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
|
||||
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
|
||||
"render.denoise_view_image": "Ảnh đã kết xuất",
|
||||
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
|
||||
"render.denoise_export_mask": "Xuất mặt nạ PNG",
|
||||
"render.denoise_format_mask": "PNG mặt nạ",
|
||||
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
|
||||
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
|
||||
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
|
||||
"render.denoise_class_background": "Bỏ qua: nền",
|
||||
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
|
||||
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
|
||||
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
|
||||
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
|
||||
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
|
||||
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
|
||||
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
|
||||
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
|
||||
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
|
||||
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
|
||||
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
|
||||
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
|
||||
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần"
|
||||
},
|
||||
"zh": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
|
||||
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
|
||||
"render.supersampling": "超采样",
|
||||
"render.supersampling_off": "关闭",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
|
||||
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
|
||||
"render.cpu_threads": "CPU 工作线程",
|
||||
"render.cpu_threads_auto": "自动",
|
||||
"render.cpu_threads_single": "单线程",
|
||||
"render.cpu_threads_custom": "自定义",
|
||||
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
|
||||
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
|
||||
"render.denoise_passes": "滤波轮数",
|
||||
"render.denoise_strength": "降噪强度",
|
||||
"render.denoise_firefly": "抑制孤立亮点",
|
||||
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
|
||||
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
|
||||
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
|
||||
"render.denoise_diagnostics_title": "降噪诊断",
|
||||
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
|
||||
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
|
||||
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
|
||||
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
|
||||
"render.denoise_view_image": "渲染图像",
|
||||
"render.denoise_view_mask": "降噪蒙版",
|
||||
"render.denoise_export_mask": "导出蒙版 PNG",
|
||||
"render.denoise_format_mask": "蒙版 PNG",
|
||||
"render.denoise_class_unavailable": "已跳过:无引导数据",
|
||||
"render.denoise_class_surface": "可滤波:表面",
|
||||
"render.denoise_class_optical": "可滤波:折射后的表面",
|
||||
"render.denoise_class_background": "已跳过:背景",
|
||||
"render.denoise_class_emitter": "已跳过:发光体",
|
||||
"render.denoise_class_material": "已跳过:不支持的材质",
|
||||
"render.denoise_class_nested": "已跳过:嵌套介质",
|
||||
"render.denoise_class_lens": "已跳过:景深",
|
||||
"render.denoise_class_volume": "已跳过:体积散射",
|
||||
"render.denoise_class_depth": "已跳过:路径深度上限",
|
||||
"render.denoise_class_degenerate": "已跳过:退化路径",
|
||||
"render.denoise_class_exit": "已跳过:不兼容的出射",
|
||||
"render.denoise_class_tir": "已跳过:全内反射",
|
||||
"render.denoise_class_transmission": "已跳过:进一步透射",
|
||||
"render.denoise_class_reflection": "已跳过:反射终点",
|
||||
"render.denoise_class_identity": "已跳过:引导标识上限",
|
||||
"render.denoise_class_coverage": "已跳过:部分覆盖"
|
||||
},
|
||||
"de": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
|
||||
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Aus",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
|
||||
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
|
||||
"render.cpu_threads": "CPU-Threads",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Einzeln",
|
||||
"render.cpu_threads_custom": "Eigene",
|
||||
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
|
||||
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
|
||||
"render.denoise_passes": "Filterdurchläufe",
|
||||
"render.denoise_strength": "Entrauschungsstärke",
|
||||
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
|
||||
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
|
||||
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
|
||||
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
|
||||
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
|
||||
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
|
||||
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
|
||||
"render.denoise_view_image": "Gerendertes Bild",
|
||||
"render.denoise_view_mask": "Entrauschungsmaske",
|
||||
"render.denoise_export_mask": "Maske als PNG exportieren",
|
||||
"render.denoise_format_mask": "Maske PNG",
|
||||
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
|
||||
"render.denoise_class_surface": "Geeignet: Oberfläche",
|
||||
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
|
||||
"render.denoise_class_background": "Übersprungen: Hintergrund",
|
||||
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
|
||||
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
|
||||
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
|
||||
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
|
||||
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
|
||||
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
|
||||
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
|
||||
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
|
||||
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
|
||||
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
|
||||
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
|
||||
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
|
||||
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung"
|
||||
},
|
||||
"ko": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
|
||||
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
|
||||
"render.supersampling": "슈퍼샘플링",
|
||||
"render.supersampling_off": "끄기",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
|
||||
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
|
||||
"render.cpu_threads": "CPU 작업 스레드",
|
||||
"render.cpu_threads_auto": "자동",
|
||||
"render.cpu_threads_single": "단일",
|
||||
"render.cpu_threads_custom": "사용자 지정",
|
||||
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
|
||||
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
|
||||
"render.denoise_passes": "필터 반복 횟수",
|
||||
"render.denoise_strength": "노이즈 제거 강도",
|
||||
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
|
||||
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
|
||||
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
|
||||
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
|
||||
"render.denoise_diagnostics_title": "노이즈 제거 진단",
|
||||
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
|
||||
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
|
||||
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
|
||||
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
|
||||
"render.denoise_view_image": "렌더링 이미지",
|
||||
"render.denoise_view_mask": "노이즈 제거 마스크",
|
||||
"render.denoise_export_mask": "마스크 PNG 내보내기",
|
||||
"render.denoise_format_mask": "마스크 PNG",
|
||||
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
|
||||
"render.denoise_class_surface": "적용 가능: 표면",
|
||||
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
|
||||
"render.denoise_class_background": "건너뜀: 배경",
|
||||
"render.denoise_class_emitter": "건너뜀: 발광체",
|
||||
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
|
||||
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
|
||||
"render.denoise_class_lens": "건너뜀: 피사계 심도",
|
||||
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
|
||||
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
|
||||
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
|
||||
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
|
||||
"render.denoise_class_tir": "건너뜀: 전반사",
|
||||
"render.denoise_class_transmission": "건너뜀: 추가 투과",
|
||||
"render.denoise_class_reflection": "건너뜀: 반사 종점",
|
||||
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
|
||||
"render.denoise_class_coverage": "건너뜀: 부분 커버리지"
|
||||
}
|
||||
}
|
||||
@@ -2204,7 +2204,6 @@
|
||||
"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,7 +2204,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +1882,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +1890,6 @@
|
||||
"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,7 +1889,6 @@
|
||||
"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,7 +2199,6 @@
|
||||
"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": "Збереження записує цей матеріал, не призначаючи його тілам. Порожні властивості й умови лишаються невідомими. Густина в картці не змінює призначену густину чи масу.",
|
||||
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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.",
|
||||
@@ -3398,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
|
||||
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
|
||||
"render.cpu_path_depth": "Độ sâu đường truyền",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
|
||||
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_point": "Nguồn sáng điểm",
|
||||
|
||||
@@ -1896,7 +1896,6 @@
|
||||
"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": "儲存材料不會將其指定給實體。空白屬性和條件仍為未知。屬性卡密度不會改變指定密度或質量。",
|
||||
|
||||
@@ -2218,7 +2218,6 @@
|
||||
"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": "保存材料不会将其指定给实体。空白属性和条件仍为未知。属性卡密度不会改变指定密度或质量。",
|
||||
@@ -3398,7 +3397,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–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_lighting": "间接照明",
|
||||
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_point": "点光源",
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
//! Original light and dark palette values.
|
||||
use crate::{IconColors, Tokens};
|
||||
//! Original light and dark palette values. Compiled by the UI theme boundary.
|
||||
use crate::theme::{AnalysisColors, IconColors, Tokens};
|
||||
use egui::Color32;
|
||||
use solidcraft_color::AnalysisColors;
|
||||
|
||||
/// The primary (`active-3`) strokes, light then dark, that the tokens share with SolidCraft's icon artwork
|
||||
/// (`assets/icons/ui/design/palette.toml`, including its colour vision variants). ui-egui's icon tests hold them equal.
|
||||
pub mod icon_strokes {
|
||||
pub const SKETCH_PRIMARY: [[u8; 3]; 2] = [[5, 127, 69], [18, 141, 69]];
|
||||
pub const CONSTRAINT_PRIMARY: [[u8; 3]; 2] = [[202, 71, 61], [193, 99, 88]];
|
||||
pub const CONSTRUCTION_PRIMARY: [[u8; 3]; 2] = [[156, 86, 0], [167, 93, 0]];
|
||||
pub const SKETCH_RED_GREEN: [[u8; 3]; 2] = [[29, 125, 82], [29, 127, 137]];
|
||||
pub const CONSTRUCTION_RED_GREEN: [[u8; 3]; 2] = [[185, 115, 0], [167, 93, 0]];
|
||||
pub const CONSTRAINT_RED_GREEN: [[u8; 3]; 2] = [[175, 64, 56], [193, 99, 88]];
|
||||
pub const CONSTRUCTION_BLUE_YELLOW: [[u8; 3]; 2] = [[185, 115, 0], [195, 125, 0]];
|
||||
pub const CONSTRAINT_BLUE_YELLOW: [[u8; 3]; 2] = [[175, 64, 56], [133, 85, 74]];
|
||||
}
|
||||
use icon_strokes as strokes;
|
||||
|
||||
const fn icon_primary(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
@@ -32,7 +17,8 @@ impl Tokens {
|
||||
nav_tip_key_border: Color32::from_rgba_unmultiplied_const(128, 140, 160, 153),
|
||||
nav_field: Color32::from_rgb(255, 255, 255),
|
||||
|
||||
borders: crate::Borders::LEGACY,
|
||||
borders: crate::theme::Borders::LEGACY,
|
||||
icon_palette: None,
|
||||
file_dim: Color32::from_rgb(58, 70, 86),
|
||||
file_recovery_well: Color32::from_rgb(247, 226, 184),
|
||||
file_action_height: 32.0,
|
||||
@@ -133,7 +119,7 @@ impl Tokens {
|
||||
text_dim: Color32::from_rgb(110, 116, 128),
|
||||
accent: Color32::from_rgb(38, 120, 218),
|
||||
accent_soft: Color32::from_rgb(214, 230, 250),
|
||||
sketch_accent: icon_primary(strokes::SKETCH_PRIMARY[0]),
|
||||
sketch_accent: icon_primary(crate::icon_svg::SKETCH_PRIMARY[0]),
|
||||
// Icon palette active-3 ramps; these accents have no compiled constants yet.
|
||||
surface_accent: Color32::from_rgb(164, 80, 0),
|
||||
mesh_accent: Color32::from_rgb(130, 103, 0),
|
||||
@@ -186,7 +172,7 @@ impl Tokens {
|
||||
axis_y: Color32::from_rgb(60, 170, 60),
|
||||
axis_z: Color32::from_rgb(60, 100, 230),
|
||||
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
|
||||
construction_geom: icon_primary(strokes::CONSTRUCTION_PRIMARY[0]),
|
||||
construction_geom: icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[0]),
|
||||
body: Color32::from_rgb(176, 186, 198),
|
||||
body_edge: Color32::from_rgb(40, 44, 52),
|
||||
manipulator: Color32::from_rgb(40, 110, 220),
|
||||
@@ -229,7 +215,7 @@ impl Tokens {
|
||||
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
|
||||
glyph_bg: Color32::WHITE,
|
||||
glyph_edge: Color32::from_rgb(190, 110, 100),
|
||||
glyph_text: icon_primary(strokes::CONSTRAINT_PRIMARY[0]),
|
||||
glyph_text: icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[0]),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
rubber_band: Color32::from_rgb(30, 120, 230),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
@@ -353,7 +339,7 @@ impl Tokens {
|
||||
t.text_dim = Color32::from_rgb(148, 155, 168);
|
||||
t.accent = Color32::from_rgb(58, 136, 238);
|
||||
t.accent_soft = Color32::from_rgb(42, 70, 110);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_PRIMARY[1]);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_PRIMARY[1]);
|
||||
t.surface_accent = Color32::from_rgb(149, 104, 45);
|
||||
t.mesh_accent = Color32::from_rgb(133, 113, 0);
|
||||
t.form_accent = Color32::from_rgb(123, 99, 166);
|
||||
@@ -413,7 +399,7 @@ impl Tokens {
|
||||
t.pref_ok = Color32::from_rgb(127, 209, 174);
|
||||
t.pref_ok_fill = Color32::from_rgb(31, 58, 49);
|
||||
t.pref_disabled = Color32::from_rgb(138, 148, 163);
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_PRIMARY[1]);
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
|
||||
t.error = Color32::from_rgb(240, 100, 96);
|
||||
t.sketch_conflict = t.error;
|
||||
t.warning = Color32::from_rgb(236, 170, 70);
|
||||
@@ -444,7 +430,7 @@ impl Tokens {
|
||||
t.chip = Color32::from_rgb(59, 101, 168);
|
||||
t.glyph_bg = Color32::from_rgb(44, 50, 60);
|
||||
t.glyph_edge = Color32::from_rgb(200, 130, 120);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_PRIMARY[1]);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[1]);
|
||||
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
|
||||
t.rubber_band = Color32::from_rgb(92, 160, 255);
|
||||
t.timeline_item = Color32::from_rgb(52, 58, 70);
|
||||
@@ -487,8 +473,8 @@ impl Tokens {
|
||||
if !dark {
|
||||
t.form_success = Color32::from_rgb(39, 88, 78);
|
||||
}
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_RED_GREEN[dark as usize]);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_RED_GREEN[dark as usize]);
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]);
|
||||
// A deeper red keeps conflicting sketch geometry (#7244) apart from the orange
|
||||
// construction and centerline lines and the crimson lost links for a red-green loss.
|
||||
t.sketch_conflict = Color32::from_rgb(195, 35, 30);
|
||||
@@ -498,14 +484,14 @@ impl Tokens {
|
||||
if dark {
|
||||
t.bend_up = Color32::from_rgb(110, 165, 255);
|
||||
t.bend_down = Color32::from_rgb(240, 180, 70);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[1]);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[1]);
|
||||
} else {
|
||||
t.sketch_projected = Color32::from_rgb(205, 120, 215);
|
||||
t.sketch_locked = Color32::from_rgb(60, 150, 120);
|
||||
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
|
||||
t.bend_up = Color32::from_rgb(30, 100, 210);
|
||||
t.bend_down = Color32::from_rgb(200, 120, 0);
|
||||
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[0]);
|
||||
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[0]);
|
||||
}
|
||||
t
|
||||
}
|
||||
@@ -520,8 +506,8 @@ impl Tokens {
|
||||
// Green and blue status lights merge for tritan vision; use rose for working.
|
||||
t.agent_busy = Color32::from_rgb(232, 150, 190);
|
||||
t.preview_sketch = Color32::from_rgb(125, 180, 105);
|
||||
t.glyph_text = icon_primary(strokes::CONSTRAINT_BLUE_YELLOW[dark as usize]);
|
||||
t.construction_geom = icon_primary(strokes::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
|
||||
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]);
|
||||
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
|
||||
// Conflicting sketch geometry (#7244) apart from the lost links and the construction and
|
||||
// centerline lines for a blue-yellow loss: darker on the light view, lighter on the dark.
|
||||
t.sketch_conflict = if dark { Color32::from_rgb(255, 170, 160) } else { Color32::from_rgb(165, 15, 30) };
|
||||
@@ -1,9 +0,0 @@
|
||||
[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
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,32 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,17 +0,0 @@
|
||||
[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 }
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,288 +0,0 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[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"
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,665 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//! 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};
|
||||
@@ -1,118 +0,0 @@
|
||||
//! 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,7 +10,6 @@ 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_color::srgb::encode8),
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
|
||||
use crate::persistent_name::{Base, FaceName, FeatureId};
|
||||
use crate::{FeatureKind, PlaneRef};
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
@@ -18,20 +18,9 @@ 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.
|
||||
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
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
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
|
||||
@@ -421,19 +410,6 @@ 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,12 +176,7 @@ mod tests {
|
||||
let sag = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.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]))
|
||||
.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);
|
||||
|
||||
@@ -2294,6 +2294,10 @@ pub struct MeshData {
|
||||
pub positions: std::sync::Arc<[f64]>,
|
||||
/// Three vertex indices per triangle.
|
||||
pub triangles: std::sync::Arc<[u32]>,
|
||||
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
|
||||
/// Independent of the welded positions and the geometric boundary certificate.
|
||||
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
|
||||
pub corner_normals: std::sync::Arc<[f64]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
@@ -2309,6 +2313,36 @@ impl PartialEq for MeshData {
|
||||
&& self.color == other.color
|
||||
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
|
||||
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
|
||||
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
/// Explicitly generate crease-aware shading for an existing imported source.
|
||||
/// Coordinates, triangle indices, topology and body identity remain unchanged.
|
||||
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
|
||||
if !angle_degrees.is_finite()
|
||||
|| !(0.0..=180.0).contains(&angle_degrees)
|
||||
|| !self.positions.len().is_multiple_of(3)
|
||||
|| !self.triangles.len().is_multiple_of(3)
|
||||
{
|
||||
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
|
||||
}
|
||||
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
|
||||
let mut positions = Vec::new();
|
||||
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
|
||||
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
|
||||
let mut triangles = Vec::new();
|
||||
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
|
||||
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
|
||||
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
|
||||
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
|
||||
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(allocation)?;
|
||||
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
|
||||
self.corner_normals = values.into();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -1224,12 +1224,15 @@ struct ImportDigests {
|
||||
struct MeshDigest {
|
||||
positions: Arc<[f64]>,
|
||||
triangles: Arc<[u32]>,
|
||||
corner_normals: Arc<[f64]>,
|
||||
digest: u64,
|
||||
}
|
||||
|
||||
impl MeshDigest {
|
||||
fn matches(&self, mesh: &crate::MeshData) -> bool {
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions)
|
||||
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,7 +1251,12 @@ impl ImportDigests {
|
||||
.enumerate()
|
||||
.map(|(index, mesh)| {
|
||||
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
|
||||
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
|
||||
MeshDigest {
|
||||
positions: mesh.positions.clone(),
|
||||
triangles: mesh.triangles.clone(),
|
||||
corner_normals: mesh.corner_normals.clone(),
|
||||
digest,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.meshes.insert(f.id, digests);
|
||||
@@ -1277,6 +1285,9 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
|
||||
coordinate.to_bits().hash(&mut h);
|
||||
}
|
||||
mesh.triangles.hash(&mut h);
|
||||
for n in mesh.corner_normals.iter() {
|
||||
n.to_bits().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -1368,6 +1379,7 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
|
||||
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
|
||||
mesh.positions = Arc::from([]);
|
||||
mesh.triangles = Arc::from([]);
|
||||
mesh.corner_normals = Arc::from([]);
|
||||
}
|
||||
}
|
||||
// Keep the complete definition (including mesh order, names and colors) without
|
||||
@@ -8398,7 +8410,17 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
|
||||
for m in meshes {
|
||||
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
|
||||
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
|
||||
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
|
||||
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
|
||||
}
|
||||
let normals: Vec<[Vec3; 3]> = m
|
||||
.corner_normals
|
||||
.as_chunks::<9>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
|
||||
.collect();
|
||||
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
out.push((m.name.clone(), b.with_color(m.color)));
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -911,6 +911,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
}],
|
||||
},
|
||||
None,
|
||||
@@ -924,7 +925,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
|
||||
assert!(!model.evaluate_shared(&doc));
|
||||
assert!(Arc::ptr_eq(&original, &model.state()));
|
||||
for edit in 0..5 {
|
||||
for edit in 0..6 {
|
||||
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
|
||||
let mesh = &mut meshes[0];
|
||||
match edit {
|
||||
@@ -932,6 +933,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
|
||||
2 => mesh.color = Some([0.2, 0.4, 0.6]),
|
||||
3 => mesh.name = "Renamed".into(),
|
||||
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
|
||||
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
|
||||
}
|
||||
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
|
||||
@@ -1044,6 +1046,7 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
let copy = mesh.clone();
|
||||
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
|
||||
@@ -1771,6 +1774,7 @@ fn imported_meshes() -> Document {
|
||||
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
|
||||
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
|
||||
color: Some([0.2, 0.4, 0.6]),
|
||||
corner_normals: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
|
||||
@@ -1802,6 +1806,16 @@ fn same_as_fresh(doc: &Document, model: &Model) {
|
||||
#[test]
|
||||
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
|
||||
let mut doc = imported_meshes();
|
||||
for mesh in meshes_mut(&mut doc) {
|
||||
mesh.generate_crease_normals(10.0).unwrap();
|
||||
assert!(!mesh.corner_normals.is_empty());
|
||||
}
|
||||
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
|
||||
for mesh in compact[0]["meshes"].as_array().unwrap() {
|
||||
assert_eq!(mesh["positions"], serde_json::json!([]));
|
||||
assert_eq!(mesh["triangles"], serde_json::json!([]));
|
||||
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
|
||||
}
|
||||
let mut model = Model::new();
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
|
||||
for k in 1..5 {
|
||||
|
||||
@@ -1831,26 +1831,6 @@ 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,7 +192,6 @@ 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);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
|
||||
//! triangle, independent of positions, topology and optical boundary certification.
|
||||
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
|
||||
//! preserve authored corner normals and smoothing groups. Explicit crease generation
|
||||
//! stores only shading metadata and preserves original coordinates and triangle indices.
|
||||
//! Older released readers warn about this format and retain fields they cannot read.
|
||||
//!
|
||||
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
|
||||
//! texture sampling. Supported consumers use the captured texture's mip chain and
|
||||
//! ray footprint without changing source image identity or source color semantics.
|
||||
//!
|
||||
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
|
||||
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
|
||||
//!
|
||||
//! Caustics options may save `area_lights: true` and `environment: true` for
|
||||
//! both path tracers. Omission preserves the hard-source photon map.
|
||||
//!
|
||||
//! Caustics may also save `guided_emission: true` to concentrate photon emission
|
||||
//! around captured optical bounds with probability-corrected power. Omission or
|
||||
//! false preserves the original emission proposal. Older released readers warn
|
||||
//! about the newer format and retain fields they cannot interpret when saving.
|
||||
//!
|
||||
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
|
||||
//! pass. Omission or false retains one map per render job.
|
||||
//!
|
||||
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
|
||||
//! using estimated source power with a uniform support component. Each source
|
||||
//! retains its matching probability correction. Omission or false preserves
|
||||
//! uniform source selection and the original random sequence.
|
||||
//!
|
||||
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
|
||||
//! HDR radiance distribution for lighting and photon emission. Omission or false
|
||||
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
|
||||
//! Older designs retain native-size output and the existing denoising settings; omitted
|
||||
//! CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
|
||||
//! The last release warns about this newer design format and preserves fields it cannot read.
|
||||
@@ -240,8 +240,12 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
|
||||
a == b
|
||||
&& meshes.len() == other.len()
|
||||
&& meshes.iter().zip(other).all(|(a, b)| {
|
||||
let crate::MeshData { name, positions, triangles, color } = a;
|
||||
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
|
||||
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
|
||||
*name == b.name
|
||||
&& *color == b.color
|
||||
&& Arc::ptr_eq(positions, &b.positions)
|
||||
&& Arc::ptr_eq(triangles, &b.triangles)
|
||||
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
|
||||
})
|
||||
}
|
||||
(
|
||||
|
||||
@@ -149,14 +149,8 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
// A mesh body is one face with no edges: the flat patch of triangles picked,
|
||||
// traced (#13464).
|
||||
if target.body.is_mesh() {
|
||||
crate::project3d::flat_outline(&mesh, q).iter().flat_map(|outline| fit_mesh_outline(plane, outline)).collect()
|
||||
} else {
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1314,8 +1308,8 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
}
|
||||
|
||||
/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether
|
||||
/// the polyline closes. Its points lie on it within `within`, by default 1e-7 of the radius.
|
||||
fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
/// the polyline closes.
|
||||
fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
|
||||
let n = pts.len();
|
||||
if n < 4 {
|
||||
return None;
|
||||
@@ -1332,15 +1326,15 @@ fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)>
|
||||
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2);
|
||||
let r = centre.dist(a);
|
||||
let un = nrm.normalized()?;
|
||||
let tol = within.unwrap_or(1e-7 * r.max(1.0));
|
||||
let tol = 1e-7 * r.max(1.0);
|
||||
pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed))
|
||||
}
|
||||
|
||||
/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc
|
||||
/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection
|
||||
/// keeps their weights).
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3], within: Option<f64>) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts, within)?;
|
||||
fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
@@ -1435,31 +1429,9 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
|
||||
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
|
||||
}
|
||||
|
||||
/// Most corners a mesh face's outline has and is still taken for a polygon.
|
||||
const MESH_POLYGON_CORNERS: usize = 12;
|
||||
|
||||
/// The outline of a mesh body's flat patch (a closed loop of its corners) as lines and arcs. Any
|
||||
/// triangle's corners and any rectangle's lie on one circle, so only a loop of more corners than
|
||||
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
|
||||
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
|
||||
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
|
||||
/// size, not of the circle's.
|
||||
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
|
||||
if pts.len() > MESH_POLYGON_CORNERS + 1
|
||||
&& let Some(e) = tilted_circle(plane, pts, Some(within))
|
||||
{
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let mut out = Vec::new();
|
||||
fit_chain(&p2, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Project a world polyline and fit lines and arcs to it.
|
||||
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
if let Some(e) = tilted_circle(plane, pts, None) {
|
||||
if let Some(e) = tilted_circle(plane, pts) {
|
||||
return e;
|
||||
}
|
||||
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
|
||||
|
||||
@@ -278,134 +278,6 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
|
||||
Some(e2.dot(q) * inv)
|
||||
}
|
||||
|
||||
/// Most triangles [`flat_outline`] looks through.
|
||||
const FLAT_OUTLINE_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The outline of the flat patch of a mesh body's triangles at `at`: the triangle nearest `at`
|
||||
/// and every triangle joined to it edge to edge on its plane, traced as closed loops, each with its
|
||||
/// first point again at its end. An imported mesh is one face, so this flat patch is what a pick
|
||||
/// on one of its faces means (#13464). Empty when the mesh has no triangle or is too large.
|
||||
pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
if m.triangles.is_empty() || m.triangles.len() > FLAT_OUTLINE_TRIANGLES {
|
||||
return Vec::new();
|
||||
}
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
// Corners at one place are one corner: an STL repeats each corner for every triangle.
|
||||
let weld = size * 1e-9;
|
||||
let mut ids: HashMap<(i64, i64, i64), usize> = HashMap::new();
|
||||
let mut corners: Vec<Vec3> = Vec::new();
|
||||
let mut tri: Vec<[usize; 3]> = Vec::with_capacity(m.triangles.len());
|
||||
for t in &m.triangles {
|
||||
let Some(ps) = m.tri(t) else { continue };
|
||||
let mut out = [0; 3];
|
||||
for (o, p) in out.iter_mut().zip(ps) {
|
||||
if !p.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
let key = ((p.x / weld).round() as i64, (p.y / weld).round() as i64, (p.z / weld).round() as i64);
|
||||
*o = *ids.entry(key).or_insert_with(|| {
|
||||
corners.push(p);
|
||||
corners.len() - 1
|
||||
});
|
||||
}
|
||||
tri.push(out);
|
||||
}
|
||||
let at_of = |t: &[usize; 3]| t.map(|i| corners.get(i).copied().unwrap_or_default());
|
||||
let normal = |t: &[usize; 3]| {
|
||||
let [a, b, c] = at_of(t);
|
||||
(b - a).cross(c - a).normalized()
|
||||
};
|
||||
// The triangle nearest the pick, and its plane.
|
||||
let Some(seed) = (0..tri.len()).filter(|i| tri.get(*i).and_then(normal).is_some()).min_by(|x, y| {
|
||||
let d = |i: &usize| {
|
||||
tri.get(*i).map_or(f64::INFINITY, |t| {
|
||||
let [a, b, c] = at_of(t);
|
||||
crate::project::closest_on_triangle(at, a, b, c).dist(at)
|
||||
})
|
||||
};
|
||||
d(x).total_cmp(&d(y))
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some((n0, p0)) = tri.get(seed).and_then(|t| Some((normal(t)?, at_of(t)[0]))) else { return Vec::new() };
|
||||
let flat = |t: &[usize; 3]| normal(t).is_some_and(|n| n.dot(n0) > 1.0 - 1e-6) && at_of(t).iter().all(|p| (*p - p0).dot(n0).abs() <= size * 1e-6);
|
||||
// Triangles by the edges they share.
|
||||
let mut by_edge: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
for (i, t) in tri.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
by_edge.entry((a.min(b), a.max(b))).or_default().push(i);
|
||||
}
|
||||
}
|
||||
// The flat patch: from the seed across shared edges onto triangles on its plane.
|
||||
let mut patch: HashSet<usize> = HashSet::from([seed]);
|
||||
let mut queue = VecDeque::from([seed]);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let Some(t) = tri.get(i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
for j in by_edge.get(&(a.min(b), a.max(b))).into_iter().flatten() {
|
||||
if !patch.contains(j) && tri.get(*j).is_some_and(flat) {
|
||||
patch.insert(*j);
|
||||
queue.push_back(*j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Its outline: the edges only one of its triangles has, each the way its triangle runs.
|
||||
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in &patch {
|
||||
let Some(t) = tri.get(*i) else { continue };
|
||||
for k in 0..3 {
|
||||
let (a, b) = (t[k], t[(k + 1) % 3]);
|
||||
let inside = by_edge.get(&(a.min(b), a.max(b))).map_or(0, |ts| ts.iter().filter(|j| patch.contains(j)).count());
|
||||
if inside == 1 {
|
||||
next.entry(a).or_default().push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut loops = Vec::new();
|
||||
let mut starts: Vec<usize> = next.keys().copied().collect();
|
||||
starts.sort_unstable();
|
||||
for start in starts {
|
||||
while let Some(mut v) = next.get_mut(&start).and_then(Vec::pop) {
|
||||
let mut pts: Vec<Vec3> = [start, v].iter().filter_map(|i| corners.get(*i).copied()).collect();
|
||||
let mut steps = 0;
|
||||
while v != start && steps < patch.len() * 3 {
|
||||
let Some(w) = next.get_mut(&v).and_then(Vec::pop) else { break };
|
||||
pts.extend(corners.get(w).copied());
|
||||
v = w;
|
||||
steps += 1;
|
||||
}
|
||||
if v == start && pts.len() >= 4 {
|
||||
loops.extend(turning_corners(&pts, size * 1e-9));
|
||||
}
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
|
||||
/// A closed loop (its first point again at its end) without the corners where it runs straight
|
||||
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
|
||||
/// meets it. None when fewer than three corners turn.
|
||||
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
|
||||
let ring = pts.get(..pts.len().saturating_sub(1))?;
|
||||
let n = ring.len();
|
||||
let turns = |i: usize| {
|
||||
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
|
||||
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
|
||||
let (a, b) = (*here - *prev, *next - *here);
|
||||
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
|
||||
};
|
||||
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
|
||||
if out.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
out.extend(out.first().copied());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
|
||||
/// miss the face split the result.
|
||||
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
|
||||
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -82,6 +82,18 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
/// Zero selects a bounded native worker count; one retains serial transport.
|
||||
#[serde(skip_serializing_if = "zero_threads")]
|
||||
pub threads: u16,
|
||||
/// Spend HDR lighting samples according to captured environment radiance.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment_importance_sampling: bool,
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -98,6 +110,13 @@ pub struct CpuJob {
|
||||
pub path: Option<solidcraft_render::job::CpuPathOptions>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -129,6 +148,10 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threads: 0,
|
||||
environment_importance_sampling: false,
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
volume: None,
|
||||
sun: None,
|
||||
@@ -136,6 +159,9 @@ impl Default for CpuJob {
|
||||
aging: Default::default(),
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
denoise_settings: DenoiseSettings::default(),
|
||||
denoise_diagnostics: false,
|
||||
supersampling: 1,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
@@ -200,6 +226,10 @@ impl CpuJob {
|
||||
})
|
||||
.transpose()?;
|
||||
let options = RaytraceOptions {
|
||||
threads: self.threads,
|
||||
environment_importance_sampling: self.environment_importance_sampling,
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
volume: self.volume,
|
||||
sun: self.sun.map(Box::new),
|
||||
@@ -207,6 +237,9 @@ impl CpuJob {
|
||||
aging: (!aging.is_empty()).then(|| Box::new(aging)),
|
||||
adaptive: self.adaptive.map(Box::new),
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
path: self.path,
|
||||
lens: self.lens,
|
||||
output: self.output.clone(),
|
||||
@@ -233,6 +266,12 @@ impl CpuJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GpuJob {
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
@@ -255,6 +294,13 @@ pub struct GpuJob {
|
||||
pub seed: u32,
|
||||
pub tone_map: ToneMap,
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -269,6 +315,8 @@ impl Default for GpuJob {
|
||||
fn default() -> Self {
|
||||
let options = JobOptions::default();
|
||||
Self {
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
aging: Default::default(),
|
||||
nested_media: options.nested_media,
|
||||
@@ -283,6 +331,9 @@ impl Default for GpuJob {
|
||||
seed: options.seed,
|
||||
tone_map: options.tone_map,
|
||||
denoise: options.denoise,
|
||||
denoise_settings: options.denoise_settings,
|
||||
denoise_diagnostics: options.denoise_diagnostics,
|
||||
supersampling: options.supersampling,
|
||||
lens: options.lens,
|
||||
lighting: options.lighting,
|
||||
environment: None,
|
||||
@@ -317,6 +368,8 @@ impl GpuJob {
|
||||
environment.validate()?;
|
||||
}
|
||||
let options = JobOptions {
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
aging: self.aging.parameters_only()?,
|
||||
nested_media: self.nested_media,
|
||||
@@ -331,6 +384,9 @@ impl GpuJob {
|
||||
seed: self.seed,
|
||||
tone_map: self.tone_map,
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
lens: self.lens,
|
||||
lighting: self.lighting,
|
||||
exposure_ev,
|
||||
@@ -365,6 +421,10 @@ impl SkyEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_threads(threads: &u16) -> bool {
|
||||
*threads == 0
|
||||
}
|
||||
|
||||
fn unit() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -478,3 +538,33 @@ mod cpu_volume_portable_tests {
|
||||
assert_eq!(serde_json::to_value(job).unwrap(), old);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
|
||||
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
|
||||
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old_cpu.threads, 0);
|
||||
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
|
||||
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
|
||||
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
|
||||
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
|
||||
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false };
|
||||
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
|
||||
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(cpu_reopened, cpu);
|
||||
assert_eq!(gpu_reopened, gpu);
|
||||
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
|
||||
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
|
||||
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
|
||||
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
|
||||
assert_eq!(cpu_runtime.denoise_settings, filter);
|
||||
assert_eq!(gpu_runtime.denoise_settings, filter);
|
||||
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
|
||||
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
|
||||
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
|
||||
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
|
||||
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ impl OpticalBindings {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.0.len() > 32 || self.0.capacity() > 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for binding in &self.0 {
|
||||
@@ -164,7 +164,7 @@ impl OpticalBindings {
|
||||
}
|
||||
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.len() > 8 {
|
||||
if !path && self.len() > 8 {
|
||||
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
|
||||
}
|
||||
for binding in self.iter() {
|
||||
@@ -183,11 +183,11 @@ impl OpticalBindings {
|
||||
}
|
||||
*old = binding;
|
||||
} else {
|
||||
if self.0.len() >= 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
|
||||
if self.0.capacity() > 32 {
|
||||
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
|
||||
}
|
||||
self.0.push(binding);
|
||||
@@ -202,7 +202,7 @@ impl OpticalBindings {
|
||||
}
|
||||
impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
|
||||
result.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -212,6 +212,25 @@ impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
mod advanced_model_contract_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
|
||||
#[test]
|
||||
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
|
||||
let mut bindings = OpticalBindings::default();
|
||||
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
let binding = SavedOptics {
|
||||
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
|
||||
optics: OpticalMaterial::default(),
|
||||
};
|
||||
bindings.set(binding, false).unwrap();
|
||||
}
|
||||
bindings.validate_for_cpu_path(true).unwrap();
|
||||
assert!(bindings.validate_for_cpu().is_err());
|
||||
let raw = serde_json::to_value(&bindings).unwrap();
|
||||
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
|
||||
let mut too_many = raw.as_array().unwrap().clone();
|
||||
too_many.push(too_many[0].clone());
|
||||
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
|
||||
let model = GlassModel {
|
||||
@@ -238,14 +257,32 @@ mod advanced_model_contract_tests {
|
||||
#[test]
|
||||
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
|
||||
use crate::render_job::{CpuJob, GpuJob};
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
|
||||
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions {
|
||||
photons: 8192,
|
||||
radius_mm: 0.4,
|
||||
max_depth: 12,
|
||||
seed: 381,
|
||||
area_lights: false,
|
||||
environment: false,
|
||||
guided_emission: true,
|
||||
refresh_per_pass: true,
|
||||
power_weighted_sources: true,
|
||||
});
|
||||
let cpu = CpuJob {
|
||||
caustics,
|
||||
environment_importance_sampling: true,
|
||||
path: Some(Default::default()),
|
||||
progressive: Some(Default::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let gpu = GpuJob { caustics, ..Default::default() };
|
||||
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
|
||||
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
|
||||
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
|
||||
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +212,27 @@ impl RenderSetup {
|
||||
));
|
||||
}
|
||||
job.validate(&self.camera, self.exposure_ev)?;
|
||||
if let RenderJob::GpuPath { options, .. } = job {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
if let RenderJob::CpuRaytrace { options, .. } = job {
|
||||
if options.denoise {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
// Existing native-size Workstation intent remains readable. X2
|
||||
// uses the bounded 8K output envelope and pays for its internal grid.
|
||||
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
|
||||
(self.width, self.height)
|
||||
} else {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?
|
||||
};
|
||||
if options.denoise || options.denoise_diagnostics {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
options
|
||||
.budget
|
||||
.validate_image(
|
||||
self.width,
|
||||
self.height,
|
||||
trace_width,
|
||||
trace_height,
|
||||
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
)
|
||||
@@ -670,7 +682,7 @@ mod tests {
|
||||
gpu(3840, 2160).validate().unwrap();
|
||||
gpu(3840, 2160).validate_for_gpu().unwrap();
|
||||
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
|
||||
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
|
||||
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
|
||||
assert!(!message.contains("CPU resource profile"), "{message}");
|
||||
}
|
||||
|
||||
@@ -883,7 +895,7 @@ mod tests {
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
|
||||
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
|
||||
@@ -1021,7 +1033,7 @@ mod cpu_budget_tests {
|
||||
let old = CpuJob::default();
|
||||
assert!(old.path.is_none());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
|
||||
for depth in [1, 8, 16] {
|
||||
for depth in [1, 8, 16, 64] {
|
||||
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
@@ -1032,7 +1044,7 @@ mod cpu_budget_tests {
|
||||
}
|
||||
for raw in [
|
||||
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":8}}),
|
||||
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
|
||||
] {
|
||||
@@ -1073,7 +1085,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
|
||||
setup.validate().unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.denoise = true;
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
|
||||
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1220,7 +1232,9 @@ mod cpu_adaptive_saved_tests {
|
||||
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
|
||||
"16 pilots cannot be hidden outside Compact's 64-sample cap"
|
||||
);
|
||||
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
|
||||
for (budget, maximum) in
|
||||
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
|
||||
{
|
||||
let mut runtime = runtime.clone();
|
||||
runtime.budget = budget;
|
||||
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
|
||||
@@ -1385,3 +1399,39 @@ mod cpu_celestial_saved_tests {
|
||||
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
|
||||
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
|
||||
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
gpu.validate().unwrap();
|
||||
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(reopened, gpu);
|
||||
assert_eq!((reopened.width, reopened.height), (7680, 4320));
|
||||
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
|
||||
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
cpu.validate().unwrap();
|
||||
assert_eq!((cpu.width, cpu.height), (7680, 4320));
|
||||
cpu.width = 1024;
|
||||
cpu.height = 1024;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.budget = solidcraft_render::job::CpuBudget::Compact;
|
||||
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
|
||||
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.supersampling = 1;
|
||||
cpu.validate().unwrap();
|
||||
let mut invalid = gpu;
|
||||
invalid.width = 8193;
|
||||
invalid.height = 1;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ 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 }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
|
||||
//! No render is started. Geometry and the original OPUS Master setup are retained.
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
|
||||
};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::{error::Error, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
|
||||
if condition { Ok(()) } else { Err(message.into()) }
|
||||
}
|
||||
|
||||
const CURVED: &[&str] = &[
|
||||
"A3 - continuous sixteen facet freeform glass knot",
|
||||
"A3 - six lobe quilted aspheric witness optic",
|
||||
"Glass - smoked rounded toroid",
|
||||
"Glass - clear calibration sphere",
|
||||
"O2 - Lens 1 - Biconvex crown",
|
||||
"O2 - Lens 2 - Biconcave flint",
|
||||
"O2 - Lens 3 - Thick meniscus",
|
||||
"O2 - Lens 4 - Plano convex magnifier",
|
||||
"O2 - Lens 5 - Aspheric correction optic",
|
||||
"R2 - clear chest reactor lens",
|
||||
"R2 - near clear glass eye",
|
||||
"R2 - far clear glass eye",
|
||||
"S2 - amber solid ellipsoid",
|
||||
"S2 - cobalt fluted solid decanter",
|
||||
"R2 - thorax ivory swept breastplates",
|
||||
"S2 - glass gallery turned wood pedestal -217",
|
||||
"S2 - glass gallery turned wood pedestal 211",
|
||||
];
|
||||
|
||||
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
|
||||
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
|
||||
let triangles = bodies
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
|
||||
Ok((bodies.len(), triangles))
|
||||
}
|
||||
|
||||
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
|
||||
let mut setup = serde_json::to_value(master)?;
|
||||
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
|
||||
for binding in &mut optics {
|
||||
binding["optics"]["shadow_transmission"] = json!(false);
|
||||
}
|
||||
setup["optics"] = json!(optics);
|
||||
setup["mode"] = json!("final");
|
||||
setup["width"] = json!(if pilot { 320 } else { 3840 });
|
||||
setup["height"] = json!(if pilot { 180 } else { 2160 });
|
||||
let samples = if pilot { 8u32 } else { 4096u32 };
|
||||
let mut options = setup["job"]["options"].clone();
|
||||
options["texture_filtering"] = json!(true);
|
||||
options["linear_exr"] = json!(true);
|
||||
options["max_depth"] = json!(64);
|
||||
options["samples"] = json!(samples);
|
||||
options["nested_media"] = json!(true);
|
||||
options["denoise"] = json!(false);
|
||||
if cpu {
|
||||
let lighting = &options["lighting"];
|
||||
let area_lights = lighting["area_lights"]
|
||||
.as_array()
|
||||
.ok_or("missing area lights")?
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
|
||||
let intensity = radiance.into_iter().fold(0.0, f64::max);
|
||||
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
|
||||
// GPU stores half-edges; the CPU rectangle contract takes full sides.
|
||||
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
|
||||
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
|
||||
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
|
||||
})
|
||||
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
|
||||
let cpu_options = json!({
|
||||
"budget":"workstation", "texture_filtering":true,
|
||||
"progressive":{"samples_per_pixel":samples,"seed":4619},
|
||||
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
|
||||
"lights":[], "area_lights":area_lights,"area_samples":1,
|
||||
"punctual_lights":lighting["punctual_lights"],
|
||||
"environment":{"color":lighting["environment"],"intensity":1.0},
|
||||
"aging":options["aging"], "denoise":false,
|
||||
"output":{"tone_map":options["tone_map"],"linear_exr":true}
|
||||
});
|
||||
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
|
||||
} else {
|
||||
setup["job"]["options"] = options;
|
||||
}
|
||||
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
|
||||
checked.validate()?;
|
||||
require(checked.camera == master.camera, "reference camera differs from Master")?;
|
||||
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
|
||||
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
|
||||
let triangles = mesh.triangles.as_chunks::<3>().0;
|
||||
let mut weld = std::collections::HashMap::new();
|
||||
let mut canonical = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let next = weld.len();
|
||||
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
|
||||
}
|
||||
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
|
||||
let mut face_normals = Vec::with_capacity(triangles.len());
|
||||
for (i, t) in triangles.iter().enumerate() {
|
||||
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
|
||||
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
|
||||
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
|
||||
edges
|
||||
.entry((a.min(b), a.max(b)))
|
||||
.and_modify(|e| {
|
||||
e.1 = Some(i * 3 + k);
|
||||
e.2 += 1;
|
||||
})
|
||||
.or_insert((i * 3 + k, None, 1));
|
||||
}
|
||||
}
|
||||
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
|
||||
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
|
||||
Ok(Vec3::new(n[0], n[1], n[2]))
|
||||
};
|
||||
let mut bins = [0u64; 7];
|
||||
let mut sharp = 0u64;
|
||||
let mut sharp_discontinuous = 0u64;
|
||||
let mut boundary_or_nonmanifold = 0u64;
|
||||
for (_, (a, b, count)) in edges {
|
||||
let Some(b) = b.filter(|_| count == 2) else {
|
||||
boundary_or_nonmanifold += 1;
|
||||
continue;
|
||||
};
|
||||
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
|
||||
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
|
||||
bins[bin] += 1;
|
||||
if angle > 10.0 + 1e-9 {
|
||||
sharp += 1;
|
||||
let (ak, bk) = (a % 3, b % 3);
|
||||
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
|
||||
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
|
||||
if !same_a || !same_b {
|
||||
sharp_discontinuous += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
|
||||
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
|
||||
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
|
||||
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
|
||||
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
|
||||
require(args.next().is_none(), "unexpected argument")?;
|
||||
fs::create_dir(&output)?;
|
||||
let original = fs::read(&baseline)?;
|
||||
let mut doc: Document = solidcraft_io::read_design(&original)?;
|
||||
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
|
||||
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
|
||||
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
|
||||
let baseline_doc = doc.clone();
|
||||
let mut baseline_session = Session::new(doc.clone());
|
||||
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
|
||||
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
|
||||
let mut edits = Vec::new();
|
||||
for feature in &mut doc.features {
|
||||
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
|
||||
for mesh in meshes {
|
||||
if CURVED.contains(&mesh.name.as_str()) {
|
||||
let positions = mesh.positions.clone();
|
||||
let triangles = mesh.triangles.clone();
|
||||
mesh.generate_crease_normals(10.0)?;
|
||||
require(
|
||||
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
|
||||
"normal generation changed geometry arrays",
|
||||
)?;
|
||||
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
|
||||
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
|
||||
let mut session = Session::new(doc);
|
||||
let mut setup_receipts = Vec::new();
|
||||
for (cpu, pilot, name) in [
|
||||
(true, false, "OPUS Physical CPU 4K"),
|
||||
(false, false, "OPUS Physical GPU 4K"),
|
||||
(true, true, "OPUS Physical CPU Pilot"),
|
||||
(false, true, "OPUS Physical GPU Pilot"),
|
||||
] {
|
||||
let setup = physical(&master, cpu, pilot)?;
|
||||
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
|
||||
}
|
||||
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
|
||||
let scene = session.execute("render.scene", &json!({}))?;
|
||||
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
|
||||
require(
|
||||
session.doc.components == baseline_doc.components
|
||||
&& session.doc.occurrences == baseline_doc.occurrences
|
||||
&& session.doc.assembly == baseline_doc.assembly,
|
||||
"assembly, pose or occurrence metadata changed",
|
||||
)?;
|
||||
let copy_path = output.join("OPUS-04-physical-reference.sc");
|
||||
let copy = solidcraft_io::write_design_container(&session.doc)?;
|
||||
fs::write(©_path, ©)?;
|
||||
let reopened = solidcraft_io::read_design(©)?;
|
||||
require(reopened == *session.doc, "copy changed on cold reopen")?;
|
||||
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
|
||||
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
|
||||
let report = json!({
|
||||
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
|
||||
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
|
||||
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
|
||||
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
|
||||
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
|
||||
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
|
||||
"reference_shadow_transmission":false,"render_started":false,
|
||||
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
|
||||
});
|
||||
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -261,9 +261,9 @@ fn an_older_two_sided_extrude_with_a_negative_distance_keeps_its_sides() {
|
||||
let mut base = fixture(json!({"direction":"positive","distance":"10 mm","distance2":"10 mm"}));
|
||||
run(&mut base, "parameters.add", json!({"name":"width","expression":"20 mm"}));
|
||||
let mut saved: Value = serde_json::from_str(&base.doc.to_json()).unwrap();
|
||||
// As the last release saved it: the format before this build's pending changes.
|
||||
let version: u32 = saved["format"].as_str().unwrap().trim_start_matches("solidcraft/").parse().unwrap();
|
||||
saved["format"] = json!(format!("solidcraft/{}", version - 1));
|
||||
// Format 50 predates the v51 second-side migration. A moving current-1 tag
|
||||
// can already include that migration after unrelated format changes.
|
||||
saved["format"] = json!("solidcraft/50");
|
||||
saved.as_object_mut().unwrap().remove("format_pending");
|
||||
for (distance, lo, hi) in [("-width", -20.0, 10.0), ("-0 mm", -10.0, 0.0), ("-10 mm", -10.0, 10.0)] {
|
||||
let mut file = saved.clone();
|
||||
|
||||
@@ -12,11 +12,10 @@ 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::SweepParams;
|
||||
use crate::schema::features::{
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
|
||||
SphereParams, TorusParams,
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, 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};
|
||||
@@ -28,14 +27,12 @@ const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
|
||||
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
|
||||
const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
|
||||
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
|
||||
const SWEEP_CONTRACT: TypedContract<SweepParams> = TypedContract::new();
|
||||
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||
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))
|
||||
@@ -49,11 +46,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.contract(&SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
|
||||
@@ -93,12 +88,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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?: 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.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?: 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")
|
||||
@@ -122,7 +112,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); 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)"),
|
||||
.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)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -2951,7 +2941,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.
|
||||
pub(super) fn brief(text: &str) -> String {
|
||||
fn brief(text: &str) -> String {
|
||||
const MAX: usize = 32;
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((end, _)) => format!("{}…", &text[..end]),
|
||||
@@ -2995,35 +2985,6 @@ 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());
|
||||
@@ -3247,28 +3208,18 @@ 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)))),
|
||||
};
|
||||
// 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" }) {
|
||||
let kind = match str_(p, "type").unwrap_or("simple") {
|
||||
"simple" => match expr(p, "tip_angle") {
|
||||
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
|
||||
_ => solidcraft_doc::HoleKind::Simple,
|
||||
},
|
||||
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
|
||||
"counterbore" => {
|
||||
// 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() }
|
||||
}
|
||||
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
|
||||
cb_diameter: get("cb_diameter")?,
|
||||
cb_depth: get("cb_depth")?,
|
||||
tip_angle: expr(p, "tip_angle"),
|
||||
bridge: bridge.clone(),
|
||||
},
|
||||
"countersink" => solidcraft_doc::HoleKind::Countersink {
|
||||
cs_diameter: get("cs_diameter")?,
|
||||
// The default only when no angle is given: a supplied one out of range is refused (#9625).
|
||||
@@ -3565,7 +3516,17 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(v) => sweep_orientation(v).map_err(|e| bad(cmd, e))?,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(Dir(d))
|
||||
}
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -3593,24 +3554,6 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
/// A sweep's `orientation` as given (not `null`): perpendicular or parallel (any case), or a
|
||||
/// non-zero direction `[x, y, z]`, `{x, y, z}` or `{direction: …}`. The error is the message.
|
||||
pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientation, &'static str> {
|
||||
match v {
|
||||
Value::String(o) if o.eq_ignore_ascii_case("perpendicular") => Ok(SweepOrientation::Perpendicular),
|
||||
Value::String(o) if o.eq_ignore_ascii_case("parallel") => Ok(SweepOrientation::Parallel),
|
||||
v => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or("`orientation` must be perpendicular, parallel or a direction [x, y, z]")?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err("the `orientation` direction must not be zero");
|
||||
}
|
||||
Ok(SweepOrientation::Direction(Dir(d)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
|
||||
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
|
||||
/// stretch; and no orientation, twist or scale besides, since the rails set those.
|
||||
@@ -3631,7 +3574,9 @@ pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -
|
||||
pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
|
||||
let rail_fit = match p.get("rail_fit") {
|
||||
None | Some(Value::Null) => RailFit::Scale,
|
||||
Some(v) => rail_fit_form(v).map_err(|e| bad(cmd, e))?,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
|
||||
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
|
||||
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
|
||||
};
|
||||
let given = match p.get("rails") {
|
||||
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
|
||||
@@ -3657,16 +3602,6 @@ pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default
|
||||
Ok((rails, rail_fit))
|
||||
}
|
||||
|
||||
/// Guide rails' `rail_fit` as given (not `null`): scale or stretch, in any case. The error is
|
||||
/// the message.
|
||||
pub(crate) fn rail_fit_form(v: &Value) -> std::result::Result<RailFit, &'static str> {
|
||||
match v {
|
||||
Value::String(f) if f.eq_ignore_ascii_case("scale") => Ok(RailFit::Scale),
|
||||
Value::String(f) if f.eq_ignore_ascii_case("stretch") => Ok(RailFit::Stretch),
|
||||
_ => Err("`rail_fit` must be scale or stretch"),
|
||||
}
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
|
||||
+137
-32
@@ -23,8 +23,6 @@ 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] = &[
|
||||
@@ -41,20 +39,16 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.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", crate::checked!("file.save", save))
|
||||
.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); crease_angle_degrees?: 0..180 (0 clears imported shading normals for the previous flat-shading behavior; positive angles regenerate angle-weighted normals across manifold edges below this crease angle; omitted keeps authored OBJ normals/smoothing groups, or flat shading when absent; geometry stays unchanged)"),
|
||||
CommandSpec::new("file.save", "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", crate::checked!("file.save_as", save_as))
|
||||
CommandSpec::new("file.save_as", "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()
|
||||
@@ -128,8 +122,6 @@ 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();
|
||||
@@ -270,11 +262,19 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
|
||||
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
|
||||
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
|
||||
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
|
||||
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
|
||||
fn add_mesh(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
name: Option<&str>,
|
||||
y_up: Option<bool>,
|
||||
unit: Option<(&'static str, f64)>,
|
||||
origin: bool,
|
||||
crease_angle: Option<f64>,
|
||||
) -> Result<Value> {
|
||||
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let gltf = solidcraft_io::is_gltf_path(path);
|
||||
let (mf, plan, looks) = if gltf {
|
||||
let (mut mf, plan, looks) = if gltf {
|
||||
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let mut load =
|
||||
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
|
||||
@@ -286,6 +286,19 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
|
||||
} else {
|
||||
(solidcraft_io::mesh_import_feature_in(&bytes, path, unit.map_or(1.0, |(_, mm)| mm)).map_err(err)?, None, Vec::new())
|
||||
};
|
||||
if let Some(angle) = crease_angle
|
||||
&& let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut mf.kind
|
||||
{
|
||||
for mesh in meshes {
|
||||
if angle == 0.0 {
|
||||
// Keep the original flat display path, including its normalization
|
||||
// and storage behavior, when the caller opts out of imported shading.
|
||||
mesh.corner_normals = Default::default();
|
||||
} else {
|
||||
mesh.generate_crease_normals(angle).map_err(|e| bad("file.insert_mesh", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
|
||||
let cname = import::component_name(path, name);
|
||||
let tree = match &plan {
|
||||
@@ -373,7 +386,16 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let up = mesh_up(p, path, cmd)?;
|
||||
let unit = mesh_units(p, path, cmd)?;
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
|
||||
let crease_angle = match p.get("crease_angle_degrees") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (0.0..=180.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "crease_angle_degrees must be between 0 and 180 degrees"))?,
|
||||
),
|
||||
};
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?, crease_angle)
|
||||
}
|
||||
|
||||
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
|
||||
@@ -466,7 +488,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = if solidcraft_io::is_step_path(path) {
|
||||
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
|
||||
} else {
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?, None)?
|
||||
};
|
||||
// The design is the file: its component is the one being worked in.
|
||||
if let Some(c) = r["component"].as_u64() {
|
||||
@@ -690,15 +712,7 @@ fn write_bytes(
|
||||
s.disk = solidcraft_io::design_lock::stamp(path);
|
||||
s.read_only = false;
|
||||
s.mark_saved();
|
||||
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(),
|
||||
};
|
||||
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
|
||||
// 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() {
|
||||
@@ -706,13 +720,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 = Some(s.doc.format.clone());
|
||||
out["kept_format"] = json!(s.doc.format);
|
||||
if !lost.is_empty() {
|
||||
out.warnings = Some(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect());
|
||||
out.not_kept = Some(lost);
|
||||
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
|
||||
out["not_kept"] = json!(lost);
|
||||
}
|
||||
}
|
||||
serde_json::to_value(out).map_err(|e| bad("file.save", e.to_string()))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -850,7 +864,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
for body in &world.bodies {
|
||||
let mesh = body.cached_mesh()?;
|
||||
bytes =
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(84)).saturating_add(32);
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
if bytes > 8 << 20 || triangles > 100_000 {
|
||||
return None;
|
||||
@@ -860,6 +874,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: s.doc.body_color(body),
|
||||
corner_normals: mesh.triangles.iter().flatten().filter_map(|&i| mesh.normals.get(i as usize)).flat_map(|n| [n.x, n.y, n.z]).collect(),
|
||||
});
|
||||
}
|
||||
Some(preview)
|
||||
@@ -1410,11 +1425,12 @@ mod tests {
|
||||
assert!(bytes.starts_with(signature), "{cmd}");
|
||||
}
|
||||
// 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`).
|
||||
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
|
||||
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
|
||||
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
|
||||
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..]);
|
||||
let triangles = 6 * 2;
|
||||
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 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);
|
||||
@@ -1998,6 +2014,95 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_explicit_crease_normals_are_undoable_and_survive_cached_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-normals-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("fold.obj");
|
||||
std::fs::write(&path, "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\nf 1 2 3\nf 2 1 4\n").unwrap();
|
||||
let mut session = Session::default();
|
||||
let before = session.doc.to_json();
|
||||
for bad_angle in [json!(-1), json!(181), json!("smooth")] {
|
||||
let result = session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":bad_angle}));
|
||||
assert!(result.unwrap_err().to_string().contains("crease_angle_degrees"));
|
||||
assert_eq!(session.doc.to_json(), before);
|
||||
}
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":40})).unwrap();
|
||||
let normals = |s: &Session| s.model.state().bodies[0].mesh().normals.clone();
|
||||
let expected = normals(&session);
|
||||
assert!(expected[0].y > 0.2 && expected[0].z > 0.9);
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(normals(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).expect("shaded mesh retains native cache");
|
||||
assert!(!cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join("shaded.sc");
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(normals(&reopened), expected);
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
let cold = Session::new(doc);
|
||||
assert_eq!(normals(&cold), expected);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_zero_crease_restores_legacy_flat_shading_and_survives_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-obj-flat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
|
||||
let faces = "f 1 2 3\nf 2 1 4\n";
|
||||
let reference_path = dir.join("reference.obj");
|
||||
std::fs::write(&reference_path, format!("{vertices}{faces}")).unwrap();
|
||||
let mut reference = Session::default();
|
||||
reference.execute("file.insert_mesh", &json!({"path":reference_path})).unwrap();
|
||||
let display = |s: &Session| {
|
||||
let mesh = s.model.state().bodies[0].mesh();
|
||||
(mesh.positions.clone(), mesh.triangles.clone(), mesh.normals.clone())
|
||||
};
|
||||
let expected = display(&reference);
|
||||
for (name, source) in
|
||||
[("groups", format!("{vertices}s 1\n{faces}")), ("authored", format!("{vertices}vn 0 1 1\nf 1//1 2//1 3//1\nf 2//1 1//1 4//1\n"))]
|
||||
{
|
||||
let path = dir.join(format!("{name}.obj"));
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut annotated = Session::default();
|
||||
annotated.execute("file.insert_mesh", &json!({"path":path})).unwrap();
|
||||
assert_ne!(display(&annotated).2, expected.2, "{name} must exercise imported shading");
|
||||
let mut session = Session::default();
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":0})).unwrap();
|
||||
assert_eq!(display(&session), expected, "{name} uses the exact former flat display path");
|
||||
let meshes = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => Some(meshes),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(meshes.iter().all(|mesh| mesh.corner_normals.is_empty()));
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(display(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).unwrap();
|
||||
assert!(cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join(format!("{name}.sc"));
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(display(&reopened), expected);
|
||||
let cold = Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
assert_eq!(display(&cold), expected);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
|
||||
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
|
||||
/// too, was evaluated again on opening.
|
||||
|
||||
@@ -8,21 +8,14 @@ use super::{
|
||||
CommandSpec,
|
||||
features::{add_feature, check_expr},
|
||||
};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::mesh::MeshConvertParams;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{
|
||||
Result, Sel, Session,
|
||||
params::{bad, expr, str_},
|
||||
};
|
||||
|
||||
const MESH_CONVERT_CONTRACT: TypedContract<MeshConvertParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", crate::checked!("mesh.convert", convert))
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
|
||||
.icon("import")
|
||||
.contract(&MESH_CONVERT_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body?: mesh body name (default: the selected mesh body); tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature in the mesh body's owning component (the active component must be that component or an ancestor; otherwise activate the named component first); the active component is unchanged; name?"),
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user