mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 03:01:58 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f042b2c9e |
@@ -30,6 +30,7 @@ on:
|
||||
- "crates/kernel/**"
|
||||
- "crates/geom/**"
|
||||
- "crates/smlib/**"
|
||||
- "crates/step/**"
|
||||
- "crates/io/src/**"
|
||||
- "crates/doc/src/**"
|
||||
- "crates/engine/src/**"
|
||||
@@ -50,6 +51,7 @@ on:
|
||||
- "crates/kernel/**"
|
||||
- "crates/geom/**"
|
||||
- "crates/smlib/**"
|
||||
- "crates/step/**"
|
||||
- "crates/io/src/**"
|
||||
- "crates/doc/src/**"
|
||||
- "crates/engine/src/**"
|
||||
|
||||
Generated
+44
-44
@@ -4234,16 +4234,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-step"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"solidcore",
|
||||
"solidcore-convert",
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-tess"
|
||||
version = "0.1.0"
|
||||
@@ -4259,7 +4249,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4302,11 +4292,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4322,11 +4312,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-color"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"serde",
|
||||
@@ -4335,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control-egui"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"image",
|
||||
@@ -4345,7 +4335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4354,7 +4344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4376,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-dynamics"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4387,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4413,7 +4403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4426,7 +4416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4451,7 +4441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4479,7 +4469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4488,11 +4478,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
"solidcore-step",
|
||||
"solidcore-tess",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-profile",
|
||||
"solidcraft-smlib",
|
||||
"solidcraft-step",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -4500,7 +4490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-look"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
@@ -4512,7 +4502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4526,14 +4516,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp-server"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-profile"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"web-time",
|
||||
@@ -4541,7 +4531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4564,7 +4554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4578,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-smlib"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -4592,18 +4582,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-step"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-profile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-theme"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -4613,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4655,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-kit"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4678,14 +4678,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-registry"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-text"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -4707,7 +4707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport-egui"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -4718,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport-gpu"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4734,7 +4734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proc-macro2",
|
||||
|
||||
+2
-3
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["solidcore", "vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.40.0-rc.4"
|
||||
version = "0.40.0-rc.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,6 +21,7 @@ solidcraft-profile = { path = "crates/profile" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-smlib = { path = "crates/smlib" }
|
||||
solidcraft-step = { path = "crates/step" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
solidcraft-dynamics = { path = "crates/dynamics" }
|
||||
solidcraft-gpu = { path = "crates/gpu" }
|
||||
@@ -54,8 +55,6 @@ solidcore-record = { path = "solidcore/crates/solidcore-record" }
|
||||
# The modelling layer (solidcore's layers §1, "Ops"): the record-level calls SolidCraft makes into the
|
||||
# kernel, converting through solidcore-convert.
|
||||
solidcore-ops = { path = "solidcore/crates/solidcore-ops" }
|
||||
# STEP import and export (vendored with solidcore): the Part 21 parser is solidcore-record's.
|
||||
solidcore-step = { path = "solidcore/crates/solidcore-step" }
|
||||
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
|
||||
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
|
||||
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
|
||||
|
||||
@@ -361,54 +361,23 @@ const UNSTABLE: [(&str, &str); 1] = [(
|
||||
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
|
||||
)];
|
||||
|
||||
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
|
||||
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
|
||||
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
|
||||
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
|
||||
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
|
||||
/// design here gets much faster, or one not listed takes over 5 s.
|
||||
const HEAVIEST: [&str; 6] = [
|
||||
"oracle/77-fillet-variable-linear", // 35.6 s
|
||||
"oracle/83-boundary-loop-thicken", // 12.4 s
|
||||
"regressions/closed_sweep_torus_cut", // 6.8 s
|
||||
"oracle/41-tube-radial-holes", // 4.6 s
|
||||
"oracle/42-overlapping-patterns", // 4.2 s
|
||||
"oracle/35-fillet-curved-curved", // 3.2 s
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
|
||||
let designs = designs();
|
||||
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
|
||||
for heavy in HEAVIEST {
|
||||
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
|
||||
}
|
||||
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
|
||||
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
|
||||
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..workers {
|
||||
scope.spawn(|| {
|
||||
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(&designs[d].1).map(|s| build(&s));
|
||||
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
|
||||
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
|
||||
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(d, (name, _))| {
|
||||
let first = builds.remove(&(d, 0)).unwrap();
|
||||
let second = builds.remove(&(d, 1)).unwrap();
|
||||
(name.clone(), first.and_then(|first| Ok((first, second?))))
|
||||
})
|
||||
.collect();
|
||||
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut problems = Vec::new();
|
||||
for (name, got) in &results {
|
||||
|
||||
@@ -148,21 +148,14 @@ self.addEventListener("activate", (event) => {
|
||||
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
|
||||
return self.registration.unregister();
|
||||
}
|
||||
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
|
||||
// Claim first: from the moment this worker is the active one, a page the old worker controlled has
|
||||
// no controller until it is claimed, and a request it makes then goes straight to the network, which
|
||||
// serves the newest build, not the page's. Nothing slow goes before this.
|
||||
await self.clients.claim();
|
||||
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
}
|
||||
await sweep();
|
||||
// With no earlier build kept, the pages open when the first worker takes over came from the
|
||||
// network, and get their files from it. After an update a page with no record is not
|
||||
// assumed to be anything: its record may only be late, and the network now serves a newer
|
||||
// build than the page's.
|
||||
if (!older.length) {
|
||||
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
|
||||
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
|
||||
}
|
||||
await self.clients.claim();
|
||||
// Pages open when the worker first takes over came from the network, and get their files from it.
|
||||
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
|
||||
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with what SolidCraft's requests mean ([`SolidCraft`]).
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -12,7 +12,6 @@ use solidcraft_control::{ControlRequest, auth};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth::APP;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_ui_egui::control_switch;
|
||||
|
||||
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
|
||||
/// token in each, removed when the app ends unless another launch has written the file since.
|
||||
@@ -86,60 +85,40 @@ pub fn remove_token_file() {
|
||||
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
|
||||
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Open the app's control channel (at launch, or turned on in the settings): its listener, its
|
||||
/// token file and the discovery file. Stopping it removes both files again.
|
||||
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Result<control_switch::Started, control_switch::StartError> {
|
||||
use control_switch::StartError;
|
||||
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
let Some(token) = APP.env_token().or_else(server::new_token) else {
|
||||
solidcraft_engine::note!("solidcraft: control server not started: no random token available");
|
||||
return Err(StartError::Failed("no random token available".into()));
|
||||
};
|
||||
let listener = match listen_on {
|
||||
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
|
||||
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
|
||||
};
|
||||
let listener = listener.map_err(|e| {
|
||||
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
|
||||
match (listen_on, e.kind()) {
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
(auth::Listen::Exact(port), std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(port),
|
||||
(auth::Listen::Default, std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(APP.default_port),
|
||||
_ => StartError::Failed(e.to_string()),
|
||||
}
|
||||
})?;
|
||||
let (port, stop) =
|
||||
listen_on_listener(listener, token.clone(), tx, ctx, false).ok_or_else(|| StartError::Failed("the listener did not start".into()))?;
|
||||
if matches!(listen_on, auth::Listen::Default) {
|
||||
solidcraft_engine::note!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match APP.write_current_port(port) {
|
||||
Ok(_) => {
|
||||
if let Ok(mut p) = APP_PORT.lock() {
|
||||
*p = Some((port, token.clone()));
|
||||
match APP.env_token().or_else(server::new_token) {
|
||||
Some(token) => {
|
||||
let listener = match listen_on {
|
||||
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
|
||||
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
|
||||
};
|
||||
match listener {
|
||||
Ok(l) => {
|
||||
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
|
||||
if matches!(listen_on, auth::Listen::Default) {
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
|
||||
);
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match APP.write_current_port(port) {
|
||||
Ok(_) => {
|
||||
if let Ok(mut p) = APP_PORT.lock() {
|
||||
*p = Some((port, token));
|
||||
}
|
||||
}
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
|
||||
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
|
||||
}
|
||||
let stop = Box::new(move || {
|
||||
stop.stop();
|
||||
if let Ok(mut p) = APP_PORT.lock()
|
||||
&& p.as_ref().is_some_and(|(held, _)| *held == port)
|
||||
{
|
||||
*p = None;
|
||||
}
|
||||
APP.remove_current_port(port, &token);
|
||||
if let Some(path) = APP.token_path(port) {
|
||||
auth::remove_token_file(&path, &token);
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.retain(|(p, _)| *p != path);
|
||||
}
|
||||
}
|
||||
solidcraft_engine::note!("solidcraft: control channel on 127.0.0.1:{port} closed");
|
||||
});
|
||||
Ok(control_switch::Started { port, requests: rx, stop })
|
||||
rx
|
||||
}
|
||||
|
||||
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
|
||||
@@ -158,19 +137,12 @@ pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u1
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The chat's endpoint lives as long as the app.
|
||||
listen_on_listener(listener, token, tx, ctx, true).map(|(port, _)| port)
|
||||
listen_on_listener(listener, token, tx, ctx, true)
|
||||
}
|
||||
|
||||
/// Serve `listener` (loopback) with `token`; the port it listens on and how to stop it.
|
||||
/// Serve `listener` (loopback) with `token`; the port it listens on.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen_on_listener(
|
||||
listener: TcpListener,
|
||||
token: String,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
) -> Option<(u16, server::Stop)> {
|
||||
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
let port = listener.local_addr().ok()?.port();
|
||||
match APP.token_path(port) {
|
||||
Some(path) => match APP.write_token_file(&path, &token) {
|
||||
@@ -192,8 +164,8 @@ fn listen_on_listener(
|
||||
)
|
||||
}
|
||||
}
|
||||
match hub(ctx).listen_stoppable(listener, token, tx, patient) {
|
||||
Ok(stop) => Some((port, stop)),
|
||||
match hub(ctx).listen(listener, token, tx, patient) {
|
||||
Ok(()) => Some(port),
|
||||
Err(e) => {
|
||||
solidcraft_engine::note!("solidcraft: control server not started: {e}");
|
||||
None
|
||||
|
||||
@@ -1320,11 +1320,12 @@ fn main() -> eframe::Result {
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port for this run; otherwise the preference (off
|
||||
// by default) opens its port, and turning it off or on later takes effect at once
|
||||
// (docs/control-protocol.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
solidcraft_ui_egui::control_switch::launch(&mut app, Box::new(move |listen| control_server::start(listen, ctx.clone())), control_port);
|
||||
// `--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) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
|
||||
// on a free port the first time the chat connects (docs/agent-chat.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
|
||||
+41
-41
@@ -931,7 +931,7 @@
|
||||
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
|
||||
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها.",
|
||||
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال",
|
||||
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، على المنفذ أدناه؛ وإذا كان 27878 مستخدمًا فيُستخدم المنفذ الحر التالي). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يسري فورًا. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
@@ -1890,13 +1890,13 @@
|
||||
"help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.",
|
||||
"help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.",
|
||||
"help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.",
|
||||
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور X للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له، أو أدخل إزاحته. تصبح الإزاحة المُدخلة مسافة قائدة. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.perpendicular": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا عموديًا عليه. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.tangent_at": "أنشئ مماسًا عند نقطة على دائرة أو قوس دائري، أو عند نهاية منحنى شريحي مفتوح أو قطع مخروطي مفتوح. اختر المنحنى ثم نقطة التماس.",
|
||||
"help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.",
|
||||
"help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.",
|
||||
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور Y للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.copy": "ينسخ منحنيات الرسم ونقاطه المحددة مع القيود والأبعاد بينها. الصقها في هذا الرسم أو في رسم آخر.",
|
||||
"help.sketch.create": "يبدأ رسمًا ثنائي الأبعاد جديدًا على مستوى أو على وجه مسطح من النموذج. يبدأ كل بثق ودوران وكنس برسم.",
|
||||
"help.sketch.cut": "ينسخ هندسة الرسم المحددة ويحذفها من الرسم بخطوة تراجع واحدة. الصقها في مكان آخر من هذا الرسم أو في رسم آخر.",
|
||||
@@ -3224,10 +3224,10 @@
|
||||
"render.category_paint": "طلاء",
|
||||
"render.category_plastic": "بلاستيك",
|
||||
"render.category_wood": "خشب",
|
||||
"render.caustics_area_lights": "تجمّع الضوء من الأضواء المساحية",
|
||||
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
|
||||
"render.caustics_depth": "عمق مسار تجمّع الضوء",
|
||||
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
|
||||
"render.caustics_environment": "تجمّع الضوء من البيئة",
|
||||
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
|
||||
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_photons": "عدد الفوتونات",
|
||||
@@ -3235,7 +3235,7 @@
|
||||
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
|
||||
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
|
||||
"render.check_media_remaining": "لا تزال شبكات الكثافة والإزاحة والتكامل الطيفي الكامل ونقل حالة الاستقطاب غير متاحة.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة التشويش. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة تشويش الأسطح.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
|
||||
"render.check_opaque": "الشفافية: أسطح معتمة فقط؛ ألفا الخلفية مدعومة.",
|
||||
"render.check_sorted_alpha": "الشفافية: تقريب ألفا مرتّب.",
|
||||
"render.check_transmission": "الشفافية: نفاذية فيزيائية.",
|
||||
@@ -3441,8 +3441,8 @@
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة التشويش. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة التشويش. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
|
||||
"render.cpu_threads": "خيوط المعالجة",
|
||||
"render.cpu_threads_auto": "تلقائي",
|
||||
@@ -3458,40 +3458,40 @@
|
||||
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
|
||||
"render.cpu_warm_cool": "دافئ وبارد",
|
||||
"render.denoise_class_background": "مستبعد: خلفية",
|
||||
"render.denoise_class_coverage": "مستبعد: تغطية جزئية",
|
||||
"render.denoise_class_degenerate": "مستبعد: مسار منحلّ",
|
||||
"render.denoise_class_depth": "مستبعد: حد عمق المسار",
|
||||
"render.denoise_class_emitter": "مستبعد: مصدر ضوء",
|
||||
"render.denoise_class_exit": "مستبعد: خروج غير متوافق",
|
||||
"render.denoise_class_identity": "مستبعد: حد هوية الدليل",
|
||||
"render.denoise_class_lens": "مستبعد: عمق المجال",
|
||||
"render.denoise_class_material": "مستبعد: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "مستبعد: وسط متداخل",
|
||||
"render.denoise_class_optical": "مؤهل: سطح مرئي عبر الانكسار",
|
||||
"render.denoise_class_reflection": "مستبعد: نهاية عاكسة",
|
||||
"render.denoise_class_background": "متجاوز: خلفية",
|
||||
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
|
||||
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
|
||||
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
|
||||
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
|
||||
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
|
||||
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
|
||||
"render.denoise_class_lens": "متجاوز: عمق المجال",
|
||||
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "متجاوز: وسط متداخل",
|
||||
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
|
||||
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
|
||||
"render.denoise_class_surface": "مؤهل: سطح",
|
||||
"render.denoise_class_tir": "مستبعد: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "مستبعد: نفاذ إضافي",
|
||||
"render.denoise_class_unavailable": "مستبعد: بلا دليل",
|
||||
"render.denoise_class_volume": "مستبعد: تشتت حجمي",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المستبعدة: {skipped}",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة التشويش",
|
||||
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
|
||||
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
|
||||
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمستبعدة حتى عند إيقاف إزالة التشويش. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة التشويش",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
|
||||
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
|
||||
"render.denoise_format_mask": "قناع PNG",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة التشويش بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة التشويش وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_optical_filter": "ترشيح المرايا والزجاج",
|
||||
"render.denoise_optical_filter_help": "يرشّح الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة التشويش من الأسطح المعتمة فقط.",
|
||||
"render.denoise_passes": "تمريرات الترشيح",
|
||||
"render.denoise_settings_help": "تُنعّم تمريرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح الذي يطبّقه كل تمرير. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_strength": "قوة إزالة التشويش",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
|
||||
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
|
||||
"render.denoise_passes": "مرات الترشيح",
|
||||
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_strength": "قوة إزالة الضوضاء",
|
||||
"render.denoise_view_image": "الصورة المصيّرة",
|
||||
"render.denoise_view_mask": "قناع إزالة التشويش",
|
||||
"render.denoise_view_mask": "قناع إزالة الضوضاء",
|
||||
"render.diag_bodies": "الأجسام المرئية",
|
||||
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
|
||||
"render.diag_last_check": "آخر فحص مسبق",
|
||||
@@ -4047,7 +4047,7 @@
|
||||
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
|
||||
"render.material_weight": "وزن الطبقة (0–1)",
|
||||
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء المفقود بين الأوجه المجهرية للمعادن الخشنة. يتطلب التصيير على CPU إضاءة غير مباشرة.",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
|
||||
"render.method_cpu": "تتبع الأشعة على CPU",
|
||||
"render.method_gpu": "تتبع المسارات على GPU",
|
||||
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
|
||||
@@ -4507,7 +4507,7 @@
|
||||
"render.surface_import.source": "المصدر",
|
||||
"render.surface_import.title": "استيراد صورة",
|
||||
"render.texture_filtering": "ترشيح تفاصيل الخامات",
|
||||
"render.texture_filtering_help": "استخدم مستويات MIP والترشيح متباين الخواص لتقليل تعرّج الخامات عند التصغير. يستهلك ذاكرة أكبر؛ وعند إيقافه يبقى الترشيح السابق.",
|
||||
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
|
||||
"render.tone_aces_fitted": "ACES مُلاءَم",
|
||||
"render.tone_reinhard": "راينهارد",
|
||||
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
|
||||
@@ -4552,7 +4552,7 @@
|
||||
"render.use_preview": "استخدام المعاينة المدمجة",
|
||||
"render.volume_albedo": "بياض التشتت · RGB خطي",
|
||||
"render.volume_anisotropy": "متوسط جيب تمام الطور",
|
||||
"render.volume_denoise_required": "أوقف تقليل التشويش لاستخدام الغلاف الجوي.",
|
||||
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
|
||||
"render.volume_enable": "تفعيل الوسط المشارك",
|
||||
"render.volume_extinction": "معامل الإخماد",
|
||||
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
|
||||
@@ -5041,7 +5041,7 @@
|
||||
"ui.agent_chat_permission": "يطلب الوكيل تشغيل: {title}",
|
||||
"ui.agent_chat_pick": "الوكيل",
|
||||
"ui.agent_chat_pick_context": "اختيار السياق",
|
||||
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج، ثم اضغط «موافق»: تُدرج في الرسالة كشارة في موضع المؤشر (كتابة @ تفعل الشيء نفسه)",
|
||||
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج ليطّلع عليها الوكيل، ثم اضغط «موافق»",
|
||||
"ui.agent_chat_placeholder": "اطلب من الوكيل تغيير التصميم…",
|
||||
"ui.agent_chat_placeholder_busy": "ضع رسالة في قائمة الانتظار لحين انتهاء الوكيل (Ctrl+Enter: أرسل الآن)",
|
||||
"ui.agent_chat_pop_out": "فتح في نافذة مستقلة يمكنك نقلها إلى شاشة أخرى",
|
||||
|
||||
@@ -931,7 +931,7 @@
|
||||
"ai.http.confirm": "Allow for this endpoint",
|
||||
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust.",
|
||||
"ai.prefs.allow_control": "Let AI agents connect",
|
||||
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, on the port below; 27878 takes the next free one when it is in use). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect at once. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.availability": "Availability",
|
||||
"ai.prefs.background_commands": "Run slow commands in the background",
|
||||
"ai.prefs.connect_apps": "Add SolidCraft to AI apps",
|
||||
|
||||
@@ -218,7 +218,6 @@
|
||||
"Expected result (optional)": "Résultat attendu (facultatif)",
|
||||
"Explode": "Éclater",
|
||||
"Explode Text": "Décomposer le texte",
|
||||
"Export 3MF…": "Exporter 3MF…",
|
||||
"Export Animation…": "Exporter l'animation…",
|
||||
"Export Bend Table…": "Exporter la table des plis…",
|
||||
"Export CSV…": "Exporter CSV…",
|
||||
@@ -227,10 +226,6 @@
|
||||
"Export DXF": "Exporter en DXF",
|
||||
"Export DXF…": "Exporter en DXF…",
|
||||
"Export JSON…": "Exporter JSON…",
|
||||
"Export OBJ…": "Exporter OBJ…",
|
||||
"Export STEP…": "Exporter STEP…",
|
||||
"Export STL…": "Exporter STL…",
|
||||
"Export URDF…": "Exporter URDF…",
|
||||
"Export design library...": "Exporter la bibliothèque de la conception...",
|
||||
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
|
||||
"Exported by SolidWorks, which usually saves Y up": "Exporté par SolidWorks, qui enregistre généralement en Y vers le haut",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"en": { "ui.agent_chat_transcript_assistant": "Assistant:", "ui.agent_chat_transcript_user": "User:" },
|
||||
"ar": { "ui.agent_chat_transcript_assistant": "المساعد:", "ui.agent_chat_transcript_user": "المستخدم:" },
|
||||
"de": { "ui.agent_chat_transcript_assistant": "Assistent:", "ui.agent_chat_transcript_user": "Benutzer:" },
|
||||
"he": { "ui.agent_chat_transcript_assistant": "עוזר:", "ui.agent_chat_transcript_user": "משתמש:" },
|
||||
"ja": { "ui.agent_chat_transcript_assistant": "アシスタント:", "ui.agent_chat_transcript_user": "ユーザー:" },
|
||||
"ko": { "ui.agent_chat_transcript_assistant": "어시스턴트:", "ui.agent_chat_transcript_user": "사용자:" },
|
||||
"vi": { "ui.agent_chat_transcript_assistant": "Trợ lý:", "ui.agent_chat_transcript_user": "Người dùng:" },
|
||||
"zh": { "ui.agent_chat_transcript_assistant": "助手:", "ui.agent_chat_transcript_user": "用户:" }
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.agent_chat_answered_in": "Answered in {time}",
|
||||
"ui.agent_chat_ended_after": "Ended after {time}",
|
||||
"ui.agent_chat_span_h": "{h} h {min} min",
|
||||
"ui.agent_chat_span_min": "{n} min",
|
||||
"ui.agent_chat_span_s": "{n} s",
|
||||
"ui.agent_chat_thinking_for": "Thinking for {time}",
|
||||
"ui.agent_chat_tool_running_for": "running for {time}"
|
||||
},
|
||||
"ar": {
|
||||
"ui.agent_chat_answered_in": "أُجيب خلال {time}",
|
||||
"ui.agent_chat_ended_after": "انتهى بعد {time}",
|
||||
"ui.agent_chat_span_h": "{h} ساعة {min} دقيقة",
|
||||
"ui.agent_chat_span_min": "{n} دقيقة",
|
||||
"ui.agent_chat_span_s": "{n} ث",
|
||||
"ui.agent_chat_thinking_for": "يفكر منذ {time}",
|
||||
"ui.agent_chat_tool_running_for": "قيد التشغيل منذ {time}"
|
||||
},
|
||||
"de": {
|
||||
"ui.agent_chat_answered_in": "Beantwortet in {time}",
|
||||
"ui.agent_chat_ended_after": "Beendet nach {time}",
|
||||
"ui.agent_chat_span_h": "{h} Std. {min} Min.",
|
||||
"ui.agent_chat_span_min": "{n} Min.",
|
||||
"ui.agent_chat_span_s": "{n} Sek.",
|
||||
"ui.agent_chat_thinking_for": "Denkt seit {time} nach",
|
||||
"ui.agent_chat_tool_running_for": "läuft seit {time}"
|
||||
},
|
||||
"fr": {
|
||||
"ui.agent_chat_answered_in": "Répondu en {time}",
|
||||
"ui.agent_chat_ended_after": "Arrêté après {time}",
|
||||
"ui.agent_chat_span_h": "{h} h {min} min",
|
||||
"ui.agent_chat_span_min": "{n} min",
|
||||
"ui.agent_chat_span_s": "{n} s",
|
||||
"ui.agent_chat_thinking_for": "Réfléchit depuis {time}",
|
||||
"ui.agent_chat_tool_running_for": "en cours depuis {time}"
|
||||
},
|
||||
"he": {
|
||||
"ui.agent_chat_answered_in": "נענה תוך {time}",
|
||||
"ui.agent_chat_ended_after": "הסתיים אחרי {time}",
|
||||
"ui.agent_chat_span_h": "{h} שע' {min} דק'",
|
||||
"ui.agent_chat_span_min": "{n} דק'",
|
||||
"ui.agent_chat_span_s": "{n} שנ'",
|
||||
"ui.agent_chat_thinking_for": "חושב כבר {time}",
|
||||
"ui.agent_chat_tool_running_for": "רץ כבר {time}"
|
||||
},
|
||||
"ja": {
|
||||
"ui.agent_chat_answered_in": "{time}で回答",
|
||||
"ui.agent_chat_ended_after": "{time}後に終了",
|
||||
"ui.agent_chat_span_h": "{h} 時間 {min} 分",
|
||||
"ui.agent_chat_span_min": "{n} 分",
|
||||
"ui.agent_chat_span_s": "{n} 秒",
|
||||
"ui.agent_chat_thinking_for": "{time}考え中",
|
||||
"ui.agent_chat_tool_running_for": "実行中 ({time})"
|
||||
},
|
||||
"ko": {
|
||||
"ui.agent_chat_answered_in": "{time} 만에 답변",
|
||||
"ui.agent_chat_ended_after": "{time} 후 종료",
|
||||
"ui.agent_chat_span_h": "{h}시간 {min}분",
|
||||
"ui.agent_chat_span_min": "{n}분",
|
||||
"ui.agent_chat_span_s": "{n}초",
|
||||
"ui.agent_chat_thinking_for": "{time}째 생각 중",
|
||||
"ui.agent_chat_tool_running_for": "{time}째 실행 중"
|
||||
},
|
||||
"vi": {
|
||||
"ui.agent_chat_answered_in": "Đã trả lời trong {time}",
|
||||
"ui.agent_chat_ended_after": "Đã dừng sau {time}",
|
||||
"ui.agent_chat_span_h": "{h} giờ {min} phút",
|
||||
"ui.agent_chat_span_min": "{n} phút",
|
||||
"ui.agent_chat_span_s": "{n} giây",
|
||||
"ui.agent_chat_thinking_for": "Đang suy nghĩ {time}",
|
||||
"ui.agent_chat_tool_running_for": "đang chạy {time}"
|
||||
},
|
||||
"zh": {
|
||||
"ui.agent_chat_answered_in": "用时 {time} 完成回答",
|
||||
"ui.agent_chat_ended_after": "{time} 后结束",
|
||||
"ui.agent_chat_span_h": "{h} 小时 {min} 分钟",
|
||||
"ui.agent_chat_span_min": "{n} 分钟",
|
||||
"ui.agent_chat_span_s": "{n} 秒",
|
||||
"ui.agent_chat_thinking_for": "已思考 {time}",
|
||||
"ui.agent_chat_tool_running_for": "已运行 {time}"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"en": { "ui.agent_chat_model_search_or_id": "Search, or type any model id…" },
|
||||
"ar": { "ui.agent_chat_model_search_or_id": "ابحث، أو اكتب أي معرّف نموذج…" },
|
||||
"de": { "ui.agent_chat_model_search_or_id": "Suchen oder eine beliebige Modell-ID eingeben…" },
|
||||
"he": { "ui.agent_chat_model_search_or_id": "חיפוש, או הקלדת מזהה מודל כלשהו…" },
|
||||
"ja": { "ui.agent_chat_model_search_or_id": "検索、または任意のモデル ID を入力…" },
|
||||
"ko": { "ui.agent_chat_model_search_or_id": "검색하거나 모델 ID를 직접 입력…" },
|
||||
"vi": { "ui.agent_chat_model_search_or_id": "Tìm, hoặc nhập mã mô hình bất kỳ…" },
|
||||
"zh": { "ui.agent_chat_model_search_or_id": "搜索,或输入任意模型 ID…" }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.assembly_constraint_create": "Constrain"
|
||||
},
|
||||
"de": {
|
||||
"cmd.assembly_constraint_create": "Abhängigkeit"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.assembly_constraint_create": "拘束"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.assembly_constraint_create": "约束"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.assembly_constraint_create": "구속"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.assembly_constraint_create": "Ràng buộc"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.assembly_constraint_create": "تقييد"
|
||||
},
|
||||
"he": {
|
||||
"cmd.assembly_constraint_create": "אילוץ"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"ar": {
|
||||
"browser.straighten_angle": "اتجاه الحافة",
|
||||
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط «تقويم».",
|
||||
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط تقويم.",
|
||||
"browser.straighten_click_a": "انقر الصورة عند أحد طرفي حافة يجب أن تكون مستقيمة، أو اكتب إحداثياتها.",
|
||||
"browser.straighten_click_b": "انقر الطرف الآخر للحافة، أو اكتب إحداثياته.",
|
||||
"browser.straighten_horizontal": "أفقي",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"ar": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"de": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"he": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"ja": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"vi": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
},
|
||||
"zh": {
|
||||
"dialog.component_origin": "{placement} · {item}"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.constraint_glyph_size": "Constraint glyph size",
|
||||
"prefs.constraint_glyph_size_note": "How large constraint glyphs and their letters or icons are drawn in a sketch. 100% is the usual size; glyphs keep this screen size at every zoom."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.constraint_glyph_size": "حجم رموز القيود",
|
||||
"prefs.constraint_glyph_size_note": "حجم رسم رموز القيود وحروفها أو أيقوناتها في الرسم. 100% هو الحجم المعتاد؛ وتحتفظ الرموز بهذا الحجم على الشاشة عند أي مستوى تكبير."
|
||||
},
|
||||
"de": {
|
||||
"prefs.constraint_glyph_size": "Größe der Bindungssymbole",
|
||||
"prefs.constraint_glyph_size_note": "Wie groß Bindungssymbole samt Buchstaben oder Icons in einer Skizze gezeichnet werden. 100 % ist die übliche Größe; die Symbole behalten diese Bildschirmgröße bei jedem Zoom."
|
||||
},
|
||||
"fr": {
|
||||
"prefs.constraint_glyph_size": "Taille des symboles de contraintes",
|
||||
"prefs.constraint_glyph_size_note": "Taille à laquelle les symboles de contraintes, avec leurs lettres ou icônes, sont dessinés dans une esquisse. 100 % est la taille habituelle ; les symboles gardent cette taille à l'écran quel que soit le zoom."
|
||||
},
|
||||
"he": {
|
||||
"prefs.constraint_glyph_size": "גודל סמלי האילוצים",
|
||||
"prefs.constraint_glyph_size_note": "הגודל שבו מצוירים סמלי האילוצים, עם האותיות או הסמלונים שלהם, בשרטוט. 100% הוא הגודל הרגיל; הסמלים שומרים על גודל המסך הזה בכל זום."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.constraint_glyph_size": "拘束グリフのサイズ",
|
||||
"prefs.constraint_glyph_size_note": "スケッチ内の拘束グリフとその文字やアイコンを描く大きさ。100% が通常のサイズです。グリフはどのズームでもこの画面サイズを保ちます。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.constraint_glyph_size": "구속 조건 기호 크기",
|
||||
"prefs.constraint_glyph_size_note": "스케치에서 구속 조건 기호와 그 문자나 아이콘을 그리는 크기입니다. 100%가 기본 크기이며, 기호는 확대/축소와 관계없이 이 화면 크기를 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.constraint_glyph_size": "Kích thước biểu tượng ràng buộc",
|
||||
"prefs.constraint_glyph_size_note": "Kích thước vẽ biểu tượng ràng buộc cùng chữ cái hoặc biểu tượng bên trong trong bản phác. 100% là kích thước thông thường; biểu tượng giữ kích thước màn hình này ở mọi mức thu phóng."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.constraint_glyph_size": "约束符号大小",
|
||||
"prefs.constraint_glyph_size_note": "草图中约束符号及其字母或图标的绘制大小。100% 为常规大小;任意缩放比例下符号都保持此屏幕大小。"
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.construct.axis": "Axis",
|
||||
"cmd.construct.plane": "Plane",
|
||||
"cmd.construct.point": "Point",
|
||||
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
|
||||
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
|
||||
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
|
||||
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
|
||||
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
|
||||
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
|
||||
"ui.construct_auto.refs": "References",
|
||||
"ui.construct_auto.select": "Select references to build from first"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.construct.axis": "軸",
|
||||
"cmd.construct.plane": "平面",
|
||||
"cmd.construct.point": "点",
|
||||
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
|
||||
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
|
||||
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
|
||||
"ui.construct_auto.refs": "参照",
|
||||
"ui.construct_auto.select": "先に作成元の参照を選択してください"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.construct.axis": "轴",
|
||||
"cmd.construct.plane": "平面",
|
||||
"cmd.construct.point": "点",
|
||||
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
|
||||
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
|
||||
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
|
||||
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
|
||||
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
|
||||
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
|
||||
"ui.construct_auto.refs": "参照",
|
||||
"ui.construct_auto.select": "请先选择创建依据的参照"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.construct.axis": "محور",
|
||||
"cmd.construct.plane": "مستوى",
|
||||
"cmd.construct.point": "نقطة",
|
||||
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
|
||||
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
|
||||
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
|
||||
"ui.construct_auto.refs": "المراجع",
|
||||
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
|
||||
},
|
||||
"he": {
|
||||
"cmd.construct.axis": "ציר",
|
||||
"cmd.construct.plane": "מישור",
|
||||
"cmd.construct.point": "נקודה",
|
||||
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
|
||||
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
|
||||
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
|
||||
"ui.construct_auto.refs": "הפניות",
|
||||
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.construct.axis": "Trục",
|
||||
"cmd.construct.plane": "Mặt phẳng",
|
||||
"cmd.construct.point": "Điểm",
|
||||
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
|
||||
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
|
||||
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
|
||||
"ui.construct_auto.refs": "Tham chiếu",
|
||||
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.construct_planes": "Planes",
|
||||
"ui.construct_axes": "Axes",
|
||||
"ui.construct_points": "Points",
|
||||
"prefs.construct_menu_groups": "Group Construct tools by entity type",
|
||||
"prefs.construct_menu_groups_help": "Show Planes, Axes and Points headings. Off keeps the original menu order."
|
||||
},
|
||||
"ar": {
|
||||
"ui.construct_planes": "المستويات",
|
||||
"ui.construct_axes": "المحاور",
|
||||
"ui.construct_points": "النقاط",
|
||||
"prefs.construct_menu_groups": "تجميع أدوات الإنشاء حسب نوع العنصر",
|
||||
"prefs.construct_menu_groups_help": "إظهار عناوين المستويات والمحاور والنقاط. يحافظ التعطيل على ترتيب القائمة الأصلي."
|
||||
},
|
||||
"he": {
|
||||
"ui.construct_planes": "מישורים",
|
||||
"ui.construct_axes": "צירים",
|
||||
"ui.construct_points": "נקודות",
|
||||
"prefs.construct_menu_groups": "קיבוץ כלי הבנייה לפי סוג ישות",
|
||||
"prefs.construct_menu_groups_help": "הצגת כותרות מישורים, צירים ונקודות. כיבוי שומר על סדר התפריט המקורי."
|
||||
},
|
||||
"ja": {
|
||||
"ui.construct_planes": "平面",
|
||||
"ui.construct_axes": "軸",
|
||||
"ui.construct_points": "点",
|
||||
"prefs.construct_menu_groups": "構築ツールを要素の種類でグループ化",
|
||||
"prefs.construct_menu_groups_help": "平面、軸、点の見出しを表示します。オフにすると元のメニュー順序を維持します。"
|
||||
},
|
||||
"zh": {
|
||||
"ui.construct_planes": "平面",
|
||||
"ui.construct_axes": "轴",
|
||||
"ui.construct_points": "点",
|
||||
"prefs.construct_menu_groups": "按实体类型分组构造工具",
|
||||
"prefs.construct_menu_groups_help": "显示平面、轴和点标题。关闭时保留原始菜单顺序。"
|
||||
},
|
||||
"de": {
|
||||
"ui.construct_planes": "Ebenen",
|
||||
"ui.construct_axes": "Achsen",
|
||||
"ui.construct_points": "Punkte",
|
||||
"prefs.construct_menu_groups": "Konstruktionswerkzeuge nach Elementtyp gruppieren",
|
||||
"prefs.construct_menu_groups_help": "Zeigt Überschriften für Ebenen, Achsen und Punkte. Aus behält die ursprüngliche Menüreihenfolge bei."
|
||||
},
|
||||
"ko": {
|
||||
"ui.construct_planes": "평면",
|
||||
"ui.construct_axes": "축",
|
||||
"ui.construct_points": "점",
|
||||
"prefs.construct_menu_groups": "요소 유형별로 구성 도구 그룹화",
|
||||
"prefs.construct_menu_groups_help": "평면, 축, 점 제목을 표시합니다. 끄면 원래 메뉴 순서를 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"ui.construct_planes": "Các mặt phẳng",
|
||||
"ui.construct_axes": "Các trục",
|
||||
"ui.construct_points": "Các điểm",
|
||||
"prefs.construct_menu_groups": "Nhóm công cụ dựng theo loại đối tượng",
|
||||
"prefs.construct_menu_groups_help": "Hiện tiêu đề Mặt phẳng, Trục và Điểm. Tắt để giữ thứ tự menu ban đầu."
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
|
||||
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
|
||||
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
|
||||
},
|
||||
"de": {
|
||||
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
|
||||
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
|
||||
},
|
||||
"he": {
|
||||
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
|
||||
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
|
||||
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
|
||||
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
|
||||
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
|
||||
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"prefs.export_name": "اسم ملف التصدير",
|
||||
"prefs.export_name.body": "اسم الجسم المُصدَّر (الأول إن تعددت الأجسام)",
|
||||
"prefs.export_name.design": "اسم التصميم",
|
||||
"prefs.export_name_hint": "مثلًا {design}-{body}",
|
||||
"prefs.export_name_hint": "مثلاً {design}-{body}",
|
||||
"prefs.export_name_sample": "تصدير هذا التصميم يقترح: {name}",
|
||||
"prefs.export_name_tip": "اسم الملف الذي يقترحه «ملف › تصدير» و«حفظ كشبكة». تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغًا لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
|
||||
"prefs.export_name_tip": "اسم الملف الذي يقترحه ملف › تصدير وحفظ كشبكة. تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغاً لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.export": "Export",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"ar": {
|
||||
"prefs.fillet_legacy_radius": "استخدام نصف القطر الافتراضي السابق للتقويس المصمت",
|
||||
"prefs.fillet_legacy_radius.help": "معطّل افتراضيًا. عند التفعيل، يفتح التقويس المصمت الجديد على الرقم 2 مقروءًا بوحدات التصميم («2 بوصة» في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح على نصف القطر الافتراضي للأداة، 2 مم، معروضًا بوحدات التصميم («0.07874 بوصة» في تصميم بالبوصة)."
|
||||
"prefs.fillet_legacy_radius": "استخدم نصف قطر Fillet الصلب الافتراضي السابق",
|
||||
"prefs.fillet_legacy_radius.help": "مطفأ افتراضيًا. عند التشغيل، يفتح Fillet الصلب الجديد عند الرقم 2 مقروءًا بوحدات التصميم (\"2 بوصة\" في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح عند نصف القطر الذي تُسلَّم به الأداة، 2 مم، معروضًا بوحدات التصميم (\"0.07874 بوصة\" في تصميم بالبوصة)."
|
||||
},
|
||||
"de": {
|
||||
"prefs.fillet_legacy_radius": "Den vorherigen Standardradius für Solid-Fillet verwenden",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"ui.first_dimension_scales_about_origin_tip": "On: the drawing always grows or shrinks about the sketch origin, so a shape drawn away from the origin moves. Off: a drawing that nothing ties to the origin scales about its own middle and stays where it was drawn."
|
||||
},
|
||||
"ar": {
|
||||
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم",
|
||||
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر المرسوم أو يصغر دائمًا حول أصل الرسم، فإذا كان الشكل مرسومًا بعيدًا عن الأصل تحرّك. عند الإيقاف: المرسوم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى حيث رُسم."
|
||||
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم التخطيطي",
|
||||
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر الرسم أو يصغر دائمًا حول أصل الرسم التخطيطي، فيتحرك الشكل المرسوم بعيدًا عن الأصل. عند الإيقاف: الرسم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى في مكانه."
|
||||
},
|
||||
"de": {
|
||||
"ui.first_dimension_scales_about_origin": "Um den Skizzenursprung skalieren",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"prefs.graphics_api_auto": "تلقائي",
|
||||
"prefs.graphics_api_help": "اختر واجهة الرسوميات المستخدمة لفتح SolidCraft. إذا تعذّر فتح النافذة عند التشغيل التالي، فستُستعاد السابقة.",
|
||||
"prefs.graphics_api_override": "يتحكم إعداد عند التشغيل في واجهة الرسوميات. سيُطبّق الاختيار المحفوظ عند فتح SolidCraft بالطريقة العادية.",
|
||||
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصاميم غير المحفوظة قبل الإغلاق.",
|
||||
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصميمات غير المحفوظة قبل الإغلاق.",
|
||||
"prefs.graphics_api_restart_unavailable": "أعد تشغيل SolidCraft لتطبيق واجهة الرسوميات المحفوظة.",
|
||||
"prefs.graphics_api_running": "الحالية: {current}. المختارة للتشغيل التالي: {selected}.",
|
||||
"prefs.graphics_api_save_failed": "تعذّر حفظ اختيار واجهة الرسوميات: {error}",
|
||||
@@ -29,7 +29,7 @@
|
||||
"prefs.graphics_api_start_reverted": "لم يفتح التشغيل السابق باستخدام {requested} النافذة. استعاد SolidCraft الاختيار {previous}.",
|
||||
"prefs.graphics_api_unavailable": "اختيار واجهة الرسوميات متاح في تطبيق سطح المكتب.",
|
||||
"render.graphics_api_restart_vulkan": "إعادة التشغيل باستخدام Vulkan",
|
||||
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصاميم غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
|
||||
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصميمات غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
|
||||
"render.graphics_api_settings": "إعدادات الرسوميات",
|
||||
"render.graphics_api_vulkan_required": "يتطلب تتبّع المسارات بمعالج الرسوميات Vulkan في هذه الجلسة. أعد التشغيل باستخدام Vulkan، أو واصل استخدام طريقة صور بمعالج CPU."
|
||||
},
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+right drag: right zooms in, left zooms out. Turn off to restore vertical drag zoom.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: horizontal drag zoom"
|
||||
},
|
||||
"ar": {
|
||||
"nav.maya_horizontal_zoom.help": "اسحب مع Alt وزر الفأرة الأيمن: إلى اليمين للتكبير وإلى اليسار للتصغير. عطّل هذا الخيار لاستعادة التكبير بالسحب العمودي.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: التكبير بالسحب الأفقي"
|
||||
},
|
||||
"he": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt וגרירה עם לחצן העכבר הימני: ימינה לקירוב, שמאלה להרחקה. כבו כדי לחזור לזום בגרירה אנכית.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: זום בגרירה אופקית"
|
||||
},
|
||||
"ja": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+右ドラッグで右に動かすと拡大、左に動かすと縮小します。オフにすると従来の垂直ドラッグに戻ります。",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: 水平方向のドラッグでズーム"
|
||||
},
|
||||
"vi": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+kéo chuột phải: kéo sang phải để phóng to, sang trái để thu nhỏ. Tắt để khôi phục thu phóng bằng cách kéo dọc.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: thu phóng bằng cách kéo ngang"
|
||||
},
|
||||
"zh": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+右键拖动:向右放大,向左缩小。关闭此选项可恢复垂直拖动缩放。",
|
||||
"nav.maya_horizontal_zoom.label": "Maya:水平拖动缩放"
|
||||
},
|
||||
"de": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+Rechtsziehen: nach rechts vergrößern, nach links verkleinern. Ausschalten, um wieder vertikal zu zoomen.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: horizontal ziehen zum Zoomen"
|
||||
},
|
||||
"ko": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+오른쪽 버튼 드래그: 오른쪽으로 움직이면 확대하고 왼쪽으로 움직이면 축소합니다. 끄면 세로 드래그 확대/축소로 돌아갑니다.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: 가로 드래그로 확대/축소"
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"ai.prefs.control_port": "Port",
|
||||
"ai.prefs.control_port_note": "The port AI agents connect to on this computer (127.0.0.1). 27878 is the default: when it is in use, the next free port is taken. Any other port is used exactly. Point your MCP client at the same port, for example solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Listening on 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Not listening",
|
||||
"ai.prefs.control_port_taken": "Port {port} is in use by another program (or another SolidCraft window). Choose another port.",
|
||||
"ai.prefs.control_failed": "The control channel could not start: {error}",
|
||||
"ai.prefs.control_from_launch": "Started with --control on port {port} for this run. Changes here take effect the next time SolidCraft starts without --control."
|
||||
},
|
||||
"de": {
|
||||
"ai.prefs.control_port": "Port",
|
||||
"ai.prefs.control_port_note": "Der Port, über den sich KI-Agenten auf diesem Computer verbinden (127.0.0.1). Standard ist 27878: Ist er belegt, wird der nächste freie Port genommen. Jeder andere Port wird genau so verwendet. Richte deinen MCP-Client auf denselben Port, zum Beispiel solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Wartet auf 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Nicht aktiv",
|
||||
"ai.prefs.control_port_taken": "Port {port} wird von einem anderen Programm (oder einem anderen SolidCraft-Fenster) verwendet. Wähle einen anderen Port.",
|
||||
"ai.prefs.control_failed": "Der Steuerkanal konnte nicht starten: {error}",
|
||||
"ai.prefs.control_from_launch": "Für diese Sitzung mit --control auf Port {port} gestartet. Änderungen hier gelten beim nächsten Start von SolidCraft ohne --control."
|
||||
},
|
||||
"ja": {
|
||||
"ai.prefs.control_port": "ポート",
|
||||
"ai.prefs.control_port_note": "このコンピューター上で AI エージェントが接続するポート (127.0.0.1)。既定は 27878 で、使用中の場合は次の空いているポートを使います。それ以外のポートはそのまま使います。MCP クライアントも同じポートに向けてください (例: solidcraft-cli mcp --connect 127.0.0.1:7878)。",
|
||||
"ai.prefs.control_listening": "127.0.0.1:{port} で待機中",
|
||||
"ai.prefs.control_not_listening": "待機していません",
|
||||
"ai.prefs.control_port_taken": "ポート {port} は別のプログラム (または別の SolidCraft ウィンドウ) が使用しています。別のポートを選んでください。",
|
||||
"ai.prefs.control_failed": "コントロールチャネルを開始できませんでした: {error}",
|
||||
"ai.prefs.control_from_launch": "この実行は --control でポート {port} を使って開始されました。ここでの変更は、次に --control なしで SolidCraft を起動したときに反映されます。"
|
||||
},
|
||||
"zh": {
|
||||
"ai.prefs.control_port": "端口",
|
||||
"ai.prefs.control_port_note": "AI 代理在本机上连接的端口 (127.0.0.1)。默认为 27878:若被占用,则使用下一个空闲端口。其他端口按原样使用。请让 MCP 客户端指向同一端口,例如 solidcraft-cli mcp --connect 127.0.0.1:7878。",
|
||||
"ai.prefs.control_listening": "正在监听 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "未在监听",
|
||||
"ai.prefs.control_port_taken": "端口 {port} 已被其他程序(或另一个 SolidCraft 窗口)占用。请选择其他端口。",
|
||||
"ai.prefs.control_failed": "控制通道无法启动:{error}",
|
||||
"ai.prefs.control_from_launch": "本次运行通过 --control 在端口 {port} 上启动。此处的更改将在下次不带 --control 启动 SolidCraft 时生效。"
|
||||
},
|
||||
"ko": {
|
||||
"ai.prefs.control_port": "포트",
|
||||
"ai.prefs.control_port_note": "이 컴퓨터에서 AI 에이전트가 연결하는 포트(127.0.0.1)입니다. 기본값은 27878이며, 사용 중이면 다음 빈 포트를 사용합니다. 다른 포트는 그대로 사용합니다. MCP 클라이언트도 같은 포트를 가리키게 하세요(예: solidcraft-cli mcp --connect 127.0.0.1:7878).",
|
||||
"ai.prefs.control_listening": "127.0.0.1:{port}에서 대기 중",
|
||||
"ai.prefs.control_not_listening": "대기하지 않음",
|
||||
"ai.prefs.control_port_taken": "포트 {port}을(를) 다른 프로그램(또는 다른 SolidCraft 창)이 사용 중입니다. 다른 포트를 선택하세요.",
|
||||
"ai.prefs.control_failed": "제어 채널을 시작할 수 없습니다: {error}",
|
||||
"ai.prefs.control_from_launch": "이번 실행은 --control로 포트 {port}에서 시작되었습니다. 여기서 바꾼 내용은 다음에 --control 없이 SolidCraft를 시작할 때 적용됩니다."
|
||||
},
|
||||
"vi": {
|
||||
"ai.prefs.control_port": "Cổng",
|
||||
"ai.prefs.control_port_note": "Cổng mà các tác tử AI kết nối trên máy tính này (127.0.0.1). Mặc định là 27878: nếu cổng đang được dùng, cổng trống tiếp theo sẽ được dùng. Mọi cổng khác được dùng đúng như vậy. Hãy trỏ ứng dụng MCP của bạn đến cùng cổng, ví dụ solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Đang lắng nghe trên 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Không lắng nghe",
|
||||
"ai.prefs.control_port_taken": "Cổng {port} đang được một chương trình khác (hoặc một cửa sổ SolidCraft khác) sử dụng. Hãy chọn cổng khác.",
|
||||
"ai.prefs.control_failed": "Không thể khởi động kênh điều khiển: {error}",
|
||||
"ai.prefs.control_from_launch": "Lần chạy này được khởi động với --control trên cổng {port}. Các thay đổi ở đây có hiệu lực vào lần tới SolidCraft khởi động mà không có --control."
|
||||
},
|
||||
"ar": {
|
||||
"ai.prefs.control_port": "المنفذ",
|
||||
"ai.prefs.control_port_note": "المنفذ الذي يتصل عبره وكلاء الذكاء الاصطناعي على هذا الحاسوب (127.0.0.1). الافتراضي هو 27878: إذا كان مستخدمًا يُستخدم المنفذ الحر التالي. أي منفذ آخر يُستخدم كما هو. وجّه عميل MCP إلى المنفذ نفسه، مثل solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "يستمع على 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "لا يستمع",
|
||||
"ai.prefs.control_port_taken": "المنفذ {port} يستخدمه برنامج آخر (أو نافذة SolidCraft أخرى). اختر منفذًا آخر.",
|
||||
"ai.prefs.control_failed": "تعذّر بدء قناة التحكم: {error}",
|
||||
"ai.prefs.control_from_launch": "بدأ هذا التشغيل باستخدام --control على المنفذ {port}. تسري التغييرات هنا عند تشغيل SolidCraft في المرة القادمة دون --control."
|
||||
},
|
||||
"he": {
|
||||
"ai.prefs.control_port": "פורט",
|
||||
"ai.prefs.control_port_note": "הפורט שאליו סוכני AI מתחברים במחשב זה (127.0.0.1). ברירת המחדל היא 27878: אם הוא תפוס, נלקח הפורט הפנוי הבא. כל פורט אחר משמש בדיוק כפי שהוא. כוונו את לקוח ה-MCP לאותו פורט, למשל solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "מאזין ב-127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "לא מאזין",
|
||||
"ai.prefs.control_port_taken": "פורט {port} בשימוש של תוכנה אחרת (או חלון SolidCraft אחר). בחרו פורט אחר.",
|
||||
"ai.prefs.control_failed": "לא ניתן היה להפעיל את ערוץ הבקרה: {error}",
|
||||
"ai.prefs.control_from_launch": "הפעלה זו התחילה עם --control בפורט {port}. שינויים כאן ייכנסו לתוקף בפעם הבאה ש-SolidCraft יופעל ללא --control."
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.note_add": "إضافة ملاحظة",
|
||||
"cmd.note_delete": "حذف ملاحظة",
|
||||
"cmd.note_list": "عرض الملاحظات",
|
||||
"cmd.note_update": "تحرير الملاحظة"
|
||||
},
|
||||
"en": {
|
||||
"cmd.note_add": "Add Note",
|
||||
"cmd.note_delete": "Delete Note",
|
||||
"cmd.note_list": "List Notes",
|
||||
"cmd.note_update": "Edit Note"
|
||||
},
|
||||
"he": {
|
||||
"cmd.note_add": "הוספת הערה",
|
||||
"cmd.note_delete": "מחיקת הערה",
|
||||
"cmd.note_list": "הצגת הערות",
|
||||
"cmd.note_update": "עריכת הערה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.note_add": "メモを追加",
|
||||
"cmd.note_delete": "メモを削除",
|
||||
"cmd.note_list": "メモを一覧表示",
|
||||
"cmd.note_update": "メモを編集"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.note_add": "Thêm ghi chú",
|
||||
"cmd.note_delete": "Xóa ghi chú",
|
||||
"cmd.note_list": "Liệt kê ghi chú",
|
||||
"cmd.note_update": "Chỉnh sửa ghi chú"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.note_add": "添加备注",
|
||||
"cmd.note_delete": "删除备注",
|
||||
"cmd.note_list": "列出备注",
|
||||
"cmd.note_update": "编辑备注"
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,9 @@
|
||||
"ui.right_click_confirms_tip": "對話方塊開啟時,在視圖中按右鍵會轉到下一個空白輸入,在最後一個輸入上執行 OK"
|
||||
},
|
||||
"ar": {
|
||||
"dialog.right_click_pick": "حدد {input} أولًا",
|
||||
"ui.right_click_confirms": "النقر بزر الفأرة الأيمن يؤكد مدخلات مربع الحوار",
|
||||
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحًا، ينتقل النقر بزر الفأرة الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ «موافق»"
|
||||
"dialog.right_click_pick": "اختر {input} أولاً",
|
||||
"ui.right_click_confirms": "النقر بزر الماوس الأيمن يؤكد مدخلات مربع الحوار",
|
||||
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحاً، ينتقل النقر بزر الماوس الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ موافق"
|
||||
},
|
||||
"he": {
|
||||
"dialog.right_click_pick": "יש לבחור תחילה {input}",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"document.save_row": "حفظ",
|
||||
"document.save_selected": "حفظ المحدد",
|
||||
"prefs.save_prompt_one_at_a_time": "السؤال عن كل تصميم غير محفوظ على حدة عند إغلاق عدة تصاميم",
|
||||
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلًا من قائمة مجمّعة."
|
||||
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلاً من قائمة مجمّعة."
|
||||
},
|
||||
"de": {
|
||||
"document.discard_row": "Verwerfen",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"en": { "cmd.selection_get": "Get Selection" },
|
||||
"ar": { "cmd.selection_get": "الحصول على التحديد" },
|
||||
"he": { "cmd.selection_get": "קבלת הבחירה" },
|
||||
"ja": { "cmd.selection_get": "選択を取得" },
|
||||
"vi": { "cmd.selection_get": "Lấy vùng chọn" },
|
||||
"zh": { "cmd.selection_get": "获取选择" }
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"ar": {
|
||||
"prefs.sketch.cursor_coordinates": "إظهار إحداثيات المؤشر في الرسم",
|
||||
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. عند الإيقاف: فقط عندما تضع أداةٌ نقطة."
|
||||
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. إيقاف: فقط أثناء وضع أداة لنقطة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.sketch.cursor_coordinates": "Cursorkoordinaten in der Skizze anzeigen",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"prefs.sketch.max_points_unit": "points"
|
||||
},
|
||||
"ar": {
|
||||
"prefs.sketch.max_points": "أكبر حجم للرسم",
|
||||
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض «إدراج SVG» و«إدراج DXF» رسمًا أكبر. الرسم الكبير يُحل ويُعرض بشكل أبطأ.",
|
||||
"prefs.sketch.max_points": "أكبر رسم تخطيطي",
|
||||
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم تخطيطي واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض إدراج SVG وإدراج DXF رسمًا أكبر. الرسم التخطيطي الكبير يُحل ويُعرض بشكل أبطأ.",
|
||||
"prefs.sketch.max_points_unit": "نقطة"
|
||||
},
|
||||
"de": {
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Baseline",
|
||||
"sketch_text.valign_top": "Top",
|
||||
"sketch_text.valign_middle": "Middle",
|
||||
"sketch_text.valign_bottom": "Bottom"
|
||||
},
|
||||
"ar": {
|
||||
"Vertical": "عمودي",
|
||||
"sketch_text.valign_baseline": "خط الأساس",
|
||||
"sketch_text.valign_top": "أعلى",
|
||||
"sketch_text.valign_middle": "وسط",
|
||||
"sketch_text.valign_bottom": "أسفل"
|
||||
},
|
||||
"bg": {
|
||||
"Vertical": "Вертикално",
|
||||
"sketch_text.valign_baseline": "Базова линия",
|
||||
"sketch_text.valign_top": "Горе",
|
||||
"sketch_text.valign_middle": "Среда",
|
||||
"sketch_text.valign_bottom": "Долу"
|
||||
},
|
||||
"bs": {
|
||||
"Vertical": "Vertikalno",
|
||||
"sketch_text.valign_baseline": "Osnovna linija",
|
||||
"sketch_text.valign_top": "Vrh",
|
||||
"sketch_text.valign_middle": "Sredina",
|
||||
"sketch_text.valign_bottom": "Dno"
|
||||
},
|
||||
"ca": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Línia base",
|
||||
"sketch_text.valign_top": "Dalt",
|
||||
"sketch_text.valign_middle": "Mig",
|
||||
"sketch_text.valign_bottom": "Baix"
|
||||
},
|
||||
"cs": {
|
||||
"Vertical": "Svisle",
|
||||
"sketch_text.valign_baseline": "Účaří",
|
||||
"sketch_text.valign_top": "Nahoře",
|
||||
"sketch_text.valign_middle": "Uprostřed",
|
||||
"sketch_text.valign_bottom": "Dole"
|
||||
},
|
||||
"da": {
|
||||
"Vertical": "Lodret",
|
||||
"sketch_text.valign_baseline": "Grundlinje",
|
||||
"sketch_text.valign_top": "Top",
|
||||
"sketch_text.valign_middle": "Midte",
|
||||
"sketch_text.valign_bottom": "Bund"
|
||||
},
|
||||
"de": {
|
||||
"Vertical": "Vertikal",
|
||||
"sketch_text.valign_baseline": "Grundlinie",
|
||||
"sketch_text.valign_top": "Oben",
|
||||
"sketch_text.valign_middle": "Mitte",
|
||||
"sketch_text.valign_bottom": "Unten"
|
||||
},
|
||||
"es": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Línea base",
|
||||
"sketch_text.valign_top": "Arriba",
|
||||
"sketch_text.valign_middle": "Centro",
|
||||
"sketch_text.valign_bottom": "Abajo"
|
||||
},
|
||||
"fi": {
|
||||
"Vertical": "Pystysuunta",
|
||||
"sketch_text.valign_baseline": "Perusviiva",
|
||||
"sketch_text.valign_top": "Ylä",
|
||||
"sketch_text.valign_middle": "Keski",
|
||||
"sketch_text.valign_bottom": "Ala"
|
||||
},
|
||||
"fr": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Ligne de base",
|
||||
"sketch_text.valign_top": "Haut",
|
||||
"sketch_text.valign_middle": "Milieu",
|
||||
"sketch_text.valign_bottom": "Bas"
|
||||
},
|
||||
"gl": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Liña base",
|
||||
"sketch_text.valign_top": "Arriba",
|
||||
"sketch_text.valign_middle": "Medio",
|
||||
"sketch_text.valign_bottom": "Abaixo"
|
||||
},
|
||||
"he": {
|
||||
"Vertical": "אנכי",
|
||||
"sketch_text.valign_baseline": "קו בסיס",
|
||||
"sketch_text.valign_top": "למעלה",
|
||||
"sketch_text.valign_middle": "אמצע",
|
||||
"sketch_text.valign_bottom": "למטה"
|
||||
},
|
||||
"hr": {
|
||||
"Vertical": "Okomito",
|
||||
"sketch_text.valign_baseline": "Osnovna linija",
|
||||
"sketch_text.valign_top": "Vrh",
|
||||
"sketch_text.valign_middle": "Sredina",
|
||||
"sketch_text.valign_bottom": "Dno"
|
||||
},
|
||||
"hu": {
|
||||
"Vertical": "Függőleges",
|
||||
"sketch_text.valign_baseline": "Alapvonal",
|
||||
"sketch_text.valign_top": "Fent",
|
||||
"sketch_text.valign_middle": "Középen",
|
||||
"sketch_text.valign_bottom": "Lent"
|
||||
},
|
||||
"id": {
|
||||
"Vertical": "Vertikal",
|
||||
"sketch_text.valign_baseline": "Garis dasar",
|
||||
"sketch_text.valign_top": "Atas",
|
||||
"sketch_text.valign_middle": "Tengah",
|
||||
"sketch_text.valign_bottom": "Bawah"
|
||||
},
|
||||
"it": {
|
||||
"Vertical": "Verticale",
|
||||
"sketch_text.valign_baseline": "Linea di base",
|
||||
"sketch_text.valign_top": "Alto",
|
||||
"sketch_text.valign_middle": "Centro",
|
||||
"sketch_text.valign_bottom": "Basso"
|
||||
},
|
||||
"ja": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "ベースライン",
|
||||
"sketch_text.valign_top": "上",
|
||||
"sketch_text.valign_middle": "中央",
|
||||
"sketch_text.valign_bottom": "下"
|
||||
},
|
||||
"ka": {
|
||||
"Vertical": "ვერტიკალური",
|
||||
"sketch_text.valign_baseline": "საბაზისო ხაზი",
|
||||
"sketch_text.valign_top": "ზემოთ",
|
||||
"sketch_text.valign_middle": "შუა",
|
||||
"sketch_text.valign_bottom": "ქვემოთ"
|
||||
},
|
||||
"ko": {
|
||||
"Vertical": "수직",
|
||||
"sketch_text.valign_baseline": "기준선",
|
||||
"sketch_text.valign_top": "위",
|
||||
"sketch_text.valign_middle": "가운데",
|
||||
"sketch_text.valign_bottom": "아래"
|
||||
},
|
||||
"ms": {
|
||||
"Vertical": "Menegak",
|
||||
"sketch_text.valign_baseline": "Garis dasar",
|
||||
"sketch_text.valign_top": "Atas",
|
||||
"sketch_text.valign_middle": "Tengah",
|
||||
"sketch_text.valign_bottom": "Bawah"
|
||||
},
|
||||
"nb": {
|
||||
"Vertical": "Loddrett",
|
||||
"sketch_text.valign_baseline": "Grunnlinje",
|
||||
"sketch_text.valign_top": "Topp",
|
||||
"sketch_text.valign_middle": "Midt",
|
||||
"sketch_text.valign_bottom": "Bunn"
|
||||
},
|
||||
"nl": {
|
||||
"Vertical": "Verticaal",
|
||||
"sketch_text.valign_baseline": "Basislijn",
|
||||
"sketch_text.valign_top": "Boven",
|
||||
"sketch_text.valign_middle": "Midden",
|
||||
"sketch_text.valign_bottom": "Onder"
|
||||
},
|
||||
"pl": {
|
||||
"Vertical": "Pionowo",
|
||||
"sketch_text.valign_baseline": "Linia bazowa",
|
||||
"sketch_text.valign_top": "Góra",
|
||||
"sketch_text.valign_middle": "Środek",
|
||||
"sketch_text.valign_bottom": "Dół"
|
||||
},
|
||||
"pt-BR": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Linha de base",
|
||||
"sketch_text.valign_top": "Topo",
|
||||
"sketch_text.valign_middle": "Meio",
|
||||
"sketch_text.valign_bottom": "Base"
|
||||
},
|
||||
"ro": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Linie de bază",
|
||||
"sketch_text.valign_top": "Sus",
|
||||
"sketch_text.valign_middle": "Mijloc",
|
||||
"sketch_text.valign_bottom": "Jos"
|
||||
},
|
||||
"ru": {
|
||||
"Vertical": "По вертикали",
|
||||
"sketch_text.valign_baseline": "Базовая линия",
|
||||
"sketch_text.valign_top": "Верх",
|
||||
"sketch_text.valign_middle": "Середина",
|
||||
"sketch_text.valign_bottom": "Низ"
|
||||
},
|
||||
"sr": {
|
||||
"Vertical": "Вертикално",
|
||||
"sketch_text.valign_baseline": "Основна линија",
|
||||
"sketch_text.valign_top": "Врх",
|
||||
"sketch_text.valign_middle": "Средина",
|
||||
"sketch_text.valign_bottom": "Дно"
|
||||
},
|
||||
"sv": {
|
||||
"Vertical": "Lodrät",
|
||||
"sketch_text.valign_baseline": "Baslinje",
|
||||
"sketch_text.valign_top": "Överkant",
|
||||
"sketch_text.valign_middle": "Mitten",
|
||||
"sketch_text.valign_bottom": "Nederkant"
|
||||
},
|
||||
"tr": {
|
||||
"Vertical": "Dikey",
|
||||
"sketch_text.valign_baseline": "Taban çizgisi",
|
||||
"sketch_text.valign_top": "Üst",
|
||||
"sketch_text.valign_middle": "Orta",
|
||||
"sketch_text.valign_bottom": "Alt"
|
||||
},
|
||||
"uk": {
|
||||
"Vertical": "По вертикалі",
|
||||
"sketch_text.valign_baseline": "Базова лінія",
|
||||
"sketch_text.valign_top": "Верх",
|
||||
"sketch_text.valign_middle": "Середина",
|
||||
"sketch_text.valign_bottom": "Низ"
|
||||
},
|
||||
"vi": {
|
||||
"Vertical": "Dọc",
|
||||
"sketch_text.valign_baseline": "Đường cơ sở",
|
||||
"sketch_text.valign_top": "Trên",
|
||||
"sketch_text.valign_middle": "Giữa",
|
||||
"sketch_text.valign_bottom": "Dưới"
|
||||
},
|
||||
"zh-TW": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "基線",
|
||||
"sketch_text.valign_top": "頂端",
|
||||
"sketch_text.valign_middle": "置中",
|
||||
"sketch_text.valign_bottom": "底端"
|
||||
},
|
||||
"zh": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "基线",
|
||||
"sketch_text.valign_top": "顶端",
|
||||
"sketch_text.valign_middle": "居中",
|
||||
"sketch_text.valign_bottom": "底端"
|
||||
}
|
||||
}
|
||||
+212
-1214
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
| assets/locales/pending/agent-chat-select-all.json | SolidCraft contributors | Original assistant chat select-all strings and translations | MIT OR Apache-2.0 |
|
||||
@@ -1 +0,0 @@
|
||||
| assets/locales/pending/constraint-glyph-size.json | SolidCraft contributors | Original constraint glyph size preference label and help text | MIT OR Apache-2.0 |
|
||||
@@ -1,3 +0,0 @@
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
|
||||
@@ -1,118 +0,0 @@
|
||||
//! Where a control request is, so the connection that sent it can cancel it (`control.cancel`,
|
||||
//! #1989): shared between the window, which moves it along, and the connection. Whoever changes
|
||||
//! it first wins.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
/// A request's place in the app, and how to stop the work computing it.
|
||||
#[derive(Default)]
|
||||
pub struct Ticket {
|
||||
state: AtomicU8,
|
||||
/// It is an export computing on a copy, whose worker writes its file.
|
||||
writes_file: AtomicBool,
|
||||
/// Tells its worker to stop: set by the window when it hands the request to one.
|
||||
stopper: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Ticket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Ticket").field("state", &self.state).field("writes_file", &self.writes_file).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waiting its turn (sent, or held behind another request).
|
||||
const WAITING: u8 = 0;
|
||||
/// Running on the window thread (or handed to something that can't stop it, such as the file
|
||||
/// loader).
|
||||
const RUNNING: u8 = 1;
|
||||
/// Computing off the window thread.
|
||||
const COMPUTING: u8 = 2;
|
||||
const CANCELLED: u8 = 3;
|
||||
|
||||
/// What [`Ticket::cancel`] did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Cancel {
|
||||
/// It was waiting its turn: it will not run.
|
||||
Removed,
|
||||
/// It was computing off the window thread: it is answered as stopped and nothing of it is
|
||||
/// applied. An export's worker may still write its file.
|
||||
Stopped { writes_file: bool },
|
||||
/// It runs on the window thread: it will finish.
|
||||
Running,
|
||||
/// It was cancelled or stopped before.
|
||||
Already,
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// Cancel the request, unless it already runs on the window thread.
|
||||
pub fn cancel(&self) -> Cancel {
|
||||
let mut state = self.state.load(Ordering::Acquire);
|
||||
loop {
|
||||
let done = match state {
|
||||
WAITING => Cancel::Removed,
|
||||
COMPUTING => Cancel::Stopped { writes_file: self.writes_file.load(Ordering::Acquire) },
|
||||
CANCELLED => return Cancel::Already,
|
||||
_ => return Cancel::Running,
|
||||
};
|
||||
match self.state.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire) {
|
||||
Ok(_) => {
|
||||
self.stop_worker();
|
||||
return done;
|
||||
}
|
||||
Err(now) => state = now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.state.load(Ordering::Acquire) == CANCELLED
|
||||
}
|
||||
|
||||
/// The window takes the request to run it: false when it was cancelled.
|
||||
pub fn take(&self) -> bool {
|
||||
self.moves(WAITING, RUNNING)
|
||||
}
|
||||
|
||||
/// The work computing it stops on `stop` (a worker's own stop flag). Cancelled already, it is
|
||||
/// stopped at once.
|
||||
pub fn on_stop(&self, stop: impl Fn() + Send + Sync + 'static) {
|
||||
*self.stopper.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::new(stop));
|
||||
if self.is_cancelled() {
|
||||
self.stop_worker();
|
||||
}
|
||||
}
|
||||
|
||||
/// The window hands it to a worker (`writes_file`: an export on a copy).
|
||||
pub fn computing(&self, writes_file: bool) {
|
||||
self.writes_file.store(writes_file, Ordering::Release);
|
||||
let _ = self.moves(RUNNING, COMPUTING) || self.moves(WAITING, COMPUTING);
|
||||
}
|
||||
|
||||
/// Its worker is done and it runs on the window thread now (or it never left): false when it
|
||||
/// was cancelled meanwhile. Only the window moves it on from running, so running stays running.
|
||||
pub fn back(&self) -> bool {
|
||||
self.moves(COMPUTING, RUNNING) || self.state.load(Ordering::Acquire) == RUNNING
|
||||
}
|
||||
|
||||
/// Computed, but it waits its turn again (a file opening meanwhile): false when cancelled.
|
||||
pub fn wait_again(&self) -> bool {
|
||||
self.moves(COMPUTING, WAITING)
|
||||
}
|
||||
|
||||
/// The person at the app stopped it (Stop): its worker stops too.
|
||||
pub fn stop(&self) {
|
||||
self.state.store(CANCELLED, Ordering::Release);
|
||||
self.stop_worker();
|
||||
}
|
||||
|
||||
fn stop_worker(&self) {
|
||||
if let Some(stop) = self.stopper.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn moves(&self, from: u8, to: u8) -> bool {
|
||||
self.state.compare_exchange(from, to, Ordering::AcqRel, Ordering::Acquire).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,10 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod cancel;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
|
||||
pub use cancel::{Cancel, Ticket};
|
||||
pub use effect::Effect;
|
||||
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
|
||||
|
||||
@@ -18,15 +18,12 @@ pub struct ControlRequest {
|
||||
/// 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>>,
|
||||
/// Where the request is, shared with the connection that sent it so it can cancel it
|
||||
/// (`control.cancel`, #1989).
|
||||
pub ticket: std::sync::Arc<crate::Ticket>,
|
||||
}
|
||||
|
||||
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, ticket: Default::default() }, rx)
|
||||
(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.
|
||||
|
||||
+11
-276
@@ -14,7 +14,6 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Effect;
|
||||
use crate::auth::tokens_match;
|
||||
use crate::cancel::{Cancel, Ticket};
|
||||
use crate::request::{ControlRequest, DOCUMENT_WATCH, DocumentWatch, Subscriber};
|
||||
|
||||
/// What the server needs to know of a request before it sends it to the window: how to name it
|
||||
@@ -95,101 +94,28 @@ impl Hub {
|
||||
/// on it). A listener that is not on a loopback address is refused: the channel never goes on
|
||||
/// the network.
|
||||
pub fn listen(self: &Arc<Self>, listener: TcpListener, token: String, requests: Sender<ControlRequest>, patient: bool) -> std::io::Result<()> {
|
||||
self.listen_stoppable(listener, token, requests, patient).map(drop)
|
||||
}
|
||||
|
||||
/// [`Hub::listen`], and a [`Stop`] that closes the listener and the connections it accepted
|
||||
/// (a channel turned off in the app's settings). Dropping the [`Stop`] leaves it serving.
|
||||
pub fn listen_stoppable(
|
||||
self: &Arc<Self>,
|
||||
listener: TcpListener,
|
||||
token: String,
|
||||
requests: Sender<ControlRequest>,
|
||||
patient: bool,
|
||||
) -> std::io::Result<Stop> {
|
||||
let addr = listener.local_addr()?;
|
||||
if !addr.ip().is_loopback() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("the control channel listens on loopback only, not {addr}")));
|
||||
}
|
||||
let hub = Arc::clone(self);
|
||||
let token = Arc::new(token);
|
||||
let shared = Arc::new(Accepted::default());
|
||||
let (closed_tx, closed) = channel::<()>();
|
||||
let stop = Stop { addr, shared: Arc::clone(&shared), closed };
|
||||
std::thread::spawn(move || {
|
||||
// Dropped when this thread ends, with the listener: `Stop::stop` waits for that.
|
||||
let _closed = closed_tx;
|
||||
for stream in listener.incoming().flatten() {
|
||||
if shared.stopped.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if hub.open.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
hub.open.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let id = shared.next.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(copy) = stream.try_clone() {
|
||||
shared.lock().push((id, copy));
|
||||
}
|
||||
// Stopped while this one was being accepted: it is closed with the rest.
|
||||
if shared.stopped.load(Ordering::SeqCst) {
|
||||
shared.close_all();
|
||||
}
|
||||
let (hub, tx, token, shared) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token), Arc::clone(&shared));
|
||||
let (hub, tx, token) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token));
|
||||
std::thread::spawn(move || {
|
||||
hub.serve(stream, &token, tx, patient);
|
||||
hub.open.fetch_sub(1, Ordering::Relaxed);
|
||||
shared.lock().retain(|(i, _)| *i != id);
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(stop)
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections a stoppable listener accepted, so stopping it can close them too.
|
||||
#[derive(Default)]
|
||||
struct Accepted {
|
||||
stopped: AtomicBool,
|
||||
next: AtomicU64,
|
||||
streams: Mutex<Vec<(u64, TcpStream)>>,
|
||||
}
|
||||
|
||||
impl Accepted {
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<(u64, TcpStream)>> {
|
||||
self.streams.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn close_all(&self) {
|
||||
for (_, stream) in self.lock().drain(..) {
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a listener started with [`Hub::listen_stoppable`].
|
||||
pub struct Stop {
|
||||
addr: std::net::SocketAddr,
|
||||
shared: Arc<Accepted>,
|
||||
closed: Receiver<()>,
|
||||
}
|
||||
|
||||
impl Stop {
|
||||
/// The port the listener is on.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.addr.port()
|
||||
}
|
||||
|
||||
/// Stop accepting, close the port and end every connection it accepted. Returns once the
|
||||
/// port is closed (at most a couple of seconds), so the same port can be bound again at once.
|
||||
pub fn stop(self) {
|
||||
self.shared.stopped.store(true, Ordering::SeqCst);
|
||||
// The listener waits in `accept`: a connection of our own wakes it to see the flag.
|
||||
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_secs(1));
|
||||
let _ = self.closed.recv_timeout(Duration::from_secs(2));
|
||||
self.shared.close_all();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,62 +277,6 @@ struct Pending {
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
what: String,
|
||||
reply: Receiver<Value>,
|
||||
/// Where it is in the app, to cancel it (`control.cancel`).
|
||||
job: Arc<Ticket>,
|
||||
}
|
||||
|
||||
/// `control.cancel {id?}`: cancel the request this connection still waits for (the one
|
||||
/// `control.pending` reports; `id`, when given, must name it). A connection can only cancel its
|
||||
/// own requests. Waiting its turn, it is removed; computing off the window thread, it is stopped
|
||||
/// as the app's Stop stops it. Either way nothing of it is applied and the connection is free.
|
||||
/// One that runs on the window thread can't be cancelled; one that finished is answered.
|
||||
fn cancel(pending: &mut Option<Pending>, late: Option<Value>, id: Option<&Value>, document: &str) -> Value {
|
||||
let id = id.filter(|v| !v.is_null());
|
||||
let mine = pending.as_ref().filter(|p| id.is_none_or(|id| *id == p.id));
|
||||
let Some(p) = mine else {
|
||||
return match id {
|
||||
None => json!({"ok": true, "result": {"cancelled": null, "note": "nothing to cancel: no request of this connection is running"}}),
|
||||
Some(id) => json!({"ok": false, "error": format!(
|
||||
"nothing to cancel: request {} is not running on this connection (a connection can only cancel its own requests)",
|
||||
id.to_string().chars().take(64).collect::<String>()
|
||||
)}),
|
||||
};
|
||||
};
|
||||
let (what, id) = (format!("request {} ({})", p.id, p.what), p.id.clone());
|
||||
let finished = |p: Pending, reply: Value| {
|
||||
json!({"ok": true, "result": {"cancelled": null, "pending": p.id, "method": p.method, "done": true, "reply": reply,
|
||||
"note": format!("nothing to cancel: {what} had already finished; its reply is here")}})
|
||||
};
|
||||
if let Some(reply) = late {
|
||||
return pending.take().map_or(Value::Null, |p| finished(p, reply));
|
||||
}
|
||||
let outcome = p.job.cancel();
|
||||
let note = match outcome {
|
||||
Cancel::Removed => format!("cancelled: {what} was still waiting its turn and will not run; nothing was applied"),
|
||||
Cancel::Stopped { writes_file: false } => format!("stopped: {what} was computing off the window thread; nothing was applied"),
|
||||
Cancel::Stopped { writes_file: true } => {
|
||||
format!("stopped: {what} was computing on a copy of the {document}, which is unchanged; its worker may still write the file")
|
||||
}
|
||||
Cancel::Already => format!("{what} was already cancelled or stopped; nothing of it was applied"),
|
||||
Cancel::Running => {
|
||||
// It may have just finished.
|
||||
if let Some(reply) = pending.as_ref().and_then(|p| p.reply.try_recv().ok()) {
|
||||
return pending.take().map_or(Value::Null, |p| finished(p, reply));
|
||||
}
|
||||
return json!({"ok": false, "cancellable": false, "error": format!(
|
||||
"cannot be cancelled: {what} already runs in the app (on its window thread, or as a file it opens) and will \
|
||||
still finish; ask `control.pending` for its result. Only a request still waiting its turn, or a change or \
|
||||
export computing off the window thread, can be cancelled"
|
||||
)});
|
||||
}
|
||||
};
|
||||
let method = pending.take().map(|p| p.method);
|
||||
let how = match outcome {
|
||||
Cancel::Removed => "removed",
|
||||
Cancel::Stopped { .. } => "stopped",
|
||||
_ => "already",
|
||||
};
|
||||
json!({"ok": true, "result": {"cancelled": id, "method": method, "outcome": how, "note": note}})
|
||||
}
|
||||
|
||||
/// Every request the app has been sent and not answered yet, from every connection, for
|
||||
@@ -441,7 +311,7 @@ impl Running {
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped; `command` and
|
||||
/// `steps` as the app's [`Protocol`] summarised it.
|
||||
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Listing {
|
||||
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Ticket {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
@@ -452,7 +322,7 @@ impl Running {
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Listing { running: Arc::clone(self), key }
|
||||
Ticket { running: Arc::clone(self), key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
@@ -485,12 +355,12 @@ impl Running {
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Listing {
|
||||
struct Ticket {
|
||||
running: Arc<Running>,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Listing {
|
||||
impl Drop for Ticket {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
@@ -499,7 +369,7 @@ impl Drop for Listing {
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Listing) -> Receiver<Value> {
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
@@ -525,12 +395,11 @@ pub fn timeout_message(what: &str, effect: Effect, document: &str) -> String {
|
||||
}
|
||||
Effect::WritesFile => format!(
|
||||
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
|
||||
don't send it again; the {document} does not change); ask `control.pending` for its result, or `control.cancel` to \
|
||||
cancel it"
|
||||
don't send it again; the {document} does not change); ask `control.pending` for its result"
|
||||
),
|
||||
Effect::Changes => format!(
|
||||
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
|
||||
again); ask `control.pending` for its result, or `control.cancel` to cancel it"
|
||||
again); ask `control.pending` for its result"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -728,10 +597,6 @@ impl Hub {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.capabilities" {
|
||||
json!({"ok": true, "result": self.protocol.capabilities()})
|
||||
} else if method == "control.cancel" {
|
||||
let r = cancel(&mut pending, late, params.get("id"), self.protocol.document());
|
||||
(self.wake)();
|
||||
r
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
@@ -771,7 +636,6 @@ impl Hub {
|
||||
// answer on.
|
||||
joining = (method == "control.subscribe").then(|| documents.join());
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let job = Arc::clone(&req.ticket);
|
||||
let ticket = running.add(connection, &id, &req, command, steps);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
@@ -781,7 +645,7 @@ impl Hub {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect, self.protocol.document())});
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket), job });
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -797,7 +661,7 @@ impl Hub {
|
||||
}
|
||||
};
|
||||
// Subscribed once the window named the design (not when it refused or timed out).
|
||||
let sent = match joining.zip(document_of(&reply)) {
|
||||
let sent = match joining.and_then(|counted| document_of(&reply).map(|seen| (counted, seen))) {
|
||||
Some((counted, seen)) => match Subscription::start(documents, self.protocol.change_event(), counted, &out, seen, &reply) {
|
||||
Some((subscription, sent)) => {
|
||||
events = Some(subscription);
|
||||
@@ -943,104 +807,6 @@ mod tests {
|
||||
assert!(l.contains("unauthorized"), "{l}");
|
||||
}
|
||||
|
||||
/// #1989: `control.cancel` cancels the connection's own outstanding request. One still
|
||||
/// waiting its turn is removed and never runs, and the connection is free at once; one that
|
||||
/// already runs in the app can't be, and says so; another connection's request is refused;
|
||||
/// with nothing outstanding there is nothing to cancel.
|
||||
#[test]
|
||||
fn control_cancel_cancels_only_this_connections_request() {
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
// A stand-in for the window thread: `queued` waits behind the others (never taken until
|
||||
// `slow` is done), `slow` runs until the gate opens.
|
||||
std::thread::spawn(move || {
|
||||
let mut held = Vec::new();
|
||||
for req in rx {
|
||||
if req.method == "queued" {
|
||||
held.push(req);
|
||||
continue;
|
||||
}
|
||||
if !req.ticket.take() {
|
||||
continue;
|
||||
}
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
if req.method == "slow" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
if req.method == "slow" {
|
||||
for req in held.drain(..) {
|
||||
if req.ticket.take() {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT };
|
||||
let hub = hub(waits, &DOCUMENT_WATCH);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (tx, hub) = (tx.clone(), Arc::clone(&hub));
|
||||
std::thread::spawn(move || hub.serve(stream, token, tx, false));
|
||||
}
|
||||
});
|
||||
let connect = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let out = s.try_clone().unwrap();
|
||||
(out, BufReader::new(s))
|
||||
};
|
||||
let ask = |(out, lines): &mut (TcpStream, BufReader<TcpStream>), v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let mut a = connect();
|
||||
let mut b = connect();
|
||||
let r = ask(&mut b, json!({"id": 1, "method": "control.cancel", "token": token}));
|
||||
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "nothing to cancel: {r}");
|
||||
|
||||
// Waiting its turn: removed, never run, and the connection is free.
|
||||
let r = ask(&mut a, json!({"id": 1, "method": "queued", "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
let r = ask(&mut a, json!({"id": 2, "method": "control.cancel", "params": {"id": 1}}));
|
||||
assert_eq!((&r["ok"], &r["result"]["cancelled"], &r["result"]["outcome"]), (&json!(true), &json!(1), &json!("removed")), "{r}");
|
||||
assert!(r["result"]["note"].as_str().is_some_and(|n| n.contains("will not run")), "{r}");
|
||||
let r = ask(&mut a, json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
|
||||
|
||||
// Already running in the app: it can't be, and it still answers.
|
||||
let r = ask(&mut a, json!({"id": 4, "method": "slow"}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
let r = ask(&mut b, json!({"id": 2, "method": "control.cancel", "params": {"id": 4}}));
|
||||
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.contains("its own requests")), "another connection's: {r}");
|
||||
let r = ask(&mut a, json!({"id": 5, "method": "control.cancel", "params": {"id": 99}}));
|
||||
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("nothing to cancel")), "{r}");
|
||||
let r = ask(&mut a, json!({"id": 6, "method": "control.cancel"}));
|
||||
assert!(r["ok"] == false && r["cancellable"] == false, "{r}");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.starts_with("cannot be cancelled") && e.contains("control.pending")), "{r}");
|
||||
gate_tx.send(()).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let r = loop {
|
||||
let r = ask(&mut a, json!({"id": 7, "method": "control.pending"}));
|
||||
if r["result"]["done"] == true || Instant::now() > deadline {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(r["result"]["reply"]["result"], "slow", "{r}");
|
||||
let r = ask(&mut a, json!({"id": 8, "method": "control.cancel"}));
|
||||
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "{r}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow"], "the cancelled request never ran");
|
||||
}
|
||||
|
||||
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
|
||||
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
|
||||
/// is queued to run unseen behind it.
|
||||
@@ -1388,35 +1154,4 @@ mod tests {
|
||||
assert_eq!(out.bytes.last(), Some(&b'\n'));
|
||||
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
|
||||
}
|
||||
|
||||
/// A stopped listener frees its port at once and ends the connections it had, so a channel
|
||||
/// turned off in the settings leaves nothing listening and can be turned on again on the
|
||||
/// same port.
|
||||
#[test]
|
||||
fn a_stopped_listener_closes_its_port_and_its_connections() {
|
||||
use std::io::Read;
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, _rx) = channel();
|
||||
let stop = hub(Waits::default(), &DOCUMENT_WATCH).listen_stoppable(listener, "0123456789abcdef0123456789abcdef".into(), tx, false).unwrap();
|
||||
assert_eq!(stop.port(), port);
|
||||
let mut client = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
|
||||
// Wait until the server holds the connection, so stopping has one to close.
|
||||
let held = Instant::now();
|
||||
while stop.shared.lock().is_empty() && held.elapsed() < Duration::from_secs(4) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let started = Instant::now();
|
||||
stop.stop();
|
||||
let mut byte = [0u8; 1];
|
||||
match client.read(&mut byte) {
|
||||
Ok(n) => assert_eq!(n, 0, "the connection ends"),
|
||||
Err(e) => {
|
||||
assert!(!matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "the connection ends, not waits: {e}")
|
||||
}
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
|
||||
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
|
||||
}
|
||||
}
|
||||
|
||||
+21
-56
@@ -97,14 +97,7 @@ pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) ->
|
||||
result
|
||||
}
|
||||
|
||||
fn remap_geo(
|
||||
g: &mut crate::construct::GeoRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
doc: &crate::Document,
|
||||
) -> crate::Result<()> {
|
||||
fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
|
||||
use crate::construct::GeoRef;
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
@@ -121,38 +114,29 @@ fn remap_geo(
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
|
||||
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_plane(
|
||||
p: &mut PlaneRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
doc: &crate::Document,
|
||||
) -> crate::Result<()> {
|
||||
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_geo(point, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_plane(base, ids, bodies, depth + 1)?;
|
||||
remap_geo(point, ids, bodies, depth + 1)?;
|
||||
}
|
||||
PlaneRef::AtAngle { base, line, pivot, .. } => {
|
||||
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_plane(base, ids, bodies, depth + 1)?;
|
||||
if let Some(g) = line {
|
||||
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_geo(g, ids, bodies, depth + 1)?;
|
||||
}
|
||||
if let Some(g) = pivot {
|
||||
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_geo(g, ids, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
@@ -165,28 +149,13 @@ fn remap_plane(
|
||||
}
|
||||
_ => {
|
||||
for r in p.refs_mut() {
|
||||
remap_geo(r, ids, bodies, depth + 1, occurrences, doc)?;
|
||||
remap_geo(r, ids, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A datum reference names an exact placement by its complete occurrence path. A copy within one
|
||||
/// design rewrites the path to the copy's own walk (the copied subtree's source ancestors are not
|
||||
/// in the map); an empty occurrence map (a paste from another design) or a path that leaves the
|
||||
/// copied tree is refused - the reference could not follow, and a dangling one would resolve
|
||||
/// against whatever then sits at those ids.
|
||||
fn remap_datum_path(d: &mut crate::construct::DatumRef, occurrences: &BTreeMap<u64, u64>, doc: &crate::Document) -> crate::Result<()> {
|
||||
let last = d.path.last().copied().ok_or_else(|| crate::DocError::Invalid("a copied datum names no placement".into()))?;
|
||||
let new = occurrences
|
||||
.get(&last)
|
||||
.copied()
|
||||
.ok_or_else(|| crate::DocError::Invalid("the copied reference names a placement outside the copied tree".into()))?;
|
||||
d.path = doc.occurrence_path(new).ok_or_else(|| crate::DocError::Invalid("the copied placement's path is broken".into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remap_link(
|
||||
source: &mut solidcraft_sketch::LinkSource,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
@@ -235,8 +204,6 @@ impl crate::Feature {
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
doc: &crate::Document,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
@@ -299,7 +266,7 @@ impl crate::Feature {
|
||||
self.kind.remap_bodies(bodies);
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, bodies, 0, occurrences, doc)?;
|
||||
remap_plane(plane, ids, bodies, 0)?;
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(n) = &mut c.param {
|
||||
mapped_name(n, parameters);
|
||||
@@ -323,33 +290,31 @@ impl crate::Feature {
|
||||
}
|
||||
K::FrameEndCap { ends, .. } => {
|
||||
for end in ends {
|
||||
remap_plane(&mut end.plane, ids, bodies, 0, occurrences, doc)?;
|
||||
remap_plane(&mut end.plane, ids, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0, occurrences, doc)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0, occurrences, doc)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
|
||||
remap_geo(r, ids, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
|
||||
remap_geo(r, ids, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
|
||||
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => {
|
||||
remap_geo(geo, ids, bodies, 0, occurrences, doc)?
|
||||
}
|
||||
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
|
||||
// An Align frame's joint origin finds its faces and edges by name, as the feature's
|
||||
// own face names are found.
|
||||
K::Align { from_frame, to_frame, .. } => {
|
||||
@@ -452,7 +417,7 @@ mod clone_tests {
|
||||
value["name"] = json!("Copy");
|
||||
let mut feature: crate::Feature = serde_json::from_value(value).unwrap();
|
||||
let before = serde_json::to_string(&feature).unwrap();
|
||||
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new(), &crate::Document::default()).unwrap();
|
||||
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new()).unwrap();
|
||||
assert_eq!(serde_json::to_string(&feature).unwrap(), before.replace("\"Old\"", "\"New\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,7 +988,6 @@ mod tests {
|
||||
sketch,
|
||||
report,
|
||||
profiles: Vec::new(),
|
||||
profiles_3d: Vec::new(),
|
||||
ends: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,10 +1033,6 @@ pub enum FeatureKind {
|
||||
length: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
modeled: bool,
|
||||
/// Continuous flank faces for this feature. Missing in older designs: keep
|
||||
/// their half-turn faces and groove-before-chamfer order for saved references.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
continuous_flanks: bool,
|
||||
/// A point near the end of the face the thread starts at; `None`: the face's only free
|
||||
/// end (no material past it), or the end the pick at `face` lies near.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1704,10 +1700,6 @@ pub enum FeatureKind {
|
||||
#[serde(default = "all_profiles")]
|
||||
#[walk(profiles_of = sketch)]
|
||||
profiles: ProfileSel,
|
||||
/// Indices into the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`): each is
|
||||
/// patched in the plane it lies in (#741).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
profiles_3d: Vec<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -3399,9 +3391,6 @@ pub struct Document {
|
||||
/// Named views: saved cameras.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<crate::notes::Note>,
|
||||
/// What was hidden when the design was saved. A session takes it from here when the design
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
|
||||
/// other change), so a document in a session leaves this empty; the session puts it back when
|
||||
@@ -3431,9 +3420,6 @@ pub struct Visibility {
|
||||
/// The browser's Origin folder is hidden (`None`: the app's preference decides).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin_hidden: Option<bool>,
|
||||
/// Component Origin folders explicitly shown, by complete occurrence path. Hidden by default.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub shown_component_origins: Vec<Vec<u64>>,
|
||||
/// Finished sketches whose closed profiles are not shaded, by feature id.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub hidden_profiles: Vec<u64>,
|
||||
@@ -3461,7 +3447,6 @@ impl Visibility {
|
||||
&& self.shown_sketches.is_empty()
|
||||
&& self.hidden_joints.is_empty()
|
||||
&& self.origin_hidden.is_none()
|
||||
&& self.shown_component_origins.is_empty()
|
||||
&& self.hidden_profiles.is_empty()
|
||||
&& self.shown_dimensions.is_empty()
|
||||
&& self.locked_bodies.is_empty()
|
||||
@@ -3644,7 +3629,6 @@ impl Document {
|
||||
.chain(self.timeline_groups.iter().map(|g| g.id))
|
||||
.chain(self.canvases.iter().map(|c| c.id))
|
||||
.chain(self.point_clouds.iter().map(|c| c.id))
|
||||
.chain(self.notes.iter().map(|n| n.id))
|
||||
.chain(a.joints.iter().map(|j| j.id))
|
||||
.chain(a.contact_sets.iter().map(|c| c.id))
|
||||
.chain(a.studies.iter().map(|s| s.id))
|
||||
@@ -3700,7 +3684,6 @@ impl Document {
|
||||
timeline_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
+2
-134
@@ -437,8 +437,6 @@ pub struct SolvedSketch {
|
||||
pub sketch: solidcraft_sketch::Sketch,
|
||||
pub report: SolveReport,
|
||||
pub profiles: Vec<Profile>,
|
||||
/// Planar closed loops the 3D sketch curves take part in, in planes of their own (#741).
|
||||
pub profiles_3d: Vec<solidcraft_sketch::Profile3D>,
|
||||
/// Open and untied ends of its profile curves (where a profile can fail).
|
||||
pub ends: solidcraft_sketch::EndReport,
|
||||
}
|
||||
@@ -2336,7 +2334,6 @@ impl Model {
|
||||
name: last.name.clone(),
|
||||
plane: last.plane,
|
||||
profiles: find_profiles(&sketch),
|
||||
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, last.plane),
|
||||
ends: solidcraft_sketch::loose_ends(&sketch),
|
||||
sketch,
|
||||
report,
|
||||
@@ -3720,70 +3717,6 @@ pub(crate) fn empty_intersection(bodies: &[&str]) -> DocError {
|
||||
DocError::Invalid(format!("the intersection is empty: nothing of {body} would remain; check that the profile overlaps the body"))
|
||||
}
|
||||
|
||||
/// The body a feature's target name `name` picks. Body names are unique, so a body of the
|
||||
/// feature's component asked to be called a name another component's body already has (a
|
||||
/// linked component's `AS5047P_PCB`) was named otherwise (`Body1`), and the name still picks the
|
||||
/// other component's body. That is refused with both bodies listed, instead of running the
|
||||
/// boolean on the other component's body and failing there with a kernel error (#12141).
|
||||
pub(crate) fn named_target(doc: &Document, state: &ModelState, f: &Feature, name: &str) -> Result<usize> {
|
||||
let i = state.bodies.iter().position(|b| b.name == name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
|
||||
let Some(found) = state.bodies.get(i).filter(|b| b.component != f.component) else { return Ok(i) };
|
||||
let asked: Vec<&str> =
|
||||
asked_names(doc, state).filter(|(b, asked)| b.component == f.component && *asked == Some(name)).map(|(b, _)| b.name.as_str()).collect();
|
||||
if asked.is_empty() {
|
||||
return Ok(i);
|
||||
}
|
||||
let component = |id: u64| doc.components.iter().find(|c| c.id == id).map_or_else(|| "the root".to_string(), |c| format!("`{}`", c.name));
|
||||
Err(DocError::Invalid(format!(
|
||||
"the body name `{name}` is ambiguous: it names the body of component {}, while this feature's component {} has {} that was asked to be called `{name}` (the name was taken); target {} or rename the bodies so each name is used once",
|
||||
component(found.component),
|
||||
component(f.component),
|
||||
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", "),
|
||||
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(" or "),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Each body with the name it was asked to be called: its slot of its feature's stored names.
|
||||
/// An Import's skipped solids make no body, so its bodies take the slots left, in order; any
|
||||
/// other feature's k-th body takes slot k. Siblings a later feature removed shift the count, so
|
||||
/// a body then reads a later slot's name (only ever refusing an ambiguous target). One pass
|
||||
/// over the bodies, each feature's slots listed once, so a large import costs linear time.
|
||||
fn asked_names<'a, 's>(doc: &'a Document, state: &'s ModelState) -> impl Iterator<Item = (&'s ModelBody, Option<&'a str>)> {
|
||||
let mut made: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
|
||||
let mut slots: std::collections::HashMap<u64, Vec<usize>> = std::collections::HashMap::new();
|
||||
state.bodies.iter().map(move |b| {
|
||||
let j = made.entry(b.feature).or_insert(0);
|
||||
let k = *j;
|
||||
*j += 1;
|
||||
let asked = doc.feature(b.feature).and_then(|m| {
|
||||
let slot = match &m.kind {
|
||||
FeatureKind::Import { skip, .. } => *slots
|
||||
.entry(m.id)
|
||||
.or_insert_with(|| {
|
||||
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
|
||||
(0..m.body_names.len()).filter(|s| !skip.contains(&(*s as u64))).collect()
|
||||
})
|
||||
.get(k)?,
|
||||
_ => k,
|
||||
};
|
||||
m.body_names.get(slot).map(String::as_str).filter(|n| !n.is_empty())
|
||||
});
|
||||
(b, asked)
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse the target names among `names` that [`named_target`] finds ambiguous, before any
|
||||
/// work that does not go through it (a sheet's cut-outs are machined before the boolean). Names
|
||||
/// that pick no body are left to the feature, which may allow them.
|
||||
fn refuse_ambiguous_targets(doc: &Document, state: &ModelState, f: &Feature, names: &[String]) -> Result<()> {
|
||||
for name in names.iter().filter(|n| *n != "*") {
|
||||
if let Err(e @ DocError::Invalid(_)) = named_target(doc, state, f, name) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bodies a feature's boolean takes part in, and whether they were named rather than taken
|
||||
/// by default (every body, or the feature's component).
|
||||
pub(crate) fn boolean_scope(state: &ModelState, f: &Feature, targets: &[String]) -> (bool, Vec<String>) {
|
||||
@@ -3879,7 +3812,7 @@ fn apply_traced_with(
|
||||
} else {
|
||||
let mut v = Vec::new();
|
||||
for t in targets {
|
||||
v.push(named_target(doc, state, f, t)?);
|
||||
v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?);
|
||||
}
|
||||
v
|
||||
};
|
||||
@@ -8309,7 +8242,6 @@ fn eval_feature(
|
||||
) -> Result<()> {
|
||||
// Its tools meet bodies in other placements where those are shown (#3383).
|
||||
let _frames = ToolFrames::install(doc, f, st);
|
||||
refuse_ambiguous_targets(doc, st, f, &f.participants)?;
|
||||
match &f.kind {
|
||||
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
|
||||
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
|
||||
@@ -8358,7 +8290,6 @@ fn eval_feature(
|
||||
| FeatureKind::Loft { operation, targets, .. }
|
||||
| FeatureKind::Coil { operation, targets, .. }
|
||||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||||
refuse_ambiguous_targets(doc, st, f, &crate::plain(targets))?;
|
||||
let mut constructor = None;
|
||||
let mut to_body = None;
|
||||
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body, warning)?;
|
||||
@@ -9342,10 +9273,9 @@ fn eval_sketch(
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let profiles_3d = solidcraft_sketch::find_planar_3d_profiles(&sk, plane);
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, profiles_3d, ends }));
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10216,68 +10146,6 @@ mod world_placement_cache_tests {
|
||||
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
|
||||
}
|
||||
|
||||
/// A body asked to be called a name a linked component's body has is named otherwise; a
|
||||
/// later feature of its component that targets the name is refused with both bodies listed,
|
||||
/// not run on the other component's body (#12141).
|
||||
#[test]
|
||||
fn a_target_name_taken_by_another_component_is_refused_as_ambiguous() {
|
||||
let mut doc = Document::new("names");
|
||||
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
|
||||
let local = doc.add_component(Some("Assembly"), 0).unwrap();
|
||||
let mut boxes = |comp: u64, operation: Operation, corner: Vec3| {
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box { corner: crate::point_expr(corner), length: "10".into(), width: "10".into(), height: "2".into(), operation },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let f = doc.feature_mut(id).unwrap();
|
||||
f.component = comp;
|
||||
id
|
||||
};
|
||||
let a = boxes(linked, Operation::NewBody, Vec3::new(100., 0., 0.));
|
||||
let b = boxes(local, Operation::NewBody, Vec3::ZERO);
|
||||
let cut = boxes(local, Operation::Cut, Vec3::new(2., 2., -1.));
|
||||
doc.feature_mut(a).unwrap().body_names = vec!["AS5047P_PCB".into()];
|
||||
doc.feature_mut(b).unwrap().body_names = vec!["AS5047P_PCB".into()];
|
||||
if let FeatureKind::Box { length, width, .. } = &mut doc.feature_mut(cut).unwrap().kind {
|
||||
*length = "2".into();
|
||||
*width = "2".into();
|
||||
}
|
||||
doc.feature_mut(cut).unwrap().participants = vec!["AS5047P_PCB".into()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let st = model.state();
|
||||
let own = st.bodies.iter().find(|x| x.component == local).unwrap().name.clone();
|
||||
assert_ne!(own, "AS5047P_PCB", "names stay unique");
|
||||
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
|
||||
assert!(err.contains("ambiguous") && err.contains("`Encoder`") && err.contains(&format!("`{own}`")), "{err}");
|
||||
// The linked body is left alone.
|
||||
let linked_body = st.body("AS5047P_PCB").unwrap();
|
||||
assert_eq!(linked_body.component, linked);
|
||||
|
||||
// Targeting the local body by the name it has works.
|
||||
doc.feature_mut(cut).unwrap().participants = vec![own.clone()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.result(cut).and_then(|r| r.error.clone()).is_none());
|
||||
}
|
||||
|
||||
/// An Import's skipped solids make no body: its bodies take the stored names left, so one
|
||||
/// built from slot B was asked for B, not for the skipped slot's A (#12141).
|
||||
#[test]
|
||||
fn an_imports_body_is_asked_for_its_own_slots_name() {
|
||||
let mut doc = Document::new("names");
|
||||
let id =
|
||||
doc.add_feature(FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: vec![0] }, None).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec!["A".into(), "B".into()];
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("B (2)".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(1., 1., 1.)).unwrap(), id));
|
||||
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("B"));
|
||||
doc.feature_mut(id).unwrap().kind = FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: Vec::new() };
|
||||
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
|
||||
let mut doc = Document::new("placements");
|
||||
|
||||
@@ -141,8 +141,6 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
});
|
||||
// One body supplies both old nearest-face snaps and persistent named-face snaps.
|
||||
// A scratch model shares its measurements, even though each sample changes the pose.
|
||||
@@ -1414,7 +1412,6 @@ fn marking(p: Vec3) -> FeatureKind {
|
||||
chamfer_angle: None,
|
||||
chamfer_depth: None,
|
||||
steep_flank: Default::default(),
|
||||
continuous_flanks: false,
|
||||
old_auto_size: false,
|
||||
}
|
||||
}
|
||||
@@ -2003,56 +2000,6 @@ fn a_hole_classifies_the_faces_it_reads_not_every_face_of_the_design() {
|
||||
assert!((side - Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-6, "into the side wall: {side:?}");
|
||||
}
|
||||
|
||||
/// A cut whose target name is taken by another component's sheet, while a body of the cut's own
|
||||
/// component was asked for that name, is refused before the sheet is machined (#12141).
|
||||
#[test]
|
||||
fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
|
||||
let mut doc = Document::new("names");
|
||||
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
|
||||
let local = doc.add_component(Some("Assembly"), 0).unwrap();
|
||||
flat_sheet(&mut doc, Vec2::new(0.0, 0.0), 40.0, 40.0);
|
||||
for f in doc.features.iter_mut() {
|
||||
f.component = linked;
|
||||
}
|
||||
let base = doc.features.last().unwrap().id;
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Plate".into()];
|
||||
let own = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(100.0, 0.0, 0.0)),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "2".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(own).unwrap().component = local;
|
||||
doc.feature_mut(own).unwrap().body_names = vec!["Plate".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(10.0, 10.0, -5.0)),
|
||||
length: "5".into(),
|
||||
width: "5".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::Cut,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(cut).unwrap().component = local;
|
||||
doc.feature_mut(cut).unwrap().participants = vec!["Plate".into()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
|
||||
assert!(err.contains("ambiguous") && err.contains("`Encoder`"), "{err}");
|
||||
let st = model.state();
|
||||
let sheet = st.sheets.iter().find(|s| s.body == "Plate").expect("the linked sheet");
|
||||
assert!(sheet.holes.is_empty(), "the linked sheet is not machined");
|
||||
}
|
||||
|
||||
/// A hole's look at the material over its entry face (#10695) is best effort: when the kernel
|
||||
/// cannot build the run-on's column (here a column of no width), the hole keeps its full run-on
|
||||
/// and cuts nothing out of its tools, as before the check, instead of failing.
|
||||
|
||||
@@ -8,20 +8,13 @@ use solidcore_record::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Patch { sketch, profiles, profiles_3d, body, edges, crown, continuity, points } => {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
|
||||
let mut made = Vec::new();
|
||||
if let Some(sk) = sketch {
|
||||
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
// An empty index list means the caller asked only for 3D loops (#741).
|
||||
if !matches!(profiles, ProfileSel::Indices { indices } if indices.is_empty()) {
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
for r in ®ions {
|
||||
made.push(kernel::patch_region(&ss.plane, r)?);
|
||||
}
|
||||
}
|
||||
for &i in profiles_3d {
|
||||
let p = ss.profiles_3d.get(i).ok_or_else(|| DocError::Invalid(format!("sketch {sk} has no 3D profile {i}")))?;
|
||||
made.push(kernel::patch_region(&p.plane, &p.region)?);
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
for r in ®ions {
|
||||
made.push(kernel::patch_region(&ss.plane, r)?);
|
||||
}
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
@@ -40,7 +33,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Err(DocError::Invalid("a patch needs a sketch region, a 3D sketch loop, or a loop of edges".into()));
|
||||
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
|
||||
}
|
||||
for (k, b) in made.into_iter().enumerate() {
|
||||
let name = new_name(st, f, k);
|
||||
|
||||
@@ -765,7 +765,6 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
continuous_flanks,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
@@ -828,7 +827,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
if let Some(note) = cut_with_flanks(st, on, (*tip, angle, depth), *continuous_flanks)? {
|
||||
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {note}"),
|
||||
None => note,
|
||||
@@ -847,18 +846,11 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
|
||||
})
|
||||
}
|
||||
|
||||
/// Cut a modelled thread with its historical half-turn faces and groove-before-chamfer
|
||||
/// order. Existing hole features use this layout; Thread features keep their saved choice.
|
||||
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
|
||||
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
|
||||
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
|
||||
/// anything, is returned.
|
||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
|
||||
cut_with_flanks(st, on, (tip, angle, depth), false)
|
||||
}
|
||||
|
||||
fn cut_with_flanks(
|
||||
st: &mut ModelState,
|
||||
on: ThreadOn,
|
||||
(tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>),
|
||||
continuous_flanks: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
|
||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
@@ -896,28 +888,11 @@ fn cut_with_flanks(
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
// Preserve historical construction order when rebuilding an older feature.
|
||||
let chamfer = |mut input: Traced| -> Result<Traced> {
|
||||
if tip != ThreadTip::None {
|
||||
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
|
||||
let faces = cone.face_count();
|
||||
let cut =
|
||||
kernel::boolean(&input.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
|
||||
input = Traced { body: cut, lineage };
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
};
|
||||
if continuous_flanks {
|
||||
input = chamfer(input)?;
|
||||
}
|
||||
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law, continuous_flanks)?);
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
}
|
||||
if let Some(taper) = taper
|
||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||
@@ -957,8 +932,16 @@ fn cut_with_flanks(
|
||||
// The groove's faces have no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let nb = Traced { body: nb, lineage };
|
||||
let nb = if continuous_flanks { nb } else { chamfer(nb)? };
|
||||
let mut nb = Traced { body: nb, lineage };
|
||||
if tip != ThreadTip::None {
|
||||
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
|
||||
let faces = cone.face_count();
|
||||
let cut = kernel::boolean(&nb.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = nb.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
|
||||
nb = Traced { body: cut, lineage };
|
||||
}
|
||||
}
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
@@ -968,7 +951,7 @@ fn cut_with_flanks(
|
||||
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
|
||||
/// names (`Start`: the end of the axis `from`, which must be free; `Both`: each free end):
|
||||
/// the material outside a cone `angle` to the axis (default 45°) that reaches the diameter
|
||||
/// d_f = d - 2 `depth` at the face's end, as a ring to cut from the stock, so the last teeth
|
||||
/// d_f = d - 2 `depth` at the face's end, as a ring to cut after the groove, so the last teeth
|
||||
/// taper into it. ISO 4753 bounds d_f by the external thread's minor diameter; the default is
|
||||
/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies
|
||||
/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Component Origin folders explicitly shown save their complete occurrence paths
|
||||
//! (`Visibility::shown_component_origins`; they are hidden by default). Older designs have none:
|
||||
//! nothing new, they round-trip as they are. The last release warns about the newer format
|
||||
//! number, opens what it can read and keeps the rest.
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Joints save their captured values: `captured`, the values Capture Position
|
||||
//! (`component.capture_position`) saved and Revert Position (`component.revert_position`) puts
|
||||
//! the joint back to. An older design has none, so its joints load with their values as before
|
||||
//! and Revert Position leaves them where they are. The last release warns about the newer format
|
||||
//! number, opens the design and keeps the `captured` values it can't read.
|
||||
@@ -1,9 +0,0 @@
|
||||
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
|
||||
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
|
||||
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
|
||||
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
|
||||
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
|
||||
//! joint start where its parts are when it is changed. Older designs need no transformation: their
|
||||
//! joints have no constraint and are written as before. The last release opens a design with such
|
||||
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
|
||||
//! kind.
|
||||
@@ -1,4 +0,0 @@
|
||||
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
|
||||
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
|
||||
//! before. The last release refuses a design that has notes and asks for an update, instead of
|
||||
//! opening it and losing them on the next save.
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A sketch text's style can say where its block of lines sits up and down against its anchor
|
||||
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
|
||||
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
|
||||
//! without the key a text stays on its baseline. The last release opens a design that carries the
|
||||
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A surface patch can patch planar loops of 3D sketch curves: `profiles_3d` holds indices into
|
||||
//! the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`), each patched in the plane it
|
||||
//! lies in (#741). Older designs need no transformation: without the field the patch takes its
|
||||
//! profiles from the sketch's 2D profile list exactly as before. The last release refuses a
|
||||
//! design saved with this (the newer format number).
|
||||
@@ -1,3 +0,0 @@
|
||||
//! Saves each thread feature's continuous-flank layout choice. Older threads omit
|
||||
//! the field and keep their historical faces and chamfer order, preserving references.
|
||||
//! The previous release refuses designs with this newer format rather than losing it.
|
||||
@@ -429,75 +429,6 @@ impl JointKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
|
||||
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
|
||||
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Constraint {
|
||||
/// Two planes against each other (planar), `offset` apart.
|
||||
Mate,
|
||||
/// Two planes facing the same way (planar, flipped), `offset` apart.
|
||||
Flush,
|
||||
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
|
||||
Distance,
|
||||
/// Two axes on one line (cylindrical): B slides and turns along it.
|
||||
Concentric,
|
||||
/// Two round edges at one centre on one axis (revolute): B only turns.
|
||||
Insert,
|
||||
/// Two planes or axes parallel.
|
||||
Parallel,
|
||||
/// Two planes or axes square to each other.
|
||||
Perpendicular,
|
||||
/// Two planes or axes at `angle`.
|
||||
Angle,
|
||||
/// A cylinder resting on a plane (line on plane, `offset` its radius).
|
||||
Tangent,
|
||||
/// A point on an axis (point on line).
|
||||
PointOnAxis,
|
||||
}
|
||||
|
||||
impl Constraint {
|
||||
pub const ALL: [Constraint; 10] = [
|
||||
Constraint::Mate,
|
||||
Constraint::Flush,
|
||||
Constraint::Distance,
|
||||
Constraint::Concentric,
|
||||
Constraint::Insert,
|
||||
Constraint::Parallel,
|
||||
Constraint::Perpendicular,
|
||||
Constraint::Angle,
|
||||
Constraint::Tangent,
|
||||
Constraint::PointOnAxis,
|
||||
];
|
||||
|
||||
/// The rule's command name (`mate`, `point_on_axis`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Constraint::Mate => "mate",
|
||||
Constraint::Flush => "flush",
|
||||
Constraint::Distance => "distance",
|
||||
Constraint::Concentric => "concentric",
|
||||
Constraint::Insert => "insert",
|
||||
Constraint::Parallel => "parallel",
|
||||
Constraint::Perpendicular => "perpendicular",
|
||||
Constraint::Angle => "angle",
|
||||
Constraint::Tangent => "tangent",
|
||||
Constraint::PointOnAxis => "point_on_axis",
|
||||
}
|
||||
}
|
||||
|
||||
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
|
||||
pub fn parse(s: &str) -> Option<Constraint> {
|
||||
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
match key.as_str() {
|
||||
"coincident" => Some(Constraint::Mate),
|
||||
"axis" => Some(Constraint::Concentric),
|
||||
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint between two occurrences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Joint {
|
||||
@@ -527,21 +458,6 @@ pub struct Joint {
|
||||
/// again whenever the design changes, so a joint follows the parameters it is defined by.
|
||||
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
|
||||
pub exprs: JointExprs,
|
||||
/// The values Capture Position saved, which Revert Position puts the joint back to. None in
|
||||
/// files from before captured joint values: such a joint keeps its values on Revert Position.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub captured: Option<Vec<f64>>,
|
||||
/// The assembly constraint it was made as; `None` for a joint made as a joint.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub constraint: Option<Constraint>,
|
||||
}
|
||||
|
||||
impl Joint {
|
||||
/// Whether its values start where its parts are when it is made or changed, so B moves no
|
||||
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
|
||||
pub fn starts_where_it_is(&self) -> bool {
|
||||
self.kind.is_relation() || self.constraint.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
|
||||
@@ -2607,11 +2523,6 @@ impl Document {
|
||||
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
|
||||
Ok(mat_mul(&t, &f))
|
||||
}
|
||||
|
||||
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
|
||||
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
|
||||
circle_at(self, st, o).map(|c| c.radius)
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "joints_cam.rs"]
|
||||
@@ -2840,8 +2751,6 @@ mod tests {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
|
||||
@@ -944,8 +944,6 @@ mod tests {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
|
||||
@@ -93,8 +93,6 @@ mod tests {
|
||||
angle: 0.6,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,6 @@ mod lineage;
|
||||
pub mod metadata;
|
||||
pub use metadata::Metadata;
|
||||
pub mod motion;
|
||||
pub mod notes;
|
||||
pub use notes::Note;
|
||||
mod name_lookup;
|
||||
mod names;
|
||||
pub mod naming;
|
||||
|
||||
@@ -409,14 +409,12 @@ impl SavedModel {
|
||||
let mut sketches = Vec::new();
|
||||
for s in list("sketches")? {
|
||||
let sketch: solidcraft_sketch::Sketch = serde_json::from_value(parse(s.get("sketch"))?).map_err(|_| bad("saved model: sketch"))?;
|
||||
let plane: Plane = serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?;
|
||||
sketches.push(Arc::new(SolvedSketch {
|
||||
feature: s.get("feature").and_then(Json::as_u64).ok_or_else(|| bad("saved model: sketch"))?,
|
||||
name: text(s.get("name"))?,
|
||||
plane,
|
||||
plane: serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?,
|
||||
report: serde_json::from_value(parse(s.get("report"))?).map_err(|_| bad("saved model: sketch"))?,
|
||||
profiles: find_profiles(&sketch),
|
||||
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, plane),
|
||||
ends: solidcraft_sketch::loose_ends(&sketch),
|
||||
sketch,
|
||||
}));
|
||||
|
||||
@@ -350,45 +350,6 @@ fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
|
||||
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
|
||||
}
|
||||
|
||||
/// #13464: a mesh's flat patch outline drops a corner part way along a side, even when single
|
||||
/// precision has moved it off the line (as an STL's corners are), and when the loop starts there.
|
||||
#[test]
|
||||
fn a_mesh_outline_drops_a_rounded_corner_part_way_along_a_side() {
|
||||
let single = |x: f64| f64::from(x as f32);
|
||||
// a, c and d make a triangle; b lies on its side a–c up to single-precision rounding.
|
||||
let a = Vec3::new(0.0, 0.0, 0.0);
|
||||
let b = Vec3::new(single(0.3), single(0.33), 0.0);
|
||||
let c = Vec3::new(1.0, single(1.1), 0.0);
|
||||
let d = Vec3::new(0.0, 2.0, 0.0);
|
||||
let off = (b - a).cross(c - a).len() / (c - a).len();
|
||||
assert!(off > 0.0 && off < 1e-7, "b is off the line only by rounding: {off}");
|
||||
// b first, so the outline's loop starts at it.
|
||||
let mesh =
|
||||
solidcraft_geom::Mesh { positions: vec![b, c, d, a], triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
|
||||
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(0.3, 0.8, 0.0));
|
||||
assert_eq!(loops.len(), 1, "{loops:?}");
|
||||
let corners = loops.first().unwrap();
|
||||
assert_eq!(corners.len(), 4, "three corners and the first again: {corners:?}");
|
||||
assert!(!corners.iter().any(|p| p.dist(b) < 1e-12), "the corner on the side is gone: {corners:?}");
|
||||
for p in [a, c, d] {
|
||||
assert!(corners.iter().any(|q| q.dist(p) < 1e-12), "{p:?} is a corner: {corners:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13464: far from the origin, a small flat patch keeps its corners. A tolerance that grew with
|
||||
/// the distance from the origin alone took every corner of a 1 × 1 square at x = 1 000 000 for one
|
||||
/// on a straight side, and the face projected to nothing.
|
||||
#[test]
|
||||
fn a_small_mesh_patch_far_from_the_origin_keeps_its_corners() {
|
||||
let x = 1_000_000.0;
|
||||
let corners = [Vec3::new(x, 0.0, 0.0), Vec3::new(x + 1.0, 0.0, 0.0), Vec3::new(x + 1.0, 1.0, 0.0), Vec3::new(x, 1.0, 0.0)];
|
||||
let mesh =
|
||||
solidcraft_geom::Mesh { positions: corners.to_vec(), triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
|
||||
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(x + 0.5, 0.5, 0.0));
|
||||
assert_eq!(loops.len(), 1, "{loops:?}");
|
||||
assert_eq!(loops.first().map(Vec::len), Some(5), "four corners and the first again: {loops:?}");
|
||||
}
|
||||
|
||||
/// A ring turned from a rectangle on YZ about Z: each side of the rectangle makes one round face.
|
||||
fn ring() -> Document {
|
||||
let doc = Document {
|
||||
|
||||
@@ -1606,7 +1606,6 @@ mod tests {
|
||||
sketch,
|
||||
report,
|
||||
profiles,
|
||||
profiles_3d: Vec::new(),
|
||||
ends: Default::default(),
|
||||
};
|
||||
let full_scan = |q: Vec2| {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
|
||||
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
|
||||
//! warnings — saved with the design, not collaboration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
/// Longest text a note may hold (characters).
|
||||
pub const MAX_NOTE_CHARS: usize = 4000;
|
||||
|
||||
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
|
||||
/// its projection. The callout always faces the screen because it is drawn in screen space.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: u64,
|
||||
/// The point the note is anchored to (design coordinates, mm).
|
||||
pub anchor: Vec3,
|
||||
/// The note's text (may span lines).
|
||||
pub text: String,
|
||||
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
|
||||
pub fn text_is_valid(text: &str) -> bool {
|
||||
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn note(id: u64) -> Note {
|
||||
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_with_notes_survives_a_round_trip() {
|
||||
let mut doc = crate::Document::new("Noted");
|
||||
doc.notes.push(note(1));
|
||||
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
let back: crate::Document = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert_eq!(back.notes.len(), 2);
|
||||
assert!(back.notes[1].hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_saved_before_notes_loads_with_none() {
|
||||
// An older design file: no `notes` key at all.
|
||||
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
|
||||
let back: crate::Document = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_notes_are_not_written_when_they_are_visible() {
|
||||
let mut doc = crate::Document::new("Noted");
|
||||
doc.notes.push(note(1));
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(!json.contains("hidden"));
|
||||
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_text_must_be_non_empty_and_bounded() {
|
||||
assert!(Note::text_is_valid("Extra thickness added for strength."));
|
||||
assert!(!Note::text_is_valid(" "));
|
||||
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
|
||||
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,6 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
named_views,
|
||||
visibility,
|
||||
point_clouds,
|
||||
notes: _,
|
||||
} = a;
|
||||
(Arc::ptr_eq(render, &b.render) || *render == b.render)
|
||||
&& *format == b.format
|
||||
|
||||
@@ -545,16 +545,7 @@ mod tests {
|
||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
let ss = SolvedSketch {
|
||||
feature: 1,
|
||||
name: "ordinary circle".into(),
|
||||
plane: Plane::XY,
|
||||
sketch,
|
||||
report,
|
||||
profiles,
|
||||
profiles_3d: Vec::new(),
|
||||
ends: Default::default(),
|
||||
};
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let indices = vec![0; 600];
|
||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||
@@ -602,7 +593,7 @@ mod tests {
|
||||
}
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, profiles_3d: Vec::new(), ends: Default::default() }
|
||||
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -160,12 +160,6 @@ pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
stop.within(|| solidcraft_kernel::watch::watching(kernel, f))
|
||||
}
|
||||
|
||||
/// Whether the watch over this thread's evaluations asked them to stop. Work done after that may
|
||||
/// have been cut short: it is not kept.
|
||||
pub fn cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|cur| cur.progress.is_cancelled()))
|
||||
}
|
||||
|
||||
/// Entered at the start of an evaluation; the guard leaves it.
|
||||
pub(crate) struct Evaluation {
|
||||
outer: bool,
|
||||
|
||||
@@ -618,8 +618,7 @@ fn circular(samples: &[Vec2]) -> Option<(LinkGeom, f64)> {
|
||||
sweep += d;
|
||||
}
|
||||
let turn = sweep.abs();
|
||||
// A NaN turn is not refused here, as before.
|
||||
if !turn.is_nan() && !(CIRCLE_MIN_TURN..=std::f64::consts::TAU + 1e-6).contains(&turn) {
|
||||
if turn > std::f64::consts::TAU + 1e-6 || turn < CIRCLE_MIN_TURN {
|
||||
return None;
|
||||
}
|
||||
if first.dist(last) <= tol {
|
||||
@@ -1489,15 +1488,9 @@ const MESH_POLYGON_CORNERS: usize = 12;
|
||||
/// [`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
|
||||
/// distance from the origin, not of the circle's size; and never more loosely than 1e-4 of the
|
||||
/// outline's own size, so a small outline far from the origin is not taken for a circle it misses.
|
||||
/// size, not of the circle's.
|
||||
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
|
||||
let (lo, hi) = pts.iter().fold(
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)),
|
||||
|(lo, hi), p| (lo.min(*p), hi.max(*p)),
|
||||
);
|
||||
let size = lo.dist(hi);
|
||||
let within = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(size * 1e-4);
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -379,13 +379,7 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
|
||||
steps += 1;
|
||||
}
|
||||
if v == start && pts.len() >= 4 {
|
||||
// A corner off the line within the single precision a mesh file keeps is on it, but
|
||||
// never one farther off than 1e-4 of the loop's own size: far from the origin a small
|
||||
// patch's real corners stay.
|
||||
let far = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let (lo, hi) = pts.iter().fold((far, -far), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
|
||||
let precision = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(lo.dist(hi) * 1e-4);
|
||||
loops.extend(turning_corners(&pts, precision));
|
||||
loops.extend(turning_corners(&pts, size * 1e-9));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,18 +388,15 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
|
||||
|
||||
/// 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. A corner within `tol` of the line through its neighbours runs straight on. None when
|
||||
/// fewer than three corners turn.
|
||||
/// 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 };
|
||||
// How far the corner lies off the line from the one before it to the one after.
|
||||
let span = *next - *prev;
|
||||
let off = (*here - *prev).cross(span).len() / span.len().max(1e-300);
|
||||
span.len() <= tol || off > tol
|
||||
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 {
|
||||
|
||||
@@ -114,112 +114,6 @@ fn parametric_plate_reevaluates() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// #14256: an end plate whose 25 mm arc meets two lines tangentially, each joint held by two
|
||||
/// "point on curve" constraints instead of a shared point. The solver leaves the arc's ends on
|
||||
/// the lines only to its 1e-9 mm residual, so along the tangent they lie up to 2.25e-4 mm from
|
||||
/// the lines' ends. The outline is still one closed profile, and extruding it gives the plate.
|
||||
#[test]
|
||||
fn an_outline_closed_by_point_on_curve_tangent_joints_extrudes() {
|
||||
let mut doc = Document::new("end plate");
|
||||
let mut sk = Sketch::new();
|
||||
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
|
||||
let mut point = |x: f64, y: f64| sk.add_point(Vec2::new(x, y), None).unwrap();
|
||||
let corners = [(-14.0, 90.0), (14.0, 90.0), (14.0, 48.0), (knee, 11.125), (90.0, 11.125), (90.0, -11.125), (25.0, -11.125), (25.0, 0.0)];
|
||||
let [top_left, top_right, shoulder, knee_point, right_top, right_bottom, step_bottom, step_top] = corners.map(|(x, y)| point(x, y));
|
||||
// Where the solver left the 25 mm arc's ends, and the end of the line along its top.
|
||||
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
|
||||
let arc_end = point(0.0002252128472149822, 24.999999999324313);
|
||||
let ledge_right = point(0.0, 25.0);
|
||||
let ledge_left = point(-14.0, 25.0);
|
||||
let centre = point(0.0, 0.0);
|
||||
let small_centre = point(0.0, 0.0);
|
||||
let pos = |sk: &Sketch, i: usize| sk.points[i].pos;
|
||||
let line = |sk: &mut Sketch, a: usize, b: usize| sk.add_line_pts(a, b, None).unwrap();
|
||||
line(&mut sk, top_left, top_right);
|
||||
line(&mut sk, top_right, shoulder);
|
||||
sk.add_arc(Vec2::ZERO, pos(&sk, knee_point), pos(&sk, shoulder), [Some(centre), Some(knee_point), Some(shoulder)], None).unwrap();
|
||||
line(&mut sk, right_top, knee_point);
|
||||
line(&mut sk, right_bottom, right_top);
|
||||
line(&mut sk, step_bottom, right_bottom);
|
||||
let side = line(&mut sk, step_top, step_bottom);
|
||||
let arc = sk.add_arc(Vec2::ZERO, pos(&sk, arc_start), pos(&sk, arc_end), [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
|
||||
let ledge = line(&mut sk, ledge_left, ledge_right);
|
||||
line(&mut sk, top_left, ledge_left);
|
||||
use ConstraintKind::*;
|
||||
for kind in [
|
||||
Coincident { p: centre, q: 0 },
|
||||
Coincident { p: small_centre, q: 0 },
|
||||
Horizontal { l: ledge },
|
||||
Vertical { l: side },
|
||||
Tangent { a: ledge, b: arc },
|
||||
Tangent { a: side, b: arc },
|
||||
PointOnCurve { p: ledge_right, c: arc },
|
||||
PointOnCurve { p: step_top, c: arc },
|
||||
PointOnCurve { p: arc_start, c: side },
|
||||
PointOnCurve { p: arc_end, c: ledge },
|
||||
] {
|
||||
sk.add_constraint(kind, None).unwrap();
|
||||
}
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
second_follows_first: false,
|
||||
taper: None,
|
||||
corner: Default::default(),
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
to_body_apart: false,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.sketches[0].profiles.len(), 1);
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
// The polygon through the corners, plus the segment the 50 mm arc adds, less the segment the
|
||||
// quarter of the 25 mm circle takes away, 10 mm deep.
|
||||
let outline = [
|
||||
(25.0, -11.125),
|
||||
(90.0, -11.125),
|
||||
(90.0, 11.125),
|
||||
(knee, 11.125),
|
||||
(14.0, 48.0),
|
||||
(14.0, 90.0),
|
||||
(-14.0, 90.0),
|
||||
(-14.0, 25.0),
|
||||
(0.0, 25.0),
|
||||
(25.0, 0.0),
|
||||
];
|
||||
let polygon: f64 = (0..outline.len())
|
||||
.map(|i| {
|
||||
let ((x0, y0), (x1, y1)) = (outline[i], outline[(i + 1) % outline.len()]);
|
||||
x0 * y1 - x1 * y0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ 2.0;
|
||||
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
|
||||
let area = polygon + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, std::f64::consts::FRAC_PI_2);
|
||||
let volume = measure(&st.bodies[0].body).unwrap().volume;
|
||||
assert!(rel(volume, area * 10.0) < 1e-6, "{volume} vs {}", area * 10.0);
|
||||
}
|
||||
|
||||
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||
/// the evaluation reports the features it restored (their geometry did change back).
|
||||
#[test]
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
|
||||
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
|
||||
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
|
||||
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
|
||||
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
|
||||
|
||||
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] =
|
||||
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
|
||||
.icon("joint")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
.params(
|
||||
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
|
||||
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
|
||||
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
|
||||
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
|
||||
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
|
||||
rule needs → joint, name, dof, conflicts, fixed as joint.create",
|
||||
)];
|
||||
|
||||
/// What a reference is, for the rules that need one kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Shape {
|
||||
/// A planar face or a frame: its plane and normal.
|
||||
Plane,
|
||||
/// A round face's or edge's centre and axis.
|
||||
Round,
|
||||
/// A point (a vertex, an edge, a sketch point): its position.
|
||||
Point,
|
||||
/// A datum or construction axis: a line, with no rim or radius.
|
||||
Axis,
|
||||
}
|
||||
|
||||
fn shape(o: &JointOrigin) -> Shape {
|
||||
match o.snap {
|
||||
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
|
||||
Snap::Axis { .. } => Shape::Axis,
|
||||
Snap::CircleCenter { .. } => Shape::Round,
|
||||
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
|
||||
/// the two references were given the other way round (the plane or axis goes on side a).
|
||||
struct Kept {
|
||||
kind: JointKind,
|
||||
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
|
||||
flip: Option<bool>,
|
||||
swap: bool,
|
||||
}
|
||||
|
||||
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
|
||||
use Shape::*;
|
||||
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
|
||||
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
|
||||
// A line: a round face's or edge's axis, or an axis itself.
|
||||
let axial = |s: Shape| matches!(s, Round | Axis);
|
||||
// A rule between one plane or axis and something else: the plane or axis on side a.
|
||||
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
|
||||
if want(a) && other_ok(b) {
|
||||
Ok(Kept { kind, flip: Some(false), swap: false })
|
||||
} else if want(b) && other_ok(a) {
|
||||
Ok(Kept { kind, flip: Some(false), swap: true })
|
||||
} else {
|
||||
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
|
||||
}
|
||||
};
|
||||
match (c, a, b) {
|
||||
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
|
||||
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
|
||||
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
|
||||
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
|
||||
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
|
||||
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
|
||||
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
|
||||
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
|
||||
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
|
||||
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
|
||||
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
|
||||
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The references a rule takes, for its refusal.
|
||||
fn needs(c: Constraint) -> &'static str {
|
||||
match c {
|
||||
Constraint::Mate | Constraint::Flush => "two planar faces",
|
||||
Constraint::Distance => "two planar faces, or a planar face and a point",
|
||||
Constraint::Concentric => "two round faces, round edges or axes",
|
||||
Constraint::Insert => "two round edges",
|
||||
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
|
||||
Constraint::Tangent => "a planar face and a round face",
|
||||
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
|
||||
fn default_name(s: &Session, c: Constraint) -> String {
|
||||
let word = c.name().replace('_', " ");
|
||||
let mut label = word.clone();
|
||||
if let Some(first) = label.get(..1) {
|
||||
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
|
||||
}
|
||||
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
|
||||
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
|
||||
format!("{label}{n}")
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.constraint.create";
|
||||
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| {
|
||||
bad(cmd, "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
|
||||
})?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
// The numbers are read before the references are checked, in the order the contract reads them.
|
||||
for (key, angle) in [("offset", false), ("angle", true)] {
|
||||
if let Some(v) = p.get(key) {
|
||||
value(s, v, angle, cmd)?;
|
||||
}
|
||||
}
|
||||
let rule = kept(c, shape(&a), shape(&b), cmd)?;
|
||||
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
|
||||
if c == Constraint::Distance && p.get("offset").is_none() {
|
||||
return Err(bad(cmd, "distance needs `offset`"));
|
||||
}
|
||||
if c == Constraint::Angle && p.get("angle").is_none() {
|
||||
return Err(bad(cmd, "angle needs `angle`"));
|
||||
}
|
||||
let flip = {
|
||||
let st = s.model.state();
|
||||
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
|
||||
// not turned over.
|
||||
let along = || -> Result<bool> {
|
||||
let za = s.doc.origin_world(&st, &a).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let zb = s.doc.origin_world(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
|
||||
};
|
||||
let mut flip = match rule.flip {
|
||||
Some(f) => f,
|
||||
None => along()?,
|
||||
};
|
||||
if bool_(p, "flip") == Some(true) {
|
||||
flip = !flip;
|
||||
}
|
||||
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
|
||||
if c == Constraint::Tangent {
|
||||
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
flip
|
||||
};
|
||||
// The joint's own params: the kind and facing the rule needs, the rest as given.
|
||||
let mut q = p.clone();
|
||||
if let Some(m) = q.as_object_mut() {
|
||||
m.remove("type");
|
||||
m.remove("flip");
|
||||
m.remove("values");
|
||||
m.remove("value");
|
||||
m.remove("limits");
|
||||
m.insert("flip".into(), Value::Bool(flip));
|
||||
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
|
||||
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
|
||||
m.insert("name".into(), Value::String(default_name(s, c)));
|
||||
}
|
||||
}
|
||||
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
|
||||
if c == Constraint::Tangent {
|
||||
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
|
||||
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
|
||||
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
|
||||
}
|
||||
if j.offset != 0.0 && !j.offset.is_finite() {
|
||||
return Err(bad(cmd, "`offset` must be finite"));
|
||||
}
|
||||
j.constraint = Some(c);
|
||||
add_joint(s, j, &q)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
|
||||
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
|
||||
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, o:4/origin, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state; component origin items use o:4/origin/XY etc. in items); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
|
||||
),
|
||||
CommandSpec::new("browser.expand_all", "Expand All", crate::checked!("browser.expand_all", expand_all))
|
||||
@@ -187,10 +187,7 @@ fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
|
||||
_ => None,
|
||||
};
|
||||
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
|
||||
// Past the 256-character cap only the datum keys go through, each bounded by its own parser:
|
||||
// an item's `o:<ids>/origin/<item>`, a folder's `o:<ids>/origin` - deep placements need them.
|
||||
let past_cap = |x: &String| x.len() > 256 && crate::origins::item(x).is_none() && crate::origins::folder_path(x).is_none();
|
||||
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || past_cap(x)) {
|
||||
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
|
||||
return Err(bad(cmd, format!("`{k}` has bad keys")));
|
||||
}
|
||||
Ok(v)
|
||||
@@ -438,6 +435,8 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(cmd, BrowserOrdered { param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
|
||||
|
||||
/// A browser item whose visibility can change.
|
||||
enum Shown {
|
||||
Sketch(u64),
|
||||
@@ -458,13 +457,10 @@ fn is_construction(k: &FeatureKind) -> bool {
|
||||
/// The rule, the same for showing and for hiding: an exact name wins over a name that only
|
||||
/// matches it by case, so the body called "Back Lid" is not the sketch called "Back lid"
|
||||
/// (#6967). Among the exact matches the order is the one there always was: a feature (by id
|
||||
/// or by name), then a body, then a joint. With no exact match the key may name a component
|
||||
/// origin item by its key; else the name is compared without regard to case, as it always was:
|
||||
/// a feature first, then an origin item.
|
||||
/// or by name), then a body, then a joint. With no exact match the name is compared without
|
||||
/// regard to case, as it always was: a feature first, then an origin item.
|
||||
fn shown_item(s: &Session, world: &solidcraft_doc::ModelState, key: &str) -> Option<Shown> {
|
||||
shown_item_exact(s, world, key)
|
||||
.or_else(|| s.origin_geometry(key).map(|_| Shown::Origin(key.to_string())))
|
||||
.or_else(|| shown_item_ignore_case(s, world, key))
|
||||
shown_item_exact(s, world, key).or_else(|| shown_item_ignore_case(s, world, key))
|
||||
}
|
||||
|
||||
/// The item a key names exactly: a feature, a body or a joint whose name is the key itself.
|
||||
@@ -490,7 +486,7 @@ fn shown_item_ignore_case(s: &Session, world: &solidcraft_doc::ModelState, key:
|
||||
if let Some(shown) = s.doc.find_feature(key).and_then(|f| shown_feature(world, f, key)) {
|
||||
return Some(shown);
|
||||
}
|
||||
crate::origins::ITEMS.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
/// The item a feature is: a sketch, or construction geometry by name. A feature that is neither
|
||||
@@ -513,11 +509,8 @@ fn shown_feature(world: &solidcraft_doc::ModelState, f: &solidcraft_doc::Feature
|
||||
}
|
||||
|
||||
/// A browser folder or group key (`Visibility::hidden_folders`): `c0` or `o:<ids>`, then
|
||||
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases`, `/origin` or `/g<group id>`.
|
||||
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases` or `/g<group id>`.
|
||||
pub(crate) fn tree_folder_key(k: &str) -> bool {
|
||||
if crate::origins::folder_path(k).is_some() {
|
||||
return true;
|
||||
}
|
||||
let Some((scope, last)) = k.rsplit_once('/') else { return false };
|
||||
let scope_ok =
|
||||
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
|
||||
@@ -634,28 +627,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
// A component Origin folder's placement has to exist, and showing one is bounded.
|
||||
for path in keys.iter().filter_map(|key| crate::origins::folder_path(key)) {
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
if visible {
|
||||
let mut shown: BTreeSet<_> = s.visibility.shown_component_origins.iter().cloned().collect();
|
||||
shown.extend(keys.iter().filter_map(|key| crate::origins::folder_path(key)));
|
||||
if shown.len() > crate::origins::MAX_SHOWN {
|
||||
return Err(bad(cmd, "at most 10000 component origins can be shown at once"));
|
||||
}
|
||||
}
|
||||
for k in &keys {
|
||||
if let Some(path) = crate::origins::folder_path(k) {
|
||||
if visible {
|
||||
if !s.visibility.shown_component_origins.contains(&path) {
|
||||
s.visibility.shown_component_origins.push(path);
|
||||
}
|
||||
} else {
|
||||
s.visibility.shown_component_origins.retain(|p| p != &path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
s.visibility.hidden_folders.retain(|h| h != k);
|
||||
if !visible {
|
||||
s.visibility.hidden_folders.push(k.clone());
|
||||
@@ -761,7 +733,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some("bodies"), None) => s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
||||
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
||||
(Some("origin"), None) => crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
|
||||
// Everything at once, for a single "show all" step: every body, sketch, construction
|
||||
// item, origin item and joint.
|
||||
@@ -769,7 +741,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut all: Vec<Shown> = s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect();
|
||||
all.extend(s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)));
|
||||
all.extend(s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())));
|
||||
all.extend(crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())));
|
||||
all.extend(ORIGIN.iter().map(|o| Shown::Origin(o.to_string())));
|
||||
all.extend(s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)));
|
||||
all
|
||||
}
|
||||
@@ -934,9 +906,6 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
|
||||
keys.closed.insert(format!("{prefix}/features"));
|
||||
}
|
||||
let occurrence = prefix.starts_with("o:");
|
||||
if occurrence {
|
||||
keys.closed.insert(format!("{prefix}/origin"));
|
||||
}
|
||||
for f in s.doc.features.iter().filter(|f| f.component == component) {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind
|
||||
&& !sketch.patterns.is_empty()
|
||||
@@ -2971,35 +2940,6 @@ mod tests {
|
||||
assert!(plain.visibility.is_empty() && plain.visibility.origin_hidden.is_none());
|
||||
}
|
||||
|
||||
/// A batch with a key that does not name anything (no such placement) fails whole and
|
||||
/// changes nothing: the atomic contract, for the component origin keys too.
|
||||
/// A visibility batch that fails validation changes nothing: every key is checked
|
||||
/// before any of them is applied, and the failed command leaves the saved visibility
|
||||
/// as it was (#7313).
|
||||
#[test]
|
||||
fn invalid_visibility_batches_are_atomic() {
|
||||
let mut s = Session::default();
|
||||
// The Origin folder hangs on a placement, so the key takes the placement's occurrence id
|
||||
// (the create reply also carries the component id, which names no occurrence).
|
||||
let created = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
let folder = crate::origins::folder(&[occurrence]);
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": [&folder], "visible": true}));
|
||||
run(&mut s, "browser.visibility", json!({"items": [crate::origins::key(&[occurrence], "X")], "visible": false}));
|
||||
let before = s.visibility.clone();
|
||||
// The bad key fails the whole batch: the valid folder key changes nothing.
|
||||
assert!(s.execute("browser.visibility", &json!({"tree_folders": ["c0/bodies", "o:999999/origin"], "visible": true})).is_err());
|
||||
assert_eq!(s.visibility, before, "invalid batches must be atomic");
|
||||
// The item form is atomic the same way.
|
||||
assert!(s.execute("browser.visibility", &json!({"items": ["X", "o:999999/origin/X"], "visible": true})).is_err());
|
||||
assert_eq!(s.visibility, before);
|
||||
// And the bounds hold: no `o:` prefix, a zero or non-canonical id, the design's own scope.
|
||||
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
|
||||
assert!(s.execute("browser.visibility", &json!({"tree_folders": [invalid], "visible": true})).is_err(), "{invalid}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -269,7 +269,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped, &BTreeMap::new(), ©.doc)?;
|
||||
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped)?;
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut feature.kind {
|
||||
// 3D wires carry their own coordinate space, independently of the sketch plane.
|
||||
for wire in &mut sketch.wires {
|
||||
|
||||
@@ -279,16 +279,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("capture")
|
||||
.contract(&CAPTURE_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<CapturePositionResult>)
|
||||
.params("keeps pending occurrence moves and saves every moving joint's values → captured, joints"),
|
||||
.params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("revert")
|
||||
.noundo()
|
||||
.contract(&REVERT_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<RevertPositionResult>)
|
||||
.params(
|
||||
"occurrences?: [ids] (default all); drops selected pending occurrence moves; with no occurrences the joints also go back to \
|
||||
the values Capture Position saved (clamped to limits) → reverted, joints",
|
||||
),
|
||||
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
@@ -1007,9 +1005,8 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
let joints = super::joints::capture_values(s);
|
||||
s.revision += 1;
|
||||
reply(CapturePositionResult { captured: n, joints, param_warnings: Vec::new() })
|
||||
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1030,11 +1027,8 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
// Every occurrence going back takes the joints back too; a revert of some occurrences
|
||||
// leaves the joints alone.
|
||||
let joints = if p.get("occurrences").is_none() { super::joints::revert_values(s) } else { 0 };
|
||||
s.revision += 1;
|
||||
reply(RevertPositionResult { reverted: n, joints, param_warnings: Vec::new() })
|
||||
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The active component's frame from world coordinates (for new features' geometry).
|
||||
|
||||
@@ -486,7 +486,7 @@ pub(super) fn build(
|
||||
} else if f.body_names.is_empty() {
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters, &occurrences, &scratch.doc)?;
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
transfer_into_copy(&mut f.kind, &components)?;
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
|
||||
@@ -23,8 +23,6 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -749,51 +749,3 @@ fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_ea
|
||||
assert_eq!(corners(&s, part), lifted());
|
||||
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
|
||||
}
|
||||
|
||||
/// A construction axis through a nested placement's origin datum is copied with its datum path
|
||||
/// rebuilt through the copy (the source's ancestors are not in the occurrence map); an axis
|
||||
/// whose datum names a placement outside the copied tree refuses the copy - the reference could
|
||||
/// not follow, and a dangling one would resolve against whatever then sits at those ids.
|
||||
#[test]
|
||||
fn cloned_datum_references_follow_the_copy() {
|
||||
let mut s = Session::default();
|
||||
let parent = component(&mut s, "Parent");
|
||||
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
|
||||
let parent_occ = s.doc.occurrence_of(parent).unwrap().id;
|
||||
let child_occ = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
|
||||
run(&mut s, "component.activate", json!({"component": "Parent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"construct.axis.two_points",
|
||||
json!({"a": format!("o:{parent_occ}/{child_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "DatumAxis"}),
|
||||
);
|
||||
let r = copy(&mut s, parent);
|
||||
let new_comp = r["component"].as_u64().unwrap();
|
||||
let new_parent_occ = r["occurrence"].as_u64().unwrap();
|
||||
let new_child_occ = s.doc.occurrences.iter().find(|o| o.parent == new_comp).unwrap().id;
|
||||
let axis =
|
||||
s.doc.features.iter().find(|f| f.component == new_comp && matches!(f.kind, solidcraft_doc::FeatureKind::ConstructionAxis { .. })).unwrap();
|
||||
let solidcraft_doc::FeatureKind::ConstructionAxis { def } = &axis.kind else { panic!("expected a construction axis: {:?}", axis.kind) };
|
||||
let solidcraft_doc::construct::AxisDef::TwoPoints { a, .. } = def else { panic!("expected a two-points axis: {def:?}") };
|
||||
match a {
|
||||
solidcraft_doc::construct::GeoRef::Datum { datum } => {
|
||||
assert_eq!(datum.path, vec![new_parent_occ, new_child_occ], "the datum names the copy's own nested placement");
|
||||
}
|
||||
other => panic!("expected a datum reference, got {other:?}"),
|
||||
}
|
||||
// An axis whose datum names a placement outside the copied tree refuses the copy. Outside is
|
||||
// made at the root: `component.create` places under the active component (the paste left
|
||||
// Parent active), and a datum key names its placement from the root, so a placement inside
|
||||
// Parent would be unreachable to the resolver and inside the copy's map at once.
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
let outside = component(&mut s, "Outside");
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
|
||||
let outside_occ = s.doc.occurrence_of(outside).unwrap().id;
|
||||
run(&mut s, "component.activate", json!({"component": "Parent"}));
|
||||
run(&mut s, "construct.axis.two_points", json!({"a": format!("o:{outside_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "OutsideAxis"}));
|
||||
assert!(
|
||||
s.execute("component.paste_new", &json!({"component": parent, "translate": [40, 0, 0]})).is_err(),
|
||||
"a datum into another tree cannot follow the copy"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_doc::{DesignLink, Document, FeatureKind, LinkedBodyProperties, UserMaterial};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::design_link::{LinkState, absolute, link_presence, read_linked_file, resolve, same_file, stored};
|
||||
use super::design_link::{absolute, resolve, stored};
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::schema::component_link::{
|
||||
BreakLinkParams, BreakLinkResult, CopiesParams, CopiesResult, DesignInserted, ExtractParams, ExtractResult, InsertDesignParams,
|
||||
@@ -112,9 +112,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
|
||||
or names a device: it is not looked for), changed (whether the file is no longer what this design last read: Update Links \
|
||||
reads it again; null when not found or the link is older than this record), then host (the network location); state (missing, \
|
||||
unreadable, not_allowed: a network location not allowed or outside the folders this agent may use) and reason when the file \
|
||||
can't be reached",
|
||||
reads it again; null when not found or the link is older than this record), and then host (the network location) and reason",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -213,12 +211,16 @@ fn remember_read(file: &Path, bytes: u64, feature: &solidcraft_io::StepFeature,
|
||||
/// The bytes of the design at `file`, which must not be the design saved at `own`.
|
||||
fn read_link(file: &Path, own: Option<&str>) -> Result<Vec<u8>> {
|
||||
let shown = file.display().to_string();
|
||||
if own.is_some_and(|o| same_file(Path::new(o), file)) {
|
||||
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
|
||||
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
|
||||
}
|
||||
// A network location only when its server is allowed, a device never: judged from the text
|
||||
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
|
||||
read_linked_file(file, own).map_err(|e| EngineError::Other(e.reason))
|
||||
let design = solidcraft_io::limits::Kind::Design;
|
||||
solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::limits::max_design_bytes()).map_err(|e| {
|
||||
let e = solidcraft_io::limits::explain(design, e).to_string();
|
||||
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
|
||||
})
|
||||
}
|
||||
|
||||
/// The solid bodies of the design at `file`, evaluated under `validation`, as an Import feature, with the digest of
|
||||
@@ -555,7 +557,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A network or device path is judged from its text, as given, before anything reads it.
|
||||
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let file = absolute(Path::new(given));
|
||||
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
|
||||
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
|
||||
return Err(bad(cmd, "a design can't be linked into itself"));
|
||||
}
|
||||
if p.get("reuse").and_then(Value::as_bool).unwrap_or(false) {
|
||||
@@ -797,9 +799,11 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// A network or device path is judged from its text before the disk is asked anything.
|
||||
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// The same path, or on native hosts an alias of this assembly (a symlink or a path with
|
||||
// different case): the one self-link rule Insert Design and Update Links use too.
|
||||
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
|
||||
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
|
||||
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
|
||||
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
|
||||
&& candidate == own
|
||||
{
|
||||
return Err(bad(cmd, "a design can't be linked into itself"));
|
||||
}
|
||||
if let Some(occurrence) = lone_placement(s, p, comp, cmd)? {
|
||||
@@ -955,45 +959,33 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let file = resolve(s.path.as_deref(), &l.path);
|
||||
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
let shown = file.to_string_lossy().into_owned();
|
||||
let file = file.to_string_lossy();
|
||||
// A refused path is not looked for at all: on Windows asking whether a file on
|
||||
// `\\host\share` exists already connects to the host.
|
||||
match link_refusal(s.path.as_deref(), &l.path) {
|
||||
None => {
|
||||
let found = solidcraft_io::vfs::design_path_is_file(&shown, s.path.as_deref());
|
||||
let found = solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref());
|
||||
// Whether the file is no longer what this design last read (#9739): `null`
|
||||
// when it isn't found, or the link keeps no digest (made before they were).
|
||||
let changed = l
|
||||
.file_sha256
|
||||
.as_ref()
|
||||
.filter(|_| found == Some(true))
|
||||
.and_then(|kept| file_sha256(Path::new(&shown), s.path.as_deref()).map(|now| now != *kept));
|
||||
// Why a file that can't be reached can't be: `None` when it can be read.
|
||||
let (state, reason) = match link_presence(&file, s.path.as_deref()) {
|
||||
Some(why) => (Some(why.state), Some(why.reason)),
|
||||
None => (None, None),
|
||||
};
|
||||
LinkInfo::Looked { component: *c, name, path, file: shown, found, changed, state, reason }
|
||||
.and_then(|kept| file_sha256(Path::new(file.as_ref()), s.path.as_deref()).map(|now| now != *kept));
|
||||
LinkInfo::Looked { component: *c, name, path, file: file.into_owned(), found, changed }
|
||||
}
|
||||
Some(solidcraft_io::vfs::Remote::Network(host)) => LinkInfo::Network {
|
||||
component: *c,
|
||||
name,
|
||||
path,
|
||||
file: shown,
|
||||
file: file.into_owned(),
|
||||
found: None,
|
||||
state: LinkState::NotAllowed,
|
||||
reason: format!("on the network location \\\\{host}, which is not allowed"),
|
||||
host,
|
||||
},
|
||||
Some(solidcraft_io::vfs::Remote::Device) => LinkInfo::Device {
|
||||
component: *c,
|
||||
name,
|
||||
path,
|
||||
file: shown,
|
||||
found: None,
|
||||
state: LinkState::Unreadable,
|
||||
reason: "names a device, not a file".into(),
|
||||
},
|
||||
Some(solidcraft_io::vfs::Remote::Device) => {
|
||||
LinkInfo::Device { component: *c, name, path, file: file.into_owned(), found: None, reason: "names a device, not a file".into() }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -1138,8 +1130,6 @@ mod tests {
|
||||
std::fs::remove_file(&part).unwrap();
|
||||
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("plate"), "{err}");
|
||||
let links = run(&mut b, "component.links", json!({}));
|
||||
assert_eq!((&links["links"][0]["found"], &links["links"][0]["state"]), (&json!(false), &json!("missing")), "{links}");
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
|
||||
// Break Link keeps the bodies and forgets the file.
|
||||
run(&mut b, "component.break_link", json!({"component": "plate"}));
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef, PointDef};
|
||||
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
|
||||
use solidcraft_doc::{FeatureKind, PlaneRef};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -171,7 +171,7 @@ pub(crate) fn is_model_axis(v: &Value) -> bool {
|
||||
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
|
||||
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
|
||||
let g = geo(s, Some(v), cmd, "axis")?;
|
||||
let (o, d) = solidcraft_doc::construct::model_axis_in(Some(&s.doc), &*s.world_state()?, &g, 0).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
|
||||
let (o, d) = solidcraft_doc::construct::model_axis(&*s.world_state()?, &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
|
||||
Ok(Some((g, o, d)))
|
||||
}
|
||||
|
||||
@@ -265,47 +265,21 @@ pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Resu
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
|
||||
Ok(GeoForm::Plane(plane)) => {
|
||||
// A bare component origin datum key (`o:2/origin/O`, `o:2/origin/X`, `o:2/origin/XY`)
|
||||
// names a point or an axis as often as a plane, and only the plane items reach
|
||||
// `plane_param`'s own datum branch. A feature of the same name still wins (the one
|
||||
// resolution order), so the string is left to `plane_param` then; the O, X, Y and Z
|
||||
// items are saved as their datum, so the reference follows the placement.
|
||||
let datum = plane
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|k| s.doc.find_feature(k).is_none())
|
||||
.and_then(crate::origins::datum)
|
||||
.filter(|d| matches!(d.item, DatumItem::O | DatumItem::X | DatumItem::Y | DatumItem::Z));
|
||||
match datum {
|
||||
Some(datum) => GeoRef::Datum { datum },
|
||||
None => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
|
||||
Ok(GeoForm::Axis(a)) => {
|
||||
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
|
||||
GeoRef::World { axis: a.to_ascii_uppercase() }
|
||||
} else {
|
||||
match s.doc.find_feature(a).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
|
||||
// A component origin axis: saved as its datum, so it follows the placement.
|
||||
_ => match crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
|
||||
Some(datum) => GeoRef::Datum { datum },
|
||||
None => return Err(bad(cmd, format!("no axis `{a}`"))),
|
||||
},
|
||||
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::ConstructPoint(n)) => {
|
||||
match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
|
||||
// A component origin's O point: its datum, so it follows the placement.
|
||||
_ => match crate::origins::datum(n).filter(|d| d.item == DatumItem::O) {
|
||||
Some(datum) => GeoRef::Datum { datum },
|
||||
None => return Err(bad(cmd, format!("no construction point `{n}`"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
|
||||
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
|
||||
},
|
||||
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
|
||||
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
|
||||
};
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
|
||||
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
|
||||
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
|
||||
//! references in each command's reference parameters, and build with the first that takes them.
|
||||
//! A method added to a family is tried as well, with nothing to change here.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
|
||||
|
||||
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_auto")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
];
|
||||
|
||||
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
|
||||
pub fn is_construct_tool(id: &str) -> bool {
|
||||
COMMANDS.iter().any(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
|
||||
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
|
||||
/// left out.
|
||||
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
|
||||
let prefix = format!("{tool}.");
|
||||
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
|
||||
}
|
||||
|
||||
/// How a reference parameter reads a reference (its schema type).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Form {
|
||||
/// Any `GeoRef` form, as given.
|
||||
Geo,
|
||||
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
|
||||
Plane,
|
||||
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
|
||||
Axis,
|
||||
}
|
||||
|
||||
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
|
||||
/// method needs it.
|
||||
struct Slot {
|
||||
key: String,
|
||||
form: Form,
|
||||
required: bool,
|
||||
}
|
||||
|
||||
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
|
||||
/// optional parameter's).
|
||||
fn form_of(property: &Value) -> Option<Form> {
|
||||
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
|
||||
Some("GeoRef") => Some(Form::Geo),
|
||||
Some("PlaneOrFace") => Some(Form::Plane),
|
||||
Some("AxisLine") => Some(Form::Axis),
|
||||
_ => None,
|
||||
};
|
||||
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
|
||||
}
|
||||
|
||||
/// The reference parameters of `spec`, read from its input schema.
|
||||
fn slots(spec: &CommandSpec) -> Vec<Slot> {
|
||||
let Some(contract) = spec.contract else { return Vec::new() };
|
||||
let schema = contract.input_schema();
|
||||
let root = schema.as_value();
|
||||
let required: Vec<&str> =
|
||||
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
|
||||
properties
|
||||
.iter()
|
||||
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `r` as a parameter of `form` reads it.
|
||||
fn as_form(r: &Value, form: Form) -> Value {
|
||||
let key = match form {
|
||||
Form::Geo => return r.clone(),
|
||||
Form::Plane => "plane",
|
||||
Form::Axis => "axis",
|
||||
};
|
||||
match r.as_object() {
|
||||
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
|
||||
_ => r.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
|
||||
/// the slot index of each reference. The first ways keep the references in the slots' order.
|
||||
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
|
||||
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
|
||||
/// one that fills every required slot.
|
||||
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
|
||||
if at.len() == refs {
|
||||
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
|
||||
out.push(at.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..slots.len() {
|
||||
if !at.contains(&i) {
|
||||
at.push(i);
|
||||
go(refs, slots, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if refs <= slots.len() {
|
||||
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
|
||||
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
|
||||
let mut p = with.clone();
|
||||
for (r, i) in refs.iter().zip(at) {
|
||||
if let Some(slot) = slots.get(*i) {
|
||||
p.insert(slot.key.clone(), as_form(r, slot.form));
|
||||
}
|
||||
}
|
||||
Value::Object(p)
|
||||
}
|
||||
|
||||
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
|
||||
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
|
||||
/// parameters fitted, else a list of what can be built from.
|
||||
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
|
||||
let mut first_error = None;
|
||||
for spec in construct_methods(tool) {
|
||||
let Some(contract) = spec.contract else { continue };
|
||||
let slots = slots(spec);
|
||||
for at in placements(refs.len(), &slots) {
|
||||
let p = params(refs, &slots, &at, with);
|
||||
if !contract.check(&p).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut trial = s.scratch();
|
||||
match spec.run_nested(&mut trial, &p) {
|
||||
Ok(_) => return Ok((spec.id, p)),
|
||||
Err(e) => {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_error.unwrap_or_else(|| {
|
||||
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
|
||||
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
|
||||
/// one undo step, and name the method in the reply.
|
||||
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
|
||||
// The contract has checked `refs` and `with`; this reads them as given.
|
||||
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let (method, params) = construct_method(s, tool, &refs, &with)?;
|
||||
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
|
||||
let mut reply = spec.run_nested(s, ¶ms)?;
|
||||
if let Value::Object(m) = &mut reply {
|
||||
m.insert("method".into(), json!(method));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// `construct.plane`.
|
||||
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.plane", p)
|
||||
}
|
||||
|
||||
/// `construct.axis`.
|
||||
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.axis", p)
|
||||
}
|
||||
|
||||
/// `construct.point`.
|
||||
fn point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.point", p)
|
||||
}
|
||||
@@ -2,15 +2,9 @@
|
||||
//! link to come): where a link keeps its file and how it finds it again. A link keeps the file
|
||||
//! relative to the linking design's folder once that design is saved, so a folder of designs can
|
||||
//! move together; before the design has a file the path is kept whole.
|
||||
//!
|
||||
//! Reading a linked design ([`read_linked_file`]) gives its bytes or its [`LinkState`] with the
|
||||
//! reason, in one set of state words every kind of link reports.
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Component as PathPart, Path, PathBuf};
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
use solidcraft_doc::{DesignLink, Document};
|
||||
|
||||
/// The folder relative link paths are kept against: the design's own, once it has a file.
|
||||
@@ -74,120 +68,7 @@ pub(super) fn resolve(design: Option<&str>, link: &str) -> PathBuf {
|
||||
|
||||
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
|
||||
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
|
||||
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: rebase(&l.path, from, to), ..l.clone() })).collect()
|
||||
}
|
||||
|
||||
/// One link's path, kept for a design saved at `from`, as a design saved at `to` keeps it: the
|
||||
/// one Save As rule every kind of link follows.
|
||||
pub(super) fn rebase(link: &str, from: Option<&str>, to: &str) -> String {
|
||||
stored(Some(to), &resolve(from, link))
|
||||
}
|
||||
|
||||
/// Whether two paths name one file: the same path, or (on native hosts) the same file through a
|
||||
/// symlink or a path with different case. A design can't link itself under either.
|
||||
pub(super) fn same_file(a: &Path, b: &Path) -> bool {
|
||||
absolute(a) == absolute(b) || matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
|
||||
}
|
||||
|
||||
/// Where a link stands against its file: the one set of state words every kind of link reports.
|
||||
/// Reading tells [`LinkState::Missing`], [`LinkState::Unreadable`] and [`LinkState::NotAllowed`]
|
||||
/// apart; whether a design that was read is [`LinkState::UpToDate`] or [`LinkState::OutOfDate`]
|
||||
/// is the link's own comparison. A design a newer SolidCraft saved is not a state of its own: it
|
||||
/// opens, and its `newer_file` says what this build couldn't read (#12166).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LinkState {
|
||||
/// The file holds what the link last took from it.
|
||||
UpToDate,
|
||||
/// The file changed since the link last took from it.
|
||||
OutOfDate,
|
||||
/// There is no file at the link's path.
|
||||
Missing,
|
||||
/// The file is there but isn't a design this build can read, or the path names a device.
|
||||
Unreadable,
|
||||
/// The file is on a network location that is not allowed, or outside the folders this agent
|
||||
/// may use.
|
||||
NotAllowed,
|
||||
}
|
||||
|
||||
impl LinkState {
|
||||
/// The state word (`up_to_date`, `missing`…) that link listings report.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
LinkState::UpToDate => "up_to_date",
|
||||
LinkState::OutOfDate => "out_of_date",
|
||||
LinkState::Missing => "missing",
|
||||
LinkState::Unreadable => "unreadable",
|
||||
LinkState::NotAllowed => "not_allowed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a linked design can't be read: its state, and the reason to show.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LinkProblem {
|
||||
pub state: LinkState,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LinkProblem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
fn problem(state: LinkState, reason: String) -> LinkProblem {
|
||||
LinkProblem { state, reason }
|
||||
}
|
||||
|
||||
/// The state of an I/O error reading `shown`, with the reason naming the file once.
|
||||
fn io_problem(shown: &str, e: &std::io::Error) -> LinkProblem {
|
||||
let named = |e: String| if e.starts_with(shown) { e } else { format!("{shown}: {e}") };
|
||||
match e.kind() {
|
||||
// The folder scope's refusal says which folders the agent may use.
|
||||
ErrorKind::PermissionDenied => problem(LinkState::NotAllowed, e.to_string()),
|
||||
ErrorKind::NotFound => problem(LinkState::Missing, format!("{shown}: not found")),
|
||||
_ => problem(LinkState::Unreadable, named(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the path a design names may not be reached at all, judged from its text before anything
|
||||
/// asks the disk or the network (#7372): a network location whose server isn't allowed
|
||||
/// ([`LinkState::NotAllowed`]), or a device ([`LinkState::Unreadable`]). `design` is the linking
|
||||
/// design's file, whose own server is allowed.
|
||||
fn refusal(shown: &str, design: Option<&str>) -> Option<LinkProblem> {
|
||||
let remote = solidcraft_io::vfs::design_path_refusal(shown, design)?;
|
||||
let reason = solidcraft_io::vfs::check_design_path(shown, design).err().map(|e| e.to_string()).unwrap_or_else(|| shown.to_string());
|
||||
Some(match remote {
|
||||
solidcraft_io::vfs::Remote::Network(_) => problem(LinkState::NotAllowed, reason),
|
||||
solidcraft_io::vfs::Remote::Device => problem(LinkState::Unreadable, reason),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the linked design at `file` stands without reading it: `None` when it can be read.
|
||||
/// Cheap, for a design being opened, which must not read every file it links. A refused network
|
||||
/// or device path is judged from its text alone, with nothing asked of the disk or the network.
|
||||
pub(super) fn link_presence(file: &Path, design: Option<&str>) -> Option<LinkProblem> {
|
||||
let shown = file.display().to_string();
|
||||
refusal(&shown, design).or_else(|| solidcraft_io::vfs::len(&shown).err().map(|e| io_problem(&shown, &e)))
|
||||
}
|
||||
|
||||
/// The bytes of the linked design at `file`, linked from the design saved at `design`, read
|
||||
/// through the folders the caller may use, or why they can't be: a network location not allowed
|
||||
/// or a device (judged from the path's text before anything is read), not a design file name,
|
||||
/// missing, larger than the design size limit, or outside an agent's folders. Each with the
|
||||
/// reason to show. Every kind of link reads its file here.
|
||||
pub(super) fn read_linked_file(file: &Path, design: Option<&str>) -> Result<Vec<u8>, LinkProblem> {
|
||||
let shown = file.display().to_string();
|
||||
if let Some(why) = refusal(&shown, design) {
|
||||
return Err(why);
|
||||
}
|
||||
if !solidcraft_io::is_design_path(&shown) {
|
||||
return Err(problem(LinkState::Unreadable, format!("{shown}: not a .{} design", solidcraft_io::DESIGN_EXT)));
|
||||
}
|
||||
let limit = solidcraft_io::limits::max_design_bytes();
|
||||
solidcraft_io::vfs::read_design_path(&shown, design, limit)
|
||||
.map_err(|e| io_problem(&shown, &solidcraft_io::limits::explain(solidcraft_io::limits::Kind::Design, e)))
|
||||
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), ..l.clone() })).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -218,94 +99,4 @@ mod tests {
|
||||
Some("plate.solidcraft")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_rule_rebases_every_link_for_save_as() {
|
||||
let root = absolute(Path::new("projects"));
|
||||
let from = root.join("asm").join("case.solidcraft").to_string_lossy().into_owned();
|
||||
let to = root.join("asm").join("v2").join("case.solidcraft").to_string_lossy().into_owned();
|
||||
assert_eq!(rebase("../parts/plate.solidcraft", Some(&from), &to), "../../parts/plate.solidcraft");
|
||||
// A link kept whole (the design had no file yet) becomes relative too.
|
||||
let whole = root.join("parts").join("plate.solidcraft").to_string_lossy().into_owned();
|
||||
assert_eq!(rebase(&whole, None, &to), "../../parts/plate.solidcraft");
|
||||
}
|
||||
|
||||
/// One folder per test, with the files it needs.
|
||||
fn folder(test: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-design-link-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_linked_design_reports_its_state_and_reason() {
|
||||
let dir = folder("read");
|
||||
let good = dir.join("plate.sc");
|
||||
std::fs::write(&good, solidcraft_io::write_design(&Document::new("plate"))).unwrap();
|
||||
let read = read_linked_file(&good, None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.name).ok());
|
||||
assert_eq!(read, Ok(Some("plate".to_string())));
|
||||
assert_eq!(link_presence(&good, None), None);
|
||||
let state = |file: &Path| read_linked_file(file, None).err().map(|e| (e.state, e.reason));
|
||||
// Missing: no file there (and the same without reading it).
|
||||
let missing = dir.join("gone.sc");
|
||||
assert!(matches!(state(&missing), Some((LinkState::Missing, r)) if r.contains("gone.sc")));
|
||||
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::Missing));
|
||||
// Unreadable: not a design file name, or a folder.
|
||||
std::fs::write(dir.join("part.step"), b"ISO-10303-21;").unwrap();
|
||||
assert!(matches!(state(&dir.join("part.step")), Some((LinkState::Unreadable, r)) if r.contains("not a .sc design")));
|
||||
std::fs::create_dir_all(dir.join("folder.sc")).unwrap();
|
||||
assert!(matches!(state(&dir.join("folder.sc")), Some((LinkState::Unreadable, _))));
|
||||
// A design a newer SolidCraft saved is read like any other, and opens saying so (#12166).
|
||||
let mut newer: serde_json::Value = serde_json::from_slice(&solidcraft_io::write_design(&Document::new("next"))).unwrap();
|
||||
newer["format"] = serde_json::json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
|
||||
std::fs::write(dir.join("next.sc"), newer.to_string()).unwrap();
|
||||
let opened =
|
||||
read_linked_file(&dir.join("next.sc"), None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.newer_file.is_some()).ok());
|
||||
assert_eq!(opened, Ok(Some(true)));
|
||||
// Not allowed: outside the folders an agent may use, without saying whether it exists.
|
||||
let inside = dir.join("inside");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([inside])), || {
|
||||
assert_eq!(state(&good).map(|s| s.0), Some(LinkState::NotAllowed));
|
||||
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::NotAllowed));
|
||||
});
|
||||
let words: Vec<&str> = [LinkState::UpToDate, LinkState::OutOfDate, LinkState::Missing, LinkState::Unreadable, LinkState::NotAllowed]
|
||||
.iter()
|
||||
.map(|s| s.word())
|
||||
.collect();
|
||||
assert_eq!(words, ["up_to_date", "out_of_date", "missing", "unreadable", "not_allowed"]);
|
||||
assert_eq!(serde_json::json!(LinkState::NotAllowed), serde_json::json!("not_allowed"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A network location not allowed, or a device, is refused from the path's text alone:
|
||||
/// reading it asks nothing of the disk or the network (#7372).
|
||||
#[test]
|
||||
fn network_and_device_paths_are_refused_before_anything_is_read() {
|
||||
let calls = solidcraft_io::vfs::file_calls();
|
||||
let network = Path::new(r"\\fileserver\share\standard.sc");
|
||||
let refused = read_linked_file(network, None).err();
|
||||
assert!(matches!(&refused, Some(p) if p.state == LinkState::NotAllowed && p.reason.contains("fileserver")), "{refused:?}");
|
||||
assert_eq!(link_presence(network, None).map(|p| p.state), Some(LinkState::NotAllowed));
|
||||
let device = Path::new(r"\\.\pipe\standard.sc");
|
||||
assert_eq!(read_linked_file(device, None).err().map(|p| p.state), Some(LinkState::Unreadable));
|
||||
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "a refused path asked the file system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_and_its_alias_are_the_same_file() {
|
||||
let dir = folder("same");
|
||||
let file = dir.join("a.sc");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
assert!(same_file(&file, &dir.join(".").join("a.sc")));
|
||||
assert!(!same_file(&file, &dir.join("b.sc")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = dir.join("alias.sc");
|
||||
std::os::unix::fs::symlink(&file, &alias).unwrap();
|
||||
assert!(same_file(&file, &alias));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2109,12 +2109,6 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if let FeatureKind::Thread { continuous_flanks, .. } = &old.kind
|
||||
&& cid == "solid.thread"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("continuous_flanks").or_insert_with(|| json!(continuous_flanks));
|
||||
}
|
||||
// A thread saved before holes were sized by their nominal diameter keeps the size it had
|
||||
// when it is edited without naming one.
|
||||
if let FeatureKind::Thread { old_auto_size: true, .. } = &old.kind
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//! Edit Feature must replace the selections as well as the numerical definition; the selection
|
||||
//! commands (`select.set`, `selection.get`).
|
||||
//! Edit Feature must replace the selections as well as the numerical definition.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::MeshGeom;
|
||||
|
||||
use crate::{EngineError, Sel, Session};
|
||||
use crate::{Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
@@ -2156,229 +2154,3 @@ fn a_pick_point_that_moves_a_hair_keeps_its_stretches_name_and_a_new_edge_keeps_
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
// `selection.get`: the selection read back, resolved, without changing anything.
|
||||
|
||||
fn close(v: &Value, want: f64) -> bool {
|
||||
v.as_f64().is_some_and(|x| (x - want).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn close3(v: &Value, want: [f64; 3]) -> bool {
|
||||
v.as_array().is_some_and(|a| a.len() == 3 && a.iter().zip(want).all(|(x, w)| close(x, w)))
|
||||
}
|
||||
|
||||
/// A 40 × 30 × 20 box with a Ø10 hole through it at (20, 15).
|
||||
fn holed_box(s: &mut Session) -> String {
|
||||
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(s, "solid.cylinder", json!({"radius": 5, "height": 22, "base": [20, 15, -1], "operation": "cut"}));
|
||||
s.model.state().bodies.first().unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// The face of the first body whose surface satisfies `want`, as a pick.
|
||||
fn face_pick(s: &Session, want: impl Fn(solidcraft_kernel::FaceSurface) -> bool) -> Value {
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
|
||||
let f = (0..n).find(|&f| solidcraft_kernel::face_surface(&b.body, f).is_some_and(&want)).expect("face");
|
||||
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
|
||||
json!({"type": "face", "body": b.name, "index": f, "point": p})
|
||||
}
|
||||
|
||||
/// The mesh edge all of whose points satisfy `on`, as a pick.
|
||||
fn edge_pick(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge");
|
||||
let e = &m.edges[i];
|
||||
json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_is_an_empty_list() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(run(&mut s, "selection.get", json!({})), json!({"count": 0, "items": []}));
|
||||
holed_box(&mut s);
|
||||
assert_eq!(run(&mut s, "selection.get", json!({"detail": false})), json!({"count": 0, "items": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_edges_and_bodies_are_resolved() {
|
||||
let mut s = Session::default();
|
||||
let body = holed_box(&mut s);
|
||||
let top = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Plane { normal, .. } if normal.z > 0.99));
|
||||
let hole = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { .. }));
|
||||
// The top front edge: y = 0, z = 20, along X.
|
||||
let line = edge_pick(&s, |p| p.y.abs() < 1e-9 && (p.z - 20.0).abs() < 1e-9);
|
||||
// The hole's rim on the top face.
|
||||
let rim = edge_pick(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p.x - 20.0).hypot(p.y - 15.0) - 5.0).abs() < 1e-6);
|
||||
let items = json!([top, hole, line, rim, {"type": "body", "name": body}]);
|
||||
run(&mut s, "select.set", json!({ "items": items }));
|
||||
let revision = s.revision;
|
||||
let (undo, dirty, doc) = (s.undo.len(), s.is_dirty(), s.doc.clone());
|
||||
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
assert_eq!(r["count"], 5, "{r}");
|
||||
let it = r["items"].as_array().unwrap();
|
||||
|
||||
let top = &it[0];
|
||||
assert_eq!(top["kind"], "face");
|
||||
assert_eq!(top["type"], "face", "items keep the select.set form");
|
||||
assert_eq!(top["body"], json!(body));
|
||||
assert_eq!(top["surface"]["type"], "plane", "{top}");
|
||||
assert!(close3(&top["surface"]["normal"], [0.0, 0.0, 1.0]), "{top}");
|
||||
let area = 40.0 * 30.0 - std::f64::consts::PI * 25.0;
|
||||
assert!(top["area_mm2"].as_f64().is_some_and(|a| (a - area).abs() < area * 1e-3), "{top}");
|
||||
assert!(top["name"].as_str().is_some_and(|n| !n.is_empty()), "persistent face name: {top}");
|
||||
assert!(top["name_exact"].is_boolean());
|
||||
|
||||
let hole = &it[1];
|
||||
assert_eq!(hole["surface"]["type"], "cylinder", "{hole}");
|
||||
assert!(close(&hole["surface"]["radius"], 5.0), "{hole}");
|
||||
assert!(hole["surface"]["axis"].as_array().is_some_and(|a| a[2].as_f64().is_some_and(|z| z.abs() > 0.999)), "{hole}");
|
||||
assert!(close(&hole["diameter"], 10.0), "{hole}");
|
||||
|
||||
let line = &it[2];
|
||||
assert_eq!(line["kind"], "edge");
|
||||
assert_eq!(line["curve"], "line", "{line}");
|
||||
assert!(close(&line["length_mm"], 40.0), "{line}");
|
||||
let ends: Vec<f64> = [&line["start"], &line["end"]].iter().map(|p| p[0].as_f64().unwrap()).collect();
|
||||
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 40.0).abs() < 1e-9), "{line}");
|
||||
assert!(line["name"].as_str().is_some_and(|n| n.contains('|')), "edge names join their faces' names: {line}");
|
||||
|
||||
let rim = &it[3];
|
||||
assert_eq!(rim["curve"], "circle", "{rim}");
|
||||
assert!(close(&rim["radius"], 5.0), "{rim}");
|
||||
assert!(close3(&rim["center"], [20.0, 15.0, 20.0]), "{rim}");
|
||||
|
||||
let b = &it[4];
|
||||
assert_eq!(b["kind"], "body");
|
||||
assert_eq!(b["name"], json!(body));
|
||||
let v = 40.0 * 30.0 * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
|
||||
assert!(b["volume_mm3"].as_f64().is_some_and(|x| (x - v).abs() < v * 1e-2), "{b}");
|
||||
assert!(b["feature"]["name"].is_string(), "{b}");
|
||||
assert_eq!(b["visible"], true);
|
||||
assert!(b.get("component").is_none(), "no component outside an assembly");
|
||||
|
||||
// Read-only: nothing changed.
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.is_dirty(), dirty);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.selection.len(), 5);
|
||||
|
||||
// Without detail: identity only.
|
||||
let r = run(&mut s, "selection.get", json!({"detail": false}));
|
||||
let first = r["items"][0].as_object().unwrap();
|
||||
assert!(first.contains_key("index") && first.contains_key("point") && !first.contains_key("surface"), "{r}");
|
||||
// The items go straight back into select.set.
|
||||
let items: Vec<Value> = r["items"].as_array().unwrap().clone();
|
||||
run(&mut s, "select.clear", json!({}));
|
||||
run(&mut s, "select.set", json!({ "items": items }));
|
||||
assert_eq!(s.selection.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_entities_name_their_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 7}));
|
||||
let curve = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "sketch_curve");
|
||||
assert_eq!(it["entity"], "circle", "{it}");
|
||||
assert!(close(&it["radius"], 7.0), "{it}");
|
||||
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
|
||||
assert!(it["sketch_name"].is_string());
|
||||
// What it returns goes back in as it is.
|
||||
run(&mut s, "select.set", json!({"items": [it.clone()]}));
|
||||
assert_eq!(run(&mut s, "selection.get", json!({}))["items"][0], *it);
|
||||
|
||||
// A curve picked into a dialog names its sketch; the item keeps it.
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve, "sketch": sketch}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!((&it["sketch"], &it["entity"]), (&json!(sketch), &json!("circle")), "{it}");
|
||||
|
||||
// A line's midpoint: named by its line, reported where it is.
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[2, 4], [12, 10]]}));
|
||||
let line = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_midpoint", "id": line}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "sketch_midpoint", "{it}");
|
||||
assert_eq!(it["id"], json!(line), "{it}");
|
||||
assert_eq!(it["entity"], "midpoint", "{it}");
|
||||
assert!(close3(&it["position"], [7.0, 7.0, 0.0]), "{it}");
|
||||
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_points_report_their_position() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.point.vertex", json!({"point": {"point": [15, 6, 7]}, "name": "Pin"}));
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "construction_point", "name": "Pin"}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "construction_point", "{it}");
|
||||
assert_eq!(it["name"], "Pin", "{it}");
|
||||
assert!(close3(&it["position"], [15.0, 6.0, 7.0]), "{it}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_bodies_report_their_component_and_placement() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name": "Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 3, "height": 4, "body_name": "Block", "operation": "new"}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence": first}))["occurrence"].as_u64().unwrap();
|
||||
let name = s.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "body", "name": name}, {"type": "occurrence", "path": [second]}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let b = &r["items"][0];
|
||||
assert_eq!(b["component"]["id"], component, "{b}");
|
||||
assert_eq!(b["component"]["name"], "Part", "{b}");
|
||||
assert_eq!(b["occurrence_path"], json!([second]), "{b}");
|
||||
assert_eq!(b["source_body"], "Block", "{b}");
|
||||
let o = &r["items"][1];
|
||||
assert_eq!(o["kind"], "occurrence");
|
||||
assert_eq!(o["component"]["id"], component, "{o}");
|
||||
assert_eq!(o["bodies"], json!([name]), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_and_stale_selections_never_fail_badly() {
|
||||
let mut s = Session::default();
|
||||
let body = holed_box(&mut s);
|
||||
// `select.set` refuses a body that isn't there (#9737), so `selection.get` never sees one.
|
||||
let e = s.execute("select.set", &json!({"items": [{"type": "edge", "body": "nope", "index": 0, "point": [0, 0, 0]}]}));
|
||||
assert!(matches!(e, Err(EngineError::BadParams { .. })), "{e:?}");
|
||||
// Items that point at nothing still come back, by identity.
|
||||
let stale = json!([
|
||||
{"type": "face", "body": body, "index": 999_999, "point": [1e300, -1e300, 0]},
|
||||
{"type": "edge", "body": body, "index": usize::MAX, "point": [0, 0, 0]},
|
||||
{"type": "vertex", "body": body, "point": [0, 0, 0]},
|
||||
{"type": "feature", "id": u64::MAX},
|
||||
{"type": "sketch_curve", "id": "zz"},
|
||||
{"type": "sketch_point", "id": "99999999999999999999999:p"},
|
||||
{"type": "sketch_constraint", "id": ""},
|
||||
{"type": "profile", "sketch": 12345, "index": 3},
|
||||
{"type": "plane", "name": "XY"},
|
||||
{"type": "axis", "name": "Q"},
|
||||
]);
|
||||
run(&mut s, "select.set", json!({ "items": stale }));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
assert_eq!(r["count"], 10, "{r}");
|
||||
for v in [json!(1), json!("yes"), json!([true]), json!({"a": 1})] {
|
||||
let e = s.execute("selection.get", &json!({ "detail": v })).unwrap_err();
|
||||
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
|
||||
}
|
||||
for v in [json!(null), json!([]), json!("x"), json!(f64::MAX), json!({"detail": null})] {
|
||||
assert!(s.execute("selection.get", &v).is_ok(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::construct::DatumItem;
|
||||
use solidcraft_doc::{
|
||||
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall,
|
||||
Touching, expr::Kind, point_expr,
|
||||
@@ -164,7 +163,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false); continuous_flanks?: bool (default true; false keeps the historical half-turn faces and groove-before-chamfer construction). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -429,11 +428,6 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool)
|
||||
let st = s.model.state();
|
||||
for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let Some(ss) = st.sketch(sketch) else { continue };
|
||||
// An empty selection (a patch asked for only the sketch's 3D loops, #741) has nothing to
|
||||
// capture: it stays empty.
|
||||
if matches!(sel, ProfileSel::Indices { indices } if indices.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let indices = match sel {
|
||||
ProfileSel::Indices { indices } => {
|
||||
let have = ss.profiles.len();
|
||||
@@ -1398,8 +1392,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
|
||||
/// world axis (also as saved: {type: world, axis: X|Y|Z}), a construction axis by name
|
||||
/// (followed when it is edited), a component origin axis (saved as its datum, so it follows the
|
||||
/// placement), {origin, dir} (each origin coordinate a number or an expression over
|
||||
/// (followed when it is edited), {origin, dir} (each origin coordinate a number or an expression over
|
||||
/// the parameters, which the axis follows), {sketch, curve} (a line of another sketch, followed in
|
||||
/// that sketch, #6548; also as saved: {type: sketch_line, …}), or an edge
|
||||
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
|
||||
@@ -1430,13 +1423,6 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
|
||||
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
|
||||
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
|
||||
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
|
||||
// A component origin axis: saved as its datum, so the revolve follows the placement.
|
||||
Some(name) if !is_sketch_line(s, sketch, name) => {
|
||||
match crate::origins::datum(name).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
|
||||
Some(datum) => Ok(AxisRef::Model { geo: solidcraft_doc::construct::GeoRef::Datum { datum } }),
|
||||
None => Ok(AxisRef::SketchLine { curve: name.into(), sketch: None }),
|
||||
}
|
||||
}
|
||||
Some(id) => Ok(AxisRef::SketchLine { curve: id.into(), sketch: None }),
|
||||
None => {
|
||||
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
|
||||
@@ -2543,39 +2529,33 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<so
|
||||
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
|
||||
return Ok((o, d, Some(geo), None));
|
||||
}
|
||||
named_axis_line(s, p, cmd)
|
||||
let (o, d, name) = named_axis_line(s, p, cmd)?;
|
||||
Ok((o, d, None, name))
|
||||
}
|
||||
|
||||
/// An axis line from `axis`: X, Y, Z, a construction axis by name, a component origin axis (its
|
||||
/// datum, followed) or {origin, dir}. A construction axis comes back where it is now, with its
|
||||
/// name; a component origin axis with the datum that finds it again on each evaluation.
|
||||
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
|
||||
if let Some(o @ Value::Object(_)) = p.get("axis") {
|
||||
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
|
||||
return Ok((origin, dir, None, None));
|
||||
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
|
||||
/// axis comes back where it is now, with its name.
|
||||
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
|
||||
match p.get("axis") {
|
||||
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
|
||||
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
|
||||
"Y" => Ok((Vec3::ZERO, Vec3::Y, None)),
|
||||
"Z" => Ok((Vec3::ZERO, Vec3::Z, None)),
|
||||
_ if is_construction_axis(s, a) => {
|
||||
let (_, _, origin, dir) = crate::view::construction_axes(s)
|
||||
.into_iter()
|
||||
.find(|(_, name, _, _)| name == a)
|
||||
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
|
||||
Ok((origin, dir, Some(a.as_str().into())))
|
||||
}
|
||||
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
|
||||
},
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
|
||||
Ok((origin, dir, None))
|
||||
}
|
||||
_ => Err(bad(cmd, "`axis` is required")),
|
||||
}
|
||||
let Some(a) = str_(p, "axis") else { return Err(bad(cmd, "`axis` is required")) };
|
||||
// The world axes keep the precedence they always had: X, Y and Z name the design's axes
|
||||
// before a construction axis answers to the same letters (an older design can hold one
|
||||
// named `x`, and a case-only match must not take them) (#7313 review).
|
||||
match a.to_ascii_uppercase().as_str() {
|
||||
"X" => return Ok((Vec3::ZERO, Vec3::X, None, None)),
|
||||
"Y" => return Ok((Vec3::ZERO, Vec3::Y, None, None)),
|
||||
"Z" => return Ok((Vec3::ZERO, Vec3::Z, None, None)),
|
||||
_ => {}
|
||||
}
|
||||
if is_construction_axis(s, a) {
|
||||
let (_, _, origin, dir) = crate::view::construction_axes(s)
|
||||
.into_iter()
|
||||
.find(|(_, name, _, _)| name == a)
|
||||
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
|
||||
return Ok((origin, dir, None, Some(a.into())));
|
||||
}
|
||||
if let Some(datum) = crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
|
||||
let (origin, dir) = s.origin_axis(a).ok_or_else(|| bad(cmd, format!("no axis `{a}`")))?;
|
||||
return Ok((origin, dir, Some(solidcraft_doc::construct::GeoRef::Datum { datum }), None));
|
||||
}
|
||||
Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
|
||||
}
|
||||
|
||||
/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the
|
||||
@@ -2845,18 +2825,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
|
||||
}
|
||||
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
|
||||
}
|
||||
Some(Value::String(n)) => {
|
||||
// A construction plane feature by name first, so a name that looks like a datum key
|
||||
// still reaches it; a component origin plane is saved as its datum, so it follows
|
||||
// the placement instead of staying where it was picked.
|
||||
if s.doc.find_feature(n.trim()).is_some() {
|
||||
return construction(n.trim());
|
||||
}
|
||||
match crate::origins::datum(n).filter(|d| matches!(d.item, DatumItem::XY | DatumItem::XZ | DatumItem::YZ)) {
|
||||
Some(datum) => Ok(PlaneRef::Datum { datum }),
|
||||
None => construction(n.trim()),
|
||||
}
|
||||
}
|
||||
Some(Value::String(n)) => construction(n.trim()),
|
||||
Some(Value::Number(id)) => match id.as_u64() {
|
||||
Some(id) => construction(&id.to_string()),
|
||||
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
|
||||
@@ -3915,17 +3884,11 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, msg));
|
||||
}
|
||||
let designation_is_none = designation.is_none();
|
||||
let continuous_flanks = match p.get("continuous_flanks") {
|
||||
None => true,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err(bad(cmd, "`continuous_flanks` must be a boolean")),
|
||||
};
|
||||
let kind = FeatureKind::Thread {
|
||||
face: FacePick(face),
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
continuous_flanks,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
|
||||
@@ -424,8 +424,6 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
@@ -428,23 +428,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
|
||||
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
|
||||
})
|
||||
.collect();
|
||||
// Planar loops the 3D sketch curves close in planes of their own (#741): the plane is given
|
||||
// as an origin and a unit normal, and the centroid is in the same frame as the sketch plane.
|
||||
let profiles_3d: Vec<Value> = ss
|
||||
.profiles_3d
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
json!({
|
||||
"index": i,
|
||||
"area_mm2": p.area,
|
||||
"plane": { "origin": p.plane.origin, "normal": p.plane.normal() },
|
||||
"centroid": p.centroid,
|
||||
"curves": p.outer_curves,
|
||||
"holes": p.hole_curves,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let wires = sk
|
||||
.wires
|
||||
.iter()
|
||||
@@ -475,7 +458,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
|
||||
points,
|
||||
constraints,
|
||||
profiles,
|
||||
profiles_3d,
|
||||
links: serde_json::to_value(&sk.links).unwrap_or_default(),
|
||||
wires,
|
||||
visible: crate::view::sketch_shown(s, id),
|
||||
|
||||
@@ -47,9 +47,6 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
|
||||
#[path = "joints_inspect.rs"]
|
||||
mod inspect;
|
||||
|
||||
#[path = "assembly_constraint.rs"]
|
||||
pub(crate) mod constraint;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -239,12 +236,9 @@ pub fn place(s: &mut Session) -> joints::Solution {
|
||||
|
||||
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
|
||||
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
|
||||
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
|
||||
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
|
||||
/// radius changes; a face that can't be found keeps the numbers it has.
|
||||
/// solve leaves that joint out and says why.
|
||||
fn reexpress(s: &mut Session) {
|
||||
let params = joints::Params::new(&s.doc);
|
||||
let st = s.model.state();
|
||||
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
|
||||
.doc
|
||||
.assembly
|
||||
@@ -253,10 +247,7 @@ fn reexpress(s: &mut Session) {
|
||||
.enumerate()
|
||||
.filter(|(_, j)| !j.exprs.is_empty())
|
||||
.filter_map(|(i, j)| {
|
||||
let (mut o, a, mut q) = joints::expressed(¶ms, j).ok()?;
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
o += s.doc.origin_radius(&st, &j.b).ok()?;
|
||||
}
|
||||
let (o, a, mut q) = joints::expressed(¶ms, j).ok()?;
|
||||
joints::clamp(j, &mut q);
|
||||
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
|
||||
})
|
||||
@@ -1161,14 +1152,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("too many joints".into()));
|
||||
}
|
||||
check_sides(&j.a, &j.b, "joint.create")?;
|
||||
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
|
||||
// Relations and loop-closing typed joints start where their parts are. A typed joint in
|
||||
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
|
||||
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
|
||||
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
|
||||
// jumping to the geometry's origin.
|
||||
if p.get("values").is_none()
|
||||
&& p.get("value").is_none()
|
||||
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
|
||||
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
|
||||
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
|
||||
{
|
||||
j.values = q;
|
||||
@@ -1354,13 +1345,6 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p, cmd)?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
let j = new_joint(s, p, kind, a, b, cmd)?;
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
|
||||
/// name `p` gives (as joint.create reads them), not yet added.
|
||||
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
|
||||
let id = s.doc_mut().allocate_id()?;
|
||||
let name = new_joint_name(s, p, kind);
|
||||
let offset = match p.get("offset") {
|
||||
@@ -1384,15 +1368,13 @@ fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: Joi
|
||||
angle: angle.unwrap_or(0.0),
|
||||
suppressed: false,
|
||||
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
|
||||
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
|
||||
if angle.is_none() && !j.kind.is_relation() {
|
||||
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
|
||||
}
|
||||
Ok(j)
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1431,8 +1413,6 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
@@ -2087,14 +2067,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = kind {
|
||||
let was = j.kind;
|
||||
j.kind = k;
|
||||
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
|
||||
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
|
||||
if k != was {
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
j.exprs.offset = None;
|
||||
}
|
||||
j.constraint = None;
|
||||
}
|
||||
j.values.resize(k.dofs().len(), 0.0);
|
||||
j.limits.resize(k.dofs().len(), None);
|
||||
j.exprs.values.truncate(k.dofs().len());
|
||||
@@ -2109,10 +2081,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = offset {
|
||||
j.offset = o;
|
||||
j.exprs.offset = typed(p.get("offset"));
|
||||
// A tangent constraint's offset is its gap: the radius is added when it is solved.
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
|
||||
}
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
j.angle = a;
|
||||
@@ -2150,11 +2118,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& kind.is_none_or(|k| k == was_kind)
|
||||
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
|
||||
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
|
||||
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
|
||||
// sides move, starts again from where its parts are.
|
||||
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
|
||||
// again from where its parts are.
|
||||
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
|
||||
if reseat {
|
||||
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
|
||||
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
|
||||
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
|
||||
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
|
||||
{
|
||||
@@ -2248,7 +2216,6 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
frame_a: world(&j.a),
|
||||
frame_b: world(&j.b),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
constraint: j.constraint,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -2348,45 +2315,6 @@ mod reach_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_relations_tests.rs"]
|
||||
mod relations_tests;
|
||||
/// Capture Position: every moving joint keeps its current values (as the solver places them) as
|
||||
/// its captured position. Returns how many joints were captured.
|
||||
pub(in crate::cmd) fn capture_values(s: &mut Session) -> usize {
|
||||
let current = joints::joint_values(&s.doc.assembly);
|
||||
if s.doc.assembly.joints.iter().all(|j| j.kind == JointKind::Rigid) {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0;
|
||||
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
|
||||
let mut q = current.get(&j.id).cloned().unwrap_or_else(|| j.values.clone());
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
j.captured = Some(q);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Revert Position: every joint with a captured position goes back to it, clamped to its limits.
|
||||
/// A joint with none (a design from before captured joint values) keeps its values. The values
|
||||
/// put back drop their expressions, as `joint.drive` does. Returns how many joints went back.
|
||||
pub(in crate::cmd) fn revert_values(s: &mut Session) -> usize {
|
||||
if !s.doc.assembly.joints.iter().any(|j| j.captured.is_some() && j.kind != JointKind::Rigid) {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0;
|
||||
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
|
||||
let Some(mut q) = j.captured.clone() else { continue };
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
for i in 0..j.kind.dofs().len() {
|
||||
j.exprs.set_value(i, None);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
resolve(s);
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "joints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -64,7 +64,6 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
|
||||
errors: why(&sol.errors),
|
||||
conflicts: why(&sol.conflicts),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
constraint: j.constraint,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user