Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 545191528d | |||
| b3a01bf400 | |||
| 7cf392d7c3 | |||
| dbc6041ba7 | |||
| 53ef5bb7c2 | |||
| 53a60b772d | |||
| 57257eaecd | |||
| 299374885f | |||
| eb848ff3c8 | |||
| c42b4c1e19 | |||
| 55dc8e2e2c | |||
| 7a66117304 | |||
| fffecd56a9 | |||
| 7464a9ba23 | |||
| f76b8b8720 | |||
| 4f492f6ae3 | |||
| f346faa785 | |||
| 169ed3b934 | |||
| 25c9527bbc | |||
| 9e41909c05 | |||
| 6f7f101846 | |||
| f1697fa9db | |||
| a46f1b8373 | |||
| 928d07706d | |||
| 9001a87ea4 | |||
| 5a555d4741 | |||
| f0477d474b | |||
| 7ef3f51de4 | |||
| 415fb00232 | |||
| 0175e0c5e6 | |||
| c2e0a97c74 | |||
| a82f4ffd82 | |||
| c504532744 | |||
| 84704806bb | |||
| 02ad76be21 | |||
| 791c1366e7 | |||
| 9dfebc997c | |||
| 1e27ac72bb | |||
| ee779b6f79 | |||
| b50e70451d | |||
| 5de18a496e | |||
| f6d3362ed2 | |||
| 7a979a80b2 | |||
| 129c2a1c81 | |||
| 8790d601cc | |||
| b73cf3edd1 | |||
| ac96dc66e9 | |||
| 738cfbaf9b | |||
| 174c59209c | |||
| 8f13d82145 | |||
| b94c65b005 | |||
| 83e18240cb | |||
| 297bd144df | |||
| 2cbad02957 | |||
| d66268179d | |||
| a077906234 | |||
| b2733d607a |
@@ -609,7 +609,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -4210,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4246,11 +4246,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4262,7 +4262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4271,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4309,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4338,7 +4338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4362,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4382,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4391,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4405,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4418,14 +4418,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4455,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5998,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
version = "0.26.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
|
||||
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
|
||||
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
|
||||
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
|
||||
[docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Scripts
|
||||
@@ -150,4 +150,3 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
|
||||
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ fn run() -> Result<(), String> {
|
||||
let mut session = Session::default();
|
||||
eprintln!("Generating original CAD scene from {recipe_path}");
|
||||
let generation_start = Instant::now();
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
|
||||
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
|
||||
@@ -58,7 +57,7 @@ struct Command {
|
||||
}
|
||||
|
||||
/// `run`'s lines in the usage.
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
|
||||
/// `eval`'s lines in the usage.
|
||||
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
|
||||
/// `script`'s lines in the usage.
|
||||
@@ -300,36 +299,18 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
run_script_warning(&mut s, path, &script)?;
|
||||
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
|
||||
// (stderr, so stdout stays the caller's).
|
||||
let mut warn = |step: usize, r: &Value| {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
|
||||
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
|
||||
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
|
||||
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
|
||||
fn print_param_warnings(path: &str, step: usize, r: &Value) {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
|
||||
/// treated (docs/mcp.md); off when the option isn't given.
|
||||
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
|
||||
match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
|
||||
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
@@ -386,15 +367,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
|
||||
let check = parse_param_check("run", args)?;
|
||||
known_args(
|
||||
"run",
|
||||
args,
|
||||
&["--in", "--out", "--save", "--json-out", "--param-check"],
|
||||
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
|
||||
true,
|
||||
)?;
|
||||
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
@@ -407,25 +380,23 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
let (mut s, results) = if want_results || timings {
|
||||
// Collect every command's result or time (scripts only), then process requested files too.
|
||||
let mut s = match base {
|
||||
Some(b) => load_checked(b, check)?,
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
s.param_check = check;
|
||||
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
|
||||
let script = path.ok_or(needs)?;
|
||||
let v = read_json(script)?;
|
||||
let v = read_json(path.ok_or(needs)?)?;
|
||||
let out = if timings {
|
||||
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).map_err(|e| e.to_string())?;
|
||||
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
|
||||
if want_results && let Some(o) = t.as_object_mut() {
|
||||
o.insert("results".into(), Value::Array(r));
|
||||
}
|
||||
t
|
||||
} else {
|
||||
Value::Array(run_script_warning(&mut s, script, &v)?)
|
||||
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
|
||||
};
|
||||
(s, Some(out))
|
||||
} else {
|
||||
(open(base, path, check)?, None)
|
||||
(open(base, path)?, None)
|
||||
};
|
||||
// A feature that fails leaves the bodies as they were before it (a parameter change can
|
||||
// break a feature without failing the command that made it): writing them would hand out
|
||||
@@ -450,16 +421,16 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
|
||||
/// positional script run on top of it, or either one alone, with scripts run at `check`.
|
||||
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
/// positional script run on top of it, or either one alone.
|
||||
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
|
||||
match (base, path) {
|
||||
(Some(b), Some(p)) => {
|
||||
let mut s = load_checked(b, check)?;
|
||||
run_script_warning(&mut s, p, &read_json(p)?)?;
|
||||
let mut s = load(b)?;
|
||||
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
(Some(b), None) => load_checked(b, check),
|
||||
(None, Some(p)) => load_checked(p, check),
|
||||
(Some(b), None) => load(b),
|
||||
(None, Some(p)) => load(p),
|
||||
(None, None) => Err(usage()),
|
||||
}
|
||||
}
|
||||
@@ -502,7 +473,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let out = json!({
|
||||
@@ -596,7 +567,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// How the bodies are drawn and where the model is cut, through the commands the app's
|
||||
// Appearance dialog and Section Analysis run (`preview`).
|
||||
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
|
||||
@@ -720,8 +691,14 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How a headless session treats calls that break a command's parameter contract.
|
||||
let param_check = parse_param_check("mcp", args)?;
|
||||
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
|
||||
let param_check = match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
|
||||
None => solidcraft_engine::schema::ParamCheck::Off,
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
|
||||
}
|
||||
};
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
|
||||
@@ -288,47 +288,3 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
|
||||
/// command would, and opening it is no change. That command pinned them as part of itself, so
|
||||
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
|
||||
/// undo after opening from the command line evaluated every feature again (82-93 s in a
|
||||
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
|
||||
#[test]
|
||||
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let mut doc = built.design().into_owned();
|
||||
for f in &mut doc.features {
|
||||
f.body_names.clear();
|
||||
}
|
||||
doc.reserved_body_names.clear();
|
||||
let unpinned = Session::new(doc);
|
||||
let design = unpinned.design();
|
||||
let saved = unpinned.model.saved(&design).unwrap();
|
||||
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
|
||||
.unwrap();
|
||||
let path = temp("unpinned");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let file = path.to_string_lossy().to_string();
|
||||
// As the command line opens a design (`load`), and as the app does (`doc.open`).
|
||||
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
|
||||
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
|
||||
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
|
||||
assert!(!s.is_dirty(), "{how}: opening is no change");
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
|
||||
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
|
||||
// results in the model itself or evaluates them again.
|
||||
*s.model.cache.lock().unwrap() = Default::default();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
|
||||
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -496,78 +496,6 @@ fn timings_and_profile_report_each_command() {
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
|
||||
}
|
||||
|
||||
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
|
||||
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
|
||||
/// `--profile`), and a warning reaches stderr even when a later step fails.
|
||||
#[test]
|
||||
fn param_check_reaches_every_script_run_takes() {
|
||||
let dir = Scratch::new();
|
||||
let typo = dir.0.join("typo.json");
|
||||
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
|
||||
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let saved = dir.0.join("saved.solidcraft");
|
||||
let start = dir.script();
|
||||
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
|
||||
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
|
||||
let run = |level: Option<&str>, extra: &[&str]| {
|
||||
std::fs::remove_file(&saved).ok();
|
||||
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
|
||||
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
|
||||
};
|
||||
|
||||
// Off, the default: the step runs as it always has, without a word.
|
||||
let off = run(None, &[]);
|
||||
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
|
||||
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
|
||||
for extra in paths {
|
||||
let output = run(Some("warn"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
|
||||
assert!(saved.exists(), "{extra:?}");
|
||||
if extra == ["--results"] {
|
||||
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
|
||||
}
|
||||
}
|
||||
|
||||
// Strict: on each path the run stops at the step, and nothing is written.
|
||||
for extra in paths {
|
||||
let output = run(Some("strict"), extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
|
||||
assert!(!saved.exists(), "{extra:?}");
|
||||
}
|
||||
|
||||
// Warn, with a later step that fails: the run fails, and the first step's warning is still
|
||||
// reported, timed or not.
|
||||
let failing = dir.0.join("failing.json");
|
||||
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
|
||||
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
|
||||
for extra in [&[][..], &["--timings"]] {
|
||||
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
|
||||
}
|
||||
|
||||
// A level it doesn't know and a missing value fail.
|
||||
for (bad, says) in [
|
||||
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
|
||||
(&["--param-check"], "run: --param-check wants off, warn or strict"),
|
||||
] {
|
||||
let output = cli().args(["run", typo]).args(bad).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
|
||||
assert!(stderr.contains(says), "{bad:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
|
||||
let path = dir.0.join("render.json");
|
||||
std::fs::write(
|
||||
|
||||
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
|
||||
|
||||
@@ -119,43 +119,6 @@ fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown:
|
||||
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
|
||||
}
|
||||
|
||||
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
|
||||
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
|
||||
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
|
||||
/// change made in another. A change is now written in the frame it is made, and the other tabs
|
||||
/// load it ([`settings_written`]).
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Default, Debug)]
|
||||
struct PrefsWriter {
|
||||
written: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
impl PrefsWriter {
|
||||
/// `prefs` if they are not the settings written or loaded last (they count as written from
|
||||
/// now on).
|
||||
fn take(&mut self, prefs: String) -> Option<String> {
|
||||
if self.written.as_deref() == Some(prefs.as_str()) {
|
||||
return None;
|
||||
}
|
||||
self.written = Some(prefs.clone());
|
||||
Some(prefs)
|
||||
}
|
||||
|
||||
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
|
||||
fn loaded(&mut self, prefs: String) {
|
||||
self.written = Some(prefs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
|
||||
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
|
||||
/// data), leave this tab's settings as they are.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
|
||||
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
@@ -254,24 +217,6 @@ mod web {
|
||||
on_key.forget();
|
||||
}
|
||||
|
||||
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
|
||||
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
|
||||
|
||||
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
|
||||
/// loads them.
|
||||
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
|
||||
let ctx = ctx.clone();
|
||||
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
|
||||
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
|
||||
inbox.set(Some(prefs));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_storage.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
@@ -301,48 +246,9 @@ mod web {
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
|
||||
prefs_writer: crate::PrefsWriter,
|
||||
other_tab_prefs: PrefsInbox,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// The settings as they are kept between visits: with the user's own language, not a
|
||||
/// session-only English fallback.
|
||||
fn persisted_prefs(&mut self) -> String {
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
let prefs = self.app.prefs();
|
||||
self.app.preferences.language = live;
|
||||
prefs
|
||||
}
|
||||
|
||||
/// A settings change goes to the browser's storage in the frame it is made, and from
|
||||
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
|
||||
/// without any is skipped; eframe's own save still catches anything else.
|
||||
fn store_prefs(&mut self, ctx: &egui::Context) {
|
||||
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let prefs = self.persisted_prefs();
|
||||
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
|
||||
let stored =
|
||||
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
|
||||
if !matches!(stored, Some(Ok(()))) {
|
||||
log::warn!("settings: the browser's storage did not take them; they are saved again later");
|
||||
self.prefs_writer = crate::PrefsWriter::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
|
||||
/// instead of putting its older copy back (#8644).
|
||||
fn load_other_tabs_prefs(&mut self) {
|
||||
let Some(prefs) = self.other_tab_prefs.take() else { return };
|
||||
self.app.load_prefs(&prefs);
|
||||
let held = self.persisted_prefs();
|
||||
self.prefs_writer.loaded(held);
|
||||
}
|
||||
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if !self.launch.persists() {
|
||||
@@ -741,8 +647,6 @@ mod web {
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// First: a language another tab chose fetches its font this frame.
|
||||
self.load_other_tabs_prefs();
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.api.poll();
|
||||
@@ -785,7 +689,6 @@ mod web {
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
self.watch_text_for_font(&ctx);
|
||||
self.store_prefs(&ctx);
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
self.launch.persists()
|
||||
@@ -794,8 +697,11 @@ mod web {
|
||||
if !self.launch.persists() {
|
||||
return;
|
||||
}
|
||||
let prefs = self.persisted_prefs();
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,12 +822,6 @@ mod web {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
app.embedded = launch.embed;
|
||||
// Listening before reading: settings another tab writes from now on are
|
||||
// newer than those read here.
|
||||
let other_tab_prefs = PrefsInbox::default();
|
||||
if launch.persists() {
|
||||
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
|
||||
}
|
||||
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
@@ -965,8 +865,6 @@ mod web {
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
prefs_writer: crate::PrefsWriter::default(),
|
||||
other_tab_prefs,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -1103,28 +1001,4 @@ mod tests {
|
||||
assert!(!closes_design("w", false, false, false, true));
|
||||
assert!(!closes_design("s", true, false, false, true));
|
||||
}
|
||||
|
||||
/// A tab writes its settings when they change, and not again while they stay the same; the
|
||||
/// settings it loaded from another tab aren't written back (#8644).
|
||||
#[test]
|
||||
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
|
||||
let mut writer = PrefsWriter::default();
|
||||
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
|
||||
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
|
||||
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
|
||||
writer.loaded(r#"{"a":3}"#.into());
|
||||
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
|
||||
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
|
||||
}
|
||||
|
||||
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
|
||||
/// away by clearing the site's data (#8644).
|
||||
#[test]
|
||||
fn only_settings_another_tab_wrote_are_loaded() {
|
||||
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
|
||||
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
|
||||
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
|
||||
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
|
||||
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ assets/
|
||||
ui/design/tools/ # authoring bases, checks and included review app
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
branding/logo/ # print/web logo files; guide in branding/README.md
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# SolidCraft brand kit
|
||||
|
||||
Logo files for stickers, shirts, the web and social posts. Everything in `logo/` is drawn
|
||||
from the app icon (`assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`),
|
||||
which is also the last frame of the loading animation (`assets/styles/web/loading-logo.svg`).
|
||||
The SVGs are plain paths: no scripts, fonts, animation or external links, so any printer can use them.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Use it for |
|
||||
|---|---|
|
||||
| `logo/solidcraft-mark.svg` | The mark in full colour. First choice for stickers, print and the web. |
|
||||
| `logo/solidcraft-mark-mono-black.svg` | One-colour print on light material: screen-printed shirts, stamps, engraving, vinyl. |
|
||||
| `logo/solidcraft-mark-mono-white.svg` | One-colour print on dark material, such as a white print on a dark shirt. |
|
||||
| `logo/solidcraft-mark-512.png`, `-1024.png`, `-2048.png` | Transparent square PNGs for places that don't take SVG: avatars, social posts, slides, print shops that ask for a bitmap (send the 2048). |
|
||||
| `logo/solidcraft-wordmark.svg` | The name on its own, outlined to paths. |
|
||||
| `logo/solidcraft-lockup-horizontal.svg` | Mark and name side by side: headers, banners, shirt fronts, wide stickers. |
|
||||
| `web/source/og.png` | The existing link preview card for the docs site. |
|
||||
|
||||
The gaps between the faces are part of the logo. In the colour and one-colour files they are
|
||||
transparent, so the material shows through. For a die-cut sticker, cut around the outer shape
|
||||
and let the backing colour (white is best) fill the gaps.
|
||||
|
||||
## Colours
|
||||
|
||||
The mark's five faces each use a two-stop gradient from the icon source:
|
||||
|
||||
| Face | From | To |
|
||||
|---|---|---|
|
||||
| Top | `#51adff` | `#2b83f1` |
|
||||
| Left | `#258df5` | `#1461da` |
|
||||
| Right | `#1b66cf` | `#124596` |
|
||||
| Lower step (top) | `#459fff` | `#2379e7` |
|
||||
| Upper step (inner side) | `#1d63c6` | `#15499e` |
|
||||
|
||||
Other colours, all already used in the project:
|
||||
|
||||
| Colour | Hex | Where it comes from |
|
||||
|---|---|---|
|
||||
| Wordmark blue | `#15499e` | loading logo `--word` |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
If a printer can't do gradients, use the one-colour files, or fill every face with `#2678da`.
|
||||
|
||||
## Clear space and size
|
||||
|
||||
- Keep empty space around the mark of at least one fifth of its height on every side. Treat
|
||||
the lockup the same way, measured from the mark.
|
||||
- Smallest mark: 16 px tall on screen, 12 mm tall in print. Below that the gaps fill in.
|
||||
- Smallest lockup: 120 px wide on screen, 40 mm wide in print.
|
||||
|
||||
## Don'ts
|
||||
|
||||
- Don't recolour it outside the colours above.
|
||||
- Don't stretch, squash, rotate, skew or crop it. Scale it evenly.
|
||||
- Don't add shadows, outlines, glows, bevels or other effects.
|
||||
- Don't fill or close the gaps between the faces, or move the faces apart.
|
||||
- Don't rearrange the lockup, retype the name in another font, or put the mark on a busy
|
||||
background where it loses contrast.
|
||||
|
||||
## Name and logo
|
||||
|
||||
The SolidCraft code and these files are licensed MIT OR Apache-2.0 (see the [README](../../README.md#licence)),
|
||||
so you may copy and change them. The name SolidCraft and this logo identify the SolidCraft
|
||||
project. Use them to refer to the project, for example in an article, a talk, or a sticker for a
|
||||
meetup. If you publish a fork or a modified build, give it its own name and logo so people
|
||||
don't mistake it for SolidCraft.
|
||||
|
||||
The wordmark is set in IBM Plex Sans Bold (SIL Open Font License 1.1, the interface font in
|
||||
`assets/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 2748.71 904.436" width="2749" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g><path fill="#15499e" transform="translate(1092.78 632.20) scale(0.36281)" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 32 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#000000"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 612 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#ffffff"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 612 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="38.0 -749.0 4847.0 761.0" width="4847" height="761" role="img"><title>SolidCraft</title><path fill="#15499e" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -188,7 +188,7 @@ surface = [
|
||||
sketch = [
|
||||
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
|
||||
"blend", "circle", "circle2", "circle3", "circle_tt",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
|
||||
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
|
||||
"extend", "finish", "fit_section", "include",
|
||||
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
|
||||
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
|
||||
|
||||
@@ -95,7 +95,6 @@ edit false
|
||||
ellipse false
|
||||
emboss false
|
||||
environment_map false
|
||||
equation_curve false
|
||||
error false
|
||||
explode false
|
||||
export false
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Equation curve</title>
|
||||
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
|
||||
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
|
||||
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 666 B |
@@ -206,7 +206,6 @@
|
||||
"Edges": "الحواف",
|
||||
"Edit": "تعديل",
|
||||
"Edit Profile Sketch": "تعديل رسم المقطع",
|
||||
"Edit shortcuts": "تعديل الاختصارات",
|
||||
"Enable rule": "تفعيل القاعدة",
|
||||
"Enabled": "مفعّل",
|
||||
"End Radius": "نصف القطر النهائي",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "نصف القطر",
|
||||
"Radius Fit": "ملاءمة نصف القطر",
|
||||
"Ratio": "النسبة",
|
||||
"Read-only list: shortcuts can't be changed here.": "قائمة للقراءة فقط: لا يمكن تغيير الاختصارات هنا.",
|
||||
"Reattach selected face": "إعادة ربط الوجه المحدد",
|
||||
"Recover": "استعادة",
|
||||
"Recover unsaved design?": "استعادة التصميم غير المحفوظ؟",
|
||||
@@ -732,7 +730,7 @@
|
||||
"a picked edge is gone": "الحافة المحددة لم تعد موجودة",
|
||||
"a picked edge's body is gone": "جسم الحافة المحددة لم يعد موجودًا",
|
||||
"add a key for a joint first": "أضف أولًا مفتاحًا لمفصل",
|
||||
"ai.connected_toast": "أصبح SolidCraft متاحًا في {clients}. أعد تشغيلها لاستخدامه (التفضيلات > المساعد).",
|
||||
"ai.connected_toast": "أصبح SolidCraft متاحًا في {clients}. أعد تشغيلها لاستخدامه (الإعدادات > المساعد).",
|
||||
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال (قناة التحكم / MCP)",
|
||||
"ai.prefs.allow_control_note": "يفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.availability": "عناصر تحكم المساعد",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "نقل عناصر المستعرض",
|
||||
"cmd.browser_order": "ترتيب عناصر المستعرض",
|
||||
"cmd.browser_rename_group": "إعادة تسمية المجموعة",
|
||||
"cmd.browser_reparent": "نقل إلى مكوّن",
|
||||
"cmd.browser_ungroup": "فك التجميع",
|
||||
"cmd.browser_visibility": "إظهار/إخفاء",
|
||||
"cmd.canvas_calibrate": "معايرة اللوحة",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "خصائص القطعة",
|
||||
"cmd.component_metadata_set": "تغيير خصائص القطعة",
|
||||
"cmd.component_move_bodies": "نقل الأجسام إلى مكوّن",
|
||||
"cmd.component_move_features": "نقل الميزات إلى مكوّن",
|
||||
"cmd.component_move_sketches": "نقل الرسومات إلى مكوّن",
|
||||
"cmd.component_paste_from_design": "لصق من تصميم آخر",
|
||||
"cmd.component_paste_new": "لصق كجديد",
|
||||
"cmd.component_properties": "الخصائص الفيزيائية للمكوّن",
|
||||
"cmd.component_relink": "إعادة ربط المصدر",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "تصيير صورة",
|
||||
"cmd.render_status": "تقدّم التصيير",
|
||||
"cmd.select_clear": "مسح التحديد",
|
||||
"cmd.settings_profile_apply": "تطبيق ملف تعريف الإعدادات",
|
||||
"cmd.settings_profile_delete": "حذف ملف تعريف الإعدادات",
|
||||
"cmd.settings_profile_export": "تصدير ملف تعريف الإعدادات",
|
||||
"cmd.settings_profile_import": "استيراد ملف تعريف الإعدادات",
|
||||
"cmd.settings_profile_list": "ملفات تعريف الإعدادات",
|
||||
"cmd.settings_profile_rename": "إعادة تسمية ملف تعريف الإعدادات",
|
||||
"cmd.settings_profile_save": "حفظ ملف تعريف الإعدادات",
|
||||
"cmd.sheet_convert": "تحويل إلى صفائح معدنية",
|
||||
"cmd.sheet_export_bend_table": "تصدير جدول الثني",
|
||||
"cmd.sheet_export_dxf": "تصدير النمط المسطح بصيغة DXF",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "حجم الرسمة",
|
||||
"cmd.sketch_edit": "تعديل الرسم",
|
||||
"cmd.sketch_edit_text": "تعديل النص",
|
||||
"cmd.sketch_equation_curve": "منحنى معادلة",
|
||||
"cmd.sketch_equation_curve_edit": "تحرير منحنى المعادلة",
|
||||
"cmd.sketch_export_dxf": "حفظ بصيغة DXF",
|
||||
"cmd.sketch_finish_auto_constrain": "إنهاء مع التقييد التلقائي",
|
||||
"cmd.sketch_fit_curves_to_section": "ملاءمة منحنيات لمقطع الشبكة",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "متصفح التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
"cmd.view.render_focus": "عرض تركيز التصيير",
|
||||
"cmd.view.status_items": "عناصر حالة منطقة العرض",
|
||||
"cmd.view_ambient_occlusion": "الإظلال المحيطي",
|
||||
"cmd.view_angle_readout": "زوايا العرض",
|
||||
"cmd.view_apply": "استعادة عرض مسمّى",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.",
|
||||
"help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.",
|
||||
"help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.",
|
||||
"help.sketch.equation_curve": "يرسم منحنى من صيغ على مدى: x(t) و y(t)، أو y = f(x). يمكن أن تستخدم الصيغ المعلمات فيتبعها المنحنى: استخدمه لموجة جيبية أو لولب أو منحنى انفلات لسن ترس.",
|
||||
"help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.",
|
||||
"help.solid.chamfer": "يقطع الحواف المحددة بشطفة مستوية بالمسافة التي تحددها. استخدمه لكسر الحواف الحادة أو لتسهيل إدخال مسمار أو برغي.",
|
||||
"help.solid.combine": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.",
|
||||
@@ -1483,7 +1467,7 @@
|
||||
"home.recovery_hint": "أُغلق SolidCraft قبل حفظ هذه التصاميم.",
|
||||
"home.report_issue": "الإبلاغ عن مشكلة",
|
||||
"home.samples": "أمثلة",
|
||||
"home.settings": "التفضيلات",
|
||||
"home.settings": "الإعدادات",
|
||||
"home.short_date": "{day} {month}",
|
||||
"home.shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"home.start_hint": "أنشئ تصميمًا، أو افتح ملفًا حديثًا، أو استكشف مثالًا.",
|
||||
@@ -1658,7 +1642,7 @@
|
||||
"navbar.custom.hidden_design": "مخفي في التصميم",
|
||||
"navbar.custom.hidden_render": "مخفي في التصيير",
|
||||
"navbar.custom.hide": "إخفاء شريط التنقل",
|
||||
"navbar.custom.hide_idle": "إخفاء حتى يمر المؤشر فوقه",
|
||||
"navbar.custom.hide_idle": "إخفاء حتى التمرير",
|
||||
"navbar.custom.icon_only": "الأيقونة فقط",
|
||||
"navbar.custom.icon_value": "الأيقونة والقيمة",
|
||||
"navbar.custom.icons": "الأيقونات",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "السماكة",
|
||||
"parts.dimension.width": "العرض",
|
||||
"parts.family.bearing": "محمل كريات أخدودي عميق (الغلاف)",
|
||||
"parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي",
|
||||
"parts.family.dowel_pin": "مسمار توجيه أسطواني",
|
||||
"parts.family.hex_bolt": "برغي سداسي الرأس",
|
||||
"parts.family.hex_nut": "صامولة سداسية",
|
||||
@@ -1818,8 +1801,8 @@
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر",
|
||||
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
|
||||
"prefs.browser_reveal_on_hover": "إظهار عند التمرير",
|
||||
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند التمرير» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
|
||||
"prefs.browser_shown": "ظاهر",
|
||||
"prefs.built_in": "مدمج",
|
||||
"prefs.button_left": "سحب بالزر الأيسر",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "المضغوط يثبّت اللوحات على حواف النافذة. شفافية المستعرض متاحة في الوضع العائم. غيّر جهة المساعد من صفحة المساعد.",
|
||||
"prefs.left": "يسار",
|
||||
"prefs.left_click": "نقر أيسر",
|
||||
"prefs.line_drag_draws_one": "الضغط والسحب يرسم خطًا واحدًا",
|
||||
"prefs.line_drag_draws_one_note": "في أداة الخط، الضغط ثم السحب ثم الإفلات يرسم خطًا واحدًا من نقطة الضغط إلى نقطة الإفلات وينتهي هناك. لا تزال النقرات ترسم سلسلة. عند الإيقاف: يضع السحب نقطة حيث يُفلت، كما تفعل النقرة.",
|
||||
"prefs.linked_designs": "التصاميم المرتبطة",
|
||||
"prefs.live_input": "الإدخال المباشر",
|
||||
"prefs.live_preview": "المعاينة المباشرة",
|
||||
@@ -1925,26 +1906,10 @@
|
||||
"prefs.origin_plane_names": "أسماء مستويات الأصل",
|
||||
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
|
||||
"prefs.overall_speed": "السرعة العامة",
|
||||
"prefs.perspective_fov": "مجال الرؤية",
|
||||
"prefs.placement": "الموضع",
|
||||
"prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط",
|
||||
"prefs.preview_choices": "معاينة اختياراتك",
|
||||
"prefs.profile_active": "ملف التعريف النشط",
|
||||
"prefs.profile_applied": "تم تطبيق ملف التعريف.",
|
||||
"prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.",
|
||||
"prefs.profile_deleted": "تم حذف ملف التعريف.",
|
||||
"prefs.profile_export": "تصدير…",
|
||||
"prefs.profile_exported": "تم تصدير ملف التعريف.",
|
||||
"prefs.profile_imported": "تم استيراد ملف التعريف. اختره في «ملف التعريف النشط» للتبديل إليه.",
|
||||
"prefs.profile_name_hint": "اسم ملف التعريف، مثل: حاسوب محمول",
|
||||
"prefs.profile_rename": "إعادة تسمية",
|
||||
"prefs.profile_renamed": "تمت إعادة تسمية ملف التعريف.",
|
||||
"prefs.profile_save": "حفظ",
|
||||
"prefs.profile_saved": "تم حفظ الإعدادات الحالية في ملف التعريف.",
|
||||
"prefs.profile_skipped": "تم تطبيق ملف التعريف؛ احتفظ {n} من الإعدادات بقيمها الحالية (راجع السجل).",
|
||||
"prefs.profiles": "ملفات تعريف الإعدادات",
|
||||
"prefs.profiles_note": "الاختصارات وشريط الأدوات والتنقل والقائمة الدائرية وإعدادات الواجهة، محفوظة معًا تحت اسم واحد وتُبدَّل دفعة واحدة.",
|
||||
"prefs.project_options_dialog": "مربع حوار خيارات الإسقاط",
|
||||
"prefs.project_options_dialog": "مربع حوار خيارات Project",
|
||||
"prefs.project_options_dialog_note": "تشغيل: اختر الهندسة أو الأجسام ورابط الإسقاط قبل الإسقاط. إيقاف: الإبقاء على سير العمل السابق بالاختيار المباشر.",
|
||||
"prefs.quality_default": "افتراضي",
|
||||
"prefs.quality_fastest": "الأسرع",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير",
|
||||
"prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.",
|
||||
"prefs.reports_saved": "{n} محفوظة",
|
||||
"prefs.ribbon_reveal": "إظهار مكان نتائج البحث في الشريط",
|
||||
"prefs.ribbon_reveal_note": "عند تمرير المؤشر فوق نتيجة في «البحث في الأوامر» يظهر مكان الأمر في الشريط: تبويبه أو زره أو صفه في القائمة المنسدلة للوحة. ويومض هناك الأمر الذي بدأ من البحث، ويُعلِّم الأمر الجاري من قائمة منسدلة لوحتَه.",
|
||||
"prefs.right_click": "نقر أيمن",
|
||||
"prefs.saving": "الحفظ التلقائي والاستعادة",
|
||||
"prefs.scale_note": "أو Ctrl + / Ctrl − في النافذة. الأزرار 28 px عند 100%.",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
"prefs.shortcuts": "الاختصارات",
|
||||
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
|
||||
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
|
||||
"prefs.sketch_align_points": "المحاذاة مع نقاط الرسم الأخرى أثناء الرسم",
|
||||
"prefs.sketch_align_points_note": "أثناء الرسم، تتحاذى النقطة أفقيًا مع نقطة رسم وعموديًا مع نقطة أخرى في الوقت نفسه، مع خط إرشادي منقط، وتحتفظ النقرة بالمحاذاتين. عند الإيقاف: مع النقطة السابقة فقط، محور واحد في كل مرة.",
|
||||
"prefs.sketch_conflict_colour": "الهندسة المتعارضة بلون التعارض",
|
||||
"prefs.sketch_conflict_colour_note": "عندما تتعارض قيود الرسم، تُرسم المنحنيات والنقاط التي يمسكها قيد فاشل بلون التعارض أيضًا، لا رمزه أو بُعده فقط. عند الإيقاف: تحتفظ بألوانها المعتادة.",
|
||||
"prefs.sketch_exit_view": "عند انتهاء الرسم",
|
||||
@@ -2029,7 +1988,7 @@
|
||||
"prefs.trackpad_zoom_modifier.alt": "⌥ / Alt",
|
||||
"prefs.trackpad_zoom_modifier.cmd": "⌘",
|
||||
"prefs.trackpad_zoom_modifier.ctrl": "Ctrl",
|
||||
"prefs.trackpad_zoom_modifier.help": "اضغط مع الاستمرار على هذا المفتاح وحده للتكبير عند المؤشر بالتمرير العمودي. عند الإيقاف يبقى سلوك التمرير كما هو.",
|
||||
"prefs.trackpad_zoom_modifier.help": "اضغط على هذا المفتاح فقط للتكبير عند المؤشر بالتمرير العمودي. يحافظ الإيقاف على سلوك التمرير الحالي.",
|
||||
"prefs.trackpad_zoom_modifier.off": "إيقاف",
|
||||
"prefs.translucent": "شفاف",
|
||||
"prefs.under_browser": "تحت المستعرض",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "العجلة",
|
||||
"prefs.wheel_trackpad": "العجلة ولوحة اللمس",
|
||||
"prefs.workspace": "مساحة العمل",
|
||||
"prefs.zoom_stop_at_model": "إيقاف التكبير عند النموذج",
|
||||
"prefs.zoom_stop_at_model_tip": "تُكبّر الفأرة ثلاثية الأبعاد وتطبيق الجهاز نحو السطح في منتصف العرض، ويثبّت التكبير بالسحب نقطة بدء السحب. عند الإيقاف يمكن للتكبير المرور عبر النموذج. تستخدم العجلة وإيماءة القرص إعداد التكبير نحو المؤشر بشكل مستقل",
|
||||
"prefs.zoom_toward_cursor": "التكبير نحو المؤشر",
|
||||
"properties.title": "الخصائص: {name}",
|
||||
"recovery.days_ago": "قبل {count} يوم",
|
||||
@@ -2745,7 +2702,7 @@
|
||||
"render.light_intensity": "الشدة",
|
||||
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
|
||||
"render.light_place_highlight": "وضع بالتمييز",
|
||||
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
|
||||
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منفذ العرض وهو غير مدعوم بعد.",
|
||||
"render.light_position": "الموضع",
|
||||
"render.light_preview_help": "العرض الحي بلا ظلال؛ الأضواء المساحية تستخدم تقريب عينة المركز. التصيير يستخدم نموذج الضوء الكامل.",
|
||||
"render.light_product_centre": "مركز المنتج",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "انقر نقرًا مزدوجًا على مكوّن لتنشيطه، أو لتحرير مكوّن مرتبط في مكانه",
|
||||
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
|
||||
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
|
||||
"settings.insert_lists_open_designs": "يعرض إدراج تصميم أولًا التصاميم المفتوحة في علامات التبويب الأخرى",
|
||||
"settings.measure_axis_distances": "إظهار المسافات على X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
|
||||
"settings.msaa_applies_on_restart": "يُطبّق تنعيم الحواف في المرة القادمة التي يبدأ فيها SolidCraft",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "إلغاء التثبيت",
|
||||
"sketch is missing": "الرسم مفقود",
|
||||
"sketch.degrees_of_freedom": "{count} درجات حرية",
|
||||
"sketch.dimension_added_driven": "الرسم يحدد هذا البُعد بالفعل، لذا أُضيف بُعدًا مُشتقًا (مرجعيًا)",
|
||||
"sketch.dimension_applied": "تم تطبيق البُعد",
|
||||
"sketch.dimension_applied_defined": "تم تطبيق البُعد؛ تم تعريف المعامل `{name}`",
|
||||
"sketch.dimension_applied_renamed": "تم تطبيق البُعد؛ أصبح اسم المعامل `{name}`",
|
||||
"sketch.dof_left": "تبقّت {count} درجات حرية",
|
||||
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
|
||||
"sketch.draw_curves": "الرسم كـ {linetype}",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "الإبقاء على ميزاتها ومجلداتها الفرعية في السجل",
|
||||
"timeline.delete_folders_with_features_tip": "حذف المجلدات مع كل الميزات التي فيها والمجلدات المتداخلة فيها",
|
||||
"timeline.delete_many": "حذف {count} ميزات محددة",
|
||||
"timeline.edited_rolled_back": "تم تغيير {name}، لكنه يقع بعد مؤشر السجل ولذلك لا يُبنى: انقل المؤشر إلى ما بعده لرؤية النتيجة",
|
||||
"timeline.feature_error": "خطأ: {message}",
|
||||
"timeline.feature_hint": "انقر نقرًا مزدوجًا للتحرير · اسحب لإعادة الترتيب · انقر بالزر الأيمن للمزيد",
|
||||
"timeline.feature_rolled_back": "تم التراجع: بعد مؤشر السجل، لذلك لا يُبنى",
|
||||
"timeline.feature_warning": "تحذير: {message}",
|
||||
"timeline.group": "مجلد ميزات جديد",
|
||||
"timeline.move_many": "نقل عناصر الخط الزمني",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "معايرة الفأرة ثلاثية الأبعاد تصلح المحاور المتبادلة أو المعكوسة وتضبط سرعتها وإحساسها.",
|
||||
"ui.3d_mouse_offer_never": "عدم الإظهار مرة أخرى",
|
||||
"ui.3d_mouse_offer_swap": "التبديل إلى Hidraw",
|
||||
"ui.3d_mouse_orbit": "تدوير العرض",
|
||||
"ui.3d_mouse_orbit_classic": "كلاسيكي",
|
||||
"ui.3d_mouse_orbit_tip": "متتبَّع: تُدير الفأرة ثلاثية الأبعاد العرض حول جزء النموذج الموجود في منتصف العرض عند بدء الدوران، وتُعلّمه بكرة باهتة. كلاسيكي: حول مركز العرض",
|
||||
"ui.3d_mouse_orbit_traced": "متتبَّع",
|
||||
"ui.3d_mouse_reading": "القراءة",
|
||||
"ui.3d_mouse_reset_axes": "إعادة تعيين المحاور",
|
||||
"ui.3d_mouse_reset_view": "إعادة تعيين العرض",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "مقياس النهاية",
|
||||
"ui.entities": "العناصر",
|
||||
"ui.equal": "متساوٍ",
|
||||
"ui.equation_end": "النهاية",
|
||||
"ui.equation_explicit": "صريح",
|
||||
"ui.equation_form": "الشكل",
|
||||
"ui.equation_note": "المتغير رقم بلا وحدة: تأخذه sin(t) بالراديان. والنتيجة التي بلا وحدة تكون بوحدة التصميم.",
|
||||
"ui.equation_parametric": "بارامتري",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "نصف القطر المكافئ",
|
||||
"ui.expand": "توسيع",
|
||||
"ui.exportDxf": "تصدير DXF…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "التصميم",
|
||||
"ui.heading_draft": "ميل السحب",
|
||||
"ui.heading_edit": "تعديل",
|
||||
"ui.heading_equation_curve": "منحنى معادلة",
|
||||
"ui.heading_extrude": "بثق",
|
||||
"ui.heading_fillet": "تقويس",
|
||||
"ui.heading_finish_editing": "إنهاء التحرير",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "استيراد…",
|
||||
"ui.infinite": "لا نهائي",
|
||||
"ui.insert": "إدراج",
|
||||
"ui.insert_design.browse": "استعراض…",
|
||||
"ui.insert_design.open_designs": "التصاميم المفتوحة",
|
||||
"ui.insert_design.saved_file": "في {name} تغييرات غير محفوظة، لذا أُدرج ملفه المحفوظ. احفظه، ثم استخدم تحديث الروابط لإدخال التغييرات.",
|
||||
"ui.insert_design.unsaved": "تغييرات غير محفوظة",
|
||||
"ui.inspect": "فحص",
|
||||
"ui.intersect": "تقاطع",
|
||||
"ui.isolate": "عزل",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "تحديد",
|
||||
"ui.selectOther": "تحديد آخر…",
|
||||
"ui.selectTangentChain": "تحديد سلسلة مماسية",
|
||||
"ui.select_all": "تحديد الكل",
|
||||
"ui.selection": "التحديد",
|
||||
"ui.selection_modifiers": "مُعدِّلات التحديد",
|
||||
"ui.selection_shift_add_only": "Shift مع النقر يضيف دون إزالة",
|
||||
|
||||
@@ -810,7 +810,6 @@
|
||||
"cmd.browser_move": "Browser-Elemente verschieben",
|
||||
"cmd.browser_order": "Browser-Elemente ordnen",
|
||||
"cmd.browser_rename_group": "Gruppe umbenennen",
|
||||
"cmd.browser_reparent": "In andere Komponente verschieben",
|
||||
"cmd.browser_ungroup": "Gruppierung aufheben",
|
||||
"cmd.browser_visibility": "Ein-/Ausblenden",
|
||||
"cmd.canvas_calibrate": "Zeichenfläche kalibrieren",
|
||||
@@ -837,7 +836,6 @@
|
||||
"cmd.component_metadata": "Teileeigenschaften",
|
||||
"cmd.component_metadata_set": "Teileeigenschaften ändern",
|
||||
"cmd.component_move_bodies": "Körper in Komponente verschieben",
|
||||
"cmd.component_move_features": "Merkmale in Komponente verschieben",
|
||||
"cmd.component_move_sketches": "Skizzen in Komponente verschieben",
|
||||
"cmd.component_paste_new": "Neu einfügen",
|
||||
"cmd.component_properties": "Physikalische Eigenschaften der Komponente",
|
||||
@@ -1065,13 +1063,6 @@
|
||||
"cmd.render_start": "Bild rendern",
|
||||
"cmd.render_status": "Renderfortschritt",
|
||||
"cmd.select_clear": "Auswahl aufheben",
|
||||
"cmd.settings_profile_apply": "Einstellungsprofil anwenden",
|
||||
"cmd.settings_profile_delete": "Einstellungsprofil löschen",
|
||||
"cmd.settings_profile_export": "Einstellungsprofil exportieren",
|
||||
"cmd.settings_profile_import": "Einstellungsprofil importieren",
|
||||
"cmd.settings_profile_list": "Einstellungsprofile",
|
||||
"cmd.settings_profile_rename": "Einstellungsprofil umbenennen",
|
||||
"cmd.settings_profile_save": "Einstellungsprofil speichern",
|
||||
"cmd.sheet_convert": "In Blech umwandeln",
|
||||
"cmd.sheet_export_bend_table": "Biegungstabelle exportieren",
|
||||
"cmd.sheet_export_dxf": "Flachmuster als DXF exportieren",
|
||||
@@ -1117,8 +1108,6 @@
|
||||
"cmd.sketch_drawing_bounds": "Zeichnungsgröße",
|
||||
"cmd.sketch_edit": "Skizze bearbeiten",
|
||||
"cmd.sketch_edit_text": "Text bearbeiten",
|
||||
"cmd.sketch_equation_curve": "Gleichungskurve",
|
||||
"cmd.sketch_equation_curve_edit": "Gleichungskurve bearbeiten",
|
||||
"cmd.sketch_export_dxf": "Als DXF speichern",
|
||||
"cmd.sketch_finish_auto_constrain": "Mit Auto-Bedingung fertigstellen",
|
||||
"cmd.sketch_fit_curves_to_section": "Kurven an Netzquerschnitt anpassen",
|
||||
@@ -1215,7 +1204,6 @@
|
||||
"cmd.view.render_browser": "Render-Browser",
|
||||
"cmd.view.render_dock": "Render-Panel verschieben",
|
||||
"cmd.view.render_focus": "Render-Fokusansicht",
|
||||
"cmd.view.status_items": "Ansichtsstatus-Elemente",
|
||||
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
|
||||
"cmd.view_angle_readout": "Ansichtswinkel",
|
||||
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
|
||||
@@ -1367,7 +1355,6 @@
|
||||
"help.mit": "MIT-Lizenz",
|
||||
"help.notice": "NOTICE",
|
||||
"help.pointcloud.insert": "Fügt einen PLY-, XYZ- oder PTS-Scan als Punktwolke neben dem Modell ein. Verwenden Sie ihn als Referenz beim Modellieren anhand von Messdaten.",
|
||||
"help.sketch.equation_curve": "Zeichnet eine Kurve aus Formeln über einen Bereich: x(t) und y(t) oder y = f(x). Die Formeln können Parameter verwenden, und die Kurve folgt ihnen: für eine Sinuswelle, eine Spirale oder die Evolvente eines Zahnrads.",
|
||||
"help.solid.chamfer": "Schneidet die gewählten Kanten mit einer ebenen Fase im eingestellten Abstand ab. Damit brechen Sie scharfe Kanten oder erleichtern das Einführen eines Stifts oder einer Schraube.",
|
||||
"help.solid.combine": "Kombiniert Körper: vereinigt sie, zieht sie von einem Zielkörper ab oder behält nur das gemeinsame Volumen; die Werkzeugkörper können erhalten bleiben. Damit fügen Sie Teile zusammen, schneiden eine Form mit einer anderen aus oder finden, wo sich zwei Körper überschneiden.",
|
||||
"help.solid.extrude": "Zieht ein Skizzenprofil über eine Strecke in die dritte Dimension und fügt dabei Material hinzu oder entfernt es. Damit machen Sie aus einer flachen Kontur einen Block, eine Erhebung oder eine Tasche.",
|
||||
@@ -1710,7 +1697,6 @@
|
||||
"parts.dimension.thickness": "Dicke",
|
||||
"parts.dimension.width": "Breite",
|
||||
"parts.family.bearing": "Rillenkugellager (Hüllform)",
|
||||
"parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant",
|
||||
"parts.family.dowel_pin": "Zylinderstift",
|
||||
"parts.family.hex_bolt": "Sechskantschraube",
|
||||
"parts.family.hex_nut": "Sechskantmutter",
|
||||
@@ -1840,8 +1826,6 @@
|
||||
"prefs.layout_note": "Im Layout „Kompakt“ werden Bereiche an den Fensterrändern angedockt. Transparenz für den Browser ist nur im Layout „Schwebend“ verfügbar. Die Position des Assistenten ändern Sie auf der Seite „Assistent“.",
|
||||
"prefs.left": "Links",
|
||||
"prefs.left_click": "Linksklick",
|
||||
"prefs.line_drag_draws_one": "Drücken und Ziehen zeichnet eine Linie",
|
||||
"prefs.line_drag_draws_one_note": "Im Linienwerkzeug zeichnet Drücken, Ziehen und Loslassen eine Linie vom Drückpunkt bis zum Loslassen, und dort endet sie. Klicks zeichnen weiterhin eine Kette. Aus: Ein Ziehen setzt wie ein Klick einen Punkt dort, wo losgelassen wird.",
|
||||
"prefs.linked_designs": "Verknüpfte Designs",
|
||||
"prefs.max_file_size": "Maximale Dateigröße zum Öffnen",
|
||||
"prefs.max_file_size_design": "Designdateien",
|
||||
@@ -1853,32 +1837,10 @@
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.origin_plane_names": "Namen der Ursprungsebenen",
|
||||
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
|
||||
"prefs.perspective_fov": "Sichtfeld",
|
||||
"prefs.profile_active": "Aktives Profil",
|
||||
"prefs.profile_applied": "Profil angewendet.",
|
||||
"prefs.profile_changed": "Die Einstellungen wurden seit dem Speichern des Profils geändert. „Speichern“ übernimmt sie ins Profil.",
|
||||
"prefs.profile_deleted": "Profil gelöscht.",
|
||||
"prefs.profile_export": "Exportieren…",
|
||||
"prefs.profile_exported": "Profil exportiert.",
|
||||
"prefs.profile_imported": "Profil importiert. Unter „Aktives Profil“ auswählen, um dazu zu wechseln.",
|
||||
"prefs.profile_name_hint": "Profilname, z. B. Laptop",
|
||||
"prefs.profile_rename": "Umbenennen",
|
||||
"prefs.profile_renamed": "Profil umbenannt.",
|
||||
"prefs.profile_save": "Speichern",
|
||||
"prefs.profile_saved": "Aktuelle Einstellungen im Profil gespeichert.",
|
||||
"prefs.profile_skipped": "Profil angewendet; {n} Einstellung(en) behielten ihren aktuellen Wert (siehe Protokoll).",
|
||||
"prefs.profiles": "Einstellungsprofile",
|
||||
"prefs.profiles_note": "Tastenkürzel, Werkzeugleiste, Navigation, Radialmenü und Oberflächeneinstellungen, gemeinsam unter einem Namen gespeichert und auf einmal umgeschaltet.",
|
||||
"prefs.project_options_dialog": "Project-Optionendialog",
|
||||
"prefs.project_options_dialog_note": "Ein: Vor dem Projizieren Geometrie oder Körper und die Projektionsverknüpfung auswählen. Aus: Den bisherigen Direktwahl-Ablauf beibehalten.",
|
||||
"prefs.ribbon_reveal": "Suchergebnisse im Menüband zeigen",
|
||||
"prefs.ribbon_reveal_note": "Steht der Zeiger über einem Ergebnis in Befehle suchen, zeigt das Menüband, wo der Befehl liegt: seine Registerkarte, seine Schaltfläche oder seine Zeile im Aufklappmenü eines Panels. Ein aus der Suche gestarteter Befehl blinkt dort, und ein laufender Befehl aus einem Aufklappmenü markiert sein Panel.",
|
||||
"prefs.search": "Einstellungen suchen",
|
||||
"prefs.search_empty": "Keine passenden Einstellungen",
|
||||
"prefs.single_surface_analysis": "Nur eine Flächenanalyse verwenden",
|
||||
"prefs.single_surface_analysis_help": "Wenn deaktiviert, können Flächenschattierung und Stetigkeitsanalyse gleichzeitig aktiv bleiben. Beim Aktivieren bleibt die zuletzt gestartete oder geänderte Analyse erhalten.",
|
||||
"prefs.sketch_align_points": "Beim Zeichnen an anderen Skizzenpunkten ausrichten",
|
||||
"prefs.sketch_align_points_note": "Beim Zeichnen richtet sich ein Punkt zugleich waagerecht an einem Skizzenpunkt und senkrecht an einem anderen aus, mit einer gepunkteten Hilfslinie; der Klick behält beide Ausrichtungen. Aus: nur am vorherigen Punkt, jeweils eine Achse.",
|
||||
"prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe",
|
||||
"prefs.sketch_conflict_colour_note": "Solange die Bedingungen einer Skizze im Konflikt stehen, werden auch die Kurven und Punkte, die eine nicht erfüllte Bedingung hält, in der Konfliktfarbe gezeichnet, nicht nur ihr Symbol oder ihre Bemaßung. Aus: Sie behalten ihre üblichen Farben.",
|
||||
"prefs.sketch_state_icons": "Skizzensymbole in der Farbe ihres Zustands",
|
||||
@@ -1915,8 +1877,6 @@
|
||||
"prefs.viewport_background": "Hintergrund",
|
||||
"prefs.viewport_background_colour": "Farbe",
|
||||
"prefs.viewport_background_theme": "Verlauf des Designs",
|
||||
"prefs.zoom_stop_at_model": "Zoom am Modell stoppen",
|
||||
"prefs.zoom_stop_at_model_tip": "3D-Maus und Geräte-App zoomen zur Oberfläche in der Ansichtsmitte; Zieh-Zoom hält den Punkt am Beginn des Ziehens fest. Aus: Zoom kann durch das Modell gehen. Mausrad und Pinch verwenden separat Zoom zum Mauszeiger",
|
||||
"properties.title": "Eigenschaften: {name}",
|
||||
"recovery.days_ago": "vor {count} Tagen",
|
||||
"recovery.detail": "{file} · {features} Merkmale · automatisch gespeichert {when}",
|
||||
@@ -2812,7 +2772,6 @@
|
||||
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
|
||||
"settings.double_click_components": "Doppelklick auf eine Komponente aktiviert sie oder bearbeitet eine verknüpfte an Ort und Stelle",
|
||||
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.",
|
||||
"settings.insert_lists_open_designs": "Konstruktion einfügen zeigt zuerst die in anderen Tabs geöffneten Konstruktionen",
|
||||
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
|
||||
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
|
||||
@@ -2833,10 +2792,6 @@
|
||||
"shortcut.unpin": "Loslösen",
|
||||
"sketch is missing": "die Skizze fehlt",
|
||||
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
|
||||
"sketch.dimension_added_driven": "Die Skizze legt dieses Maß bereits fest, daher wurde es als abhängiges Maß (Referenzmaß) hinzugefügt",
|
||||
"sketch.dimension_applied": "Maß angewendet",
|
||||
"sketch.dimension_applied_defined": "Maß angewendet; Parameter `{name}` definiert",
|
||||
"sketch.dimension_applied_renamed": "Maß angewendet; der Parameter heißt jetzt `{name}`",
|
||||
"sketch.dof_left": "{count} Freiheitsgrade übrig",
|
||||
"sketch.dof_left_one": "{count} Freiheitsgrad übrig",
|
||||
"sketch.draw_curves": "Als {linetype} zeichnen",
|
||||
@@ -2894,9 +2849,7 @@
|
||||
"thread.iso_metric": "ISO metrisch",
|
||||
"thread.select_size": "Größe wählen",
|
||||
"timeline.delete_folders_with_features_tip": "Löscht die Ordner mit allen Features darin und ihren Unterordnern",
|
||||
"timeline.edited_rolled_back": "{name} geändert, liegt aber hinter der Verlaufsmarke und wird daher nicht erzeugt: Marke dahinter ziehen, um das Ergebnis zu sehen",
|
||||
"timeline.feature_hint": "Doppelklicken zum Bearbeiten · Ziehen zum Neuordnen · Rechtsklicken für weitere Optionen",
|
||||
"timeline.feature_rolled_back": "Zurückgesetzt: hinter der Verlaufsmarke, daher nicht erzeugt",
|
||||
"timeline.group": "Neuen Merkmalsordner anlegen",
|
||||
"timeline.move_many": "Zeitleistenelemente verschieben",
|
||||
"timeline.move_to_group": "Merkmale in Ordner verschieben",
|
||||
@@ -2977,10 +2930,6 @@
|
||||
"ui.3d_mouse_offer": "Die Kalibrierung Ihrer 3D-Maus behebt vertauschte oder umgekehrte Achsen und stellt Geschwindigkeit und Gefühl ein.",
|
||||
"ui.3d_mouse_offer_never": "Nicht mehr anzeigen",
|
||||
"ui.3d_mouse_offer_swap": "Zu Hidraw wechseln (direkter Treiber)",
|
||||
"ui.3d_mouse_orbit": "Orbit",
|
||||
"ui.3d_mouse_orbit_classic": "Klassisch",
|
||||
"ui.3d_mouse_orbit_tip": "Verfolgt: Die 3D-Maus dreht die Ansicht um den Teil des Modells, der zu Beginn der Drehung in der Ansichtsmitte liegt, markiert durch eine blasse Kugel. Klassisch: um die Ansichtsmitte",
|
||||
"ui.3d_mouse_orbit_traced": "Verfolgt",
|
||||
"ui.3d_mouse_reading": "Werte",
|
||||
"ui.3d_mouse_reset_axes": "Achsenzuordnung zurücksetzen",
|
||||
"ui.3d_mouse_reset_view": "Ansicht zurücksetzen",
|
||||
@@ -3406,14 +3355,6 @@
|
||||
"ui.end_scale": "Endskalierung",
|
||||
"ui.entities": "Elemente",
|
||||
"ui.equal": "Gleich",
|
||||
"ui.equation_end": "Ende",
|
||||
"ui.equation_explicit": "Explizit",
|
||||
"ui.equation_form": "Form",
|
||||
"ui.equation_note": "Die Variable ist eine Zahl ohne Einheit: sin(t) liest sie im Bogenmaß. Ein Ergebnis ohne Einheit ist in der Einheit der Konstruktion.",
|
||||
"ui.equation_parametric": "Parametrisch",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "Äquivalenter Radius",
|
||||
"ui.expand": "Aufklappen",
|
||||
"ui.exportDxf": "Als DXF exportieren…",
|
||||
@@ -3476,7 +3417,6 @@
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
"ui.heading_edit": "BEARBEITEN",
|
||||
"ui.heading_equation_curve": "GLEICHUNGSKURVE",
|
||||
"ui.heading_extrude": "EXTRUDIEREN",
|
||||
"ui.heading_fillet": "VOLLRUNDUNG",
|
||||
"ui.heading_finish_editing": "BEARBEITUNG BEENDEN",
|
||||
@@ -3557,10 +3497,6 @@
|
||||
"ui.import_ellipsis": "Importieren…",
|
||||
"ui.infinite": "Unendlich",
|
||||
"ui.insert": "Einfügen",
|
||||
"ui.insert_design.browse": "Durchsuchen…",
|
||||
"ui.insert_design.open_designs": "Geöffnete Konstruktionen",
|
||||
"ui.insert_design.saved_file": "{name} hat ungespeicherte Änderungen, daher wurde die gespeicherte Datei eingefügt. Speichern Sie sie und wählen Sie dann Verknüpfungen aktualisieren, um die Änderungen zu übernehmen.",
|
||||
"ui.insert_design.unsaved": "Ungespeicherte Änderungen",
|
||||
"ui.inspect": "Prüfen",
|
||||
"ui.intersect": "Schnitt",
|
||||
"ui.isolate": "Isolieren",
|
||||
@@ -3766,7 +3702,6 @@
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selectOther": "Andere auswählen…",
|
||||
"ui.selectTangentChain": "Tangentialkette auswählen",
|
||||
"ui.select_all": "Alles auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
"ui.selection_modifiers": "Auswahlmodifikatoren",
|
||||
"ui.selection_shift_add_only": "Umschalt+Klick fügt hinzu, ohne zu entfernen",
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"Edges": "Edges",
|
||||
"Edit": "Edit",
|
||||
"Edit Profile Sketch": "Edit Profile Sketch",
|
||||
"Edit shortcuts": "Edit shortcuts",
|
||||
"Enable rule": "Enable rule",
|
||||
"Enabled": "Enabled",
|
||||
"End Radius": "End Radius",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "Radius",
|
||||
"Radius Fit": "Radius Fit",
|
||||
"Ratio": "Ratio",
|
||||
"Read-only list: shortcuts can't be changed here.": "Read-only list: shortcuts can't be changed here.",
|
||||
"Reattach selected face": "Reattach selected face",
|
||||
"Recover": "Recover",
|
||||
"Recover unsaved design?": "Recover unsaved design?",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "Move Browser Items",
|
||||
"cmd.browser_order": "Order Browser Items",
|
||||
"cmd.browser_rename_group": "Rename Group",
|
||||
"cmd.browser_reparent": "Move to Component",
|
||||
"cmd.browser_ungroup": "Ungroup",
|
||||
"cmd.browser_visibility": "Show/Hide",
|
||||
"cmd.canvas_calibrate": "Calibrate Canvas",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "Part Properties",
|
||||
"cmd.component_metadata_set": "Change Part Properties",
|
||||
"cmd.component_move_bodies": "Move Bodies to Component",
|
||||
"cmd.component_move_features": "Move Features to Component",
|
||||
"cmd.component_move_sketches": "Move Sketches to Component",
|
||||
"cmd.component_paste_from_design": "Paste from Another Design",
|
||||
"cmd.component_paste_new": "Paste New",
|
||||
"cmd.component_properties": "Component Physical Properties",
|
||||
"cmd.component_relink": "Relink Source",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "Render Image",
|
||||
"cmd.render_status": "Render Progress",
|
||||
"cmd.select_clear": "Clear Selection",
|
||||
"cmd.settings_profile_apply": "Apply Settings Profile",
|
||||
"cmd.settings_profile_delete": "Delete Settings Profile",
|
||||
"cmd.settings_profile_export": "Export Settings Profile",
|
||||
"cmd.settings_profile_import": "Import Settings Profile",
|
||||
"cmd.settings_profile_list": "Settings Profiles",
|
||||
"cmd.settings_profile_rename": "Rename Settings Profile",
|
||||
"cmd.settings_profile_save": "Save Settings Profile",
|
||||
"cmd.sheet_convert": "Convert to Sheet Metal",
|
||||
"cmd.sheet_export_bend_table": "Export Bend Table",
|
||||
"cmd.sheet_export_dxf": "Export Flat Pattern as DXF",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "Drawing Size",
|
||||
"cmd.sketch_edit": "Edit Sketch",
|
||||
"cmd.sketch_edit_text": "Edit Text",
|
||||
"cmd.sketch_equation_curve": "Equation Curve",
|
||||
"cmd.sketch_equation_curve_edit": "Edit Equation Curve",
|
||||
"cmd.sketch_export_dxf": "Save As DXF",
|
||||
"cmd.sketch_finish_auto_constrain": "Finish with AutoConstrain",
|
||||
"cmd.sketch_fit_curves_to_section": "Fit Curves to Mesh Section",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "Render Browser",
|
||||
"cmd.view.render_dock": "Move Render Dock",
|
||||
"cmd.view.render_focus": "Render Focus View",
|
||||
"cmd.view.status_items": "Viewport Status Items",
|
||||
"cmd.view_ambient_occlusion": "Ambient Occlusion",
|
||||
"cmd.view_angle_readout": "View Angles",
|
||||
"cmd.view_apply": "Restore Named View",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "Moves an assembly part only along freedoms allowed by its joints. Select a part face, then drag an available arrow or arc.",
|
||||
"help.parameters.change": "Opens the table of named values and expressions that drive the dimensions of the design. Use it to change a size in one place and let every feature that uses it follow.",
|
||||
"help.pointcloud.insert": "Insert a PLY, XYZ or PTS scan as a point cloud alongside the model. Use it as a reference when building a model from measured data.",
|
||||
"help.sketch.equation_curve": "Draws a curve from formulas: x(t) and y(t), or y = f(x), over a range. The formulas can use parameters and the curve follows them: use it for a sine wave, a spiral or a gear tooth's involute.",
|
||||
"help.solid.align": "Moves the selected bodies so that a point, face or plane on them meets a point, face or plane elsewhere. Use it to place a part against another one without measuring the offset.",
|
||||
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
|
||||
"help.solid.combine": "Joins bodies, cuts them out of a target body, or keeps only the volume they share, and can keep the tool bodies. Use it to merge parts, to cut one shape with another or to find where two bodies overlap.",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "Thickness",
|
||||
"parts.dimension.width": "Width",
|
||||
"parts.family.bearing": "Deep groove ball bearing (envelope)",
|
||||
"parts.family.button_head_cap_screw": "Button head cap screw",
|
||||
"parts.family.dowel_pin": "Parallel pin (dowel)",
|
||||
"parts.family.hex_bolt": "Hex head bolt / screw",
|
||||
"parts.family.hex_nut": "Hex nut",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "Compact docks panels to the window edges. Browser translucency is available in Floating. Change the Assistant side on the Assistant page.",
|
||||
"prefs.left": "Left",
|
||||
"prefs.left_click": "Left click",
|
||||
"prefs.line_drag_draws_one": "Press and drag draws one line",
|
||||
"prefs.line_drag_draws_one_note": "In the Line tool, pressing, dragging and letting go draws one line from the press to the release, and it ends there. Clicks still draw a chain. Off: a drag places a point where it is let go, as a click does.",
|
||||
"prefs.linked_designs": "Linked designs",
|
||||
"prefs.live_input": "Live input",
|
||||
"prefs.live_preview": "Live preview",
|
||||
@@ -1925,25 +1906,9 @@
|
||||
"prefs.origin_plane_names": "Origin plane names",
|
||||
"prefs.origin_plane_names_note": "Names shown in the browser, dialogs and lists. {top} is XY, {front} is XZ and {right} is YZ, with Z up. Commands, scripts and saved designs keep XY, XZ and YZ.",
|
||||
"prefs.overall_speed": "Overall speed",
|
||||
"prefs.perspective_fov": "Field of view",
|
||||
"prefs.placement": "Placement",
|
||||
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
|
||||
"prefs.preview_choices": "Preview of your choices",
|
||||
"prefs.profile_active": "Active profile",
|
||||
"prefs.profile_applied": "Profile applied.",
|
||||
"prefs.profile_changed": "Settings changed since the profile was saved. Save keeps them in it.",
|
||||
"prefs.profile_deleted": "Profile deleted.",
|
||||
"prefs.profile_export": "Export…",
|
||||
"prefs.profile_exported": "Profile exported.",
|
||||
"prefs.profile_imported": "Profile imported. Choose it under Active profile to switch to it.",
|
||||
"prefs.profile_name_hint": "Profile name, e.g. Laptop",
|
||||
"prefs.profile_rename": "Rename",
|
||||
"prefs.profile_renamed": "Profile renamed.",
|
||||
"prefs.profile_save": "Save",
|
||||
"prefs.profile_saved": "Current settings saved to the profile.",
|
||||
"prefs.profile_skipped": "Profile applied; {n} setting(s) kept their current value (see the log).",
|
||||
"prefs.profiles": "Settings profiles",
|
||||
"prefs.profiles_note": "Shortcuts, the toolbar, navigation, the radial menu and interface settings, kept together under a name and switched at once.",
|
||||
"prefs.project_options_dialog": "Project options dialog",
|
||||
"prefs.project_options_dialog_note": "On: choose Geometry or Bodies and Projection Link before projecting. Off: keep the previous direct-pick workflow.",
|
||||
"prefs.quality_default": "default",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget",
|
||||
"prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.",
|
||||
"prefs.reports_saved": "{n} saved",
|
||||
"prefs.ribbon_reveal": "Show search results in the ribbon",
|
||||
"prefs.ribbon_reveal_note": "Hovering a result in Search commands shows where the command sits in the ribbon: its tab, its button or its row in a panel's drop-down. A command started from the search blinks there, and a running command from a drop-down marks its panel.",
|
||||
"prefs.right_click": "Right click",
|
||||
"prefs.saving": "Autosave and recovery",
|
||||
"prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
"prefs.shortcuts": "Shortcuts",
|
||||
"prefs.single_surface_analysis": "Use a single surface analysis",
|
||||
"prefs.single_surface_analysis_help": "When off, surface shading and Continuity can stay on together. Turning this on keeps the last analysis started or changed.",
|
||||
"prefs.sketch_align_points": "Line up with other sketch points while drawing",
|
||||
"prefs.sketch_align_points_note": "While drawing, a point lines up horizontally with one sketch point and vertically with another at the same time, with a dotted guide, and the click keeps both alignments. Off: only with the previous point, one axis at a time.",
|
||||
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict color",
|
||||
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict color too, not only its glyph or dimension. Off: they keep their usual colors.",
|
||||
"prefs.sketch_exit_view": "When a sketch ends",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "Wheel",
|
||||
"prefs.wheel_trackpad": "Wheel and trackpad",
|
||||
"prefs.workspace": "Workspace",
|
||||
"prefs.zoom_stop_at_model": "Stop zoom at the model",
|
||||
"prefs.zoom_stop_at_model_tip": "The 3D mouse and device app zoom toward the surface in the middle of the view; drag-zoom holds the point where the drag began. Off: zoom can pass through the model. Wheel and pinch use Zoom to the cursor separately",
|
||||
"prefs.zoom_toward_cursor": "Zoom, toward the cursor",
|
||||
"properties.title": "Properties: {name}",
|
||||
"recovery.days_ago": "{count} days ago",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "Double-click a component to activate it, or to edit a linked one in place",
|
||||
"settings.frame_rate_details_note": "Adds a graph of the last frames' times with the triangles drawn over it, a histogram of frame times and what the GPU drew. Click the frame rate in the view to show or hide them.",
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
|
||||
"settings.insert_lists_open_designs": "Insert Design lists the designs open in other tabs first",
|
||||
"settings.measure_axis_distances": "Show X/Y/Z distances",
|
||||
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "Unpin",
|
||||
"sketch is missing": "sketch is missing",
|
||||
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
||||
"sketch.dimension_added_driven": "The sketch already determines that dimension, so it was added as a driven (reference) dimension",
|
||||
"sketch.dimension_applied": "Dimension applied",
|
||||
"sketch.dimension_applied_defined": "Dimension applied; parameter `{name}` defined",
|
||||
"sketch.dimension_applied_renamed": "Dimension applied; parameter named `{name}`",
|
||||
"sketch.dof_left": "{count} degrees of freedom left",
|
||||
"sketch.dof_left_one": "{count} degree of freedom left",
|
||||
"sketch.draw_curves": "Draw as {linetype}",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "Keep their features and nested folders in the history",
|
||||
"timeline.delete_folders_with_features_tip": "Delete the folders with every feature in them and their nested folders",
|
||||
"timeline.delete_many": "Delete {count} Selected Features",
|
||||
"timeline.edited_rolled_back": "{name} changed, but it is after the history marker, so it is not built: move the marker past it to see the result",
|
||||
"timeline.feature_error": "Error: {message}",
|
||||
"timeline.feature_hint": "Double-click to edit · drag to reorder · right-click for more",
|
||||
"timeline.feature_rolled_back": "Rolled back: after the history marker, so not built",
|
||||
"timeline.feature_warning": "Warning: {message}",
|
||||
"timeline.group": "New Feature Folder",
|
||||
"timeline.move_many": "Move Timeline Items",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "Calibrating your 3D mouse fixes swapped or reversed axes and sets its speed and feel.",
|
||||
"ui.3d_mouse_offer_never": "Never show again",
|
||||
"ui.3d_mouse_offer_swap": "Swap to Hidraw (Direct Driver)",
|
||||
"ui.3d_mouse_orbit": "Orbit",
|
||||
"ui.3d_mouse_orbit_classic": "Classic",
|
||||
"ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre",
|
||||
"ui.3d_mouse_orbit_traced": "Traced",
|
||||
"ui.3d_mouse_reading": "Reading",
|
||||
"ui.3d_mouse_reset_axes": "Reset axis assignment",
|
||||
"ui.3d_mouse_reset_view": "Reset view",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "End Scale",
|
||||
"ui.entities": "Entities",
|
||||
"ui.equal": "Equal",
|
||||
"ui.equation_end": "End",
|
||||
"ui.equation_explicit": "Explicit",
|
||||
"ui.equation_form": "Form",
|
||||
"ui.equation_note": "The variable is a plain number: sin(t) takes it in radians. A result without a unit is in the design's unit.",
|
||||
"ui.equation_parametric": "Parametric",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "Equivalent Radius",
|
||||
"ui.expand": "Expand",
|
||||
"ui.exportDxf": "Export DXF…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "DRAFT",
|
||||
"ui.heading_edit": "EDIT",
|
||||
"ui.heading_equation_curve": "EQUATION CURVE",
|
||||
"ui.heading_extrude": "EXTRUDE",
|
||||
"ui.heading_fillet": "FILLET",
|
||||
"ui.heading_finish_editing": "FINISH EDITING",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "Import…",
|
||||
"ui.infinite": "Infinite",
|
||||
"ui.insert": "Insert",
|
||||
"ui.insert_design.browse": "Browse…",
|
||||
"ui.insert_design.open_designs": "Open designs",
|
||||
"ui.insert_design.saved_file": "{name} has unsaved changes, so its saved file was inserted. Save it, then Update Links to bring the changes in.",
|
||||
"ui.insert_design.unsaved": "Unsaved changes",
|
||||
"ui.inspect": "Inspect",
|
||||
"ui.intersect": "Intersect",
|
||||
"ui.isolate": "Isolate",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "Select",
|
||||
"ui.selectOther": "Select Other…",
|
||||
"ui.selectTangentChain": "Select Tangent Chain",
|
||||
"ui.select_all": "Select All",
|
||||
"ui.selection": "Selection",
|
||||
"ui.selection_modifiers": "Selection modifiers",
|
||||
"ui.selection_shift_add_only": "Shift+click adds without removing",
|
||||
|
||||
@@ -3449,7 +3449,6 @@
|
||||
"ui.select": "Seleccionar",
|
||||
"ui.selectOther": "Seleccionar otro…",
|
||||
"ui.selectTangentChain": "Seleccionar cadena tangente",
|
||||
"ui.select_all": "Seleccionar todo",
|
||||
"ui.selection": "Selección",
|
||||
"ui.selection_modifiers": "Modificadores de selección",
|
||||
"ui.selection_shift_add_only": "Mayús+clic añade sin quitar",
|
||||
|
||||
@@ -3166,10 +3166,8 @@
|
||||
"timeline.delete_folders_tip": "Garder leurs fonctions et leurs dossiers imbriqués dans l'historique",
|
||||
"timeline.delete_folders_with_features_tip": "Supprimer les dossiers avec toutes leurs fonctions et leurs dossiers imbriqués",
|
||||
"timeline.delete_many": "Supprimer {count} fonctions sélectionnées",
|
||||
"timeline.edited_rolled_back": "{name} modifié, mais il est après le marqueur d'historique et n'est donc pas construit : déplacez le marqueur au-delà pour voir le résultat",
|
||||
"timeline.feature_error": "Erreur : {message}",
|
||||
"timeline.feature_hint": "Double-clic pour modifier · glisser pour réordonner · clic droit pour plus",
|
||||
"timeline.feature_rolled_back": "En retrait : après le marqueur d'historique, donc non construit",
|
||||
"timeline.feature_warning": "Avertissement : {message}",
|
||||
"timeline.group": "Nouveau dossier de fonctions",
|
||||
"timeline.move_many": "Déplacer des éléments de la chronologie",
|
||||
@@ -4091,7 +4089,6 @@
|
||||
"ui.select": "Sélectionner",
|
||||
"ui.selectOther": "Sélectionner un autre…",
|
||||
"ui.selectTangentChain": "Sélectionner la chaîne tangente",
|
||||
"ui.select_all": "Tout sélectionner",
|
||||
"ui.selection": "Sélection",
|
||||
"ui.selection_modifiers": "Modificateurs de sélection",
|
||||
"ui.selection_shift_add_only": "Maj+clic ajoute sans retirer",
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"Edges": "מקצועות",
|
||||
"Edit": "עריכה",
|
||||
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
|
||||
"Edit shortcuts": "עריכת קיצורי דרך",
|
||||
"Enable rule": "הפעלת הכלל",
|
||||
"Enabled": "מופעל",
|
||||
"End Radius": "רדיוס סיום",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "רדיוס",
|
||||
"Radius Fit": "התאמת רדיוס",
|
||||
"Ratio": "יחס",
|
||||
"Read-only list: shortcuts can't be changed here.": "רשימה לקריאה בלבד: לא ניתן לשנות כאן קיצורי דרך.",
|
||||
"Reattach selected face": "הצמדה מחדש לפאה שנבחרה",
|
||||
"Recover": "שחזור",
|
||||
"Recover unsaved design?": "לשחזר תכן שלא נשמר?",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "הזזת פריטים בעץ",
|
||||
"cmd.browser_order": "סידור פריטים בעץ",
|
||||
"cmd.browser_rename_group": "שינוי שם קבוצה",
|
||||
"cmd.browser_reparent": "העברה לרכיב",
|
||||
"cmd.browser_ungroup": "פירוק קבוצה",
|
||||
"cmd.browser_visibility": "הצגה/הסתרה",
|
||||
"cmd.canvas_calibrate": "כיול קנבס",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "מאפייני חלק",
|
||||
"cmd.component_metadata_set": "שינוי מאפייני חלק",
|
||||
"cmd.component_move_bodies": "העברת גופים לרכיב",
|
||||
"cmd.component_move_features": "העברת פיצ'רים לרכיב",
|
||||
"cmd.component_move_sketches": "העברת סקיצות לרכיב",
|
||||
"cmd.component_paste_from_design": "הדבק מעיצוב אחר",
|
||||
"cmd.component_paste_new": "הדבקה כחדש",
|
||||
"cmd.component_properties": "תכונות פיזיקליות של רכיב",
|
||||
"cmd.component_relink": "קישור מחדש למקור",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "יצירת תמונת הדמיה",
|
||||
"cmd.render_status": "התקדמות ההדמיה",
|
||||
"cmd.select_clear": "ניקוי הבחירה",
|
||||
"cmd.settings_profile_apply": "החלת פרופיל הגדרות",
|
||||
"cmd.settings_profile_delete": "מחיקת פרופיל הגדרות",
|
||||
"cmd.settings_profile_export": "ייצוא פרופיל הגדרות",
|
||||
"cmd.settings_profile_import": "ייבוא פרופיל הגדרות",
|
||||
"cmd.settings_profile_list": "פרופילי הגדרות",
|
||||
"cmd.settings_profile_rename": "שינוי שם פרופיל הגדרות",
|
||||
"cmd.settings_profile_save": "שמירת פרופיל הגדרות",
|
||||
"cmd.sheet_convert": "המרה לפח",
|
||||
"cmd.sheet_export_bend_table": "ייצוא טבלת כיפופים",
|
||||
"cmd.sheet_export_dxf": "ייצוא פריסה כ־DXF",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "גודל הציור",
|
||||
"cmd.sketch_edit": "עריכת סקיצה",
|
||||
"cmd.sketch_edit_text": "עריכת טקסט",
|
||||
"cmd.sketch_equation_curve": "עקומת משוואה",
|
||||
"cmd.sketch_equation_curve_edit": "עריכת עקומת משוואה",
|
||||
"cmd.sketch_export_dxf": "שמירה כ־DXF",
|
||||
"cmd.sketch_finish_auto_constrain": "סיום עם אילוץ אוטומטי",
|
||||
"cmd.sketch_fit_curves_to_section": "התאמת עקומות לחתך רשת משולשים",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "דפדפן רינדור",
|
||||
"cmd.view.render_dock": "הזז את עגינת הרינדור",
|
||||
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
|
||||
"cmd.view.status_items": "פריטי מצב תצוגה",
|
||||
"cmd.view_ambient_occlusion": "הצללה סביבתית",
|
||||
"cmd.view_angle_readout": "זוויות תצוגה",
|
||||
"cmd.view_apply": "שחזור תצוגה שמורה",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.",
|
||||
"help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.",
|
||||
"help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.",
|
||||
"help.sketch.equation_curve": "משרטט עקומה מנוסחאות על פני טווח: x(t) ו־y(t), או y = f(x). הנוסחאות יכולות להשתמש בפרמטרים והעקומה עוקבת אחריהם: לגל סינוס, לספירלה או לאבולבנטה של שן גלגל שיניים.",
|
||||
"help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.",
|
||||
"help.solid.chamfer": "חיתוך המקצועות שנבחרו בשיפוע ישר במרחק שנקבע. כך שוברים מקצועות חדים או מקלים על הכנסה של פין או בורג.",
|
||||
"help.solid.combine": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "עובי",
|
||||
"parts.dimension.width": "רוחב",
|
||||
"parts.family.bearing": "מסב כדורי חריץ עמוק (מעטפת)",
|
||||
"parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה",
|
||||
"parts.family.dowel_pin": "פין גלילי",
|
||||
"parts.family.hex_bolt": "בורג ראש משושה",
|
||||
"parts.family.hex_nut": "אום משושה",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "הפריסה הדחוסה מצמידה חלוניות לשולי החלון. שקיפות עץ התכן זמינה בפריסה הצפה. את צד העוזר אפשר לשנות בעמוד העוזר.",
|
||||
"prefs.left": "שמאל",
|
||||
"prefs.left_click": "לחיצה שמאלית",
|
||||
"prefs.line_drag_draws_one": "לחיצה וגרירה משרטטות קו אחד",
|
||||
"prefs.line_drag_draws_one_note": "בכלי הקו, לחיצה, גרירה ושחרור משרטטים קו אחד מנקודת הלחיצה עד נקודת השחרור, והוא מסתיים שם. לחיצות עדיין משרטטות שרשרת. כבוי: גרירה מציבה נקודה היכן שמשחררים, כמו לחיצה.",
|
||||
"prefs.linked_designs": "תכנים מקושרים",
|
||||
"prefs.live_input": "קלט חי",
|
||||
"prefs.live_preview": "תצוגה מקדימה חיה",
|
||||
@@ -1925,25 +1906,9 @@
|
||||
"prefs.origin_plane_names": "שמות מישורי הראשית",
|
||||
"prefs.origin_plane_names_note": "השמות המוצגים בדפדפן, בתיבות הדו־שיח וברשימות. {top} הוא XY, {front} הוא XZ ו־{right} הוא YZ, כש־Z למעלה. פקודות, סקריפטים ותכנונים שמורים שומרים על XY, XZ ו־YZ.",
|
||||
"prefs.overall_speed": "מהירות כללית",
|
||||
"prefs.perspective_fov": "שדה ראייה",
|
||||
"prefs.placement": "מיקום",
|
||||
"prefs.prefer_free_sketch_points": "העדפת נקודות סקיצה ניתנות להזזה כשנקודות חופפות",
|
||||
"prefs.preview_choices": "תצוגה מקדימה של הבחירות שלך",
|
||||
"prefs.profile_active": "פרופיל פעיל",
|
||||
"prefs.profile_applied": "הפרופיל הוחל.",
|
||||
"prefs.profile_changed": "ההגדרות השתנו מאז שהפרופיל נשמר. „שמירה“ מכניסה אותן לפרופיל.",
|
||||
"prefs.profile_deleted": "הפרופיל נמחק.",
|
||||
"prefs.profile_export": "ייצוא…",
|
||||
"prefs.profile_exported": "הפרופיל יוצא.",
|
||||
"prefs.profile_imported": "הפרופיל יובא. בחרו בו תחת „פרופיל פעיל“ כדי לעבור אליו.",
|
||||
"prefs.profile_name_hint": "שם הפרופיל, למשל מחשב נייד",
|
||||
"prefs.profile_rename": "שינוי שם",
|
||||
"prefs.profile_renamed": "שם הפרופיל שונה.",
|
||||
"prefs.profile_save": "שמירה",
|
||||
"prefs.profile_saved": "ההגדרות הנוכחיות נשמרו בפרופיל.",
|
||||
"prefs.profile_skipped": "הפרופיל הוחל; {n} הגדרות שמרו על ערכן הנוכחי (ראו ביומן).",
|
||||
"prefs.profiles": "פרופילי הגדרות",
|
||||
"prefs.profiles_note": "קיצורי מקלדת, סרגל הכלים, ניווט, התפריט המעגלי והגדרות הממשק, נשמרים יחד תחת שם אחד ומוחלפים בבת אחת.",
|
||||
"prefs.project_options_dialog": "תיבת האפשרויות של Project",
|
||||
"prefs.project_options_dialog_note": "פעיל: לבחור גאומטריה או גופים וקישור הטלה לפני ההטלה. כבוי: להשאיר את זרימת העבודה הקודמת של בחירה ישירה.",
|
||||
"prefs.quality_default": "ברירת מחדל",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור",
|
||||
"prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.",
|
||||
"prefs.reports_saved": "{n} שמורים",
|
||||
"prefs.ribbon_reveal": "הצגת תוצאות חיפוש בסרט",
|
||||
"prefs.ribbon_reveal_note": "מעבר עם הסמן מעל תוצאה בחיפוש פקודות מראה היכן הפקודה נמצאת בסרט: הלשונית, הכפתור או השורה שלה בתפריט הנפתח של לוח. פקודה שהופעלה מהחיפוש מהבהבת שם, ופקודה פעילה מתפריט נפתח מסמנת את הלוח שלה.",
|
||||
"prefs.right_click": "לחיצה ימנית",
|
||||
"prefs.saving": "שמירה אוטומטית ושחזור",
|
||||
"prefs.scale_note": "גם Ctrl + / Ctrl − בחלון. הכפתורים בגודל 28 px ב־100%.",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
"prefs.shortcuts": "קיצורים",
|
||||
"prefs.single_surface_analysis": "שימוש בניתוח משטח יחיד",
|
||||
"prefs.single_surface_analysis_help": "כשהאפשרות כבויה, הצללת המשטח וניתוח הרציפות יכולים להישאר פעילים יחד. הפעלתה משאירה רק את הניתוח האחרון שהופעל או שונה.",
|
||||
"prefs.sketch_align_points": "יישור לנקודות סקיצה אחרות בזמן השרטוט",
|
||||
"prefs.sketch_align_points_note": "בזמן השרטוט, נקודה מתיישרת אופקית עם נקודת סקיצה אחת ואנכית עם אחרת בו-זמנית, עם קו עזר מנוקד, והלחיצה שומרת את שני היישורים. כבוי: רק עם הנקודה הקודמת, ציר אחד בכל פעם.",
|
||||
"prefs.sketch_conflict_colour": "גאומטריה סותרת בצבע הסתירה",
|
||||
"prefs.sketch_conflict_colour_note": "כשאילוצי סקיצה סותרים, העקומות והנקודות שאילוץ כושל מחזיק מצוירות גם הן בצבע הסתירה, לא רק הסמל או המידה שלו. כבוי: הן שומרות על צבעיהן הרגילים.",
|
||||
"prefs.sketch_exit_view": "בסיום סקיצה",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "גלגלת",
|
||||
"prefs.wheel_trackpad": "גלגלת ומשטח מגע",
|
||||
"prefs.workspace": "סביבת עבודה",
|
||||
"prefs.zoom_stop_at_model": "עצירת הזום במודל",
|
||||
"prefs.zoom_stop_at_model_tip": "העכבר התלת־ממדי ויישום ההתקן מקרבים את המבט אל המשטח שבמרכז התצוגה; זום בגרירה שומר על נקודת תחילת הגרירה. כשהאפשרות כבויה, אפשר לעבור דרך המודל. הגלגלת ומחוות הצביטה משתמשות בנפרד בהגדרת הזום אל הסמן",
|
||||
"prefs.zoom_toward_cursor": "זום, לכיוון הסמן",
|
||||
"properties.title": "מאפיינים: {name}",
|
||||
"recovery.days_ago": "לפני {count} ימים",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "לחיצה כפולה על רכיב מפעילה אותו, או עורכת רכיב מקושר במקומו",
|
||||
"settings.frame_rate_details_note": "מוסיף גרף של זמני התמונות האחרונות עם המשולשים שצוירו מעליו, היסטוגרמה של זמני התמונות ואת מה שה־GPU צייר. לחיצה על קצב התמונות בתצוגה מציגה או מסתירה אותם.",
|
||||
"settings.frame_rate_note": "הצגת מספר הפריימים לשנייה בפינה הימנית התחתונה של התצוגה. כל עוד האפשרות פעילה, התצוגה מצוירת מחדש ברציפות.",
|
||||
"settings.insert_lists_open_designs": "הוספת תכן מציגה קודם את התכנים הפתוחים בלשוניות אחרות",
|
||||
"settings.measure_axis_distances": "הצגת מרחקי X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "הצגת המרחקים לאורך צירי העולם לצד המרחק הכולל כשכלי המדידה פתוח.",
|
||||
"settings.msaa_applies_on_restart": "החלקת הקצוות תחול בהפעלה הבאה של SolidCraft",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "ביטול הצמדה",
|
||||
"sketch is missing": "הסקיצה חסרה",
|
||||
"sketch.degrees_of_freedom": "{count} דרגות חופש",
|
||||
"sketch.dimension_added_driven": "הסקיצה כבר קובעת את המידה הזו, ולכן היא נוספה כמידה מונעת (מידת ייחוס)",
|
||||
"sketch.dimension_applied": "המידה הוחלה",
|
||||
"sketch.dimension_applied_defined": "המידה הוחלה; הפרמטר `{name}` הוגדר",
|
||||
"sketch.dimension_applied_renamed": "המידה הוחלה; שם הפרמטר הוא כעת `{name}`",
|
||||
"sketch.dof_left": "נותרו {count} דרגות חופש",
|
||||
"sketch.dof_left_one": "נותרה {count} דרגת חופש",
|
||||
"sketch.draw_curves": "שרטוט כ־{linetype}",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "שמירת הפיצ'רים והתיקיות המקוננות שלהן בציר הזמן",
|
||||
"timeline.delete_folders_with_features_tip": "מחיקת התיקיות עם כל הפיצ'רים שבהן והתיקיות המקוננות בהן",
|
||||
"timeline.delete_many": "מחיקת {count} הפיצ'רים שנבחרו",
|
||||
"timeline.edited_rolled_back": "{name} שונה, אך הוא אחרי סמן ההיסטוריה ולכן לא נבנה: הזיזו את הסמן אל מעבר אליו כדי לראות את התוצאה",
|
||||
"timeline.feature_error": "שגיאה: {message}",
|
||||
"timeline.feature_hint": "לחיצה כפולה לעריכה · גרירה לשינוי הסדר · לחיצה ימנית לעוד",
|
||||
"timeline.feature_rolled_back": "בוטל: אחרי סמן ההיסטוריה, ולכן לא נבנה",
|
||||
"timeline.feature_warning": "אזהרה: {message}",
|
||||
"timeline.group": "תיקיית פיצ'רים חדשה",
|
||||
"timeline.move_many": "הזזת פריטים בציר הזמן",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "כיול העכבר התלת־ממדי מתקן צירים מוחלפים או הפוכים וקובע את המהירות והתחושה שלו.",
|
||||
"ui.3d_mouse_offer_never": "לא להציג שוב",
|
||||
"ui.3d_mouse_offer_swap": "מעבר ל־Hidraw (מנהל ישיר)",
|
||||
"ui.3d_mouse_orbit": "סיבוב",
|
||||
"ui.3d_mouse_orbit_classic": "קלאסי",
|
||||
"ui.3d_mouse_orbit_tip": "במעקב: העכבר התלת־ממדי מסובב את המבט סביב החלק של המודל שבמרכז המבט כשהסיבוב מתחיל, ומסמן אותו בכדור חיוור. קלאסי: סביב מרכז המבט",
|
||||
"ui.3d_mouse_orbit_traced": "במעקב",
|
||||
"ui.3d_mouse_reading": "קריאה",
|
||||
"ui.3d_mouse_reset_axes": "איפוס הקצאת הצירים",
|
||||
"ui.3d_mouse_reset_view": "איפוס המבט",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "קנה מידה בסיום",
|
||||
"ui.entities": "ישויות",
|
||||
"ui.equal": "שווה",
|
||||
"ui.equation_end": "סוף",
|
||||
"ui.equation_explicit": "מפורשת",
|
||||
"ui.equation_form": "צורה",
|
||||
"ui.equation_note": "המשתנה הוא מספר ללא יחידה: sin(t) מקבלת אותו ברדיאנים. תוצאה ללא יחידה היא ביחידה של התכנון.",
|
||||
"ui.equation_parametric": "פרמטרית",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "רדיוס שקול",
|
||||
"ui.expand": "הרחבה",
|
||||
"ui.exportDxf": "ייצוא DXF…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "תכן",
|
||||
"ui.heading_draft": "שיפוע יציקה",
|
||||
"ui.heading_edit": "עריכה",
|
||||
"ui.heading_equation_curve": "עקומת משוואה",
|
||||
"ui.heading_extrude": "שליפה",
|
||||
"ui.heading_fillet": "עיגול פינה",
|
||||
"ui.heading_finish_editing": "סיום עריכה",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "ייבוא…",
|
||||
"ui.infinite": "אינסופי",
|
||||
"ui.insert": "הוספה",
|
||||
"ui.insert_design.browse": "עיון…",
|
||||
"ui.insert_design.open_designs": "תכנים פתוחים",
|
||||
"ui.insert_design.saved_file": "ב-{name} יש שינויים שלא נשמרו, ולכן נוסף הקובץ השמור שלו. שמרו אותו ואז בחרו עדכון קישורים כדי להכניס את השינויים.",
|
||||
"ui.insert_design.unsaved": "שינויים שלא נשמרו",
|
||||
"ui.inspect": "בדיקה",
|
||||
"ui.intersect": "חיתוך משותף",
|
||||
"ui.isolate": "בידוד",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "בחירה",
|
||||
"ui.selectOther": "בחירת אחר…",
|
||||
"ui.selectTangentChain": "בחירת שרשרת משיקה",
|
||||
"ui.select_all": "בחירת הכול",
|
||||
"ui.selection": "בחירה",
|
||||
"ui.selection_modifiers": "מקשי שינוי לבחירה",
|
||||
"ui.selection_shift_add_only": "Shift+לחיצה מוסיף בלי להסיר",
|
||||
|
||||
@@ -4070,7 +4070,6 @@
|
||||
"ui.select": "Seleziona",
|
||||
"ui.selectOther": "Seleziona altro…",
|
||||
"ui.selectTangentChain": "Seleziona catena tangente",
|
||||
"ui.select_all": "Seleziona tutto",
|
||||
"ui.selection": "Selezione",
|
||||
"ui.selection_modifiers": "Modificatori di selezione",
|
||||
"ui.selection_shift_add_only": "Maiusc+clic aggiunge senza rimuovere",
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"Edges": "エッジ",
|
||||
"Edit": "編集",
|
||||
"Edit Profile Sketch": "プロファイルスケッチを編集",
|
||||
"Edit shortcuts": "ショートカットを編集",
|
||||
"Enable rule": "ルールを有効化",
|
||||
"Enabled": "有効",
|
||||
"End Radius": "終了半径",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "半径",
|
||||
"Radius Fit": "半径フィット",
|
||||
"Ratio": "比率",
|
||||
"Read-only list: shortcuts can't be changed here.": "読み取り専用の一覧です。ここではショートカットを変更できません。",
|
||||
"Reattach selected face": "選択した面を再アタッチ",
|
||||
"Recover": "復元",
|
||||
"Recover unsaved design?": "未保存のデザインを復元しますか?",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "ブラウザ項目を移動",
|
||||
"cmd.browser_order": "ブラウザ項目の並び順を変更",
|
||||
"cmd.browser_rename_group": "グループの名前を変更",
|
||||
"cmd.browser_reparent": "コンポーネントへ移動",
|
||||
"cmd.browser_ungroup": "グループ解除",
|
||||
"cmd.browser_visibility": "表示/非表示",
|
||||
"cmd.canvas_calibrate": "キャンバスの尺度を設定",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "部品プロパティ",
|
||||
"cmd.component_metadata_set": "部品プロパティを変更",
|
||||
"cmd.component_move_bodies": "ボディをコンポーネントに移動",
|
||||
"cmd.component_move_features": "フィーチャーをコンポーネントに移動",
|
||||
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
|
||||
"cmd.component_paste_from_design": "別のデザインから貼り付け",
|
||||
"cmd.component_paste_new": "新規として貼り付け",
|
||||
"cmd.component_properties": "コンポーネントの物理的プロパティ",
|
||||
"cmd.component_relink": "参照元を再リンク",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "画像をレンダリング",
|
||||
"cmd.render_status": "レンダリングの進行状況",
|
||||
"cmd.select_clear": "選択をクリア",
|
||||
"cmd.settings_profile_apply": "設定プロファイルを適用",
|
||||
"cmd.settings_profile_delete": "設定プロファイルを削除",
|
||||
"cmd.settings_profile_export": "設定プロファイルをエクスポート",
|
||||
"cmd.settings_profile_import": "設定プロファイルをインポート",
|
||||
"cmd.settings_profile_list": "設定プロファイル",
|
||||
"cmd.settings_profile_rename": "設定プロファイルの名前を変更",
|
||||
"cmd.settings_profile_save": "設定プロファイルを保存",
|
||||
"cmd.sheet_convert": "板金に変換",
|
||||
"cmd.sheet_export_bend_table": "曲げテーブルをエクスポート",
|
||||
"cmd.sheet_export_dxf": "展開図を DXF としてエクスポート",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "図面のサイズ",
|
||||
"cmd.sketch_edit": "スケッチを編集",
|
||||
"cmd.sketch_edit_text": "テキストを編集",
|
||||
"cmd.sketch_equation_curve": "方程式曲線",
|
||||
"cmd.sketch_equation_curve_edit": "方程式曲線を編集",
|
||||
"cmd.sketch_export_dxf": "DXF として保存",
|
||||
"cmd.sketch_finish_auto_constrain": "自動拘束で終了",
|
||||
"cmd.sketch_fit_curves_to_section": "メッシュ断面に曲線をフィット",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "Renderブラウザー",
|
||||
"cmd.view.render_dock": "レンダードックを移動",
|
||||
"cmd.view.render_focus": "Renderフォーカス表示",
|
||||
"cmd.view.status_items": "ビューポートの状態項目",
|
||||
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
|
||||
"cmd.view_angle_readout": "ビュー角度",
|
||||
"cmd.view_apply": "名前付きビューを復元",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。",
|
||||
"help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。",
|
||||
"help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。",
|
||||
"help.sketch.equation_curve": "x(t) と y(t)、または y = f(x) の式から、範囲を指定して曲線を描きます。式にはパラメーターを使え、曲線はその変更に追従します。正弦波、らせん、歯車のインボリュートなどに使います。",
|
||||
"help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。",
|
||||
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
|
||||
"help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "厚さ",
|
||||
"parts.dimension.width": "幅",
|
||||
"parts.family.bearing": "深溝玉軸受(外形)",
|
||||
"parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト",
|
||||
"parts.family.dowel_pin": "平行ピン",
|
||||
"parts.family.hex_bolt": "六角ボルト",
|
||||
"parts.family.hex_nut": "六角ナット",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "コンパクトではパネルをウィンドウの端に固定します。ブラウザーの半透明表示はフローティングで使用できます。アシスタントの配置はアシスタントページで変更します。",
|
||||
"prefs.left": "左",
|
||||
"prefs.left_click": "左クリック",
|
||||
"prefs.line_drag_draws_one": "押してドラッグすると線を 1 本描く",
|
||||
"prefs.line_drag_draws_one_note": "線ツールで押してドラッグし、離すと、押した位置から離した位置まで線を 1 本描いてそこで終わります。クリックでは従来どおり連続した線を描きます。オフ: ドラッグはクリックと同じく、離した位置に点を置きます。",
|
||||
"prefs.linked_designs": "リンクされたデザイン",
|
||||
"prefs.live_input": "現在の入力",
|
||||
"prefs.live_preview": "ライブプレビュー",
|
||||
@@ -1925,25 +1906,9 @@
|
||||
"prefs.origin_plane_names": "原点平面の名前",
|
||||
"prefs.origin_plane_names_note": "ブラウザー、ダイアログ、一覧に表示される名前です。{top} は XY、{front} は XZ、{right} は YZ です (Z が上)。コマンド、スクリプト、保存されたデザインでは XY、XZ、YZ のままです。",
|
||||
"prefs.overall_speed": "全体の速度",
|
||||
"prefs.perspective_fov": "画角",
|
||||
"prefs.placement": "配置",
|
||||
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
|
||||
"prefs.preview_choices": "選択した設定のプレビュー",
|
||||
"prefs.profile_active": "使用中のプロファイル",
|
||||
"prefs.profile_applied": "プロファイルを適用しました。",
|
||||
"prefs.profile_changed": "プロファイルの保存後に設定が変更されています。「保存」でプロファイルに反映されます。",
|
||||
"prefs.profile_deleted": "プロファイルを削除しました。",
|
||||
"prefs.profile_export": "エクスポート…",
|
||||
"prefs.profile_exported": "プロファイルをエクスポートしました。",
|
||||
"prefs.profile_imported": "プロファイルをインポートしました。切り替えるには「使用中のプロファイル」で選択してください。",
|
||||
"prefs.profile_name_hint": "プロファイル名(例: ノートPC)",
|
||||
"prefs.profile_rename": "名前を変更",
|
||||
"prefs.profile_renamed": "プロファイルの名前を変更しました。",
|
||||
"prefs.profile_save": "保存",
|
||||
"prefs.profile_saved": "現在の設定をプロファイルに保存しました。",
|
||||
"prefs.profile_skipped": "プロファイルを適用しました。{n} 件の設定は現在の値のままです(ログを参照)。",
|
||||
"prefs.profiles": "設定プロファイル",
|
||||
"prefs.profiles_note": "ショートカット、ツールバー、ナビゲーション、ラジアルメニュー、インターフェース設定を名前を付けてまとめて保存し、一度に切り替えます。",
|
||||
"prefs.project_options_dialog": "Projectのオプションダイアログ",
|
||||
"prefs.project_options_dialog_note": "オン: 投影前に「ジオメトリ」または「ボディ」と「投影リンク」を選択します。オフ: 従来の直接選択方式を使用します。",
|
||||
"prefs.quality_default": "既定",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用",
|
||||
"prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。",
|
||||
"prefs.reports_saved": "{n} 件保存済み",
|
||||
"prefs.ribbon_reveal": "検索結果をリボンに表示",
|
||||
"prefs.ribbon_reveal_note": "コマンド検索で結果にポインターを合わせると、そのコマンドがリボンのどこにあるか(タブ、ボタン、またはパネルのドロップダウン内の行)を表示します。検索から開始したコマンドはそこで点滅し、ドロップダウンから実行中のコマンドはそのパネルに印が付きます。",
|
||||
"prefs.right_click": "右クリック",
|
||||
"prefs.saving": "自動保存と復元",
|
||||
"prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
"prefs.shortcuts": "ショートカット",
|
||||
"prefs.single_surface_analysis": "サーフェス解析を1つだけ使用",
|
||||
"prefs.single_surface_analysis_help": "オフの場合、サーフェスのシェーディングと連続性解析を同時に有効にできます。オンにすると、最後に開始または変更した解析が残ります。",
|
||||
"prefs.sketch_align_points": "描画中にほかのスケッチ点に揃える",
|
||||
"prefs.sketch_align_points_note": "描画中、点が一つのスケッチ点と水平に、別の点と垂直に同時に揃い、点線のガイドが表示されます。クリックすると両方の整列が保持されます。オフ: 直前の点にのみ、一度に一方向だけ揃えます。",
|
||||
"prefs.sketch_conflict_colour": "競合するジオメトリを競合色で表示",
|
||||
"prefs.sketch_conflict_colour_note": "スケッチの拘束が競合している間、満たされない拘束のグリフや寸法だけでなく、その拘束が保持する曲線と点も競合色で描画します。オフ: 通常の色のままです。",
|
||||
"prefs.sketch_exit_view": "スケッチ終了時のビュー",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "ホイール",
|
||||
"prefs.wheel_trackpad": "ホイールとトラックパッド",
|
||||
"prefs.workspace": "ワークスペース",
|
||||
"prefs.zoom_stop_at_model": "モデルでズームを止める",
|
||||
"prefs.zoom_stop_at_model_tip": "3Dマウスとデバイスアプリはビュー中央の表面に向かってズームします。ドラッグズームはドラッグ開始点を固定します。オフの場合はモデルを通り抜けられます。ホイールとピンチは別の「カーソルへズーム」設定を使います",
|
||||
"prefs.zoom_toward_cursor": "カーソルに向かってズーム",
|
||||
"properties.title": "プロパティ: {name}",
|
||||
"recovery.days_ago": "{count}日前",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "コンポーネントをダブルクリックしてアクティブにする、またはリンクされたものをその場で編集する",
|
||||
"settings.frame_rate_details_note": "直近のフレーム時間のグラフ(描画した三角形数を重ねて表示)、フレーム時間のヒストグラム、GPU が描画した内容を追加します。ビューのフレームレートをクリックすると表示/非表示を切り替えます。",
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
|
||||
"settings.insert_lists_open_designs": "デザインを挿入で、ほかのタブで開いているデザインを先に表示する",
|
||||
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
|
||||
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "ピン留めを解除",
|
||||
"sketch is missing": "スケッチが見つかりません",
|
||||
"sketch.degrees_of_freedom": "自由度 {count}",
|
||||
"sketch.dimension_added_driven": "スケッチがすでにその寸法を決めているため、従動寸法(参照寸法)として追加しました",
|
||||
"sketch.dimension_applied": "寸法を適用しました",
|
||||
"sketch.dimension_applied_defined": "寸法を適用しました。パラメータ `{name}` を定義しました",
|
||||
"sketch.dimension_applied_renamed": "寸法を適用しました。パラメータ名を `{name}` にしました",
|
||||
"sketch.dof_left": "残り自由度 {count}",
|
||||
"sketch.dof_left_one": "残り自由度 {count}",
|
||||
"sketch.draw_curves": "{linetype}として描く",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "フィーチャーと入れ子のフォルダーを履歴に残します",
|
||||
"timeline.delete_folders_with_features_tip": "フォルダーを、中のすべてのフィーチャーと入れ子のフォルダーごと削除します",
|
||||
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
|
||||
"timeline.edited_rolled_back": "{name} を変更しましたが、履歴マーカーより後ろにあるため構築されません。結果を見るにはマーカーをその先へ移動してください",
|
||||
"timeline.feature_error": "エラー: {message}",
|
||||
"timeline.feature_hint": "ダブルクリックで編集 · ドラッグで並べ替え · 右クリックでその他の操作",
|
||||
"timeline.feature_rolled_back": "ロールバック中: 履歴マーカーより後ろのため構築されません",
|
||||
"timeline.feature_warning": "警告: {message}",
|
||||
"timeline.group": "フィーチャーフォルダを新規作成",
|
||||
"timeline.move_many": "タイムライン項目を移動",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "3Dマウスをキャリブレーションすると、入れ替わった軸や反転した軸が直り、速度と操作感も整います。",
|
||||
"ui.3d_mouse_offer_never": "今後表示しない",
|
||||
"ui.3d_mouse_offer_swap": "Hidraw(直接ドライバー)に切り替える",
|
||||
"ui.3d_mouse_orbit": "回転",
|
||||
"ui.3d_mouse_orbit_classic": "クラシック",
|
||||
"ui.3d_mouse_orbit_tip": "トレース:3Dマウスで回転を始めたときにビューの中央にあるモデルの部分を中心にビューを回転し、その点を淡い球で示します。クラシック:ビューの中心を基準に回転します。",
|
||||
"ui.3d_mouse_orbit_traced": "トレース",
|
||||
"ui.3d_mouse_reading": "読み取り値",
|
||||
"ui.3d_mouse_reset_axes": "軸の割り当てをリセット",
|
||||
"ui.3d_mouse_reset_view": "表示をリセット",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "終端スケール",
|
||||
"ui.entities": "エンティティ",
|
||||
"ui.equal": "等しい",
|
||||
"ui.equation_end": "終了",
|
||||
"ui.equation_explicit": "陽関数",
|
||||
"ui.equation_form": "形式",
|
||||
"ui.equation_note": "変数は単位のない数値です。sin(t) はラジアンとして扱います。単位のない結果はデザインの単位になります。",
|
||||
"ui.equation_parametric": "パラメトリック",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "等価半径",
|
||||
"ui.expand": "展開",
|
||||
"ui.exportDxf": "DXF をエクスポート…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "デザイン",
|
||||
"ui.heading_draft": "勾配",
|
||||
"ui.heading_edit": "編集",
|
||||
"ui.heading_equation_curve": "方程式曲線",
|
||||
"ui.heading_extrude": "押し出し",
|
||||
"ui.heading_fillet": "フィレット",
|
||||
"ui.heading_finish_editing": "編集を終了",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "インポート…",
|
||||
"ui.infinite": "無限",
|
||||
"ui.insert": "挿入",
|
||||
"ui.insert_design.browse": "参照…",
|
||||
"ui.insert_design.open_designs": "開いているデザイン",
|
||||
"ui.insert_design.saved_file": "{name} に保存されていない変更があるため、保存済みのファイルを挿入しました。保存してから、リンクを更新で変更を取り込んでください。",
|
||||
"ui.insert_design.unsaved": "未保存の変更",
|
||||
"ui.inspect": "検査",
|
||||
"ui.intersect": "交差",
|
||||
"ui.isolate": "分離表示",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "選択",
|
||||
"ui.selectOther": "その他を選択…",
|
||||
"ui.selectTangentChain": "接線チェーンを選択",
|
||||
"ui.select_all": "すべて選択",
|
||||
"ui.selection": "選択",
|
||||
"ui.selection_modifiers": "選択修飾キー",
|
||||
"ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)",
|
||||
|
||||
@@ -716,7 +716,6 @@
|
||||
"cmd.browser_move": "브라우저 항목 이동",
|
||||
"cmd.browser_order": "브라우저 항목 정렬",
|
||||
"cmd.browser_rename_group": "그룹 이름 변경",
|
||||
"cmd.browser_reparent": "구성 요소로 이동",
|
||||
"cmd.browser_ungroup": "그룹 해제",
|
||||
"cmd.browser_visibility": "표시/숨기기",
|
||||
"cmd.canvas_calibrate": "캔버스 보정",
|
||||
@@ -739,7 +738,6 @@
|
||||
"cmd.component_metadata": "부품 속성",
|
||||
"cmd.component_metadata_set": "부품 속성 변경",
|
||||
"cmd.component_move_bodies": "바디를 구성 요소로 이동",
|
||||
"cmd.component_move_features": "특성을 구성 요소로 이동",
|
||||
"cmd.component_move_sketches": "스케치를 구성 요소로 이동",
|
||||
"cmd.component_paste_new": "새로 붙여넣기",
|
||||
"cmd.component_properties": "구성 요소 물리 속성",
|
||||
@@ -987,8 +985,6 @@
|
||||
"cmd.sketch_draw_dimensioned": "치수가 있는 스케치 개체 그리기",
|
||||
"cmd.sketch_edit": "스케치 편집",
|
||||
"cmd.sketch_edit_text": "텍스트 편집",
|
||||
"cmd.sketch_equation_curve": "방정식 곡선",
|
||||
"cmd.sketch_equation_curve_edit": "방정식 곡선 편집",
|
||||
"cmd.sketch_export_dxf": "DXF로 저장",
|
||||
"cmd.sketch_finish_auto_constrain": "자동 구속으로 완료",
|
||||
"cmd.sketch_fit_curves_to_section": "메시 단면에 곡선 맞추기",
|
||||
@@ -1083,7 +1079,6 @@
|
||||
"cmd.view.render_browser": "렌더 브라우저",
|
||||
"cmd.view.render_dock": "렌더 도크 이동",
|
||||
"cmd.view.render_focus": "렌더 집중 보기",
|
||||
"cmd.view.status_items": "뷰포트 상태 항목",
|
||||
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
|
||||
"cmd.view_angle_readout": "뷰 각도",
|
||||
"cmd.view_apply": "이름 있는 보기 복원",
|
||||
@@ -1199,7 +1194,6 @@
|
||||
"help.mit": "MIT 라이선스",
|
||||
"help.notice": "NOTICE",
|
||||
"help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.",
|
||||
"help.sketch.equation_curve": "범위에 걸쳐 수식으로 곡선을 그립니다: x(t)와 y(t), 또는 y = f(x). 수식에 매개변수를 쓸 수 있으며 곡선이 그 값을 따라갑니다. 사인파, 나선, 기어 이의 인벌류트에 사용합니다.",
|
||||
"help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.",
|
||||
"help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.",
|
||||
"help.solid.fillet": "선택한 모서리를 지정한 반지름의 매끄러운 원호로 둥글게 만듭니다. 강도, 편의성 또는 외관을 위해 날카로운 모서리를 부드럽게 할 때 사용합니다.",
|
||||
@@ -1566,8 +1560,6 @@
|
||||
"prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.",
|
||||
"prefs.left": "왼쪽",
|
||||
"prefs.left_click": "왼쪽 클릭",
|
||||
"prefs.line_drag_draws_one": "누른 채 드래그하면 선 하나 그리기",
|
||||
"prefs.line_drag_draws_one_note": "선 도구에서 누르고 드래그한 뒤 놓으면 누른 곳에서 놓은 곳까지 선 하나를 그리고 거기서 끝납니다. 클릭하면 이전처럼 이어지는 선을 그립니다. 끄면: 드래그는 클릭처럼 놓은 곳에 점을 둡니다.",
|
||||
"prefs.live_input": "실시간 입력",
|
||||
"prefs.live_preview": "실시간 미리보기",
|
||||
"prefs.max_file_size": "열 수 있는 최대 파일 크기",
|
||||
@@ -1592,7 +1584,6 @@
|
||||
"prefs.origin_plane_names": "원점 평면 이름",
|
||||
"prefs.origin_plane_names_note": "브라우저, 대화 상자, 목록에 표시되는 이름입니다. {top}은 XY, {front}는 XZ, {right}는 YZ이며 Z가 위쪽입니다. 명령, 스크립트, 저장된 디자인은 XY, XZ, YZ를 유지합니다.",
|
||||
"prefs.overall_speed": "전체 속도",
|
||||
"prefs.perspective_fov": "시야각",
|
||||
"prefs.preview_choices": "선택 항목 미리보기",
|
||||
"prefs.project_options_dialog": "Project 옵션 대화상자",
|
||||
"prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.",
|
||||
@@ -1614,10 +1605,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
"prefs.shortcuts": "단축키",
|
||||
"prefs.single_surface_analysis": "표면 분석 하나만 사용",
|
||||
"prefs.single_surface_analysis_help": "끄면 표면 음영 분석과 연속성 분석을 함께 유지할 수 있습니다. 켜면 마지막으로 시작하거나 변경한 분석만 유지됩니다.",
|
||||
"prefs.sketch_align_points": "그리는 동안 다른 스케치 점에 맞추기",
|
||||
"prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.",
|
||||
"prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시",
|
||||
"prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.",
|
||||
"prefs.sketch_selection": "스케치 및 선택",
|
||||
@@ -1675,8 +1662,6 @@
|
||||
"prefs.wheel": "휠",
|
||||
"prefs.wheel_trackpad": "휠 및 트랙패드",
|
||||
"prefs.workspace": "작업 공간",
|
||||
"prefs.zoom_stop_at_model": "모델에서 확대/축소 멈추기",
|
||||
"prefs.zoom_stop_at_model_tip": "3D 마우스와 장치 앱은 뷰 중앙의 표면을 향해 확대/축소하고, 드래그 확대/축소는 드래그 시작점을 고정합니다. 끄면 모델을 통과할 수 있습니다. 휠과 핀치는 별도의 커서로 확대/축소 설정을 사용합니다",
|
||||
"prefs.zoom_toward_cursor": "확대/축소, 커서 방향으로",
|
||||
"properties.title": "속성: {name}",
|
||||
"recovery.days_ago": "{count}일 전",
|
||||
@@ -2571,7 +2556,6 @@
|
||||
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
|
||||
"settings.double_click_components": "구성 요소를 두 번 클릭하여 활성화하거나 연결된 구성 요소를 제자리에서 편집",
|
||||
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
|
||||
"settings.insert_lists_open_designs": "설계 삽입에서 다른 탭에 열린 설계를 먼저 표시",
|
||||
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
|
||||
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
|
||||
@@ -2591,10 +2575,6 @@
|
||||
"shortcut.unpin": "고정 해제",
|
||||
"sketch is missing": "스케치가 없습니다",
|
||||
"sketch.degrees_of_freedom": "자유도 {count}개",
|
||||
"sketch.dimension_added_driven": "스케치가 이미 이 치수를 결정하므로 종속(참조) 치수로 추가했습니다",
|
||||
"sketch.dimension_applied": "치수를 적용했습니다",
|
||||
"sketch.dimension_applied_defined": "치수를 적용했습니다. 매개변수 `{name}`을(를) 정의했습니다",
|
||||
"sketch.dimension_applied_renamed": "치수를 적용했습니다. 매개변수 이름을 `{name}`(으)로 지정했습니다",
|
||||
"sketch.dof_left": "남은 자유도 {count}개",
|
||||
"sketch.dof_left_one": "남은 자유도 {count}개",
|
||||
"sketch.draw_curves": "{linetype}(으)로 그리기",
|
||||
@@ -2729,10 +2709,6 @@
|
||||
"ui.3d_mouse_offer": "3D 마우스를 보정하면 바뀌거나 반전된 축이 바로잡히고 속도와 조작감이 맞춰집니다.",
|
||||
"ui.3d_mouse_offer_never": "다시 표시하지 않음",
|
||||
"ui.3d_mouse_offer_swap": "Hidraw(직접 드라이버)로 전환",
|
||||
"ui.3d_mouse_orbit": "궤도 회전",
|
||||
"ui.3d_mouse_orbit_classic": "클래식",
|
||||
"ui.3d_mouse_orbit_tip": "추적: 회전을 시작할 때 뷰 가운데에 있는 모델 부분을 중심으로 3D 마우스가 뷰를 회전하며, 그 점을 희미한 구로 표시합니다. 클래식: 뷰의 중심을 기준으로",
|
||||
"ui.3d_mouse_orbit_traced": "추적",
|
||||
"ui.3d_mouse_reading": "읽은 값",
|
||||
"ui.3d_mouse_reset_axes": "축 지정 초기화",
|
||||
"ui.3d_mouse_reset_view": "뷰 초기화",
|
||||
@@ -3154,14 +3130,6 @@
|
||||
"ui.end_scale": "끝 배율",
|
||||
"ui.entities": "요소",
|
||||
"ui.equal": "동일",
|
||||
"ui.equation_end": "끝",
|
||||
"ui.equation_explicit": "명시적",
|
||||
"ui.equation_form": "형식",
|
||||
"ui.equation_note": "변수는 단위가 없는 수입니다. sin(t)는 라디안으로 읽습니다. 단위가 없는 결과는 디자인 단위입니다.",
|
||||
"ui.equation_parametric": "매개변수식",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "등가 반지름",
|
||||
"ui.expand": "펼치기",
|
||||
"ui.exportDxf": "DXF 내보내기…",
|
||||
@@ -3220,7 +3188,6 @@
|
||||
"ui.heading_design": "디자인",
|
||||
"ui.heading_draft": "구배",
|
||||
"ui.heading_edit": "편집",
|
||||
"ui.heading_equation_curve": "방정식 곡선",
|
||||
"ui.heading_extrude": "돌출",
|
||||
"ui.heading_fillet": "필렛",
|
||||
"ui.heading_finish_editing": "편집 완료",
|
||||
@@ -3308,10 +3275,6 @@
|
||||
"ui.import_ellipsis": "가져오기…",
|
||||
"ui.infinite": "무한",
|
||||
"ui.insert": "삽입",
|
||||
"ui.insert_design.browse": "찾아보기…",
|
||||
"ui.insert_design.open_designs": "열린 설계",
|
||||
"ui.insert_design.saved_file": "{name}에 저장되지 않은 변경 사항이 있어 저장된 파일을 삽입했습니다. 저장한 다음 링크 업데이트로 변경 사항을 가져오세요.",
|
||||
"ui.insert_design.unsaved": "저장되지 않은 변경 사항",
|
||||
"ui.inspect": "검사",
|
||||
"ui.intersect": "교차",
|
||||
"ui.isolate": "분리 표시",
|
||||
@@ -3512,7 +3475,6 @@
|
||||
"ui.select": "선택",
|
||||
"ui.selectOther": "기타 선택…",
|
||||
"ui.selectTangentChain": "접선 체인 선택",
|
||||
"ui.select_all": "모두 선택",
|
||||
"ui.selection": "선택",
|
||||
"ui.selection_modifiers": "선택 보조 키",
|
||||
"ui.selection_shift_add_only": "Shift+클릭은 추가만 함",
|
||||
|
||||
@@ -1823,8 +1823,6 @@
|
||||
"prefs.layout_note": "Compact plaatst panelen vast aan de vensterranden. Doorschijnendheid van de browser is beschikbaar in Zwevend. Wijzig de kant van de Assistent op de pagina Assistent.",
|
||||
"prefs.left": "Links",
|
||||
"prefs.left_click": "Linksklik",
|
||||
"prefs.line_drag_draws_one": "Indrukken en slepen tekent één lijn",
|
||||
"prefs.line_drag_draws_one_note": "In het lijngereedschap tekent indrukken, slepen en loslaten één lijn van het indrukpunt tot het loslaatpunt, en daar eindigt de lijn. Klikken tekent nog steeds een keten. Uit: slepen zet, net als een klik, een punt waar losgelaten wordt.",
|
||||
"prefs.linked_designs": "Gekoppelde ontwerpen",
|
||||
"prefs.live_input": "Live invoer",
|
||||
"prefs.live_preview": "Live voorbeeld",
|
||||
@@ -4068,7 +4066,6 @@
|
||||
"ui.select": "Selecteren",
|
||||
"ui.selectOther": "Overige selecteren…",
|
||||
"ui.selectTangentChain": "Tangentiële keten selecteren",
|
||||
"ui.select_all": "Alles selecteren",
|
||||
"ui.selection": "Selectie",
|
||||
"ui.selection_modifiers": "Selectiemodificatoren",
|
||||
"ui.selection_shift_add_only": "Shift+klik voegt toe zonder te verwijderen",
|
||||
|
||||
@@ -1300,10 +1300,6 @@
|
||||
"shortcut.pin": "Przypnij na górze pola S",
|
||||
"shortcut.unpin": "Odepnij",
|
||||
"sketch.degrees_of_freedom": "Stopnie swobody: {count}",
|
||||
"sketch.dimension_added_driven": "Szkic już określa ten wymiar, więc dodano go jako wymiar sterowany (referencyjny)",
|
||||
"sketch.dimension_applied": "Wymiar zastosowany",
|
||||
"sketch.dimension_applied_defined": "Wymiar zastosowany; zdefiniowano parametr `{name}`",
|
||||
"sketch.dimension_applied_renamed": "Wymiar zastosowany; parametr nazywa się teraz `{name}`",
|
||||
"sketch.dof_left": "Pozostałe stopnie swobody: {count}",
|
||||
"sketch.dof_left_one": "Pozostał {count} stopień swobody",
|
||||
"sketch.draw_curves": "Rysuj jako {linetype}",
|
||||
@@ -2118,7 +2114,6 @@
|
||||
"ui.select": "Zaznacz",
|
||||
"ui.selectOther": "Wybierz inny…",
|
||||
"ui.selectTangentChain": "Zaznacz łańcuch styczny",
|
||||
"ui.select_all": "Zaznacz wszystko",
|
||||
"ui.selection": "Zaznaczenie",
|
||||
"ui.selection_modifiers": "Modyfikatory zaznaczania",
|
||||
"ui.selection_shift_add_only": "Shift+kliknięcie tylko dodaje",
|
||||
|
||||
@@ -2244,7 +2244,6 @@
|
||||
"ui.select": "Selecionar",
|
||||
"ui.selectOther": "Selecionar outro…",
|
||||
"ui.selectTangentChain": "Selecionar cadeia tangente",
|
||||
"ui.select_all": "Selecionar tudo",
|
||||
"ui.selection": "Seleção",
|
||||
"ui.selection_modifiers": "Modificadores de seleção",
|
||||
"ui.selection_shift_add_only": "Shift+clique adiciona sem remover",
|
||||
|
||||
@@ -3803,7 +3803,6 @@
|
||||
"ui.select": "Выбрать",
|
||||
"ui.selectOther": "Выбрать другое…",
|
||||
"ui.selectTangentChain": "Выбрать касательную цепочку",
|
||||
"ui.select_all": "Выделить всё",
|
||||
"ui.selection": "Выделение",
|
||||
"ui.selection_modifiers": "Модификаторы выбора",
|
||||
"ui.selection_shift_add_only": "Shift+щелчок добавляет без удаления",
|
||||
|
||||
@@ -4081,7 +4081,6 @@
|
||||
"ui.select": "Seç",
|
||||
"ui.selectOther": "Diğerini seç…",
|
||||
"ui.selectTangentChain": "Teğet zinciri seç",
|
||||
"ui.select_all": "Tümünü seç",
|
||||
"ui.selection": "Seçim",
|
||||
"ui.selection_modifiers": "Seçim değiştiricileri",
|
||||
"ui.selection_shift_add_only": "Shift+tıklama kaldırmadan ekler",
|
||||
|
||||
@@ -4062,7 +4062,6 @@
|
||||
"ui.select": "Вибрати",
|
||||
"ui.selectOther": "Вибрати інше…",
|
||||
"ui.selectTangentChain": "Вибрати дотичний ланцюг",
|
||||
"ui.select_all": "Виділити все",
|
||||
"ui.selection": "Виділення",
|
||||
"ui.selection_modifiers": "Модифікатори вибору",
|
||||
"ui.selection_shift_add_only": "Shift+клацання додає без вилучення",
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"Edges": "Các cạnh",
|
||||
"Edit": "Sửa",
|
||||
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
|
||||
"Edit shortcuts": "Sửa phím tắt",
|
||||
"Enable rule": "Bật quy tắc",
|
||||
"Enabled": "Đã kích hoạt",
|
||||
"End Radius": "Bán kính kết thúc",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "Bán kính",
|
||||
"Radius Fit": "Bán kính vừa vặn",
|
||||
"Ratio": "Tỷ lệ",
|
||||
"Read-only list: shortcuts can't be changed here.": "Danh sách chỉ đọc: không thể thay đổi phím tắt ở đây.",
|
||||
"Reattach selected face": "Gắn lại bề mặt đã chọn",
|
||||
"Recover": "Khôi phục",
|
||||
"Recover unsaved design?": "Khôi phục thiết kế chưa lưu?",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "Di chuyển mục trong cây đối tượng",
|
||||
"cmd.browser_order": "Sắp xếp các mục trong cây đối tượng",
|
||||
"cmd.browser_rename_group": "Đổi tên nhóm",
|
||||
"cmd.browser_reparent": "Chuyển sang Cụm chi tiết",
|
||||
"cmd.browser_ungroup": "Hủy nhóm",
|
||||
"cmd.browser_visibility": "Hiển thị/Ẩn",
|
||||
"cmd.canvas_calibrate": "Hiệu chuẩn hình ảnh Canvas",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "Thuộc tính chi tiết",
|
||||
"cmd.component_metadata_set": "Thay đổi thuộc tính chi tiết",
|
||||
"cmd.component_move_bodies": "Di chuyển các thân khối vào cụm chi tiết",
|
||||
"cmd.component_move_features": "Di chuyển các tính năng vào cụm chi tiết",
|
||||
"cmd.component_move_sketches": "Di chuyển các phác thảo vào cụm chi tiết",
|
||||
"cmd.component_paste_from_design": "Dán từ thiết kế khác",
|
||||
"cmd.component_paste_new": "Dán Tạo mới",
|
||||
"cmd.component_properties": "Thuộc tính cơ lý của cụm chi tiết",
|
||||
"cmd.component_relink": "Liên kết lại nguồn",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "Bắt đầu kết xuất hình ảnh",
|
||||
"cmd.render_status": "Tiến độ kết xuất hình ảnh",
|
||||
"cmd.select_clear": "Hủy chọn tất cả",
|
||||
"cmd.settings_profile_apply": "Áp dụng hồ sơ cài đặt",
|
||||
"cmd.settings_profile_delete": "Xóa hồ sơ cài đặt",
|
||||
"cmd.settings_profile_export": "Xuất hồ sơ cài đặt",
|
||||
"cmd.settings_profile_import": "Nhập hồ sơ cài đặt",
|
||||
"cmd.settings_profile_list": "Hồ sơ cài đặt",
|
||||
"cmd.settings_profile_rename": "Đổi tên hồ sơ cài đặt",
|
||||
"cmd.settings_profile_save": "Lưu hồ sơ cài đặt",
|
||||
"cmd.sheet_convert": "Chuyển thành kim loại tấm",
|
||||
"cmd.sheet_export_bend_table": "Xuất bảng thông số chấn uốn (Bend Table)",
|
||||
"cmd.sheet_export_dxf": "Xuất bản trải phẳng dạng DXF",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "Kích thước bản vẽ",
|
||||
"cmd.sketch_edit": "Chỉnh sửa phác thảo",
|
||||
"cmd.sketch_edit_text": "Chỉnh sửa văn bản",
|
||||
"cmd.sketch_equation_curve": "Đường cong phương trình",
|
||||
"cmd.sketch_equation_curve_edit": "Sửa đường cong phương trình",
|
||||
"cmd.sketch_export_dxf": "Lưu dưới dạng DXF",
|
||||
"cmd.sketch_finish_auto_constrain": "Hoàn tất với tự động ràng buộc",
|
||||
"cmd.sketch_fit_curves_to_section": "Khớp đường cong vào mặt cắt lưới",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "Trình duyệt kết xuất",
|
||||
"cmd.view.render_dock": "Di chuyển neo kết xuất",
|
||||
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
|
||||
"cmd.view.status_items": "Các mục trạng thái khung nhìn",
|
||||
"cmd.view_ambient_occlusion": "Đổ bóng che khuất môi trường (AO)",
|
||||
"cmd.view_angle_readout": "Góc hướng nhìn",
|
||||
"cmd.view_apply": "Áp dụng góc nhìn đã lưu",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "Chỉ di chuyển chi tiết lắp ráp theo các bậc tự do mà khớp nối cho phép. Chọn một mặt của chi tiết, rồi kéo mũi tên hoặc cung tròn có thể sử dụng.",
|
||||
"help.parameters.change": "Mở bảng các giá trị có tên và biểu thức điều khiển kích thước của thiết kế. Dùng để sửa một kích thước ở một chỗ và mọi tính năng dùng nó sẽ cập nhật theo.",
|
||||
"help.pointcloud.insert": "Chèn bản quét PLY, XYZ hoặc PTS dưới dạng đám mây điểm bên cạnh mô hình. Dùng làm tham chiếu khi dựng mô hình từ dữ liệu đo.",
|
||||
"help.sketch.equation_curve": "Vẽ một đường cong từ công thức trên một khoảng: x(t) và y(t), hoặc y = f(x). Công thức có thể dùng tham số và đường cong thay đổi theo: dùng cho sóng sin, đường xoắn ốc hay đường thân khai của răng bánh răng.",
|
||||
"help.solid.align": "Di chuyển các khối đã chọn để một điểm, mặt hoặc mặt phẳng trên chúng gặp một điểm, mặt hoặc mặt phẳng ở nơi khác. Dùng để đặt chi tiết áp vào chi tiết khác mà không cần đo khoảng cách.",
|
||||
"help.solid.chamfer": "Vát các cạnh đã chọn theo khoảng cách bạn thiết lập. Dùng để triệt tiêu cạnh sắc hoặc giúp lắp chốt, bu lông dễ dàng hơn.",
|
||||
"help.solid.combine": "Hợp nhất các khối, cắt trừ khỏi khối đích, hoặc chỉ giữ lại phần thể tích giao nhau, đồng thời có thể giữ lại các khối công cụ. Dùng để gộp chi tiết, dùng hình này cắt hình kia hoặc tìm vị trí hai khối giao nhau.",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "Độ dày",
|
||||
"parts.dimension.width": "Bề rộng",
|
||||
"parts.family.bearing": "Ổ bi cầu rãnh sâu (bao ngoài)",
|
||||
"parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm",
|
||||
"parts.family.dowel_pin": "Chốt trụ",
|
||||
"parts.family.hex_bolt": "Bu lông đầu lục giác",
|
||||
"parts.family.hex_nut": "Đai ốc lục giác",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "Chế độ Gọn (Compact) gắn các bảng vào mép cửa sổ. Độ trong suốt của Cây thư mục chỉ khả dụng ở chế độ Nổi (Floating). Thay đổi vị trí của Trợ lý trong trang Trợ lý.",
|
||||
"prefs.left": "Bên trái",
|
||||
"prefs.left_click": "Nhấp chuột trái",
|
||||
"prefs.line_drag_draws_one": "Nhấn và kéo vẽ một đường",
|
||||
"prefs.line_drag_draws_one_note": "Trong công cụ Đường, nhấn, kéo rồi thả sẽ vẽ một đường từ điểm nhấn đến điểm thả, và kết thúc ở đó. Nhấp vẫn vẽ một chuỗi. Tắt: kéo đặt một điểm tại nơi thả, như một lần nhấp.",
|
||||
"prefs.linked_designs": "Thiết kế liên kết",
|
||||
"prefs.live_input": "Nhập liệu trực tiếp",
|
||||
"prefs.live_preview": "Xem trước trực tiếp",
|
||||
@@ -1925,25 +1906,9 @@
|
||||
"prefs.origin_plane_names": "Tên các mặt phẳng gốc",
|
||||
"prefs.origin_plane_names_note": "Tên hiển thị trong trình duyệt, hộp thoại và danh sách. {top} là XY, {front} là XZ và {right} là YZ, với Z hướng lên. Lệnh, script và thiết kế đã lưu vẫn dùng XY, XZ và YZ.",
|
||||
"prefs.overall_speed": "Tốc độ tổng thể",
|
||||
"prefs.perspective_fov": "Trường nhìn",
|
||||
"prefs.placement": "Vị trí đặt",
|
||||
"prefs.prefer_free_sketch_points": "Ưu tiên điểm phác thảo có thể di chuyển khi các điểm chồng lên nhau",
|
||||
"prefs.preview_choices": "Xem trước các lựa chọn của bạn",
|
||||
"prefs.profile_active": "Hồ sơ đang dùng",
|
||||
"prefs.profile_applied": "Đã áp dụng hồ sơ.",
|
||||
"prefs.profile_changed": "Cài đặt đã thay đổi kể từ khi lưu hồ sơ. “Lưu” sẽ ghi chúng vào hồ sơ.",
|
||||
"prefs.profile_deleted": "Đã xóa hồ sơ.",
|
||||
"prefs.profile_export": "Xuất tệp…",
|
||||
"prefs.profile_exported": "Đã xuất hồ sơ.",
|
||||
"prefs.profile_imported": "Đã nhập hồ sơ. Chọn hồ sơ trong “Hồ sơ đang dùng” để chuyển sang.",
|
||||
"prefs.profile_name_hint": "Tên hồ sơ, ví dụ Laptop",
|
||||
"prefs.profile_rename": "Đổi tên",
|
||||
"prefs.profile_renamed": "Đã đổi tên hồ sơ.",
|
||||
"prefs.profile_save": "Lưu",
|
||||
"prefs.profile_saved": "Đã lưu cài đặt hiện tại vào hồ sơ.",
|
||||
"prefs.profile_skipped": "Đã áp dụng hồ sơ; {n} cài đặt giữ nguyên giá trị hiện tại (xem nhật ký).",
|
||||
"prefs.profiles": "Hồ sơ cài đặt",
|
||||
"prefs.profiles_note": "Phím tắt, thanh công cụ, điều hướng, menu dạng vòng và cài đặt giao diện, được lưu cùng nhau dưới một tên và chuyển đổi cùng lúc.",
|
||||
"prefs.project_options_dialog": "Hộp thoại tùy chọn Project",
|
||||
"prefs.project_options_dialog_note": "Bật: chọn Hình học hoặc Thân và Liên kết chiếu trước khi chiếu. Tắt: giữ quy trình chọn trực tiếp trước đây.",
|
||||
"prefs.quality_default": "mặc định",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất",
|
||||
"prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.",
|
||||
"prefs.reports_saved": "Đã lưu {n}",
|
||||
"prefs.ribbon_reveal": "Hiện kết quả tìm kiếm trên ribbon",
|
||||
"prefs.ribbon_reveal_note": "Khi rê chuột lên một kết quả trong Tìm lệnh, ribbon cho biết lệnh nằm ở đâu: tab, nút hoặc dòng của nó trong menu thả xuống của một bảng. Lệnh bắt đầu từ tìm kiếm sẽ nhấp nháy ở đó, và lệnh đang chạy từ menu thả xuống sẽ đánh dấu bảng của nó.",
|
||||
"prefs.right_click": "Nhấp chuột phải",
|
||||
"prefs.saving": "Tự động lưu và khôi phục",
|
||||
"prefs.scale_note": "Cũng có thể dùng Ctrl + / Ctrl − trong cửa sổ. Các nút có kích thước 28 px ở mức 100%.",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
|
||||
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
|
||||
"prefs.shortcuts": "Phím tắt",
|
||||
"prefs.single_surface_analysis": "Chỉ dùng một phân tích bề mặt",
|
||||
"prefs.single_surface_analysis_help": "Khi tắt, phân tích tô bóng bề mặt và phân tích tính liên tục có thể cùng hoạt động. Khi bật, chỉ giữ phân tích được bắt đầu hoặc thay đổi gần nhất.",
|
||||
"prefs.sketch_align_points": "Căn theo các điểm phác thảo khác khi vẽ",
|
||||
"prefs.sketch_align_points_note": "Khi vẽ, một điểm được căn ngang theo một điểm phác thảo và căn dọc theo điểm khác cùng lúc, kèm đường dẫn chấm; cú nhấp giữ cả hai ràng buộc căn. Tắt: chỉ căn theo điểm trước, mỗi lần một trục.",
|
||||
"prefs.sketch_conflict_colour": "Hình học xung đột theo màu xung đột",
|
||||
"prefs.sketch_conflict_colour_note": "Khi các ràng buộc của bản phác xung đột, các đường cong và điểm mà một ràng buộc lỗi giữ cũng được vẽ bằng màu xung đột, không chỉ ký hiệu hay kích thước của nó. Tắt: chúng giữ màu thường.",
|
||||
"prefs.sketch_exit_view": "Khi kết thúc phác thảo",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "Con lăn chuột",
|
||||
"prefs.wheel_trackpad": "Con lăn chuột và bàn di cảm ứng",
|
||||
"prefs.workspace": "Không gian làm việc",
|
||||
"prefs.zoom_stop_at_model": "Dừng thu phóng tại mô hình",
|
||||
"prefs.zoom_stop_at_model_tip": "Chuột 3D và ứng dụng thiết bị thu phóng về phía bề mặt ở giữa khung nhìn; thu phóng bằng cách kéo giữ nguyên điểm bắt đầu kéo. Khi tắt, có thể thu phóng xuyên qua mô hình. Con lăn và cử chỉ chụm dùng riêng tùy chọn Thu phóng về con trỏ",
|
||||
"prefs.zoom_toward_cursor": "Thu phóng về phía con trỏ chuột",
|
||||
"properties.title": "Thuộc tính: {name}",
|
||||
"recovery.days_ago": "{count} ngày trước",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "Nhấp đúp vào một thành phần để kích hoạt nó, hoặc chỉnh sửa tại chỗ một thành phần liên kết",
|
||||
"settings.frame_rate_details_note": "Bổ sung biểu đồ thời gian các khung hình gần nhất xếp chồng số tam giác đã vẽ, biểu đồ phân bố thời gian khung hình và nội dung GPU đã vẽ. Bấm vào tốc độ khung hình trên khung nhìn để ẩn/hiện.",
|
||||
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn.",
|
||||
"settings.insert_lists_open_designs": "Chèn thiết kế vào cụm liệt kê trước các thiết kế đang mở ở các thẻ khác",
|
||||
"settings.measure_axis_distances": "Hiển thị khoảng cách theo trục X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Hiển thị kích thước thành phần theo các trục tọa độ cùng với khoảng cách tổng thể khi mở công cụ Đo lường.",
|
||||
"settings.msaa_applies_on_restart": "Khử răng cưa sẽ áp dụng vào lần khởi động tiếp theo của SolidCraft",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "Bỏ ghim",
|
||||
"sketch is missing": "thiếu phác thảo",
|
||||
"sketch.degrees_of_freedom": "{count} bậc tự do",
|
||||
"sketch.dimension_added_driven": "Bản phác thảo đã xác định kích thước đó, nên nó được thêm vào dưới dạng kích thước tham chiếu",
|
||||
"sketch.dimension_applied": "Đã áp dụng kích thước",
|
||||
"sketch.dimension_applied_defined": "Đã áp dụng kích thước; đã định nghĩa tham số `{name}`",
|
||||
"sketch.dimension_applied_renamed": "Đã áp dụng kích thước; tham số được đặt tên là `{name}`",
|
||||
"sketch.dof_left": "Còn {count} bậc tự do",
|
||||
"sketch.dof_left_one": "Còn {count} bậc tự do",
|
||||
"sketch.draw_curves": "Vẽ dưới dạng {linetype}",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "Giữ lại các tính năng và thư mục lồng nhau trong lịch sử",
|
||||
"timeline.delete_folders_with_features_tip": "Xóa các thư mục cùng mọi tính năng trong đó và các thư mục lồng bên trong",
|
||||
"timeline.delete_many": "Xóa {count} tính năng đã chọn",
|
||||
"timeline.edited_rolled_back": "{name} đã thay đổi, nhưng nó nằm sau dấu mốc lịch sử nên chưa được dựng: kéo dấu mốc qua nó để xem kết quả",
|
||||
"timeline.feature_error": "Lỗi: {message}",
|
||||
"timeline.feature_hint": "Bấm đúp để sửa · kéo rê để đổi thứ tự · bấm chuột phải để xem thêm",
|
||||
"timeline.feature_rolled_back": "Đã lùi lại: nằm sau dấu mốc lịch sử nên chưa được dựng",
|
||||
"timeline.feature_warning": "Cảnh báo: {message}",
|
||||
"timeline.group": "Thư mục tính năng mới",
|
||||
"timeline.move_many": "Di chuyển các mục trong lịch sử thiết kế",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "Hiệu chuẩn chuột 3D sẽ sửa các trục bị hoán đổi hoặc đảo ngược và chỉnh tốc độ cũng như cảm giác.",
|
||||
"ui.3d_mouse_offer_never": "Không hiện lại",
|
||||
"ui.3d_mouse_offer_swap": "Chuyển sang Hidraw (điều khiển trực tiếp)",
|
||||
"ui.3d_mouse_orbit": "Quỹ đạo",
|
||||
"ui.3d_mouse_orbit_classic": "Cổ điển",
|
||||
"ui.3d_mouse_orbit_tip": "Theo dấu: chuột 3D xoay khung nhìn quanh phần mô hình ở giữa khung nhìn lúc bắt đầu xoay, được đánh dấu bằng một quả cầu mờ. Cổ điển: quanh tâm khung nhìn",
|
||||
"ui.3d_mouse_orbit_traced": "Theo dấu",
|
||||
"ui.3d_mouse_reading": "Giá trị đọc",
|
||||
"ui.3d_mouse_reset_axes": "Đặt lại phép gán trục",
|
||||
"ui.3d_mouse_reset_view": "Đặt lại khung nhìn",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "Tỷ lệ đầu cuối",
|
||||
"ui.entities": "Các thực thể",
|
||||
"ui.equal": "Bằng nhau",
|
||||
"ui.equation_end": "Kết thúc",
|
||||
"ui.equation_explicit": "Tường minh",
|
||||
"ui.equation_form": "Dạng",
|
||||
"ui.equation_note": "Biến là một số không có đơn vị: sin(t) hiểu nó theo radian. Kết quả không có đơn vị dùng đơn vị của thiết kế.",
|
||||
"ui.equation_parametric": "Tham số",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "Bán kính tương đương",
|
||||
"ui.expand": "Mở rộng",
|
||||
"ui.exportDxf": "Xuất tệp DXF…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "THIẾT KẾ",
|
||||
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
|
||||
"ui.heading_edit": "CHỈNH SỬA",
|
||||
"ui.heading_equation_curve": "ĐƯỜNG CONG PHƯƠNG TRÌNH",
|
||||
"ui.heading_extrude": "ĐÙN KHỐI",
|
||||
"ui.heading_fillet": "BO TRÒN",
|
||||
"ui.heading_finish_editing": "HOÀN TẤT CHỈNH SỬA",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "Nhập tệp…",
|
||||
"ui.infinite": "Vô hạn",
|
||||
"ui.insert": "Chèn",
|
||||
"ui.insert_design.browse": "Duyệt…",
|
||||
"ui.insert_design.open_designs": "Thiết kế đang mở",
|
||||
"ui.insert_design.saved_file": "{name} có thay đổi chưa lưu, nên tệp đã lưu của nó đã được chèn. Hãy lưu nó, rồi dùng Cập nhật liên kết để đưa các thay đổi vào.",
|
||||
"ui.insert_design.unsaved": "Thay đổi chưa lưu",
|
||||
"ui.inspect": "Kiểm tra",
|
||||
"ui.intersect": "Giao nhau",
|
||||
"ui.isolate": "Cô lập đối tượng",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "Chọn",
|
||||
"ui.selectOther": "Chọn đối tượng khác…",
|
||||
"ui.selectTangentChain": "Chọn chuỗi tiếp tuyến",
|
||||
"ui.select_all": "Chọn tất cả",
|
||||
"ui.selection": "Vùng chọn",
|
||||
"ui.selection_modifiers": "Phím bổ trợ chọn",
|
||||
"ui.selection_shift_add_only": "Shift+nhấp chỉ thêm, không bỏ chọn",
|
||||
|
||||
@@ -4062,7 +4062,6 @@
|
||||
"ui.select": "選擇",
|
||||
"ui.selectOther": "選擇其他…",
|
||||
"ui.selectTangentChain": "選擇相切鏈",
|
||||
"ui.select_all": "全選",
|
||||
"ui.selection": "選擇",
|
||||
"ui.selection_modifiers": "選擇修飾鍵",
|
||||
"ui.selection_shift_add_only": "Shift+點擊僅新增、不取消選擇",
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"Edges": "边",
|
||||
"Edit": "编辑",
|
||||
"Edit Profile Sketch": "编辑轮廓草图",
|
||||
"Edit shortcuts": "编辑快捷键",
|
||||
"Enable rule": "启用规则",
|
||||
"Enabled": "已启用",
|
||||
"End Radius": "结束半径",
|
||||
@@ -469,7 +468,6 @@
|
||||
"Radius": "半径",
|
||||
"Radius Fit": "半径拟合",
|
||||
"Ratio": "比率",
|
||||
"Read-only list: shortcuts can't be changed here.": "只读列表:此处无法更改快捷键。",
|
||||
"Reattach selected face": "重新附着所选面",
|
||||
"Recover": "恢复",
|
||||
"Recover unsaved design?": "恢复未保存的设计?",
|
||||
@@ -824,7 +822,6 @@
|
||||
"cmd.browser_move": "移动浏览器项目",
|
||||
"cmd.browser_order": "排列浏览器项目",
|
||||
"cmd.browser_rename_group": "重命名组",
|
||||
"cmd.browser_reparent": "移动到组件",
|
||||
"cmd.browser_ungroup": "取消编组",
|
||||
"cmd.browser_visibility": "显示/隐藏",
|
||||
"cmd.canvas_calibrate": "校准画布",
|
||||
@@ -851,9 +848,7 @@
|
||||
"cmd.component_metadata": "零件属性",
|
||||
"cmd.component_metadata_set": "更改零件属性",
|
||||
"cmd.component_move_bodies": "将实体移至组件",
|
||||
"cmd.component_move_features": "将特征移至组件",
|
||||
"cmd.component_move_sketches": "将草图移至组件",
|
||||
"cmd.component_paste_from_design": "从其他设计粘贴",
|
||||
"cmd.component_paste_new": "粘贴为新对象",
|
||||
"cmd.component_properties": "组件物理属性",
|
||||
"cmd.component_relink": "重新链接源文件",
|
||||
@@ -1081,13 +1076,6 @@
|
||||
"cmd.render_start": "渲染图像",
|
||||
"cmd.render_status": "渲染进度",
|
||||
"cmd.select_clear": "清除选择",
|
||||
"cmd.settings_profile_apply": "应用设置配置文件",
|
||||
"cmd.settings_profile_delete": "删除设置配置文件",
|
||||
"cmd.settings_profile_export": "导出设置配置文件",
|
||||
"cmd.settings_profile_import": "导入设置配置文件",
|
||||
"cmd.settings_profile_list": "设置配置文件",
|
||||
"cmd.settings_profile_rename": "重命名设置配置文件",
|
||||
"cmd.settings_profile_save": "保存设置配置文件",
|
||||
"cmd.sheet_convert": "转换为钣金",
|
||||
"cmd.sheet_export_bend_table": "导出折弯表",
|
||||
"cmd.sheet_export_dxf": "将展开图导出为 DXF",
|
||||
@@ -1133,8 +1121,6 @@
|
||||
"cmd.sketch_drawing_bounds": "图纸尺寸",
|
||||
"cmd.sketch_edit": "编辑草图",
|
||||
"cmd.sketch_edit_text": "编辑文字",
|
||||
"cmd.sketch_equation_curve": "方程曲线",
|
||||
"cmd.sketch_equation_curve_edit": "编辑方程曲线",
|
||||
"cmd.sketch_export_dxf": "另存为 DXF",
|
||||
"cmd.sketch_finish_auto_constrain": "使用自动约束完成",
|
||||
"cmd.sketch_fit_curves_to_section": "将曲线拟合到网格截面",
|
||||
@@ -1231,7 +1217,6 @@
|
||||
"cmd.view.render_browser": "渲染浏览器",
|
||||
"cmd.view.render_dock": "移动渲染停靠面板",
|
||||
"cmd.view.render_focus": "渲染专注视图",
|
||||
"cmd.view.status_items": "视口状态项",
|
||||
"cmd.view_ambient_occlusion": "环境光遮蔽",
|
||||
"cmd.view_angle_readout": "视图角度",
|
||||
"cmd.view_apply": "还原命名视图",
|
||||
@@ -1389,7 +1374,6 @@
|
||||
"help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。",
|
||||
"help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。",
|
||||
"help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。",
|
||||
"help.sketch.equation_curve": "在一个范围内按公式绘制曲线:x(t) 和 y(t),或 y = f(x)。公式可以使用参数,曲线随参数变化。适用于正弦波、螺旋线或齿轮的渐开线。",
|
||||
"help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。",
|
||||
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
|
||||
"help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。",
|
||||
@@ -1759,7 +1743,6 @@
|
||||
"parts.dimension.thickness": "厚度",
|
||||
"parts.dimension.width": "宽度",
|
||||
"parts.family.bearing": "深沟球轴承(外形)",
|
||||
"parts.family.button_head_cap_screw": "圆头内六角螺钉",
|
||||
"parts.family.dowel_pin": "圆柱销",
|
||||
"parts.family.hex_bolt": "六角头螺栓",
|
||||
"parts.family.hex_nut": "六角螺母",
|
||||
@@ -1891,8 +1874,6 @@
|
||||
"prefs.layout_note": "紧凑模式将面板停靠在窗口边缘。浏览器半透明仅适用于浮动模式。助手面板的位置在助手页面中设置。",
|
||||
"prefs.left": "左侧",
|
||||
"prefs.left_click": "左键单击",
|
||||
"prefs.line_drag_draws_one": "按下并拖动绘制一条直线",
|
||||
"prefs.line_drag_draws_one_note": "在直线工具中,按下、拖动再松开,会从按下处到松开处绘制一条直线并在此结束。单击仍会绘制连续的直线。关闭时:拖动与单击一样,在松开处放置一个点。",
|
||||
"prefs.linked_designs": "链接设计",
|
||||
"prefs.live_input": "实时输入",
|
||||
"prefs.live_preview": "实时预览",
|
||||
@@ -1925,25 +1906,9 @@
|
||||
"prefs.origin_plane_names": "原点平面名称",
|
||||
"prefs.origin_plane_names_note": "浏览器、对话框和列表中显示的名称。{top} 即 XY,{front} 即 XZ,{right} 即 YZ(Z 轴朝上)。命令、脚本和已保存的设计仍使用 XY、XZ 和 YZ。",
|
||||
"prefs.overall_speed": "整体速度",
|
||||
"prefs.perspective_fov": "视场角",
|
||||
"prefs.placement": "放置",
|
||||
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
|
||||
"prefs.preview_choices": "所选设置预览",
|
||||
"prefs.profile_active": "当前配置文件",
|
||||
"prefs.profile_applied": "已应用配置文件。",
|
||||
"prefs.profile_changed": "保存配置文件后设置已更改。“保存”会将其写入配置文件。",
|
||||
"prefs.profile_deleted": "已删除配置文件。",
|
||||
"prefs.profile_export": "导出…",
|
||||
"prefs.profile_exported": "已导出配置文件。",
|
||||
"prefs.profile_imported": "已导入配置文件。在“当前配置文件”中选择它即可切换。",
|
||||
"prefs.profile_name_hint": "配置文件名称,例如 笔记本",
|
||||
"prefs.profile_rename": "重命名",
|
||||
"prefs.profile_renamed": "已重命名配置文件。",
|
||||
"prefs.profile_save": "保存",
|
||||
"prefs.profile_saved": "已将当前设置保存到配置文件。",
|
||||
"prefs.profile_skipped": "已应用配置文件;{n} 项设置保留了当前值(见日志)。",
|
||||
"prefs.profiles": "设置配置文件",
|
||||
"prefs.profiles_note": "将快捷键、工具栏、导航、径向菜单和界面设置一起以名称保存,并一次切换。",
|
||||
"prefs.project_options_dialog": "Project 选项对话框",
|
||||
"prefs.project_options_dialog_note": "开启:投影前选择“几何体”或“实体”以及“投影链接”。关闭:保留原有的直接选择工作流。",
|
||||
"prefs.quality_default": "默认",
|
||||
@@ -1953,8 +1918,6 @@
|
||||
"prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算",
|
||||
"prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。",
|
||||
"prefs.reports_saved": "已保存 {n} 份",
|
||||
"prefs.ribbon_reveal": "在功能区中显示搜索结果",
|
||||
"prefs.ribbon_reveal_note": "将指针悬停在“搜索命令”的结果上时,会显示该命令在功能区中的位置:其选项卡、按钮或面板下拉菜单中的行。从搜索启动的命令会在那里闪烁,从下拉菜单运行的命令会标记其面板。",
|
||||
"prefs.right_click": "右键单击",
|
||||
"prefs.saving": "自动保存和恢复",
|
||||
"prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。",
|
||||
@@ -1968,10 +1931,6 @@
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
"prefs.shortcuts": "快捷键",
|
||||
"prefs.single_surface_analysis": "仅使用一个曲面分析",
|
||||
"prefs.single_surface_analysis_help": "关闭时,曲面着色分析和连续性分析可以同时保持启用。开启时,仅保留最后启动或修改的分析。",
|
||||
"prefs.sketch_align_points": "绘制时与其他草图点对齐",
|
||||
"prefs.sketch_align_points_note": "绘制时,点可同时与一个草图点水平对齐、与另一个草图点竖直对齐,并显示虚线参考线;单击后保留两个对齐约束。关闭:仅与上一点对齐,每次一个方向。",
|
||||
"prefs.sketch_conflict_colour": "以冲突颜色显示冲突的几何图形",
|
||||
"prefs.sketch_conflict_colour_note": "草图约束冲突时,不仅失败约束的符号或尺寸,该约束所约束的曲线和点也以冲突颜色绘制。关闭:它们保持平常的颜色。",
|
||||
"prefs.sketch_exit_view": "草图结束时的视图",
|
||||
@@ -2046,8 +2005,6 @@
|
||||
"prefs.wheel": "滚轮",
|
||||
"prefs.wheel_trackpad": "滚轮与触控板",
|
||||
"prefs.workspace": "工作区",
|
||||
"prefs.zoom_stop_at_model": "缩放到模型表面时停止",
|
||||
"prefs.zoom_stop_at_model_tip": "3D 鼠标和设备应用向视图中央的表面缩放;拖动缩放固定拖动开始时的点。关闭后可穿过模型。滚轮和捏合手势单独使用向光标缩放设置",
|
||||
"prefs.zoom_toward_cursor": "朝光标缩放",
|
||||
"properties.title": "属性:{name}",
|
||||
"recovery.days_ago": "{count} 天前",
|
||||
@@ -3398,7 +3355,6 @@
|
||||
"settings.double_click_components": "双击组件以激活它,或在原位编辑链接组件",
|
||||
"settings.frame_rate_details_note": "添加最近各帧耗时的图表(叠加绘制的三角形数)、帧耗时直方图以及 GPU 绘制的内容。点击视图中的帧率可显示或隐藏。",
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
|
||||
"settings.insert_lists_open_designs": "插入设计时先列出其他标签页中打开的设计",
|
||||
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
|
||||
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
|
||||
@@ -3423,10 +3379,6 @@
|
||||
"shortcut.unpin": "取消固定",
|
||||
"sketch is missing": "草图不存在",
|
||||
"sketch.degrees_of_freedom": "{count} 个自由度",
|
||||
"sketch.dimension_added_driven": "草图已确定该尺寸,因此已将其添加为被驱动(参考)尺寸",
|
||||
"sketch.dimension_applied": "已应用尺寸",
|
||||
"sketch.dimension_applied_defined": "已应用尺寸;已定义参数 `{name}`",
|
||||
"sketch.dimension_applied_renamed": "已应用尺寸;参数已命名为 `{name}`",
|
||||
"sketch.dof_left": "剩余 {count} 个自由度",
|
||||
"sketch.dof_left_one": "剩余 {count} 个自由度",
|
||||
"sketch.draw_curves": "绘制为 {linetype}",
|
||||
@@ -3496,10 +3448,8 @@
|
||||
"timeline.delete_folders_tip": "在历史记录中保留其中的特征和嵌套文件夹",
|
||||
"timeline.delete_folders_with_features_tip": "删除文件夹及其中的所有特征和嵌套文件夹",
|
||||
"timeline.delete_many": "删除所选的 {count} 个特征",
|
||||
"timeline.edited_rolled_back": "{name} 已更改,但它位于历史标记之后,因此未构建:将标记移到其后即可查看结果",
|
||||
"timeline.feature_error": "错误:{message}",
|
||||
"timeline.feature_hint": "双击编辑 · 拖动以重新排序 · 右键查看更多操作",
|
||||
"timeline.feature_rolled_back": "已回退:位于历史标记之后,因此未构建",
|
||||
"timeline.feature_warning": "警告:{message}",
|
||||
"timeline.group": "新建特征文件夹",
|
||||
"timeline.move_many": "移动时间线项目",
|
||||
@@ -3581,10 +3531,6 @@
|
||||
"ui.3d_mouse_offer": "校准 3D 鼠标可以修正互换或反向的轴,并调整它的速度和手感。",
|
||||
"ui.3d_mouse_offer_never": "不再显示",
|
||||
"ui.3d_mouse_offer_swap": "切换到 Hidraw(直接驱动)",
|
||||
"ui.3d_mouse_orbit": "环绕",
|
||||
"ui.3d_mouse_orbit_classic": "经典",
|
||||
"ui.3d_mouse_orbit_tip": "追踪:3D 鼠标围绕开始旋转时视图中间的模型部分旋转视图,并用一个淡色小球标出该点。经典:围绕视图中心旋转",
|
||||
"ui.3d_mouse_orbit_traced": "追踪",
|
||||
"ui.3d_mouse_reading": "读数",
|
||||
"ui.3d_mouse_reset_axes": "重置轴分配",
|
||||
"ui.3d_mouse_reset_view": "重置视图",
|
||||
@@ -4062,14 +4008,6 @@
|
||||
"ui.end_scale": "末端缩放",
|
||||
"ui.entities": "实体",
|
||||
"ui.equal": "相等",
|
||||
"ui.equation_end": "结束",
|
||||
"ui.equation_explicit": "显式",
|
||||
"ui.equation_form": "形式",
|
||||
"ui.equation_note": "变量是不带单位的数:sin(t) 按弧度计算。不带单位的结果采用设计的单位。",
|
||||
"ui.equation_parametric": "参数式",
|
||||
"ui.equation_x_of_t": "x(t)",
|
||||
"ui.equation_y_of_t": "y(t)",
|
||||
"ui.equation_y_of_x": "y(x)",
|
||||
"ui.equivalent_radius": "等效半径",
|
||||
"ui.expand": "展开",
|
||||
"ui.exportDxf": "导出 DXF…",
|
||||
@@ -4135,7 +4073,6 @@
|
||||
"ui.heading_design": "设计",
|
||||
"ui.heading_draft": "拔模",
|
||||
"ui.heading_edit": "编辑",
|
||||
"ui.heading_equation_curve": "方程曲线",
|
||||
"ui.heading_extrude": "拉伸",
|
||||
"ui.heading_fillet": "圆角",
|
||||
"ui.heading_finish_editing": "完成编辑",
|
||||
@@ -4227,10 +4164,6 @@
|
||||
"ui.import_ellipsis": "导入…",
|
||||
"ui.infinite": "无限",
|
||||
"ui.insert": "插入",
|
||||
"ui.insert_design.browse": "浏览…",
|
||||
"ui.insert_design.open_designs": "已打开的设计",
|
||||
"ui.insert_design.saved_file": "{name} 有未保存的更改,因此插入的是其已保存的文件。请先保存,再使用“更新链接”导入这些更改。",
|
||||
"ui.insert_design.unsaved": "未保存的更改",
|
||||
"ui.inspect": "检查",
|
||||
"ui.intersect": "相交",
|
||||
"ui.isolate": "隔离",
|
||||
@@ -4454,7 +4387,6 @@
|
||||
"ui.select": "选择",
|
||||
"ui.selectOther": "选择其他…",
|
||||
"ui.selectTangentChain": "选择相切链",
|
||||
"ui.select_all": "全选",
|
||||
"ui.selection": "选择",
|
||||
"ui.selection_modifiers": "选择修饰键",
|
||||
"ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| assets/branding/logo/solidcraft-mark.svg | SolidCraft contributors | static SolidCraft mark: the five faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg (the final frame of assets/styles/web/loading-logo.svg), cropped to the mark's bounds; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-black.svg | SolidCraft contributors | one-colour (black) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-mono-white.svg | SolidCraft contributors | one-colour (white) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-512.png | SolidCraft contributors | transparent 512 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-1024.png | SolidCraft contributors | transparent 1024 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-mark-2048.png | SolidCraft contributors | transparent 2048 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-wordmark.svg | SolidCraft contributors | the name SolidCraft set like the loading logo's wordmark (bold, +0.02 em tracking, #15499e) in IBM Plex Sans Bold from assets/fonts/ibm-plex-sans (SIL OFL 1.1, IBM Corp.), outlined to paths; no font file is embedded | MIT OR Apache-2.0 |
|
||||
| assets/branding/logo/solidcraft-lockup-horizontal.svg | SolidCraft contributors | assets/branding/logo/solidcraft-mark.svg beside assets/branding/logo/solidcraft-wordmark.svg, in the proportions of assets/branding/web/source/og.png; original artwork | MIT OR Apache-2.0 |
|
||||
@@ -0,0 +1,6 @@
|
||||
| proposals/render/2026-10-30/original-long-scene/fixture-spec.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
| proposals/render/2026-10-30/original-long-scene/geometry.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
| proposals/render/2026-10-30/original-long-scene/optional-glass-probe.commands.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
| proposals/render/2026-10-30/original-long-scene/original-asymmetric-srgb.hdr | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
| proposals/render/2026-10-30/original-long-scene/raster-comparison.request.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
| proposals/render/2026-10-30/original-long-scene/static-checks.json | SolidCraft contributors | Original synthetic render acceptance fixture and analytical source data for issue #6201; no external CAD geometry, images or measured material dataset | MIT OR Apache-2.0 |
|
||||
@@ -253,12 +253,6 @@ impl crate::Feature {
|
||||
for e in sketch.text_exprs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
// An equation curve's variable is its own: a parameter of that name is not renamed in it.
|
||||
for (variable, e) in sketch.equation_exprs_mut() {
|
||||
let others: BTreeMap<String, String> =
|
||||
parameters.iter().filter(|(old, _)| old.as_str() != variable).map(|(o, n)| (o.clone(), n.clone())).collect();
|
||||
*e = clone_expression(e, &others);
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
|
||||
@@ -3705,7 +3705,7 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
|
||||
/// without a unit takes the unit of its value (a length in the design's units, deg or none).
|
||||
/// without a unit takes the unit of its value (mm, deg or none).
|
||||
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
// A text parameter: one with the text unit, or an existing one when no unit is given.
|
||||
match (unit.map(crate::text_params::is_text_unit), self.text_param(name).is_some()) {
|
||||
@@ -3889,11 +3889,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply parameter values to every dimension in a sketch, to its points placed by parameters
|
||||
/// and to its equation curves (in place).
|
||||
/// Apply parameter values to every dimension in a sketch, and to its points placed by
|
||||
/// parameters (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
self.apply_point_values(vals, sk)?;
|
||||
self.apply_equation_values(vals, sk)?;
|
||||
self.apply_pattern_values(vals, sk)?;
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
|
||||
@@ -2523,40 +2523,13 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
|
||||
|
||||
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
|
||||
// Non-literal lengths (a parameter ± a bare number) must read that bare number in the design's
|
||||
// units during the expression, not only when the whole result is unit-less (#9015).
|
||||
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
|
||||
let defaults = expr::Defaults { len: scale, ..expr::Defaults::default() };
|
||||
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
return expr::eval_with_defaults(e, &look, defaults)?.to_kind_in(k, defaults);
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
|
||||
return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() });
|
||||
}
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod design_unit_val_tests {
|
||||
use super::*;
|
||||
use expr::Value;
|
||||
|
||||
/// #9015: with inches as the design unit, `baseLength - 1` subtracts one inch (not one mm).
|
||||
#[test]
|
||||
fn bare_offsets_beside_a_length_follow_the_design_units() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert(DESIGN_LEN.into(), Value::num(25.4));
|
||||
vals.insert("baseLength".into(), Value::length(76.0 * 25.4));
|
||||
let got = val(&vals, "baseLength - 1", Kind::Length).unwrap();
|
||||
assert!((got - 75.0 * 25.4).abs() < 1e-9, "got {got} mm, want 75 in");
|
||||
let got = val(&vals, "baseLength - 1.5", Kind::Length).unwrap();
|
||||
assert!((got - 74.5 * 25.4).abs() < 1e-9, "got {got} mm, want 74.5 in");
|
||||
// An explicit millimetre still wins.
|
||||
let got = val(&vals, "baseLength - 1 mm", Kind::Length).unwrap();
|
||||
assert!((got - (76.0 * 25.4 - 1.0)).abs() < 1e-9, "got {got}");
|
||||
// A bare literal stays millimetres (feature inputs write the design unit in when typed).
|
||||
let got = val(&vals, "2", Kind::Length).unwrap();
|
||||
assert!((got - 2.0).abs() < 1e-12, "got {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsing the outline and its holes onto one apex cannot make a manifold solid.
|
||||
fn refuse_loft_holes(r: &Region2) -> Result<()> {
|
||||
if r.holes.is_empty() {
|
||||
|
||||
@@ -46,17 +46,6 @@ impl FeatureKind {
|
||||
found
|
||||
}
|
||||
|
||||
/// Bodies the feature works on (by name).
|
||||
pub fn input_bodies(&self) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_refs(&mut |r| {
|
||||
if let Ref::Body(name) = r {
|
||||
v.push(name.as_ref().to_string());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Point references to sketches and features (both by id) at their copies, and references
|
||||
/// that are still keyed by name at a feature's new name (`timeline.rename`).
|
||||
pub fn remap_refs(&mut self, ids: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A sketch saves its equation curves (#8352) in `equations`: for each, the fit point spline it
|
||||
//! places, its formulas (x(t) and y(t), or y = f(x) without `x`), the range of the variable and
|
||||
//! the tolerance its points were chosen for. An older design has none, and its sketches mean
|
||||
//! what they meant. The last release refuses a design that has them (it asks for an update)
|
||||
//! instead of opening the splines as plain fixed splines that no longer follow their formulas.
|
||||
@@ -191,11 +191,7 @@ impl Document {
|
||||
}
|
||||
let mut out = Vec::with_capacity(found.defs.len());
|
||||
for d in found.defs {
|
||||
// A value of no fixed kind (a joint's) is evaluated without the design's units, so
|
||||
// its bare number stays a millimetre length whatever they are (#8865): `w = 5` is
|
||||
// what `5` is there.
|
||||
let unit = d.unit.unwrap_or_else(|| Self::infer_unit_in(&next.param_values().0, &d.expression, "mm"));
|
||||
next.add_user_param(&d.name, &d.expression, Some(&unit), None)
|
||||
next.add_user_param(&d.name, &d.expression, d.unit.as_deref(), None)
|
||||
.map_err(|e| DocError::Invalid(format!("`{} = {}`: {e}", d.name, d.expression)))?;
|
||||
let unit = next.param(&d.name).map(|p| p.unit.clone()).unwrap_or_default();
|
||||
out.push(Defined { name: d.name, expression: d.expression, unit });
|
||||
|
||||
@@ -71,7 +71,6 @@ pub mod render_settings;
|
||||
pub mod render_surface;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
pub mod sketch_equation;
|
||||
pub mod text_params;
|
||||
|
||||
pub use assembly::{
|
||||
|
||||
@@ -268,7 +268,7 @@ impl Document {
|
||||
.filter(|(_, _, k)| *k == Kind::Length)
|
||||
.map(|(_, e, _)| {
|
||||
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
|
||||
expr::eval_with_defaults(&e, &look, d).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
|
||||
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -585,7 +585,7 @@ impl Document {
|
||||
}
|
||||
let unit = match unit {
|
||||
Some(u) => u.trim().to_string(),
|
||||
None => Self::infer_unit_in(vals, expr_s, &self.units),
|
||||
None => Self::infer_unit_in(vals, expr_s),
|
||||
};
|
||||
self.params.push(crate::Param {
|
||||
name: name.into(),
|
||||
@@ -696,19 +696,18 @@ impl Document {
|
||||
}
|
||||
|
||||
/// The unit for a new parameter with this expression: that of its value; a bare number is
|
||||
/// a length in the design's units, as a typed length reads it (#8865).
|
||||
/// a length (mm).
|
||||
pub(crate) fn infer_unit(&self, e: &str) -> String {
|
||||
Self::infer_unit_in(&self.param_values().0, e, &self.units)
|
||||
Self::infer_unit_in(&self.param_values().0, e)
|
||||
}
|
||||
|
||||
/// [`Document::infer_unit`] against parameter values already worked out, a length taking
|
||||
/// `units` (the design's, or `mm` where a bare number reads as millimetres).
|
||||
pub(crate) fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str, units: &str) -> String {
|
||||
/// [`Document::infer_unit`] against parameter values already worked out.
|
||||
fn infer_unit_in(vals: &BTreeMap<String, Value>, e: &str) -> String {
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))));
|
||||
match v.ok().and_then(|v| v.kind()) {
|
||||
Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(),
|
||||
Some(Kind::Angle) => "deg".into(),
|
||||
_ => expr::length_unit_or_mm(units).to_string(),
|
||||
_ => "mm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,18 +896,6 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
// An equation curve's variable hides a parameter of its name: such a parameter is
|
||||
// not what its formulas refer to.
|
||||
for (variable, e) in sketch.equation_exprs_mut() {
|
||||
if variable == old {
|
||||
continue;
|
||||
}
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.text_word_exprs_mut() {
|
||||
let r = crate::text_params::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Equation curves (#8352): the points of a sketch curve worked out from its formulas over the
|
||||
//! design's parameters. The formulas are evaluated at evenly spaced values of the curve's
|
||||
//! variable; the sketch draws a cubic fit spline through those points. How many points: as many
|
||||
//! as it takes for the spline to stay within the curve's tolerance of the formulas, checked
|
||||
//! between the points, up to the most a spline takes. When the parameters change, the same
|
||||
//! number of points is placed again (`Document::apply_equation_values`), before every solve.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, fit_beziers_mode};
|
||||
|
||||
use crate::expr::{self, Defaults, Kind, Value};
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// The tolerance a new equation curve gets (mm).
|
||||
pub const DEFAULT_TOLERANCE: f64 = 0.01;
|
||||
/// The tightest and loosest tolerances (mm).
|
||||
pub const MIN_TOLERANCE: f64 = 1e-4;
|
||||
pub const MAX_TOLERANCE: f64 = 10.0;
|
||||
/// The fewest points a fit starts from.
|
||||
const FIRST_COUNT: usize = 9;
|
||||
/// The widest range of the variable.
|
||||
const MAX_RANGE: f64 = 1e6;
|
||||
/// How many times the point count is raised before the fit gives up.
|
||||
const MAX_ROUNDS: usize = 12;
|
||||
|
||||
/// An equation curve's points and how well the spline through them follows the formulas.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EquationFit {
|
||||
/// The spline's points in order; a closed curve's last point is its first one again.
|
||||
pub points: Vec<Vec2>,
|
||||
/// The curve ends where it starts: the spline closes smoothly there.
|
||||
pub closed: bool,
|
||||
/// The largest distance (mm) found between the spline and the formulas.
|
||||
pub deviation: f64,
|
||||
}
|
||||
|
||||
/// How bare numbers read in the formulas: angles in radians (the variable is a plain number,
|
||||
/// `sin(t)` its sine), lengths in the design's unit.
|
||||
fn defaults(vals: &BTreeMap<String, Value>) -> Defaults {
|
||||
let len = vals.get(crate::eval::DESIGN_LEN).map(|v| v.v).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
|
||||
Defaults { len, ang: 1.0 }
|
||||
}
|
||||
|
||||
fn checked_text<'a>(what: &str, e: &'a str) -> Result<&'a str> {
|
||||
let e = e.trim();
|
||||
if e.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{what} is empty")));
|
||||
}
|
||||
if e.chars().count() > MAX_EQUATION_TEXT {
|
||||
return Err(DocError::Invalid(format!("{what} is longer than {MAX_EQUATION_TEXT} characters")));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// A formula's value (mm) where the variable is `at`. The variable hides a parameter of its name.
|
||||
fn formula(vals: &BTreeMap<String, Value>, e: &str, variable: &str, at: f64, d: Defaults) -> Result<f64> {
|
||||
let lookup = |n: &str| {
|
||||
if n == variable { Ok(Value::num(at)) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) }
|
||||
};
|
||||
let v = expr::eval_with_defaults(e, &lookup, d)?.to_kind_in(Kind::Length, d)?;
|
||||
if v.is_finite() && v.abs() < MAX_COORD { Ok(v) } else { Err(DocError::Expr("the curve goes out of range".into())) }
|
||||
}
|
||||
|
||||
/// Where the variable starts or ends, as the plain number the formulas see: a number, an angle
|
||||
/// (in radians) or a length (in the design's unit).
|
||||
fn range_end(vals: &BTreeMap<String, Value>, e: &str, d: Defaults) -> Result<f64> {
|
||||
let v = expr::eval_with_defaults(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), d)?;
|
||||
let out = match v.kind() {
|
||||
Some(Kind::Unitless | Kind::Angle) => v.v,
|
||||
Some(Kind::Length) => v.v / d.len,
|
||||
None => return Err(DocError::Expr(format!("expected a number, an angle or a length, got {}", v.describe()))),
|
||||
};
|
||||
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("not a finite number".into())) }
|
||||
}
|
||||
|
||||
/// The variable's range, checked.
|
||||
fn range(vals: &BTreeMap<String, Value>, eq: &EquationCurve, d: Defaults) -> Result<(f64, f64)> {
|
||||
let a = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?;
|
||||
let b = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?;
|
||||
if (b - a).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0) {
|
||||
return Err(DocError::Invalid("the start and the end of the range must differ".into()));
|
||||
}
|
||||
if (b - a).abs() > MAX_RANGE {
|
||||
return Err(DocError::Invalid(format!("the range is wider than {MAX_RANGE}")));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
/// The curve's point where the variable is `at`.
|
||||
fn point_at(vals: &BTreeMap<String, Value>, eq: &EquationCurve, at: f64, d: Defaults) -> Result<Vec2> {
|
||||
let variable = eq.variable();
|
||||
let x = match &eq.x {
|
||||
Some(e) => formula(vals, e, variable, at, d).map_err(|e| DocError::Expr(format!("x: {e}")))?,
|
||||
// The explicit form: x is the variable, in the design's unit.
|
||||
None => {
|
||||
let x = at * d.len;
|
||||
if !(x.is_finite() && x.abs() < MAX_COORD) {
|
||||
return Err(DocError::Expr("the curve goes out of range".into()));
|
||||
}
|
||||
x
|
||||
}
|
||||
};
|
||||
let y = formula(vals, &eq.y, variable, at, d).map_err(|e| DocError::Expr(format!("y: {e}")))?;
|
||||
Ok(Vec2::new(x, y))
|
||||
}
|
||||
|
||||
/// The formulas' points at `count` evenly spaced values of the variable, from start to end.
|
||||
fn samples(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), count: usize, d: Defaults) -> Result<Vec<Vec2>> {
|
||||
let last = count.saturating_sub(1).max(1) as f64;
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let at = a + (b - a) * (i as f64 / last);
|
||||
point_at(vals, eq, at, d).map_err(|e| DocError::Expr(format!("at {} = {}: {e}", eq.variable(), short(at))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A number short enough for a message.
|
||||
fn short(v: f64) -> String {
|
||||
let s = format!("{v:.6}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The size of the curve: the larger side of its box (for closing tolerances).
|
||||
fn extent(points: &[Vec2]) -> f64 {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
for p in points {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
(hi.x - lo.x).max(hi.y - lo.y).max(0.0)
|
||||
}
|
||||
|
||||
/// Does a curve through these samples end where it starts?
|
||||
fn closes(points: &[Vec2]) -> bool {
|
||||
match (points.first(), points.last()) {
|
||||
(Some(a), Some(b)) => points.len() >= 4 && a.dist(*b) <= 1e-7 * extent(points).max(1.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse samples a spline cannot pass through: two in a row on the same spot, or all of them.
|
||||
fn check_samples(points: &[Vec2], closed: bool) -> Result<()> {
|
||||
let size = extent(points);
|
||||
if size <= 1e-9 {
|
||||
return Err(DocError::Invalid("the formulas give the same point all along the range".into()));
|
||||
}
|
||||
let spans = if closed { points.len().saturating_sub(1) } else { points.len() };
|
||||
for w in points.windows(2).take(spans) {
|
||||
if let [p, q] = w
|
||||
&& p.dist(*q) <= 1e-9 * size.max(1.0)
|
||||
{
|
||||
return Err(DocError::Invalid("the curve stands still between two of its points: narrow the range or use fewer points".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Distance from `q` to the cubic Bézier `b`, sampled finely.
|
||||
fn bezier_distance(b: &[Vec2; 4], q: Vec2) -> f64 {
|
||||
const STEPS: usize = 32;
|
||||
let at = |t: f64| {
|
||||
let s = 1.0 - t;
|
||||
b[0] * (s * s * s) + b[1] * (3.0 * t * s * s) + b[2] * (3.0 * t * t * s) + b[3] * (t * t * t)
|
||||
};
|
||||
let mut best = f64::INFINITY;
|
||||
let mut prev = at(0.0);
|
||||
for i in 1..=STEPS {
|
||||
let next = at(i as f64 / STEPS as f64);
|
||||
let d = next - prev;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 0.0 { ((q - prev).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(q.dist(prev + d * t));
|
||||
prev = next;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// How far the spline through `points` strays from the formulas: the largest distance from the
|
||||
/// formulas' points a quarter, a half and three quarters of the way between two samples to the
|
||||
/// spline's piece between them.
|
||||
fn deviation(vals: &BTreeMap<String, Value>, eq: &EquationCurve, (a, b): (f64, f64), points: &[Vec2], closed: bool, d: Defaults) -> Result<f64> {
|
||||
let pieces = fit_beziers_mode(points, closed).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
let step = (b - a) / points.len().saturating_sub(1).max(1) as f64;
|
||||
let mut worst: f64 = 0.0;
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
for k in [0.25, 0.5, 0.75] {
|
||||
let q = point_at(vals, eq, a + step * (i as f64 + k), d)?;
|
||||
worst = worst.max(bezier_distance(piece, q));
|
||||
}
|
||||
}
|
||||
Ok(worst)
|
||||
}
|
||||
|
||||
/// Check an equation's text and tolerance before any evaluation.
|
||||
pub fn check(eq: &EquationCurve) -> Result<()> {
|
||||
if let Some(x) = &eq.x {
|
||||
checked_text("x(t)", x)?;
|
||||
}
|
||||
checked_text(if eq.x.is_some() { "y(t)" } else { "y(x)" }, &eq.y)?;
|
||||
checked_text("the start", &eq.start)?;
|
||||
checked_text("the end", &eq.end)?;
|
||||
if !(eq.tolerance.is_finite() && (MIN_TOLERANCE..=MAX_TOLERANCE).contains(&eq.tolerance)) {
|
||||
return Err(DocError::Invalid(format!("the tolerance must be from {MIN_TOLERANCE} mm to {MAX_TOLERANCE} mm")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The points of `count` samples (a closed curve's last one is its first), whether it closes,
|
||||
/// and how far the spline through them strays.
|
||||
pub fn sample(vals: &BTreeMap<String, Value>, eq: &EquationCurve, count: usize, closed: Option<bool>) -> Result<EquationFit> {
|
||||
check(eq)?;
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&count) {
|
||||
return Err(DocError::Invalid(format!("an equation curve takes 2 to {MAX_SPLINE_POINTS} points")));
|
||||
}
|
||||
let d = defaults(vals);
|
||||
let span = range(vals, eq, d)?;
|
||||
let mut points = samples(vals, eq, span, count, d)?;
|
||||
let closed = closed.unwrap_or_else(|| closes(&points)) && count >= 4;
|
||||
if closed
|
||||
&& let Some(first) = points.first().copied()
|
||||
&& let Some(last) = points.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
}
|
||||
check_samples(&points, closed)?;
|
||||
let deviation = deviation(vals, eq, span, &points, closed, d)?;
|
||||
Ok(EquationFit { points, closed, deviation })
|
||||
}
|
||||
|
||||
/// The points of an equation curve, as few as keep the spline within its tolerance of the
|
||||
/// formulas. Refused when even the most points a spline takes are not close enough.
|
||||
pub fn fit(vals: &BTreeMap<String, Value>, eq: &EquationCurve) -> Result<EquationFit> {
|
||||
let mut count = FIRST_COUNT;
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let fit = sample(vals, eq, count, None)?;
|
||||
if fit.deviation <= eq.tolerance {
|
||||
return Ok(fit);
|
||||
}
|
||||
if count >= MAX_SPLINE_POINTS {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"even {MAX_SPLINE_POINTS} points stray {} mm from the formulas, more than the tolerance of {} mm: raise the tolerance or narrow the range",
|
||||
short(fit.deviation),
|
||||
short(eq.tolerance)
|
||||
)));
|
||||
}
|
||||
// A cubic fit's error falls with the fourth power of the spacing: aim a little past
|
||||
// what that asks for, at least half as many points again.
|
||||
let ratio = (fit.deviation / eq.tolerance).powf(0.25) * 1.2;
|
||||
let next = (count as f64 * ratio.max(1.5)).ceil();
|
||||
count = if next.is_finite() && next < MAX_SPLINE_POINTS as f64 { next as usize } else { MAX_SPLINE_POINTS };
|
||||
}
|
||||
Err(DocError::Invalid("the equation curve's points could not be fitted".into()))
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Place the points of the sketch's equation curves where their formulas put them now (in
|
||||
/// place), with the number of points each has. Their points are fixed, so the solve keeps
|
||||
/// them there. A curve that is gone or is no longer a fit point spline is left as it is.
|
||||
pub fn apply_equation_values(&self, vals: &BTreeMap<String, Value>, sk: &mut Sketch) -> Result<()> {
|
||||
for k in 0..sk.equations.len() {
|
||||
let Some(eq) = sk.equations.get(k).cloned() else { continue };
|
||||
let Some(ci) = sk.curve_index(&eq.curve) else { continue };
|
||||
let Some(solidcraft_sketch::CurveKind::Spline { pts, control: false, periodic, .. }) = sk.curves.get(ci).map(|c| &c.kind) else {
|
||||
continue;
|
||||
};
|
||||
let (count, closed) = (pts.len(), *periodic);
|
||||
let fit = sample(vals, &eq, count, Some(closed)).map_err(|e| DocError::Expr(format!("equation curve {}: {e}", eq.curve)))?;
|
||||
sk.place_equation_points(ci, &fit.points)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eq(x: Option<&str>, y: &str, start: &str, end: &str) -> EquationCurve {
|
||||
EquationCurve {
|
||||
curve: String::new(),
|
||||
x: x.map(str::to_string),
|
||||
y: y.into(),
|
||||
start: start.into(),
|
||||
end: end.into(),
|
||||
tolerance: DEFAULT_TOLERANCE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_closes_and_stays_within_the_tolerance() {
|
||||
let vals = BTreeMap::new();
|
||||
let fit = fit(&vals, &eq(Some("10 * cos(t)"), "10 * sin(t)", "0", "2 * pi")).unwrap();
|
||||
assert!(fit.closed);
|
||||
assert!(fit.deviation <= DEFAULT_TOLERANCE, "{}", fit.deviation);
|
||||
for p in &fit.points {
|
||||
assert!((p.len() - 10.0).abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
assert_eq!(fit.points.first(), fit.points.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_hides_a_parameter_and_parameters_drive_the_curve() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert("t".to_string(), Value::length(1000.0));
|
||||
vals.insert("amp".to_string(), Value::length(5.0));
|
||||
let fit = fit(&vals, &eq(None, "amp * sin(x / 10)", "0 mm", "100 mm")).unwrap();
|
||||
assert!(!fit.closed);
|
||||
let (first, last) = (fit.points[0], *fit.points.last().unwrap());
|
||||
assert!((first - Vec2::new(0.0, 0.0)).len() < 1e-9, "{first:?}");
|
||||
assert!((last.x - 100.0).abs() < 1e-9 && (last.y - 5.0 * 10f64.sin()).abs() < 1e-9, "{last:?}");
|
||||
assert!(fit.points.iter().all(|p| p.y.abs() <= 5.0 + 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_in_inches_reads_bare_numbers_in_inches() {
|
||||
let mut vals = BTreeMap::new();
|
||||
vals.insert(crate::eval::DESIGN_LEN.to_string(), Value::num(25.4));
|
||||
let fit = fit(&vals, &eq(Some("t"), "2", "0", "1")).unwrap();
|
||||
let last = *fit.points.last().unwrap();
|
||||
assert!((last - Vec2::new(25.4, 50.8)).len() < 1e-9, "{last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_points_where_the_tolerance_is_tighter() {
|
||||
let vals = BTreeMap::new();
|
||||
let mut e = eq(Some("t * cos(t)"), "t * sin(t)", "0", "6 * pi");
|
||||
e.tolerance = 0.1;
|
||||
let loose = fit(&vals, &e).unwrap();
|
||||
e.tolerance = 0.005;
|
||||
let tight = fit(&vals, &e).unwrap();
|
||||
assert!(tight.points.len() > loose.points.len(), "{} vs {}", tight.points.len(), loose.points.len());
|
||||
assert!(tight.deviation <= 0.005 && loose.deviation <= 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formulas_and_ranges_are_refused() {
|
||||
let vals = BTreeMap::new();
|
||||
for (x, y, a, b, tol) in [
|
||||
(Some("t"), "sqrt(t)", "-1", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "1 / t", "-1", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "0", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "1e300", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "nan", DEFAULT_TOLERANCE),
|
||||
(Some("t * 1 deg"), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("1e300 * t"), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("5"), "5", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some(""), "t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "t", "0", "1", f64::NAN),
|
||||
(Some("t"), "t", "0", "1", 0.0),
|
||||
(Some("t"), "unknown_name * t", "0", "1", DEFAULT_TOLERANCE),
|
||||
(Some("t"), "sin(1e5 * t)", "0", "1000", 1e-4),
|
||||
] {
|
||||
let mut e = eq(x, y, a, b);
|
||||
e.tolerance = tol;
|
||||
assert!(fit(&vals, &e).is_err(), "{x:?} {y} {a}..{b} {tol}");
|
||||
}
|
||||
let long = "t + ".repeat(400) + "t";
|
||||
assert!(fit(&vals, &eq(Some(&long), "t", "0", "1")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,9 @@ pub struct TextParam {
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
/// Is `unit` the text unit, ignoring ASCII case and surrounding whitespace?
|
||||
/// Is `unit` the text unit?
|
||||
pub fn is_text_unit(unit: &str) -> bool {
|
||||
unit.trim().eq_ignore_ascii_case(TEXT_UNIT)
|
||||
unit.trim() == TEXT_UNIT
|
||||
}
|
||||
|
||||
/// A text as typed for a text parameter: inside matching quotes, what they hold (a quote doubled
|
||||
|
||||
@@ -39,7 +39,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Origin planes become explicit planes so a translation can move them.
|
||||
pub(in crate::cmd) fn explicit_planes(p: &mut PlaneRef) {
|
||||
fn explicit_planes(p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Origin { name } => {
|
||||
if let Some(plane) = solidcraft_geom::Plane::named(name) {
|
||||
|
||||
@@ -12,35 +12,26 @@ use solidcraft_geom::Vec3;
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::schema::component::{
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, OccurrenceAlignParams,
|
||||
OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
|
||||
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, OccurrenceAlignParams, OccurrenceAlignResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
|
||||
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
|
||||
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
|
||||
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
|
||||
|
||||
#[path = "component_align.rs"]
|
||||
mod align;
|
||||
#[path = "component_drag.rs"]
|
||||
mod drag;
|
||||
#[path = "component_foreign.rs"]
|
||||
mod foreign;
|
||||
#[path = "component_matrix.rs"]
|
||||
pub(super) mod matrix;
|
||||
#[path = "component_move_features.rs"]
|
||||
mod move_features;
|
||||
#[path = "component_placing.rs"]
|
||||
pub(super) mod placing;
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_reparent.rs"]
|
||||
mod reparent;
|
||||
#[path = "component_reparent_items.rs"]
|
||||
mod reparent_items;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
@@ -112,37 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
by (default: its only one, else the active one; a component placed more than once gets them in every instance). Each \
|
||||
copy stays where its body is in the world; the copies' names come back as `bodies`",
|
||||
),
|
||||
CommandSpec::new("component.paste_from_design", "Paste from Another Design", foreign::paste_from_design).icon("paste").params(
|
||||
"component?: where it goes (default: the active one); component_path?: its placement (default: the active one, else its \
|
||||
only one). Pastes what was copied in another design, at the same world coordinates: its bodies as imported bodies \
|
||||
(no history) in one Import feature, or its components as independent copies with their history, appearances, \
|
||||
joints and motion (as `component.paste_new`); the user parameters they use come along, renamed `w_2` when taken. \
|
||||
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
|
||||
`bodies` and `feature`, or the pasted components' `paths`",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
|
||||
.icon("move")
|
||||
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world)"),
|
||||
CommandSpec::new("component.move_features", "Move Features to Component", crate::checked!("component.move_features", move_features::move_features))
|
||||
.icon("move")
|
||||
.contract(&MOVE_FEATURES_CONTRACT)
|
||||
.output(output_schema_for::<MoveFeaturesResult>)
|
||||
.params(
|
||||
"features: [ids or names] (sketches too); component?: where they go (default: the active one) → moved, component, bodies. \
|
||||
Ids, names and timeline order stay; the geometry stays where it is in the world; the bodies the features made move with \
|
||||
them. Refused while the timeline is rolled back, for a component placed more than once, and for a move that would break \
|
||||
a feature (nothing changes)",
|
||||
),
|
||||
CommandSpec::new("browser.reparent", "Move to Component", crate::checked!("browser.reparent", reparent_items::reparent_items))
|
||||
.icon("move")
|
||||
.contract(&REPARENT_ITEMS_CONTRACT)
|
||||
.output(output_schema_for::<ReparentItemsResult>)
|
||||
.params(
|
||||
"Moves placements, features and bodies into a component. occurrences?: [ids or paths], features?: [ids or names], bodies?: [names], component?: where they go (default: the active \
|
||||
one), component_path?: its placement → component, occurrences, features, bodies (what moved). One undo step, all or \
|
||||
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
|
||||
still elsewhere (a body transfer, which leaves its features behind)",
|
||||
),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).icon("move").params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
|
||||
@@ -83,10 +83,8 @@ fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64],
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
/// A name for a copy of `base` that isn't in `used` (and then is): numbered, or with `keep`
|
||||
/// (pasting into another design, where the original isn't) `base` itself while it is free.
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>, keep: bool) -> String {
|
||||
let name = if keep && !used.contains(base.trim()) { base.trim().to_string() } else { solidcraft_doc::copy_name(base, |n| used.contains(n)) };
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let name = solidcraft_doc::copy_name(base, |n| used.contains(n));
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
@@ -184,32 +182,6 @@ fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
/// The design Paste New copies from: its document, its evaluated model and its placements moved
|
||||
/// but not yet applied. The session pasted into is the target; they are the same design for a
|
||||
/// paste within one design.
|
||||
pub(super) struct Source<'a> {
|
||||
pub doc: &'a Document,
|
||||
pub model: &'a solidcraft_doc::Model,
|
||||
pub pending_moves: &'a BTreeMap<u64, Mat>,
|
||||
/// Another design than the target: the copy can't point at anything outside it (it is
|
||||
/// refused, saying what), and the user parameters it uses come along.
|
||||
pub other_design: bool,
|
||||
}
|
||||
|
||||
impl<'a> Source<'a> {
|
||||
/// The session's own design.
|
||||
pub fn of(s: &'a Session) -> Source<'a> {
|
||||
Source { doc: &s.doc, model: &s.model, pending_moves: &s.pending_moves, other_design: false }
|
||||
}
|
||||
|
||||
/// What was copied in another design.
|
||||
pub fn copied(copy: &'a crate::ForeignCopy) -> Source<'a> {
|
||||
Source { doc: ©.doc, model: ©.model, pending_moves: ©.pending_moves, other_design: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// An independent copy of `component` (and what it holds), from the session's own design, into
|
||||
/// `parent`.
|
||||
pub(super) fn paste(
|
||||
s: &mut Session,
|
||||
component: u64,
|
||||
@@ -218,26 +190,7 @@ pub(super) fn paste(
|
||||
parent: u64,
|
||||
params: &Value,
|
||||
) -> Result<Value> {
|
||||
let (scratch, out) = build(s, &Source::of(s), component, source_occurrence, transform, parent, None, params)?;
|
||||
s.accept_computed(scratch);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`paste`] from `origin` into `s`: the computed session to accept, and what the paste made.
|
||||
/// `parent_path`: the placement of `parent` the copy is placed by (default: the source
|
||||
/// placement's parent's).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build(
|
||||
s: &Session,
|
||||
origin: &Source<'_>,
|
||||
component: u64,
|
||||
source_occurrence: Option<Occurrence>,
|
||||
transform: Mat,
|
||||
parent: u64,
|
||||
parent_path: Option<Vec<u64>>,
|
||||
params: &Value,
|
||||
) -> Result<(Session, Value)> {
|
||||
let source = origin.doc;
|
||||
let source = s.doc.clone();
|
||||
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
let generated_parameters: BTreeSet<_> = source
|
||||
.params
|
||||
@@ -246,25 +199,22 @@ pub(super) fn build(
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
|
||||
.collect();
|
||||
let definitions = graph(source, component)?;
|
||||
let definitions = graph(&source, component)?;
|
||||
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
|
||||
let selected: BTreeSet<_> = definitions.iter().copied().collect();
|
||||
if source.marker.is_some_and(|m| m < source.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
|
||||
}
|
||||
if origin.other_design && s.doc.marker.is_some_and(|m| m < s.doc.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end in this design before pasting"));
|
||||
}
|
||||
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
|
||||
if feats.len() + source.features.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "too many features to copy"));
|
||||
}
|
||||
for f in &feats {
|
||||
if origin.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
|
||||
}
|
||||
}
|
||||
let state = origin.model.state();
|
||||
let state = s.model.state();
|
||||
if state.bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "body set exceeds copy work limit"));
|
||||
}
|
||||
@@ -278,12 +228,11 @@ pub(super) fn build(
|
||||
let mut names = BTreeMap::new();
|
||||
let mut bodies = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
// Names are numbered among their own kind, against the names the target has: a component
|
||||
// named after its body takes the same number as the body's copy, and neither skips one the
|
||||
// other took.
|
||||
let mut used_components: BTreeSet<String> = s.doc.components.iter().map(|c| c.name.clone()).collect();
|
||||
let mut used_features: BTreeSet<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
|
||||
let mut used_bodies: BTreeSet<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
// Names are numbered among their own kind: a component named after its body takes the same
|
||||
// number as the body's copy, and neither skips one the other took.
|
||||
let mut used_components: BTreeSet<String> = source.components.iter().map(|c| c.name.clone()).collect();
|
||||
let mut used_features: BTreeSet<String> = source.features.iter().map(|f| f.name.clone()).collect();
|
||||
let mut used_bodies: BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut new_ids = Vec::new();
|
||||
let mut frozen = Vec::new();
|
||||
// Allocate definitions, discarding their automatic placeholder occurrence except root.
|
||||
@@ -294,7 +243,7 @@ pub(super) fn build(
|
||||
} else {
|
||||
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
|
||||
};
|
||||
let name = unique(&c.name, &mut used_components, origin.other_design);
|
||||
let name = unique(&c.name, &mut used_components);
|
||||
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
|
||||
// A copy keeps the definition's part properties.
|
||||
if let Some(added) = scratch.doc_mut().components.iter_mut().find(|x| x.id == new) {
|
||||
@@ -309,14 +258,12 @@ pub(super) fn build(
|
||||
}
|
||||
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
|
||||
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
|
||||
let mut clone_path = if let Some(path) = parent_path {
|
||||
path
|
||||
} else if let Some(o) = &source_occurrence {
|
||||
let mut clone_path = if let Some(o) = &source_occurrence {
|
||||
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
|
||||
path.pop();
|
||||
path
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
source.first_path(parent)
|
||||
};
|
||||
clone_path.push(root_occurrence);
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
|
||||
@@ -330,7 +277,7 @@ pub(super) fn build(
|
||||
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
|
||||
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
|
||||
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
|
||||
let pose = origin.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
if !super::component::valid_position(&pose) {
|
||||
return Err(bad(CMD, "invalid internal pending occurrence transform"));
|
||||
}
|
||||
@@ -345,10 +292,10 @@ pub(super) fn build(
|
||||
// The names the bodies of a feature have in the model, where they are its stored names, numbered.
|
||||
let mut shown: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = origin.model.result(f.id) {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
|
||||
}
|
||||
if named_by(&f.body_names, &created) {
|
||||
shown.insert(f.id, created.clone());
|
||||
@@ -360,7 +307,7 @@ pub(super) fn build(
|
||||
if !shown.contains_key(&f.id) {
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies, origin.other_design));
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used_bodies));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,32 +328,23 @@ pub(super) fn build(
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.iter().any(|b| *b == body.name)
|
||||
&& origin.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = origin.model.state_before(f.id);
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = origin.model.result(f.id)?.state.body(&body.name);
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let entered = entry.and_then(|f| origin.model.result(f.id)).map(|r| r.state.clone());
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solidcraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solidcraft_kernel::step_export(&[&geometry], "SolidCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used_bodies, origin.other_design);
|
||||
let new_name = unique(&body.name, &mut used_bodies);
|
||||
bodies.insert(body.name.clone(), new_name.clone());
|
||||
// From another design it is named as what it is there, an imported body, numbered as
|
||||
// the timeline numbers its features ("BodyImport1").
|
||||
let feature_name = if origin.other_design {
|
||||
let name =
|
||||
(1..=MAX_WORK + 1).map(|n| format!("BodyImport{n}")).find(|n| !used_features.contains(n)).unwrap_or_else(|| "BodyImport".into());
|
||||
used_features.insert(name.clone());
|
||||
name
|
||||
} else {
|
||||
unique(&format!("{} Base", body.name), &mut used_features, false)
|
||||
};
|
||||
let feature_name = unique(&format!("{} Base", body.name), &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(
|
||||
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() },
|
||||
Some(&feature_name),
|
||||
@@ -420,12 +358,8 @@ pub(super) fn build(
|
||||
}
|
||||
// Allocate all history IDs before rewriting any forward reference.
|
||||
for f in &feats {
|
||||
let name = unique(&f.name, &mut used_features, origin.other_design);
|
||||
// A transfer is checked as it is added: it names the copy's components (another design
|
||||
// has none of the original ones).
|
||||
let mut kind = f.kind.clone();
|
||||
transfer_into_copy(&mut kind, &components)?;
|
||||
let id = scratch.doc_mut().add_feature(kind, Some(&name))?;
|
||||
let name = unique(&f.name, &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
|
||||
if let Some(new) = scratch.doc.feature(id) {
|
||||
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
|
||||
parameters.insert(old.clone(), pn.clone());
|
||||
@@ -453,11 +387,6 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
// From another design, the user parameters the copy uses come along (and the ones they use),
|
||||
// renamed `w_2` where this design has a `w` already.
|
||||
if origin.other_design {
|
||||
copy_user_parameters(&mut scratch, source, &feats, &mut parameters)?;
|
||||
}
|
||||
for (old, new) in ¶meters {
|
||||
if let Some(comment) = source.param_comments.get(old) {
|
||||
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
|
||||
@@ -485,18 +414,26 @@ pub(super) fn build(
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
transfer_into_copy(&mut f.kind, &components)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solidcraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
&& bodies.contains_key(old_face.body.as_ref())
|
||||
{
|
||||
face.path = cloned_path(source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.author_path.is_empty()
|
||||
{
|
||||
face.author_path = cloned_path(source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
@@ -504,9 +441,9 @@ pub(super) fn build(
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(source, &original.path, component, &clone_path, &occurrences)?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
@@ -545,7 +482,7 @@ pub(super) fn build(
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
let name = unique("Assigned-out bodies", &mut used_features, origin.other_design);
|
||||
let name = unique("Assigned-out bodies", &mut used_features);
|
||||
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out.into_iter().map(BodyName).collect() }, Some(&name))?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = root;
|
||||
@@ -577,20 +514,14 @@ pub(super) fn build(
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
// Typed configuration and assembly helpers preserve original targets (another design's
|
||||
// configurations stay with it: this design's table has its own targets).
|
||||
if !origin.other_design {
|
||||
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
}
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), source, &occurrences, &features, &bodies)?;
|
||||
// Joints and motion to the outside are left behind (those notices); what follows is what
|
||||
// the copied features point at outside it.
|
||||
let assembly_notices = notices.len();
|
||||
// Typed configuration and assembly helpers preserve original targets.
|
||||
scratch.doc_mut().configs.clone_targets(&solidcraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
|
||||
let mut reference_work = 0usize;
|
||||
for feature in &feats {
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
|
||||
@@ -674,22 +605,12 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
if origin.other_design {
|
||||
// What the copy points at outside itself isn't in this design.
|
||||
let mut outside = outside_references(notices.get(assembly_notices..).unwrap_or_default(), &feats, &bodies);
|
||||
if !outside.is_empty() {
|
||||
outside.sort();
|
||||
outside.dedup();
|
||||
let name = definition_records.get(&component).map(|c| c.name.clone()).unwrap_or_default();
|
||||
return Err(bad(CMD, format!("{name} uses what isn't in it, so it can't be pasted into another design: {}", outside.join("; "))));
|
||||
}
|
||||
}
|
||||
notices.sort();
|
||||
notices.dedup();
|
||||
scratch.refresh();
|
||||
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
if s.doc.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
return Err(bad(CMD, "copy would change an original feature's error state"));
|
||||
}
|
||||
// Base imports acquire new face identities. Resolve copied face overrides against the
|
||||
@@ -719,14 +640,14 @@ pub(super) fn build(
|
||||
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
|
||||
// Adding placements can invalidate cached source evaluation. Compare clone effects
|
||||
// against that same evaluation's source prefix, not older shared B-rep pointers.
|
||||
if scratch.doc.features.get(..s.doc.features.len()) != Some(s.doc.features.as_slice())
|
||||
|| scratch.doc.params.get(..s.doc.params.len()) != Some(s.doc.params.as_slice())
|
||||
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|
||||
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
|
||||
{
|
||||
return Err(bad(CMD, "copy would change original feature history or parameters"));
|
||||
}
|
||||
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
|
||||
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(s.doc.configs.columns.len()).cloned().collect();
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
|
||||
let style_validation = scratch.doc.configuration_style_status(&copied);
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
@@ -793,86 +714,10 @@ pub(super) fn build(
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
|
||||
Ok((scratch, out))
|
||||
}
|
||||
|
||||
/// A copied body transfer moves its bodies between the copies of its components; one from
|
||||
/// outside the copy is where the copy starts (it stays in place, in its destination).
|
||||
fn transfer_into_copy(kind: &mut FeatureKind, components: &BTreeMap<u64, u64>) -> Result<()> {
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solidcraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What copied features point at outside the copy, in words: the external references Paste New
|
||||
/// keeps within one design (`notices`), and the bodies they work on that aren't copied.
|
||||
fn outside_references(notices: &[String], feats: &[Feature], bodies: &BTreeMap<String, String>) -> Vec<String> {
|
||||
let mut out: Vec<String> =
|
||||
notices.iter().map(|n| n.replace(" retains an external ", " uses a ").replace(" retains external ", " uses ")).collect();
|
||||
for f in feats {
|
||||
for body in f.kind.input_bodies() {
|
||||
if !bodies.contains_key(&body) {
|
||||
out.push(format!("{} uses body {body}", f.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The user parameters copied features use (their expressions and their dimensions'), and the
|
||||
/// ones those use, copied from `source` into `scratch`: named as they were where that name is
|
||||
/// free, else `w_2`, `w_3`…; `parameters` maps each to its copy.
|
||||
fn copy_user_parameters(scratch: &mut Session, source: &Document, feats: &[Feature], parameters: &mut BTreeMap<String, String>) -> Result<()> {
|
||||
let user: BTreeMap<&str, &solidcraft_doc::Param> = source.params.iter().filter(|p| !p.model).map(|p| (p.name.as_str(), p)).collect();
|
||||
let mut pending: Vec<String> = feats.iter().flat_map(|f| f.kind.expressions()).map(str::to_string).collect();
|
||||
pending.extend(parameters.keys().filter_map(|old| source.params.iter().find(|p| p.model && p.name == *old)).map(|p| p.expr.clone()));
|
||||
let mut used: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut work = 0usize;
|
||||
while let Some(expr) = pending.pop() {
|
||||
for name in solidcraft_doc::expr::references(&expr) {
|
||||
work += 1;
|
||||
if work > 100_000 {
|
||||
return Err(bad(CMD, "parameter references exceed copy work limit"));
|
||||
}
|
||||
if let Some((key, p)) = user.get_key_value(name.as_str())
|
||||
&& used.insert(key)
|
||||
{
|
||||
pending.push(p.expr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let copied: Vec<&solidcraft_doc::Param> = source.params.iter().filter(|p| used.contains(p.name.as_str())).collect();
|
||||
if scratch.doc.params.len() + copied.len() > solidcraft_doc::MAX_PARAMS {
|
||||
return Err(bad(CMD, "too many copied parameters"));
|
||||
}
|
||||
let mut taken: BTreeSet<String> = scratch.doc.params.iter().map(|p| p.name.clone()).collect();
|
||||
for p in &copied {
|
||||
let mut name = p.name.clone();
|
||||
let mut n = 2;
|
||||
while taken.contains(&name) || Document::param_name_problem(&name).is_some() {
|
||||
name = format!("{}_{n}", p.name);
|
||||
n += 1;
|
||||
if n > 10_000 {
|
||||
return Err(bad(CMD, format!("no free name for parameter {}", p.name)));
|
||||
}
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
parameters.insert(p.name.clone(), name);
|
||||
}
|
||||
for p in copied {
|
||||
let name = parameters.get(&p.name).cloned().unwrap_or_default();
|
||||
let expr = solidcraft_doc::clone_expression(&p.expr, parameters);
|
||||
scratch.doc_mut().params.push(solidcraft_doc::Param { name, expr, unit: p.unit.clone(), comment: p.comment.clone(), model: false });
|
||||
}
|
||||
Ok(())
|
||||
s.accept_computed(scratch);
|
||||
Ok(
|
||||
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Pasting what was copied in another design (`Session::foreign_copy`). Bodies come as an
|
||||
//! imported body each, without their history (that stays with the design they were made in), at
|
||||
//! the same world coordinates as in their design, in one Import base feature in the target
|
||||
//! component. Components come as a Paste New does: an independent copy of each with what it
|
||||
//! holds, its history, appearances, joints and motion, where it is in the world.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn paste_from_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.paste_from_design";
|
||||
let copy = s.foreign_copy.clone().ok_or_else(|| bad(cmd, "nothing was copied in another design"))?;
|
||||
if copy.bodies.is_empty() && copy.placed.is_empty() {
|
||||
return Err(bad(cmd, "nothing was copied in another design"));
|
||||
}
|
||||
let to = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let given = match p.get("component_path") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.filter(|a| a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
),
|
||||
};
|
||||
let path = match given {
|
||||
Some(path) => path,
|
||||
None if to == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(to)),
|
||||
None => placing::placement_of(s, &s.placed_document(), to, None, "component_path", cmd)?.0,
|
||||
};
|
||||
let frame = s.placed_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
|
||||
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
|
||||
if copy.bodies.is_empty() {
|
||||
return paste_components(s, ©, to, path, into_target, p);
|
||||
}
|
||||
// Each body at its world coordinates in its design, in the target's frame, named as in its
|
||||
// component (a body seen in another placement without that placement) where that name is
|
||||
// free here, else numbered.
|
||||
let state = s.model.state();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut shapes = Vec::new();
|
||||
for (i, seen) in copy.bodies.iter().enumerate() {
|
||||
let body = copy.world.body(seen).ok_or_else(|| bad(cmd, format!("no body `{seen}` in {}", copy.design)))?;
|
||||
let name = copy.names.get(i).unwrap_or(seen);
|
||||
let taken = |n: &str| state.body(n).is_some() || names.iter().any(|x| x == n);
|
||||
let new = if taken(name) { solidcraft_doc::copy_name(name, taken) } else { name.clone() };
|
||||
shapes.push(solidcraft_kernel::transform_matrix(&body.body, into_target)?);
|
||||
names.push(new);
|
||||
}
|
||||
drop(state);
|
||||
let list: Vec<solidcraft_kernel::ExportBody> =
|
||||
names.iter().zip(&shapes).map(|(name, body)| solidcraft_kernel::ExportBody { name: name.clone(), body, color: body.color() }).collect();
|
||||
let header = solidcraft_kernel::StepHeader { organization: "SolidCraft".into(), ..Default::default() };
|
||||
let step = solidcraft_kernel::step_export_bodies(&list, &header)?;
|
||||
let mut scratch = s.scratch();
|
||||
let kind = FeatureKind::Import { file: format!("{} (pasted)", copy.design), step: step.into(), components: Vec::new(), skip: Vec::new() };
|
||||
// Named as any import is ("Import1"); the design they came from stays on it as its file.
|
||||
let id = scratch.doc_mut().add_feature(kind, None)?;
|
||||
let feature = scratch.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = to;
|
||||
f.body_names = names.clone();
|
||||
}
|
||||
scratch.refresh();
|
||||
if let Some(error) = scratch.model.result(id).and_then(|r| r.error.as_ref()) {
|
||||
return Err(bad(cmd, format!("the pasted bodies didn't import: {error}")));
|
||||
}
|
||||
scratch.selection = names.iter().map(|name| crate::Sel::Body { name: name.clone() }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"bodies": names, "feature": feature, "component": to}))
|
||||
}
|
||||
|
||||
/// Independent copies of the components copied in another design, each with what it holds, in
|
||||
/// `to` (placed by `path`; `into_target` takes world coordinates into it), where each is in the
|
||||
/// world; one step to undo. They are selected.
|
||||
fn paste_components(
|
||||
s: &mut Session,
|
||||
copy: &crate::ForeignCopy,
|
||||
to: u64,
|
||||
path: Vec<u64>,
|
||||
into_target: solidcraft_doc::Mat,
|
||||
p: &Value,
|
||||
) -> Result<Value> {
|
||||
let origin = crate::cmd::component_clone::Source::copied(copy);
|
||||
let mut computed: Option<Session> = None;
|
||||
let mut made = Vec::new();
|
||||
for (component, occurrence, world) in ©.placed {
|
||||
let source_occurrence = copy.doc.occurrences.iter().find(|o| o.id == *occurrence).cloned();
|
||||
let transform = solidcraft_doc::mat_mul(&into_target, world);
|
||||
if !super::valid_position(&transform) {
|
||||
return Err(bad("component.paste_from_design", "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let target = computed.as_ref().unwrap_or(s);
|
||||
let (next, out) = crate::cmd::component_clone::build(target, &origin, *component, source_occurrence, transform, to, Some(path.clone()), p)?;
|
||||
if let Some(id) = out.get("occurrence").and_then(Value::as_u64) {
|
||||
made.push(path.iter().copied().chain([id]).collect::<Vec<u64>>());
|
||||
}
|
||||
computed = Some(next);
|
||||
}
|
||||
let Some(mut scratch) = computed else { return Err(bad("component.paste_from_design", "the copied components are gone")) };
|
||||
scratch.selection = made.iter().map(|path| crate::Sel::Occurrence { path: path.clone() }).collect();
|
||||
s.accept_computed(scratch);
|
||||
Ok(json!({"paths": made, "component": to}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_foreign_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,134 +0,0 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use crate::{ForeignCopy, Session};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn created(s: &mut Session, name: &str) -> (u64, u64) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
|
||||
}
|
||||
fn volume(s: &mut Session, id: u64) -> f64 {
|
||||
run(s, "component.properties", json!({"component": id}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
fn design(name: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.doc_mut().name = name.into();
|
||||
s
|
||||
}
|
||||
/// `s` copies its placement `path` for another design.
|
||||
fn copied(s: &Session, path: Vec<u64>) -> Option<Arc<ForeignCopy>> {
|
||||
Some(Arc::new(ForeignCopy::of(s, Vec::new(), vec![path])))
|
||||
}
|
||||
|
||||
/// A component copied in another design pastes as an independent copy of it and what it holds:
|
||||
/// its history, its appearances, a part placed twice in it still one component placed twice,
|
||||
/// where it is in the world, named "(2)" where the name is taken, as one step to undo.
|
||||
#[test]
|
||||
fn a_component_from_another_design_pastes_with_its_history() {
|
||||
let mut source = design("Bracket");
|
||||
let (assembly, at) = created(&mut source, "Assembly");
|
||||
let (child, _) = created(&mut source, "Child");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
run(&mut source, "appearance.assign", json!({"bodies": ["Cube"], "color": "#abcdef"}));
|
||||
run(&mut source, "occurrence.copy", json!({"component": child}));
|
||||
run(&mut source, "component.activate", json!({"component": 0}));
|
||||
run(&mut source, "occurrence.ground", json!({"occurrence": at, "grounded": false}));
|
||||
run(&mut source, "occurrence.move", json!({"occurrence": at, "translate": [0, 30, 0]}));
|
||||
let was = source.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
let box_features = source.doc.features.len();
|
||||
|
||||
let mut s = design("Frame");
|
||||
created(&mut s, "Assembly");
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
let undo = s.undo.len();
|
||||
let out = run(&mut s, "component.paste_from_design", json!({}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let new = s.doc.components.iter().find(|c| c.name == "Assembly (2)").expect("named (2)").id;
|
||||
assert_eq!(out["paths"], json!([[s.doc.occurrence_of(new).unwrap().id]]));
|
||||
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].component, kids[1].component, "still one component placed twice");
|
||||
assert_eq!(s.doc.features.len(), box_features, "the box's history came along");
|
||||
assert!((volume(&mut s, new) - 16.0).abs() < 1e-6);
|
||||
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
|
||||
let is = s.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
|
||||
// Its history is its own: changing it here leaves the original as it was.
|
||||
let copy = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Box { .. })).unwrap();
|
||||
let parameter = copy.param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name": parameter, "expression": "3 mm"}));
|
||||
assert!((volume(&mut s, new) - 24.0).abs() < 1e-6);
|
||||
assert!((volume(&mut source, assembly) - 16.0).abs() < 1e-6);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.components.iter().all(|c| c.name != "Assembly (2)"));
|
||||
}
|
||||
|
||||
/// The user parameters a pasted component uses come along: as they were where the name is
|
||||
/// free, else renamed `w_2`, its features following; this design's own `w` stays as it is.
|
||||
#[test]
|
||||
fn user_parameters_come_along_renamed_where_taken() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "parameters.add", json!({"name": "w", "expression": "4 mm"}));
|
||||
let (_, at) = created(&mut source, "Part");
|
||||
let feature = run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}))["feature"].as_u64().unwrap();
|
||||
let length = source.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut source, "parameters.change", json!({"name": length, "expression": "w"}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
run(&mut s, "component.paste_from_design", json!({}));
|
||||
let w2 = s.doc.params.iter().find(|p| p.name == "w_2").expect("w came along as w_2");
|
||||
assert_eq!(w2.expr, "4 mm");
|
||||
assert_eq!(s.doc.params.iter().find(|p| p.name == "w").unwrap().expr, "10 mm");
|
||||
let part = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
|
||||
assert!((volume(&mut s, part) - 16.0).abs() < 1e-6);
|
||||
run(&mut s, "parameters.change", json!({"name": "w_2", "expression": "1 mm"}));
|
||||
assert!((volume(&mut s, part) - 4.0).abs() < 1e-6, "the copy follows w_2");
|
||||
}
|
||||
|
||||
/// A component whose features use what isn't in it (here a body outside it) isn't pasted into
|
||||
/// another design: it is refused, saying what, and the design is left as it was.
|
||||
#[test]
|
||||
fn a_component_using_what_is_outside_it_is_refused_saying_what() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let (_, at) = created(&mut source, "Part");
|
||||
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
|
||||
run(&mut source, "solid.move", json!({"bodies": ["Cube"], "translate": [10, 0, 0]}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
let saved = s.doc.to_json();
|
||||
let error = s.execute("component.paste_from_design", &json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("uses body Cube"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
}
|
||||
|
||||
/// A component that bodies were moved into (a body transfer, as Create Components from Bodies
|
||||
/// makes) pastes into another design, which has none of the components the transfer names.
|
||||
#[test]
|
||||
fn a_component_bodies_were_moved_into_pastes_into_another_design() {
|
||||
let mut source = design("Bracket");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let (part, at) = created(&mut source, "Part");
|
||||
run(&mut source, "component.move_bodies", json!({"bodies": ["Cube"], "component": part}));
|
||||
run(&mut source, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Pin"}));
|
||||
|
||||
let mut s = design("Frame");
|
||||
s.foreign_copy = copied(&source, vec![at]);
|
||||
run(&mut s, "component.paste_from_design", json!({}));
|
||||
let new = s.doc.components.iter().find(|c| c.name == "Part").unwrap().id;
|
||||
let state = s.model.state();
|
||||
assert_eq!(s.doc.model_body_component(state.body("Cube").unwrap()), new);
|
||||
assert_eq!(s.doc.model_body_component(state.body("Pin").unwrap()), new);
|
||||
// The body moved in from outside comes as an imported body.
|
||||
assert!(s.doc.find_feature("BodyImport1").is_some_and(|f| f.component == new));
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! Moving timeline features (sketches included) into another component.
|
||||
//!
|
||||
//! It is an organisational change: ids, names and timeline positions stay, nothing is copied, and
|
||||
//! the geometry stays where it is in the world (a moved feature's picks, planes and directions are
|
||||
//! re-expressed in the new component's frame). Bodies follow the feature that made them. The move
|
||||
//! is made on a scratch copy and the whole timeline is evaluated; the result is adopted only when
|
||||
//! no feature, moved or left behind, gained an error. Otherwise nothing changes.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_doc::{Feature, FeatureKind, is_identity, mat_inverse, mat_mul};
|
||||
|
||||
use super::super::clipboard::explicit_planes;
|
||||
use super::*;
|
||||
use crate::params::name_or_id_list;
|
||||
|
||||
pub(super) fn move_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_features";
|
||||
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let keys = name_or_id_list(cmd, p, "features")?;
|
||||
if keys.is_empty() || keys.len() > 1000 {
|
||||
return Err(bad(cmd, "`features` must list 1…1000 features"));
|
||||
}
|
||||
if s.doc.marker.is_some() {
|
||||
return Err(bad(cmd, "finish timeline rollback before moving features"));
|
||||
}
|
||||
if !s.pending_moves.is_empty() {
|
||||
return Err(bad(cmd, "finish the component move in progress first"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for k in &keys {
|
||||
ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
|
||||
}
|
||||
let moving: BTreeSet<u64> = ids.into_iter().filter(|id| s.doc.feature(*id).is_some_and(|f| f.component != dest)).collect();
|
||||
if moving.is_empty() {
|
||||
return Err(bad(cmd, "already in that component"));
|
||||
}
|
||||
let moved: Vec<Feature> = s.doc.features.iter().filter(|f| moving.contains(&f.id)).cloned().collect();
|
||||
// One placement each: a component placed twice has no single frame to keep the geometry in.
|
||||
let placements = |c: u64| s.doc.occurrences.iter().filter(|o| o.component == c).count();
|
||||
let sources: BTreeSet<u64> = moved.iter().map(|f| f.component).collect();
|
||||
if sources.iter().chain([&dest]).any(|c| *c != 0 && placements(*c) > 1) {
|
||||
return Err(bad(cmd, "a component placed more than once can't take part in this move"));
|
||||
}
|
||||
if moved.iter().any(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })) {
|
||||
return Err(bad(cmd, "a recorded body transfer can't be moved"));
|
||||
}
|
||||
// The bodies that move with the features: those they are the origin of.
|
||||
let moved_bodies: BTreeSet<String> = s.body_origins().into_iter().filter(|(_, f)| moving.contains(f)).map(|(body, _)| body).collect();
|
||||
if moved_bodies.iter().any(|b| s.doc.body_components.contains_key(b) || s.doc.body_offsets.contains_key(b)) {
|
||||
return Err(bad(cmd, "a body that was transferred between components can't be moved with its features"));
|
||||
}
|
||||
// A join or boolean whose inputs would end up in two components.
|
||||
for f in &s.doc.features {
|
||||
let mut inputs: BTreeSet<String> = f.kind.input_bodies().into_iter().collect();
|
||||
inputs.extend(f.participants.iter().cloned());
|
||||
let (inside, outside) =
|
||||
(inputs.iter().filter(|b| moved_bodies.contains(*b)).count(), inputs.iter().filter(|b| !moved_bodies.contains(*b)).count());
|
||||
if inside > 0 && outside > 0 && inputs.len() > 1 {
|
||||
return Err(bad(cmd, format!("{} works on bodies that would be split between components", f.name)));
|
||||
}
|
||||
}
|
||||
let to_dest = mat_inverse(&s.doc.component_transform(dest)).ok_or_else(|| bad(cmd, "the target component's frame is not invertible"))?;
|
||||
let dest_path = s.doc.first_path(dest);
|
||||
let before_errors: BTreeMap<u64, bool> = s.doc.features.iter().map(|f| (f.id, s.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect();
|
||||
|
||||
let mut scratch = s.scratch();
|
||||
let doc = scratch.doc_mut();
|
||||
for old in &moved {
|
||||
let delta = mat_mul(&to_dest, &s.doc.component_transform(old.component));
|
||||
let Some(f) = doc.feature_mut(old.id) else { continue };
|
||||
f.component = dest;
|
||||
if !is_identity(&delta) {
|
||||
if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut f.kind {
|
||||
explicit_planes(plane);
|
||||
}
|
||||
f.kind.transform_geometry(&delta);
|
||||
}
|
||||
}
|
||||
// Attached sketches and embossed faces carry the placement of the body they sit on and of
|
||||
// the component they were authored in: those that moved are rewritten, nothing else changes.
|
||||
for f in &mut doc.features {
|
||||
let authored_here = moving.contains(&f.id);
|
||||
let rewrite = |body: &str, path: &mut Vec<u64>, author_path: &mut Vec<u64>| {
|
||||
if moved_bodies.contains(body) {
|
||||
*path = dest_path.clone();
|
||||
}
|
||||
if authored_here {
|
||||
*author_path = dest_path.clone();
|
||||
}
|
||||
};
|
||||
match &mut f.kind {
|
||||
FeatureKind::Sketch { plane, .. } => {
|
||||
if let Some(anchor) = crate::cmd::sketch::face::anchor_mut(plane) {
|
||||
let moved_body = moved_bodies.contains(&anchor.target.body);
|
||||
rewrite(&anchor.target.body, &mut anchor.path, &mut anchor.author_path);
|
||||
if moved_body {
|
||||
anchor.component = dest;
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { face: Some(face), .. } => {
|
||||
let body = face.body.as_ref().to_string();
|
||||
rewrite(&body, &mut face.path, &mut face.author_path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
scratch.refresh();
|
||||
// Any feature that gains an error, moved or left behind, stops the move.
|
||||
let broken = scratch
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| !before_errors.get(&f.id).copied().unwrap_or(false))
|
||||
.find_map(|f| scratch.model.result(f.id).and_then(|r| r.error.clone()).map(|e| (f.name.clone(), e)));
|
||||
if let Some((name, error)) = broken {
|
||||
return Err(bad(cmd, format!("moving would break {name}: {error}")));
|
||||
}
|
||||
s.accept_computed(scratch);
|
||||
let names: Vec<&str> = moved.iter().map(|f| f.name.as_str()).collect();
|
||||
Ok(json!({"moved": names, "component": dest, "bodies": moved_bodies}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The feature each body depends on first, by body name: the earliest feature whose own result
|
||||
/// holds a body of that name (a converted mesh keeps its import), else the creator the body
|
||||
/// carries (the pieces of a split). Moving that feature moves the body.
|
||||
pub fn body_origins(&self) -> BTreeMap<String, u64> {
|
||||
let mut origins = BTreeMap::new();
|
||||
for f in &self.doc.features {
|
||||
if let Some(r) = self.model.result(f.id) {
|
||||
for b in r.state.bodies.iter().filter(|b| b.feature == f.id) {
|
||||
origins.entry(b.name.clone()).or_insert(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in &self.model.state().bodies {
|
||||
origins.entry(b.name.clone()).or_insert(b.feature);
|
||||
}
|
||||
origins
|
||||
}
|
||||
}
|
||||
@@ -92,22 +92,16 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.accept_computed(scratch);
|
||||
follow_visibility(s, &old_path, &new_path);
|
||||
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
|
||||
}
|
||||
|
||||
/// Hidden placements and folders under `old_path` follow it to `new_path`. `accept_computed` does
|
||||
/// not carry them, so whoever accepts a computed move into a session does this after.
|
||||
pub(super) fn follow_visibility(s: &mut Session, old_path: &[u64], new_path: &[u64]) {
|
||||
for hidden in &mut s.visibility.hidden_occurrences {
|
||||
if let Some(moved) = rebased(hidden, old_path, new_path) {
|
||||
if let Some(moved) = rebased(hidden, &old_path, &new_path) {
|
||||
*hidden = moved;
|
||||
}
|
||||
}
|
||||
let (old_scope, new_scope) = (Session::folder_scope(old_path), Session::folder_scope(new_path));
|
||||
let (old_scope, new_scope) = (Session::folder_scope(&old_path), Session::folder_scope(&new_path));
|
||||
for key in &mut s.visibility.hidden_folders {
|
||||
if let Some(rest) = key.strip_prefix(&old_scope).filter(|rest| rest.starts_with('/')) {
|
||||
*key = format!("{new_scope}{rest}");
|
||||
}
|
||||
}
|
||||
Ok(json!({"occurrence": id, "parent": to, "path": new_path, "transform": transform}))
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//! Moving placements, timeline features and bodies into a component in one undo step
|
||||
//! (`browser.reparent`: the owner of each changes, whatever its kind).
|
||||
//!
|
||||
//! What Paste does after Cut. Each kind moves by its own command (`occurrence.reparent`,
|
||||
//! `component.move_features`, `component.move_bodies`), in that order, on a scratch copy, so what
|
||||
//! an earlier step carried along is left alone by a later one: a moved component takes its
|
||||
//! features and bodies with it, moved features take the bodies they made. The result is adopted
|
||||
//! only when every step succeeds, as one undo step; otherwise nothing changes.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::move_features::move_features;
|
||||
use super::reparent::{follow_visibility, reparent};
|
||||
use super::transfer;
|
||||
use super::*;
|
||||
use crate::params::{name_or_id_list, string_list};
|
||||
|
||||
/// Most items of each kind in one move.
|
||||
const MAX_ITEMS: usize = 1000;
|
||||
|
||||
/// A placement named by its occurrence id, or by its path from the top of the design.
|
||||
fn placements(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(u64, Option<Vec<u64>>)>> {
|
||||
let list = match p.get("occurrences") {
|
||||
None | Some(Value::Null) => return Ok(Vec::new()),
|
||||
Some(Value::Array(a)) if a.len() <= MAX_ITEMS => a,
|
||||
Some(_) => return Err(bad(cmd, format!("`occurrences` must list at most {MAX_ITEMS} placements"))),
|
||||
};
|
||||
let mut out: Vec<(u64, Option<Vec<u64>>)> = Vec::new();
|
||||
for entry in list {
|
||||
let found = match entry {
|
||||
Value::Number(n) => n.as_u64().map(|id| (id, None)),
|
||||
Value::Array(a) if !a.is_empty() && a.len() <= 64 => {
|
||||
a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>().and_then(|path| path.last().copied().map(|id| (id, Some(path))))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (id, path) = found.ok_or_else(|| bad(cmd, "`occurrences` must list occurrence ids or paths of 1..64 occurrence ids"))?;
|
||||
if !s.doc.occurrences.iter().any(|o| o.id == id) {
|
||||
return Err(bad(cmd, format!("no occurrence {id}")));
|
||||
}
|
||||
if !out.iter().any(|(seen, _)| *seen == id) {
|
||||
out.push((id, path));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn reparent_items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.reparent";
|
||||
let dest = if p.get("component").is_some() { component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let dest_path = p.get("component_path").cloned();
|
||||
let occurrences = placements(s, p, cmd)?;
|
||||
let features = name_or_id_list(cmd, p, "features")?;
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if features.len() > MAX_ITEMS || bodies.len() > MAX_ITEMS {
|
||||
return Err(bad(cmd, format!("`features` and `bodies` must list at most {MAX_ITEMS} items each")));
|
||||
}
|
||||
if occurrences.is_empty() && features.is_empty() && bodies.is_empty() {
|
||||
return Err(bad(cmd, "give `occurrences`, `features` or `bodies` to move"));
|
||||
}
|
||||
if s.doc.marker.is_some() {
|
||||
return Err(bad(cmd, "finish timeline rollback before moving"));
|
||||
}
|
||||
let mut feature_ids = BTreeSet::new();
|
||||
for k in &features {
|
||||
feature_ids.insert(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
|
||||
}
|
||||
{
|
||||
let state = s.model.state();
|
||||
if let Some(missing) = bodies.iter().find(|b| state.body(b).is_none()) {
|
||||
return Err(bad(cmd, format!("no body `{missing}`")));
|
||||
}
|
||||
}
|
||||
|
||||
let mut sc = s.scratch();
|
||||
sc.active_component = s.active_component;
|
||||
sc.active_occurrence_path = s.active_occurrence_path.clone();
|
||||
|
||||
// Components first: each takes everything it holds.
|
||||
let mut moved_placements = Vec::new();
|
||||
let mut moved_components = Vec::new();
|
||||
let mut followed: Vec<(Vec<u64>, Vec<u64>)> = Vec::new();
|
||||
for (id, path) in &occurrences {
|
||||
let Some(o) = sc.doc.occurrences.iter().find(|o| o.id == *id).cloned() else { continue };
|
||||
if o.parent == dest {
|
||||
continue;
|
||||
}
|
||||
let mut params = json!({"occurrence": id, "parent": dest});
|
||||
if let Some(path) = path {
|
||||
params["path"] = json!(path);
|
||||
}
|
||||
if let Some(parent_path) = &dest_path {
|
||||
params["parent_path"] = parent_path.clone();
|
||||
}
|
||||
let old_path = occurrence_path(&sc, ¶ms, *id, cmd)?;
|
||||
let out = reparent(&mut sc, ¶ms)?;
|
||||
let new_path = out.get("path").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect::<Vec<_>>());
|
||||
followed.extend(new_path.map(|new| (old_path, new)));
|
||||
moved_placements.push(*id);
|
||||
moved_components.push(o.component);
|
||||
}
|
||||
// What a moved component holds travels with it.
|
||||
let travels = |sc: &Session, component: u64| moved_components.iter().any(|c| sc.doc.inside_component(component, *c));
|
||||
|
||||
// Then features, which take the bodies they made.
|
||||
let features_to_move: Vec<u64> =
|
||||
feature_ids.iter().copied().filter(|id| sc.doc.feature(*id).is_some_and(|f| f.component != dest && !travels(&sc, f.component))).collect();
|
||||
let mut moved_features = Vec::new();
|
||||
if !features_to_move.is_empty() {
|
||||
let mut params = json!({"features": features_to_move, "component": dest});
|
||||
if let Some(path) = &dest_path {
|
||||
params["component_path"] = path.clone();
|
||||
}
|
||||
let out = move_features(&mut sc, ¶ms)?;
|
||||
moved_features =
|
||||
out.get("moved").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
}
|
||||
|
||||
// Last the bodies still elsewhere: one a moved component or feature carried is already there.
|
||||
let bodies_to_move: Vec<String> = {
|
||||
let state = sc.model.state();
|
||||
bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
state.body(b).is_some_and(|body| {
|
||||
let at = sc.doc.model_body_component(body);
|
||||
at != dest && !travels(&sc, at)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
if !bodies_to_move.is_empty() {
|
||||
let mut params = json!({"bodies": bodies_to_move, "component": dest});
|
||||
if let Some(path) = &dest_path {
|
||||
params["component_path"] = path.clone();
|
||||
}
|
||||
transfer::move_bodies(&mut sc, ¶ms)?;
|
||||
}
|
||||
|
||||
if moved_placements.is_empty() && moved_features.is_empty() && bodies_to_move.is_empty() {
|
||||
return Err(bad(cmd, "already in that component"));
|
||||
}
|
||||
// The steps above are this command's one undo step.
|
||||
sc.undo.clear();
|
||||
s.accept_computed(sc);
|
||||
for (old, new) in &followed {
|
||||
follow_visibility(s, old, new);
|
||||
}
|
||||
Ok(json!({"component": dest, "occurrences": moved_placements, "features": moved_features, "bodies": bodies_to_move}))
|
||||
}
|
||||
@@ -290,205 +290,3 @@ fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (2)").unwrap().component, copied);
|
||||
}
|
||||
|
||||
/// A 10 × 10 × 5 block (sketch `S`, extrude `Extrude1`, body `Block`) in the design with a sketch
|
||||
/// `Top` on its top face, and a component `Part` placed 100 mm along X. The design is active.
|
||||
fn block_and_part() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [5, 5, 5]}, "name": "Top"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
|
||||
let first = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": first, "translate": [100, 0, 0], "capture": true, "move_grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
(s, part)
|
||||
}
|
||||
|
||||
/// The lowest corner of every body in the world, rounded.
|
||||
fn world_corners(s: &Session) -> Vec<Vec<f64>> {
|
||||
s.world_state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let min = b.mesh().bounds().min;
|
||||
vec![min.x.round(), min.y.round(), min.z.round()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_keeps_the_world_and_the_attachment_in_the_active_component() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let ids: Vec<u64> = s.doc.features.iter().map(|f| f.id).collect();
|
||||
let names: Vec<String> = s.doc.features.iter().map(|f| f.name.clone()).collect();
|
||||
let corners = world_corners(&s);
|
||||
// No `component`: the features go to the active one.
|
||||
run(&mut s, "component.activate", json!({"component": part}));
|
||||
let r = run(&mut s, "component.move_features", json!({"features": ["S", "Extrude1"]}));
|
||||
assert_eq!(r["moved"], json!(["S", "Extrude1"]), "{r}");
|
||||
// Nothing was added or renamed.
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>(), names);
|
||||
for name in ["S", "Extrude1"] {
|
||||
assert_eq!(s.doc.find_feature(name).map(|f| f.component), Some(part));
|
||||
}
|
||||
assert_eq!(s.doc.find_feature("Top").map(|f| f.component), Some(0));
|
||||
assert_eq!(world_corners(&s), corners, "the block is where it was in the world");
|
||||
// The sketch left behind follows the body into its new component and still evaluates.
|
||||
let top = s.doc.find_feature("Top").unwrap();
|
||||
assert!(s.model.result(top.id).is_some_and(|r| r.error.is_none()));
|
||||
let anchor = match top.kind.clone() {
|
||||
FeatureKind::Sketch { mut plane, .. } => crate::cmd::sketch::face::anchor_mut(&mut plane).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(anchor.map(|a| (a.component, a.path)), Some((part, s.doc.first_path(part))));
|
||||
}
|
||||
|
||||
/// Moved without its body, or with the body moved from under it, a feature would break: the move
|
||||
/// is refused and nothing changes.
|
||||
#[test]
|
||||
fn move_features_refuses_a_move_that_would_break_a_feature() {
|
||||
for moves in [["S2", "Cut1"], ["S1", "Extrude1"]] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S1", "distance": 10, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("component.move_features", &json!({"features": moves, "component": part})).unwrap_err().to_string();
|
||||
assert!(e.contains("moving would"), "{moves:?}: {e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps), "{moves:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_refusals_change_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let refused = |s: &mut Session, params: Value, why: &str| {
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("component.move_features", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
};
|
||||
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
|
||||
refused(&mut s, json!({"features": ["Nope"], "component": part}), "no feature");
|
||||
refused(&mut s, json!({"features": [], "component": part}), "1…1000");
|
||||
refused(&mut s, json!({"features": ["S"], "component": "Nowhere"}), "no component");
|
||||
// A component placed twice.
|
||||
run(&mut s, "occurrence.copy", json!({"component": part}));
|
||||
refused(&mut s, json!({"features": ["S", "Extrude1"], "component": part}), "more than once");
|
||||
// Rolled back.
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_features_refuses_a_boolean_whose_inputs_would_be_split() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A", "name": "BoxA"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B", "name": "BoxB"}),
|
||||
);
|
||||
run(&mut s, "solid.combine", json!({"target": "A", "tools": ["B"], "operation": "join", "name": "Joined"}));
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part", "activate": false}))["component"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let e = s.execute("component.move_features", &json!({"features": ["BoxA"], "component": part})).unwrap_err().to_string();
|
||||
assert!(e.contains("split between components"), "{e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
/// Each kind moves by its own step, all or nothing, as one undo step: a block's features without
|
||||
/// the cut that works on it break, so the first call changes nothing; with it, the second moves
|
||||
/// the placement, the features and the body that is not made by them, and skips the block they carry.
|
||||
#[test]
|
||||
fn reparent_items_moves_each_kind_all_or_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S2"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S2", "distance": 4, "operation": "cut", "name": "Cut1"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "operation": "new_body", "body_name": "Plate", "name": "PlateBox"}),
|
||||
);
|
||||
let sub = run(&mut s, "component.create", json!({"name": "Sub", "activate": false}));
|
||||
let sub_occurrence = sub["occurrence"].as_u64().unwrap();
|
||||
let (before, steps, corners) = (s.doc.to_json(), s.undo.len(), world_corners(&s));
|
||||
let moves = |features: Value| json!({"occurrences": [sub_occurrence], "features": features, "bodies": ["Block", "Plate"], "component": part});
|
||||
// Cut1 would be left without its body: nothing moves, the placement included.
|
||||
let e = s.execute("browser.reparent", &moves(json!(["S", "Extrude1"]))).unwrap_err().to_string();
|
||||
assert!(e.contains("moving would"), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
let r = run(&mut s, "browser.reparent", moves(json!(["S", "Extrude1", "S2", "Cut1"])));
|
||||
assert_eq!(r["occurrences"], json!([sub_occurrence]), "{r}");
|
||||
assert_eq!(r["features"], json!(["S", "Extrude1", "S2", "Cut1"]), "{r}");
|
||||
assert_eq!(r["bodies"], json!(["Plate"]), "Block went with its features: {r}");
|
||||
assert_eq!(s.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == sub_occurrence).map(|o| o.parent), Some(part));
|
||||
assert_eq!(world_corners(&s), corners, "everything is where it was in the world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_items_leaves_the_contents_of_a_moved_component_alone() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let dest = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}))["component"].as_u64().unwrap();
|
||||
let occurrence = s.doc.occurrence_of(part).map(|o| o.id).unwrap();
|
||||
// A feature inside Part is named as well as Part: it travels with Part, not on its own.
|
||||
run(&mut s, "component.activate", json!({"component": part}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "Inner", "name": "InnerBox"}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
let r = run(&mut s, "browser.reparent", json!({"occurrences": [occurrence], "features": ["InnerBox"], "bodies": ["Inner"], "component": dest}));
|
||||
assert_eq!(r["features"], json!([]), "{r}");
|
||||
assert_eq!(r["bodies"], json!([]), "{r}");
|
||||
assert_eq!(s.doc.find_feature("InnerBox").map(|f| f.component), Some(part));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_items_refusals_change_nothing() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let refused = |s: &mut Session, params: Value, why: &str| {
|
||||
let (before, steps) = (s.doc.to_json(), s.undo.len());
|
||||
let e = s.execute("browser.reparent", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
assert_eq!((s.doc.to_json(), s.undo.len()), (before, steps));
|
||||
};
|
||||
refused(&mut s, json!({"component": part}), "give `occurrences`");
|
||||
refused(&mut s, json!({"features": ["S"], "component": 0}), "already in that component");
|
||||
refused(&mut s, json!({"bodies": ["Nope"], "component": part}), "no body");
|
||||
refused(&mut s, json!({"occurrences": [9999], "component": part}), "no occurrence");
|
||||
refused(&mut s, json!({"occurrences": ["x"], "component": part}), "occurrence ids");
|
||||
let many: Vec<u64> = (0..1001).collect();
|
||||
refused(&mut s, json!({"occurrences": many, "component": part}), "at most 1000");
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
refused(&mut s, json!({"features": ["S"], "component": part}), "rollback");
|
||||
}
|
||||
|
||||
/// Placements hidden inside a moved component are still hidden under their new path.
|
||||
#[test]
|
||||
fn reparent_items_keeps_hidden_placements_hidden() {
|
||||
let (mut s, part) = block_and_part();
|
||||
let made = run(&mut s, "component.create", json!({"name": "Dest", "activate": false}));
|
||||
let (dest, dest_occurrence) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
|
||||
let outer = run(&mut s, "component.create", json!({"name": "Outer", "activate": false}));
|
||||
let (outer_component, outer_occurrence) = (outer["component"].as_u64().unwrap(), outer["occurrence"].as_u64().unwrap());
|
||||
let inner =
|
||||
run(&mut s, "component.create", json!({"name": "Inner", "parent": outer_component, "activate": false}))["occurrence"].as_u64().unwrap();
|
||||
s.visibility.hidden_occurrences.push(vec![outer_occurrence, inner]);
|
||||
run(&mut s, "browser.reparent", json!({"occurrences": [outer_occurrence], "component": dest}));
|
||||
assert_eq!(s.visibility.hidden_occurrences, vec![vec![dest_occurrence, outer_occurrence, inner]]);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == outer_occurrence).map(|o| o.parent), Some(dest));
|
||||
let _ = part;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
@@ -1773,23 +1773,9 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
// A feature behind the history marker is not built, so the edit changes nothing in the model
|
||||
// yet: the reply says so, or the edit would look as if it had silently failed (#8100). Its
|
||||
// warning is the one the constructor met building it in the trial, at its place.
|
||||
let rolled_back = s.doc.marker.is_some_and(|marker| s.doc.feature_index(id).is_some_and(|index| index >= marker));
|
||||
let warning =
|
||||
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
|
||||
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
|
||||
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
|
||||
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
|
||||
if let Some(warning) = warning {
|
||||
reply["warning"] = json!(warning);
|
||||
}
|
||||
if rolled_back {
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
|
||||
}
|
||||
Ok(reply)
|
||||
Ok(json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -469,14 +469,6 @@ impl Session {
|
||||
super::component_link::resolve_design_links(&mut doc, path);
|
||||
let mut session = Self::opened(doc, saved);
|
||||
session.path = path.map(str::to_owned);
|
||||
// A design saved before body names were pinned (#6403) is pinned now, as its first
|
||||
// command would pin it: that command pinned them as part of itself, so undoing it went
|
||||
// back to the unpinned design, whose fingerprints no evaluated result matched, and the
|
||||
// first undo after opening evaluated the whole timeline again (82-93 s in a
|
||||
// 5,484-feature design). The pinned names are the ones the file's bodies have, so
|
||||
// opening is no change: the pinned design is the saved one.
|
||||
session.pin_body_names();
|
||||
session.saved = session.doc.clone();
|
||||
session
|
||||
}
|
||||
|
||||
|
||||
@@ -188,22 +188,6 @@ fn a_screw_advances_its_pitch_per_turn() {
|
||||
assert!(close_to(shown(&mut s, "Lift"), 10.0), "{}", shown(&mut s, "Lift"));
|
||||
}
|
||||
|
||||
/// A coupling's length reads a bare number as millimetres in any design, so a parameter defined
|
||||
/// in it (`lead = 1.5`) keeps that millimetre, while `parameters.add` without a unit takes the
|
||||
/// design's units (#8865): the screw advances 1.5 mm a turn in an inch design too.
|
||||
#[test]
|
||||
fn a_pitch_defined_in_an_inch_design_reads_as_the_screw_does() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
base(&mut s);
|
||||
part(&mut s, "Nut", [-1.0, -1.0, 0.0]);
|
||||
pin(&mut s, "cylindrical", "Thread", "Base", "Nut", Vec3::ZERO, Vec3::Z);
|
||||
run(&mut s, "joint.screw", json!({"joint": "Thread", "pitch": "lead = 1.5"}));
|
||||
assert_eq!(s.doc.param("lead").map(|p| (p.expr.as_str(), p.unit.as_str())), Some(("1.5", "mm")));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Thread", "values": [720]}));
|
||||
assert!(close_to(r["values"][1]["value"].as_f64().unwrap_or(f64::NAN), 3.0), "{r}");
|
||||
}
|
||||
|
||||
/// A motor gear turns the four-bar's crank: half as fast, the other way round, and the loop
|
||||
/// follows.
|
||||
#[test]
|
||||
|
||||
@@ -67,7 +67,6 @@ mod parts;
|
||||
mod point_cloud;
|
||||
mod script;
|
||||
mod section;
|
||||
pub mod settings;
|
||||
pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
pub(crate) use measure_shape::Shape;
|
||||
#[cfg(test)]
|
||||
@@ -95,7 +94,6 @@ pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_ends;
|
||||
mod sketch_equation;
|
||||
mod sketch_freeform;
|
||||
mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
@@ -288,7 +286,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_create::COMMANDS.iter());
|
||||
v.extend(sketch_equation::COMMANDS.iter());
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_import::COMMANDS.iter());
|
||||
v.extend(sketch_inspect::COMMANDS.iter());
|
||||
@@ -337,7 +334,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(view_navigation::COMMANDS.iter());
|
||||
v.extend(view_dxf::COMMANDS.iter());
|
||||
v.extend(settings::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v.extend(render::COMMANDS.iter());
|
||||
v.extend(render_ui::COMMANDS.iter());
|
||||
|
||||
@@ -1638,59 +1638,3 @@ fn occurrence_align_keeps_every_pick_and_reports_shared_definitions() {
|
||||
let r = run(&mut s, "occurrence.align", json!({"paths": [[b]], "from": [0, 0, 0], "to": [1, 0, 0]}));
|
||||
assert!(r.get("shared").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// A body copied in another design pastes here as an imported body: at the world coordinates it
|
||||
/// had there, into the component asked for (placed elsewhere), named as it was or numbered when
|
||||
/// that name is taken, from one Import feature named as any import is, as one step to undo.
|
||||
#[test]
|
||||
fn a_body_copied_in_another_design_pastes_as_an_imported_body() {
|
||||
let mut source = Session::default();
|
||||
source.doc_mut().name = "Bracket".into();
|
||||
let (_, holder) = created(&mut source, "Holder");
|
||||
run(&mut source, "occurrence.move", json!({"occurrence": holder, "translate": [0,50,0]}));
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let was = source.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
let copy = crate::ForeignCopy::of(&source, vec!["Cube".into()], Vec::new());
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Cube"}));
|
||||
let (a, a_at) = created(&mut s, "A");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": a_at, "translate": [100,0,0]}));
|
||||
s.foreign_copy = Some(std::sync::Arc::new(copy));
|
||||
let undo = s.undo.len();
|
||||
let out = run(&mut s, "component.paste_from_design", json!({"component": a, "component_path": [a_at]}));
|
||||
assert_eq!(out["bodies"], json!(["Cube (2)"]));
|
||||
assert_eq!(out["feature"], "Import1");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let is = s.world_state().body("Cube (2)").unwrap().mesh().bounds();
|
||||
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
|
||||
let state = s.model.state();
|
||||
assert_eq!(s.doc.model_body_component(state.body("Cube (2)").unwrap()), a);
|
||||
assert!(matches!(&s.doc.find_feature("Import1").unwrap().kind, solidcraft_doc::FeatureKind::Import { file, .. } if file == "Bracket (pasted)"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().body("Cube (2)").is_none());
|
||||
}
|
||||
|
||||
/// A body seen in another placement of its component ("Cube (Holder:2)") pastes into another
|
||||
/// design under its own name, at that placement's position.
|
||||
#[test]
|
||||
fn a_body_seen_in_another_placement_pastes_into_another_design_under_its_own_name() {
|
||||
let mut source = Session::default();
|
||||
source.doc_mut().name = "Bracket".into();
|
||||
let (holder, first) = created(&mut source, "Holder");
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
run(&mut source, "component.activate", json!({"component": 0}));
|
||||
let second = run(&mut source, "occurrence.copy", json!({"occurrence": first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut source, "occurrence.move", json!({"occurrence": second, "translate": [0,40,0]}));
|
||||
let seen = source.placed_document().world_body_name(&source.model.state(), "Cube", holder, &[second]);
|
||||
assert_ne!(seen, "Cube");
|
||||
let was = source.world_state().body(&seen).unwrap().mesh().bounds();
|
||||
let copy = crate::ForeignCopy::of(&source, vec![seen], Vec::new());
|
||||
assert_eq!(copy.names, vec!["Cube".to_string()]);
|
||||
|
||||
let mut s = Session { foreign_copy: Some(std::sync::Arc::new(copy)), ..Default::default() };
|
||||
let out = run(&mut s, "component.paste_from_design", json!({}));
|
||||
assert_eq!(out["bodies"], json!(["Cube"]));
|
||||
let is = s.world_state().body("Cube").unwrap().mesh().bounds();
|
||||
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit; \
|
||||
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
|
||||
or sets `new` when the expression is just `new`); or delete: name; or changes: {name: expression, …} (existing \
|
||||
@@ -467,9 +467,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => None,
|
||||
};
|
||||
let (vals, _) = s.doc.param_values();
|
||||
// Bare numbers follow the asked-for unit when given, else the design's length unit (#9015).
|
||||
let doc_len = expr::unit_info(s.doc.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0);
|
||||
let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), doc_len);
|
||||
let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), 1.0);
|
||||
let refs = expr::references(&e);
|
||||
let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| solidcraft_doc::DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
let r = expr::eval_with_defaults(&e, &lookup, defaults).and_then(|v| match kind {
|
||||
|
||||
@@ -326,81 +326,6 @@ fn csv_and_json_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #8960: the text marker accepts ASCII case variants without changing numeric units.
|
||||
#[test]
|
||||
fn text_parameter_imports_accept_mixed_case_units() {
|
||||
for unit in ["text", "Text", "TEXT", "tExT", " Text "] {
|
||||
let csv = format!("name,unit,expression,comment\nwidth,in,1,numeric control\nverID,{unit},\"\"\"REV-A\"\"\",quoted label\n");
|
||||
let json_text = json!([
|
||||
{"name": "width", "unit": "in", "expression": "1", "comment": "numeric control"},
|
||||
{"name": "verID", "unit": unit, "expression": "\"REV-A\"", "comment": "quoted label"}
|
||||
])
|
||||
.to_string();
|
||||
for text in [csv, json_text] {
|
||||
let mut s = Session::default();
|
||||
// Check both adding and updating a text parameter through the import command.
|
||||
for expected in ["REV-A", "REV-B"] {
|
||||
let r = run(&mut s, "parameters.import", json!({"text": text.replace("REV-A", expected)}));
|
||||
assert_eq!(r["imported"], json!(["width", "verID"]));
|
||||
assert_eq!(r["errors"], json!([]));
|
||||
let list = run(&mut s, "parameters.list", json!({}));
|
||||
assert_eq!(list["count"], 2);
|
||||
let label = row(&list, "verID");
|
||||
assert_eq!(label["unit"], "text");
|
||||
assert_eq!(label["text"], expected);
|
||||
assert_eq!(label["expression"], expected);
|
||||
assert_eq!(label["comment"], "quoted label");
|
||||
assert!(label["value"].is_null());
|
||||
assert_eq!(row(&list, "width")["unit"], "in");
|
||||
assert_eq!(row(&list, "width")["value"], 1.0);
|
||||
assert_eq!(s.doc.param_values().0["width"].v, 25.4);
|
||||
}
|
||||
// Export still uses the canonical marker and round-trips the unquoted text.
|
||||
let exported = run(&mut s, "parameters.export", json!({"format": "csv"}));
|
||||
assert!(exported["text"].as_str().unwrap().contains("verID,text,REV-B,,quoted label,"), "{exported}");
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "parameters.import", json!({"text": exported["text"]}));
|
||||
assert_eq!(reopened.doc.text_param("verID").unwrap().text, "REV-B");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_parameter_imports_keep_unknown_units_atomic() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "verID", "unit": "text", "expression": "original"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "width", "unit": "mm", "expression": "20"}));
|
||||
let before = s.doc.to_json();
|
||||
for unit in ["Textual", "MM"] {
|
||||
let csv = format!("name,unit,expression\nverID,Text,replacement\nwidth,mm,30\nbad,{unit},1\n");
|
||||
let json_text = json!([
|
||||
{"name": "verID", "unit": "Text", "expression": "replacement"},
|
||||
{"name": "width", "unit": "mm", "expression": "30"},
|
||||
{"name": "bad", "unit": unit, "expression": "1"}
|
||||
])
|
||||
.to_string();
|
||||
for text in [csv, json_text] {
|
||||
let why = err(&mut s, "parameters.import", json!({"text": text}));
|
||||
assert!(why.contains(&format!("unknown unit `{unit}`")), "{why}");
|
||||
assert_eq!(s.doc.to_json(), before, "a rejected row must roll back the entire import");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_parameter_commands_accept_mixed_case_units() {
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "parameters.add", json!({"name": "Label", "unit": "Text", "expression": "\"TAG-A\""}));
|
||||
assert_eq!(r["unit"], "text");
|
||||
assert_eq!(r["text"], "TAG-A");
|
||||
run(&mut s, "parameters.change", json!({"name": "Label", "unit": "TEXT", "expression": "'TAG-B'"}));
|
||||
assert_eq!(s.doc.text_param("Label").unwrap().text, "TAG-B");
|
||||
let r = run(&mut s, "expr.evaluate", json!({"unit": " tExT ", "expression": "\"TAG-C\""}));
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["kind"], "text");
|
||||
assert_eq!(r["text"], "TAG-C");
|
||||
}
|
||||
|
||||
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
|
||||
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
|
||||
/// Returns the session, the sketch feature and the nine dimensions' parameters.
|
||||
@@ -887,69 +812,6 @@ fn a_defined_length_takes_the_design_unit() {
|
||||
assert!(rel(volume(&mut s), 50.8 * 25.4 * 25.4) < 1e-9);
|
||||
}
|
||||
|
||||
/// A parameter added without a unit takes the unit of its value, a length in the design's units
|
||||
/// as a typed length would be (#8865): `40/2` in an inch design is 20 in, as the Parameters
|
||||
/// dialog makes it. A unit given, an angle and a unit-less value keep their own.
|
||||
#[test]
|
||||
fn a_parameter_added_without_a_unit_takes_the_design_unit() {
|
||||
let added = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "parameters.add", p);
|
||||
(r["unit"].as_str().unwrap_or("?").to_string(), r["value"].as_f64().unwrap_or(f64::NAN), r["display_value"].as_f64().unwrap_or(f64::NAN))
|
||||
};
|
||||
let close = |got: (String, f64, f64), unit: &str, mm: f64, shown: f64| {
|
||||
assert!(got.0 == unit && rel(got.1, mm) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {mm} mm ({shown})");
|
||||
};
|
||||
// A millimetre design is as it was.
|
||||
let mut s = Session::default();
|
||||
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "mm", 20.0, 20.0);
|
||||
close(added(&mut s, json!({"name": "w", "expression": "v + 1"})), "mm", 21.0, 21.0);
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
close(added(&mut s, json!({"name": "v", "expression": "40/2"})), "in", 508.0, 20.0);
|
||||
close(added(&mut s, json!({"name": "given", "expression": "40/2", "unit": "in"})), "in", 508.0, 20.0);
|
||||
close(added(&mut s, json!({"name": "metric", "expression": "40/2", "unit": "mm"})), "mm", 20.0, 20.0);
|
||||
// A value with a unit keeps its size and shows in the design's units.
|
||||
close(added(&mut s, json!({"name": "plate", "expression": "25.4 mm"})), "in", 25.4, 1.0);
|
||||
// One that refers to another parameter: its bare number is an inch too.
|
||||
close(added(&mut s, json!({"name": "more", "expression": "v + 1"})), "in", 533.4, 21.0);
|
||||
close(added(&mut s, json!({"name": "half", "expression": "v / 2"})), "in", 254.0, 10.0);
|
||||
// An angle stays in degrees, a unit-less count stays unit-less.
|
||||
close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30f64.to_radians(), 30.0);
|
||||
close(added(&mut s, json!({"name": "n", "expression": "4", "unit": ""})), "", 4.0, 4.0);
|
||||
close(added(&mut s, json!({"name": "twice", "expression": "n * 2"})), "", 8.0, 8.0);
|
||||
// parameters.change and parameters.import make a new parameter the same way.
|
||||
run(&mut s, "parameters.change", json!({"name": "c", "expression": "3"}));
|
||||
assert_eq!(s.doc.param("c").map(|p| p.unit.as_str()), Some("in"));
|
||||
run(&mut s, "parameters.import", json!({"text": "name,expression\nimported,2\n"}));
|
||||
assert_eq!(s.doc.param("imported").map(|p| p.unit.as_str()), Some("in"));
|
||||
let (vals, _) = s.doc.param_values();
|
||||
assert!(vals.get("imported").is_some_and(|v| rel(v.v, 50.8) < 1e-9), "{:?}", vals.get("imported"));
|
||||
// An existing parameter changed without a unit keeps its own.
|
||||
run(&mut s, "parameters.change", json!({"name": "metric", "expression": "30"}));
|
||||
assert_eq!(s.doc.param("metric").map(|p| p.unit.as_str()), Some("mm"));
|
||||
// A centimetre design works the same way.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "cm"}));
|
||||
close(added(&mut s, json!({"name": "v", "expression": "3"})), "cm", 30.0, 3.0);
|
||||
}
|
||||
|
||||
/// Hostile parameters.add input in an inch design is refused with a reason and adds nothing.
|
||||
#[test]
|
||||
fn a_hostile_parameter_in_an_inch_design_is_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
// 1e308 inches is past any finite millimetre value.
|
||||
for (name, e) in [("huge", "1e308"), ("inf", "1e308 * 1e308"), ("nan", "0/0"), ("bad", "2 furlong"), ("empty", " "), ("self_ref", "self_ref + 1")]
|
||||
{
|
||||
let why = err(&mut s, "parameters.add", json!({"name": name, "expression": e}));
|
||||
assert!(!why.is_empty() && s.doc.param(name).is_none(), "{name}: {why}");
|
||||
}
|
||||
let why = err(&mut s, "parameters.add", json!({"name": "u", "expression": "1", "unit": "furlong"}));
|
||||
assert!(why.contains("unknown unit"), "{why}");
|
||||
assert!(s.doc.params.is_empty());
|
||||
}
|
||||
|
||||
/// A definition that cannot be made fails the command and leaves the design as it was: a taken,
|
||||
/// reserved or malformed name, no value, a value that names itself, two different values for one
|
||||
/// name, and a parameter's or configuration cell's own expression.
|
||||
@@ -1033,18 +895,6 @@ fn expr_of_now(s: &Session, name: &str) -> String {
|
||||
s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #9015: `expr.evaluate` of a length with a bare offset uses the design's units.
|
||||
#[test]
|
||||
fn expr_evaluate_bare_offsets_follow_design_units() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "baseLength", "expression": "76"}));
|
||||
let r = run(&mut s, "expr.evaluate", json!({"expression": "baseLength - 1", "kind": "length"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
// 75 in in millimetres (without the fix this was 76 in − 1 mm).
|
||||
assert!((r["value"].as_f64().unwrap_or(0.0) - 75.0 * 25.4).abs() < 1e-9, "{r}");
|
||||
}
|
||||
|
||||
/// A dimension made as `w = 20` shows `w`; typing `w = 30` over it sets `w` rather than being
|
||||
/// refused as a taken name (#4071).
|
||||
#[test]
|
||||
|
||||
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
|
||||
),
|
||||
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
|
||||
"family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
|
||||
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
|
||||
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
|
||||
),
|
||||
@@ -33,7 +33,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
/// A family: (id, name, standard).
|
||||
const FAMILIES: &[(&str, &str, &str)] = &[
|
||||
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
|
||||
("button_head_cap_screw", "Button head cap screw", "ISO 7380-1"),
|
||||
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
|
||||
("hex_nut", "Hex nut", "ISO 4032"),
|
||||
("washer", "Plain washer", "ISO 7089"),
|
||||
@@ -78,33 +77,6 @@ const SHCS: [(&str, f64, f64, f64, f64); 14] = [
|
||||
("M30", 45.0, 30.0, 22.0, 15.5),
|
||||
];
|
||||
|
||||
/// ISO 7380-1: (size, head diameter dk, head height k, socket s, socket depth t, the last being
|
||||
/// the key engagement depth, which is 0.59 to 0.63 k throughout). The standard runs M3 to M16:
|
||||
/// the M2.5 and M14 some suppliers list are not in it.
|
||||
const BHCS: [(&str, f64, f64, f64, f64); 8] = [
|
||||
("M3", 5.7, 1.65, 2.0, 1.04),
|
||||
("M4", 7.6, 2.2, 2.5, 1.3),
|
||||
("M5", 9.5, 2.75, 3.0, 1.56),
|
||||
("M6", 10.5, 3.3, 4.0, 2.08),
|
||||
("M8", 14.0, 4.4, 5.0, 2.6),
|
||||
("M10", 17.5, 5.5, 6.0, 3.12),
|
||||
("M12", 21.0, 6.6, 8.0, 4.16),
|
||||
("M16", 28.0, 8.8, 10.0, 5.2),
|
||||
];
|
||||
|
||||
/// A button head's cylindrical flank, as a fraction of the head height k, and the flat top it
|
||||
/// keeps around its socket, as a fraction of the socket's across-flats s. The flat clears the
|
||||
/// socket's corners (0.577 s) at every size.
|
||||
///
|
||||
/// ISO 7380-1 draws neither, and a real button head has no flat at all: its crown curves on and
|
||||
/// the socket's corners break through it. The flat is ours, so that the socket is a plain cut on
|
||||
/// a plane. It costs us the standard's crown radius r1, which the head's own dk and k then fix:
|
||||
/// the result is inside r1's tolerance at six of the eight sizes, 0.08 under it at M3 and 0.12
|
||||
/// over it at M12. No one pair of fractions is inside it at all eight, and taking r1 as given
|
||||
/// instead leaves no flat clear of the socket below M10, so dk, k, s and t are what we keep exact.
|
||||
const BUTTON_FLANK: f64 = 0.15;
|
||||
const BUTTON_FLAT: f64 = 0.7;
|
||||
|
||||
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
|
||||
const HEX_BOLT: [(&str, f64, f64); 14] = [
|
||||
("M2", 4.0, 1.4),
|
||||
@@ -198,7 +170,7 @@ fn default_length(d: f64) -> f64 {
|
||||
/// One part's shape, in its own frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Shape {
|
||||
/// A head (round, domed or hex), a shank down −z, an optional hex socket in the head's top.
|
||||
/// A head (round or hex), a shank down −z, an optional hex socket in the head's top.
|
||||
Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> },
|
||||
/// A hex prism with a through hole (nut), z 0…m.
|
||||
Nut { d: f64, pitch: f64, s: f64, m: f64 },
|
||||
@@ -210,21 +182,8 @@ enum Shape {
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Head {
|
||||
Round {
|
||||
dk: f64,
|
||||
k: f64,
|
||||
},
|
||||
/// A button head: a short cylindrical flank, a crown domed over it, and a flat top of
|
||||
/// radius `flat` around the socket, at z = k.
|
||||
Dome {
|
||||
dk: f64,
|
||||
k: f64,
|
||||
flat: f64,
|
||||
},
|
||||
Hex {
|
||||
s: f64,
|
||||
k: f64,
|
||||
},
|
||||
Round { dk: f64, k: f64 },
|
||||
Hex { s: f64, k: f64 },
|
||||
}
|
||||
|
||||
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
|
||||
@@ -236,11 +195,6 @@ fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<S
|
||||
let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?;
|
||||
Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) }
|
||||
}
|
||||
"button_head_cap_screw" => {
|
||||
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
|
||||
let (_, dk, k, s, t) = BHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7380-1"))?;
|
||||
Shape::Screw { d, pitch, len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) }
|
||||
}
|
||||
"hex_bolt" => {
|
||||
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
|
||||
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
|
||||
@@ -306,41 +260,15 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
|
||||
|
||||
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
|
||||
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
|
||||
revolve_outline(s, pts, None, op)
|
||||
}
|
||||
|
||||
/// The same, with the outline closed from its last point back to its first by an arc through
|
||||
/// `through`: a button head's domed crown.
|
||||
fn revolve_domed(s: &mut Session, pts: &[(f64, f64)], through: (f64, f64), op: &str) -> Result<()> {
|
||||
revolve_outline(s, pts, Some(through), op)
|
||||
}
|
||||
|
||||
fn revolve_outline(s: &mut Session, pts: &[(f64, f64)], arc: Option<(f64, f64)>, op: &str) -> Result<()> {
|
||||
run(s, "sketch.create", json!({"plane": "XZ"}))?;
|
||||
let xz = |(r, z): &(f64, f64)| [*r, -*z];
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(xz).collect();
|
||||
run(s, "sketch.line", json!({"points": points, "closed": arc.is_none()}))?;
|
||||
if let (Some(through), Some(first), Some(last)) = (arc, pts.first(), pts.last()) {
|
||||
run(s, "sketch.arc.three_point", json!({"start": xz(last), "through": xz(&through), "end": xz(first)}))?;
|
||||
}
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
|
||||
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
|
||||
fix_all(s)?;
|
||||
run(s, "sketch.finish", json!({}))?;
|
||||
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A point halfway along a button head's crown: on the circle centred on the axis that runs
|
||||
/// from the top of the flank (a, c) to the rim of the flat top (b, k).
|
||||
fn crown(a: f64, c: f64, b: f64, k: f64) -> (f64, f64) {
|
||||
// The centre on the axis, equidistant from both, and the radius from it.
|
||||
let zc = (b * b + k * k - a * a - c * c) / (2.0 * (k - c));
|
||||
let r = (b * b + (k - zc) * (k - zc)).sqrt();
|
||||
// Halfway between the directions to the two ends.
|
||||
let (x, z) = ((a + b) / r, (c - zc + k - zc) / r);
|
||||
let n = x.hypot(z);
|
||||
if n.is_finite() && n > 0.0 { (r * x / n, zc + r * z / n) } else { ((a + b) / 2.0, (c + k) / 2.0) }
|
||||
}
|
||||
|
||||
/// Fix every point and curve of the sketch being drawn: a standard part's outline is its size, so
|
||||
/// its sketches are fully defined and a check of the design finds nothing left free in them.
|
||||
fn fix_all(s: &mut Session) -> Result<()> {
|
||||
@@ -369,14 +297,6 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
|
||||
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?;
|
||||
k
|
||||
}
|
||||
// The same outline up to the head's rim, then a short flank, and the arc of the
|
||||
// crown closing it back to the flat top.
|
||||
Head::Dome { dk, k, flat } => {
|
||||
let (a, c) = (dk / 2.0, BUTTON_FLANK * k);
|
||||
let outline = [(flat, k), (0.0, k), (0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (a, 0.0), (a, c)];
|
||||
revolve_domed(s, &outline, crown(a, c, flat, k), "new")?;
|
||||
k
|
||||
}
|
||||
// A hex head joined over a shank that runs up into it.
|
||||
Head::Hex { s: af, k } => {
|
||||
hex(s, af, 0.0, k, "new")?;
|
||||
@@ -414,7 +334,6 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fam = |id: &str| -> Value {
|
||||
let mut sizes: Vec<Value> = match id {
|
||||
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
|
||||
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
|
||||
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
|
||||
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
|
||||
@@ -424,8 +343,7 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
|
||||
let lengths =
|
||||
matches!(id, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
|
||||
let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
|
||||
if lengths.is_some() {
|
||||
for row in &mut sizes {
|
||||
let Some(size) = row.get("size").and_then(Value::as_str).map(str::to_string) else { continue };
|
||||
@@ -460,7 +378,6 @@ fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) {
|
||||
};
|
||||
let standard = match family {
|
||||
"socket_head_cap_screw" => "ISO 4762",
|
||||
"button_head_cap_screw" => "ISO 7380-1",
|
||||
"hex_bolt" => "ISO 4017",
|
||||
"hex_nut" => "ISO 4032",
|
||||
"washer" => "ISO 7089",
|
||||
@@ -494,7 +411,6 @@ fn set_identity(s: &mut Session, comp: u64, now: (String, String), was: Option<(
|
||||
fn label(family: &str) -> &'static str {
|
||||
match family {
|
||||
"socket_head_cap_screw" => "SHCS",
|
||||
"button_head_cap_screw" => "BHCS",
|
||||
"hex_bolt" => "Hex Bolt",
|
||||
"hex_nut" => "Hex Nut",
|
||||
"washer" => "Washer",
|
||||
@@ -717,40 +633,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7692: a button head cap screw (ISO 7380-1) takes the same parameters as a socket head
|
||||
/// cap screw, with a domed crown in place of the cylindrical head.
|
||||
#[test]
|
||||
fn a_button_head_is_domed_over_the_shank() {
|
||||
let l = run(&mut Session::default(), "parts.library", json!({"family": "button_head_cap_screw"}));
|
||||
let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default();
|
||||
assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.5), Some(3.3), Some(4.0)));
|
||||
// ISO 7380-1 has no M2, M2.5, M14 or larger than M16; the lengths are a screw's.
|
||||
let sizes: Vec<&str> = l["sizes"].as_array().map(|a| a.iter().filter_map(|x| x["size"].as_str()).collect()).unwrap_or_default();
|
||||
assert_eq!(sizes, ["M3", "M4", "M5", "M6", "M8", "M10", "M12", "M16"]);
|
||||
assert!(l["lengths"].as_array().is_some_and(|a| !a.is_empty()), "{l}");
|
||||
// An M6 x 20 button head: head Ø10.5, 3.3 high over the seating plane, shank 20 long.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parts.insert", json!({"family": "button_head_cap_screw", "size": "M6", "length": 20}));
|
||||
let (lo, hi) = bbox(&s, "BHCS M6x20");
|
||||
assert!((hi.x - lo.x - 10.5).abs() < 1e-3 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 3.3).abs() < 1e-3, "{lo:?} {hi:?}");
|
||||
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
|
||||
// The profile: full width at the rim and up the flank (0.15 k = 0.50 high), drawing in
|
||||
// over the crown (r = 4.62 halfway up the head), and a flat top of 0.7 s = 2.8, which
|
||||
// leaves a ring of material around the socket's corners at r = 2.31.
|
||||
let st = s.world_state();
|
||||
let widest = |above: f64| {
|
||||
st.bodies.iter().flat_map(|b| b.mesh().positions.clone()).filter(|p| p.z > above).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max)
|
||||
};
|
||||
assert!((5.2..5.26).contains(&widest(-0.1)), "the rim: {}", widest(-0.1));
|
||||
assert!((5.2..5.26).contains(&widest(0.3)), "the flank: {}", widest(0.3));
|
||||
assert!((3.5..4.7).contains(&widest(1.65)), "the crown halfway up: {}", widest(1.65));
|
||||
assert!((2.6..2.9).contains(&widest(3.29)), "the flat top: {}", widest(3.29));
|
||||
// Its part number is the standard and size, as the other families' are.
|
||||
let comp = s.doc.components.iter().find(|c| c.name.starts_with("BHCS")).map(|c| c.id).unwrap_or(0);
|
||||
let number = s.doc.components.iter().find(|c| c.id == comp).and_then(|c| c.metadata.text(solidcraft_doc::metadata::PART_NUMBER));
|
||||
assert_eq!(number, Some("ISO 7380-1 M6x20"));
|
||||
}
|
||||
|
||||
/// #2091: the second and larger ISO sizes, M2, M2.5, M14, M24 and M30, are in the screw, bolt,
|
||||
/// nut and washer tables (they stopped at M3 and M20).
|
||||
#[test]
|
||||
@@ -804,7 +686,6 @@ mod tests {
|
||||
fn every_sketch_of_a_standard_part_is_fully_defined() {
|
||||
for (family, size, resized) in [
|
||||
("socket_head_cap_screw", "M6", "M8"),
|
||||
("button_head_cap_screw", "M6", "M8"),
|
||||
("hex_bolt", "M6", "M8"),
|
||||
("hex_nut", "M6", "M8"),
|
||||
("washer", "M6", "M8"),
|
||||
@@ -829,7 +710,6 @@ mod tests {
|
||||
fn resizing_placed_parts_uses_local_geometry_and_millimetres_in_every_design_unit() {
|
||||
for (family, initial, resized, prefix) in [
|
||||
("socket_head_cap_screw", "M6", "M8", "SHCS"),
|
||||
("button_head_cap_screw", "M6", "M8", "BHCS"),
|
||||
("hex_bolt", "M6", "M8", "Hex Bolt"),
|
||||
("hex_nut", "M6", "M8", "Hex Nut"),
|
||||
("washer", "M6", "M8", "Washer"),
|
||||
@@ -846,7 +726,7 @@ mod tests {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{family} {units}");
|
||||
let b = bbox(&s, prefix);
|
||||
assert!(b.0.x < b.1.x && b.0.y < b.1.y && b.0.z < b.1.z, "{family} {units}: {b:?}");
|
||||
if matches!(family, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
|
||||
if matches!(family, "socket_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
|
||||
assert!(s.model.state().threads.iter().any(|t| t.designation == "M8" && (t.pitch - 1.25).abs() < 1e-9), "{family} {units}");
|
||||
}
|
||||
if family == "dowel_pin" {
|
||||
|
||||
@@ -1006,22 +1006,6 @@ fn pattern_count_units_explain_how_to_use_a_parameter() -> Result<(), Box<dyn st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #9015: in an inch design, pattern spacing `baseLength - 1` subtracts one inch, not one mm.
|
||||
#[test]
|
||||
fn pattern_spacing_bare_offsets_follow_design_units() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "baseLength", "expression": "4"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Seed"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Seed"], "dir1": [1, 0, 0], "count1": 2, "spacing1": "baseLength - 1"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
// Spacing is 3 in = 76.2 mm (not 3 mm): the seed is 10 in, so the copy starts at x = 76.2.
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let bodies = m["bodies"].as_array().expect("bodies");
|
||||
let xmin = |b: &Value| b["bbox"]["min"][0].as_f64().unwrap_or(0.0);
|
||||
assert!(bodies.iter().any(|b| (xmin(b) - 76.2).abs() < 1e-6), "expected a copy spaced 3 in apart, got {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_count_fractions_are_refused() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut s = Session::default();
|
||||
@@ -1065,41 +1049,3 @@ fn pattern_count_integer_parameters_survive_save_and_reject_fractional_edits() -
|
||||
assert!(invalid.model.results.iter().any(|r| r.error.as_ref().is_some_and(|e| e.contains("a count must be a whole number;"))));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Editing a pattern behind the history marker (double-clicking its greyed-out item and pressing
|
||||
/// OK) changes nothing in the model: the reply says so, where the edit used to look as if it had
|
||||
/// silently failed, and it still carries what building the copies met (#8100).
|
||||
#[test]
|
||||
fn editing_a_rolled_back_pattern_says_it_is_not_built() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"corner":[0,0,0],"body_name":"Plate"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":2,"height":20,"base":[10,15,-5],"operation":"cut","targets":["Plate"],"name":"Cut"}));
|
||||
let pattern = |count: u32, origin: [f64; 3]| json!({"features":["Cut"],"axis":{"origin":origin,"dir":[0,0,1]},"count":count});
|
||||
let redefine = |count, origin| json!({"feature":"Pattern","command":"solid.pattern.circular","params":pattern(count, origin)});
|
||||
let centre = [20.0, 15.0, 0.0];
|
||||
let hole = PI * 4.0 * 10.0;
|
||||
let mut first = pattern(2, centre);
|
||||
first["name"] = json!("Pattern");
|
||||
let id = run(&mut s, "solid.pattern.circular", first)["feature"].as_u64().unwrap();
|
||||
assert!(rel(volume(&mut s), 12000.0 - 2.0 * hole) < 1e-4);
|
||||
// A pattern that is built: the edit is built at once, with nothing to say.
|
||||
let reply = run(&mut s, "timeline.redefine", redefine(3, centre));
|
||||
assert_eq!(reply["rolled_back"], json!(false), "{reply}");
|
||||
assert!(reply.get("note").is_none(), "{reply}");
|
||||
assert!(rel(volume(&mut s), 12000.0 - 3.0 * hole) < 1e-4);
|
||||
// Behind the marker: the edit is kept but not built, and the reply says why nothing changed.
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 2}));
|
||||
let reply = run(&mut s, "timeline.redefine", redefine(4, centre));
|
||||
assert_eq!(reply["rolled_back"], json!(true), "{reply}");
|
||||
assert_eq!(reply["note"], "Pattern is after the history marker, so it is not built: move the marker past it to build it");
|
||||
assert!(reply.get("warning").is_none(), "{reply}");
|
||||
assert!(rel(volume(&mut s), 12000.0 - hole) < 1e-4);
|
||||
assert!(s.model.result(id).is_some_and(|r| r.skipped && r.error.is_none()));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
assert!(rel(volume(&mut s), 12000.0 - 4.0 * hole) < 1e-4);
|
||||
// What building the rolled-back copies met still reaches the reply: here a copy that misses.
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 2}));
|
||||
let reply = run(&mut s, "timeline.redefine", redefine(2, [0.0, 0.0, 0.0]));
|
||||
assert_eq!(reply["rolled_back"], json!(true), "{reply}");
|
||||
assert_eq!(reply["warning"], "1 of 1 cut copies remove no material", "{reply}");
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ fn embedded_hdr_capture_rejects_parameters_scope_and_cpu_primaries_without_mutat
|
||||
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-hdr-scope-{}-{nonce}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-hdr-scope-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let allowed = dir.join("allowed");
|
||||
std::fs::create_dir(&allowed).unwrap();
|
||||
|
||||
@@ -19,7 +19,6 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.section";
|
||||
if bool_(p, "clear").unwrap_or(false) {
|
||||
s.section = None;
|
||||
s.status_slot(crate::status_items::StatusId::Section, false);
|
||||
s.revision += 1;
|
||||
return Ok(json!({"section": null}));
|
||||
}
|
||||
@@ -76,7 +75,6 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
|
||||
s.section = Some((origin, normal));
|
||||
s.section_hatch = bool_(p, "hatch").unwrap_or(true);
|
||||
s.status_slot(crate::status_items::StatusId::Section, true);
|
||||
s.revision += 1;
|
||||
let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch});
|
||||
if let Some(i) = axis {
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Settings profiles (#6237): named sets of the input and interface preferences, kept by the
|
||||
//! app. The engine holds no preferences, so these commands only check and echo their
|
||||
//! parameters; the desktop and web apps act on the echo (`settings_profiles` in the UI crate).
|
||||
//! Run without an app (CLI, scripts), they do nothing else.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most characters in a profile's name.
|
||||
pub const MAX_PROFILE_NAME: usize = 64;
|
||||
/// Most bytes in a path given to Export or Import.
|
||||
const MAX_PATH: usize = 4096;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("settings.profile.list", "Settings Profiles", list)
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ profiles: [name], active: name|null; handled by the app"),
|
||||
CommandSpec::new("settings.profile.save", "Save Settings Profile", save)
|
||||
.icon("save")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("name: 1..64 characters → name; keeps the current input and interface settings under `name` (replacing a profile of that name) and makes it the active one"),
|
||||
CommandSpec::new("settings.profile.apply", "Apply Settings Profile", apply)
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("name → name, applied, skipped: [preference]; switches every setting the profile holds at once"),
|
||||
CommandSpec::new("settings.profile.rename", "Rename Settings Profile", rename)
|
||||
.icon("rename")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("name, to: 1..64 characters → name"),
|
||||
CommandSpec::new("settings.profile.delete", "Delete Settings Profile", delete)
|
||||
.icon("delete")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("name → deleted"),
|
||||
CommandSpec::new("settings.profile.export", "Export Settings Profile", export)
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("name, path?: file → profile (a `solidcraft-profile/1` document), written to `path` when given; never opens a dialog"),
|
||||
CommandSpec::new("settings.profile.import", "Import Settings Profile", import)
|
||||
.icon("import")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("path: file | profile: `solidcraft-profile/1` document, name?: 1..64 characters → name; adds the profile without switching to it"),
|
||||
];
|
||||
|
||||
/// A profile name as given: trimmed, 1 to [`MAX_PROFILE_NAME`] characters, no control characters.
|
||||
pub fn profile_name(name: &str) -> Option<&str> {
|
||||
let name = name.trim();
|
||||
(!name.is_empty() && name.chars().count() <= MAX_PROFILE_NAME && !name.chars().any(char::is_control)).then_some(name)
|
||||
}
|
||||
|
||||
fn name_param<'a>(cmd: &str, p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
str_(p, key)
|
||||
.and_then(profile_name)
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be 1 to {MAX_PROFILE_NAME} characters, without control characters")))
|
||||
}
|
||||
|
||||
fn path_param<'a>(cmd: &str, p: &'a Value) -> Result<Option<&'a str>> {
|
||||
match p.get("path") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(path)) if !path.trim().is_empty() && path.len() <= MAX_PATH => Ok(Some(path)),
|
||||
Some(_) => Err(bad(cmd, format!("`path` must be a file path of at most {MAX_PATH} bytes"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn save(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": name_param("settings.profile.save", p, "name")? }))
|
||||
}
|
||||
|
||||
fn apply(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": name_param("settings.profile.apply", p, "name")? }))
|
||||
}
|
||||
|
||||
fn rename(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "settings.profile.rename";
|
||||
Ok(json!({ "name": name_param(cmd, p, "name")?, "to": name_param(cmd, p, "to")? }))
|
||||
}
|
||||
|
||||
fn delete(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": name_param("settings.profile.delete", p, "name")? }))
|
||||
}
|
||||
|
||||
fn export(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "settings.profile.export";
|
||||
let mut out = Map::new();
|
||||
out.insert("name".into(), json!(name_param(cmd, p, "name")?));
|
||||
if let Some(path) = path_param(cmd, p)? {
|
||||
out.insert("path".into(), json!(path));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
fn import(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "settings.profile.import";
|
||||
let mut out = Map::new();
|
||||
let (key, value) = match (path_param(cmd, p)?, p.get("profile")) {
|
||||
(Some(path), None) => ("path", json!(path)),
|
||||
(None, Some(profile)) if profile.is_object() => ("profile", profile.clone()),
|
||||
_ => return Err(bad(cmd, "give either `path` or `profile` (a `solidcraft-profile/1` document)")),
|
||||
};
|
||||
out.insert(key.into(), value);
|
||||
if p.get("name").is_some_and(|name| !name.is_null()) {
|
||||
out.insert("name".into(), json!(name_param(cmd, p, "name")?));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(id: &str, p: Value) -> std::result::Result<Value, String> {
|
||||
let mut s = Session::default();
|
||||
s.execute(id, &p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_trimmed_and_bounded() {
|
||||
assert_eq!(run("settings.profile.save", json!({"name":" Laptop "})).unwrap(), json!({"name":"Laptop"}));
|
||||
for name in [json!(""), json!(" "), json!("a\nb"), json!("x".repeat(65)), json!(3), Value::Null] {
|
||||
assert!(run("settings.profile.save", json!({ "name": name })).is_err(), "{name}");
|
||||
}
|
||||
assert!(run("settings.profile.save", json!({"name":"é".repeat(64)})).is_ok());
|
||||
assert!(run("settings.profile.rename", json!({"name":"A"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_takes_a_path_or_a_document_but_not_both() {
|
||||
assert_eq!(run("settings.profile.import", json!({"path":"/tmp/p.json"})).unwrap(), json!({"path":"/tmp/p.json"}));
|
||||
let doc = json!({"format":"solidcraft-profile/1"});
|
||||
assert_eq!(run("settings.profile.import", json!({"profile":doc, "name":"B"})).unwrap(), json!({"profile":doc, "name":"B"}));
|
||||
assert!(run("settings.profile.import", json!({"path":"/tmp/p.json", "profile":doc})).is_err());
|
||||
assert!(run("settings.profile.import", json!({"profile":"text"})).is_err());
|
||||
assert!(run("settings.profile.import", json!({})).is_err());
|
||||
assert!(run("settings.profile.export", json!({"name":"A", "path":"x".repeat(5000)})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_commands_leave_the_design_and_its_history_alone() {
|
||||
let mut s = Session::default();
|
||||
let doc = s.doc.to_json();
|
||||
for (id, p) in [
|
||||
("settings.profile.save", json!({"name":"A"})),
|
||||
("settings.profile.apply", json!({"name":"A"})),
|
||||
("settings.profile.list", json!({})),
|
||||
("settings.profile.delete", json!({"name":"A"})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), doc);
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points, two midpoints or a line off the axes: with auto, text_at inside the box they span (as opposite corners) or in its outer corners is the distance between them, above or below the box horizontal, beside it vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -333,63 +333,12 @@ fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) ->
|
||||
if strict && !rep.keeps_orientation() {
|
||||
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
|
||||
}
|
||||
// A solve that throws points far beyond the sketch found a far solution of its constraints,
|
||||
// not the one the edit means (a line squeezed to a point under a direction constraint, an
|
||||
// angle between sides that can't take it): it counts as one that fails.
|
||||
let far = flung(&unsolved, sk);
|
||||
if strict && far {
|
||||
return Err(EngineError::Other(
|
||||
"that would throw points of the sketch far away: its constraints have no solution near the sketch as it is".into(),
|
||||
));
|
||||
}
|
||||
// A solve that shrinks a line or circle to a point met its constraints only by squeezing
|
||||
// that curve away (a corner made the midpoint of the opposite side of its rectangle).
|
||||
let gone = shrunk(&unsolved, sk);
|
||||
if strict && let Some(curve) = &gone {
|
||||
return Err(EngineError::Other(format!("that would shrink {curve} to a point: its constraints can only be met without it")));
|
||||
}
|
||||
if !rep.keeps_orientation() || far || gone.is_some() {
|
||||
if !rep.keeps_orientation() {
|
||||
*sk = unsolved;
|
||||
}
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
/// The first line or circle that solving `before` into `after` shrank to a point: longer (or
|
||||
/// wider) than 1e-3 before and shorter than 1e-5 after. A length a dimension asks for is never
|
||||
/// that short. 1e-6 let a side squeezed to 1.2e-6 by one solve through, and the next solve
|
||||
/// took it the rest of the way.
|
||||
fn shrunk(before: &Sketch, after: &Sketch) -> Option<String> {
|
||||
let size = |sk: &Sketch, i: usize| match sk.curves.get(i).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { .. }) => line_pts(sk, i).map(|(a, b)| a.dist(b)),
|
||||
Some(CurveKind::Arc { .. } | CurveKind::Circle { .. }) => sk.radius(i),
|
||||
_ => None,
|
||||
};
|
||||
(0..before.curves.len().min(after.curves.len()))
|
||||
.find(|&i| matches!((size(before, i), size(after, i)), (Some(b), Some(a)) if b > 1e-3 && a < 1e-5))
|
||||
.and_then(|i| after.curves.get(i).map(|c| c.id.clone()))
|
||||
}
|
||||
|
||||
/// A solve moving some point further than this many times the sketch's size (and its largest
|
||||
/// dimension value) has thrown the geometry to a far solution.
|
||||
const FLUNG: f64 = 1000.0;
|
||||
|
||||
/// Did solving `before` into `after` throw some point further than [`FLUNG`] times the sketch's
|
||||
/// size, or to no number at all?
|
||||
fn flung(before: &Sketch, after: &Sketch) -> bool {
|
||||
let (lo, hi) = before
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| p.pos)
|
||||
.filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
.fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
|
||||
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||
});
|
||||
let size = if lo.x <= hi.x { lo.dist(hi) } else { 0.0 };
|
||||
let longest = after.driving_values().values().map(|v| v.abs()).filter(|v| v.is_finite()).fold(0.0, f64::max);
|
||||
let reach = FLUNG * (size + longest).max(1.0);
|
||||
after.points.iter().zip(&before.points).any(|(a, b)| !(a.pos.dist(b.pos) <= reach))
|
||||
}
|
||||
|
||||
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
|
||||
pub(super) fn counted_solve(
|
||||
sk: &mut Sketch,
|
||||
@@ -1787,7 +1736,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
|
||||
}));
|
||||
let ok = solve_with(&mut trial, &h, None, Analysis::StatusOnly, None).ok();
|
||||
trial.constraints.retain(|c| c.id != TEMP);
|
||||
if ok && !collapsed(sk, &trial) && !flung(sk, &trial) {
|
||||
if ok && !collapsed(sk, &trial) {
|
||||
*sk = trial;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1799,12 +1748,6 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
|
||||
if first_movable && !second_locked {
|
||||
let mut trial = sk.clone();
|
||||
if solve_with(&mut trial, &Hold::default(), None, Analysis::StatusOnly, None).ok() {
|
||||
if flung(sk, &trial) {
|
||||
return Err(bad(cmd, "that would throw points of the sketch far away: its constraints have no solution near the sketch as it is"));
|
||||
}
|
||||
if let Some(curve) = shrunk(sk, &trial) {
|
||||
return Err(bad(cmd, format!("that would shrink {curve} to a point: its constraints can only be met without it")));
|
||||
}
|
||||
*sk = trial;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2492,10 +2435,6 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
|
||||
|
||||
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.dimension";
|
||||
// A dimension may measure model geometry (#7912): an `{edge: [x,y,z], body?}` or a
|
||||
// `{vertex: [x,y,z], body?}` among its entities is projected into the sketch first, as
|
||||
// `sketch.project` does, and stands for the projected curve (or point) there.
|
||||
let p = &super::sketch_project::project_entities(s, p, cmd)?;
|
||||
let ents = string_list(cmd, p, "entities")?;
|
||||
if ents.is_empty() || ents.len() > 2 {
|
||||
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
|
||||
|
||||
@@ -1232,94 +1232,6 @@ fn coincident_refuses_a_lines_two_ends() {
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a":"l1.end","b":"l2.end"}));
|
||||
}
|
||||
|
||||
/// A constraint whose solve finds only a far solution is not allowed to throw points far away:
|
||||
/// the held solve is tried without it, or the constraint is refused. Here a centre that is
|
||||
/// already the midpoint of two lines is made the midpoint of a third, and the solve threw two
|
||||
/// line ends to x = -2.2e8 mm while the sketch said solved (found by a local sketch fuzzer).
|
||||
#[test]
|
||||
fn a_constraint_never_throws_the_sketch_far_away() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.arc.three_point", json!({"end": [-15, 6], "start": [-24, -21], "through": [-12, -1]})),
|
||||
("sketch.arc.three_point", json!({"end": [-18, -27], "start": [3, 13], "through": [22, 21]})),
|
||||
("sketch.dimension", json!({"entities": ["a1.start", "a2.center"], "value": 29})),
|
||||
("sketch.dimension", json!({"entities": ["a2.end", "a1.start"], "value": 38})),
|
||||
("sketch.circle.center", json!({"center": [-23, -10], "radius": 7})),
|
||||
("sketch.constraint.coincident", json!({"a": "a2.start", "b": "a1.end"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [22, -9], "p1": [19, -10]})),
|
||||
("sketch.line", json!({"ids": ["l16"], "points": [[-14, -29], [-5, 5]]})),
|
||||
("sketch.circle.center", json!({"center": [-14, 19], "radius": 14})),
|
||||
("sketch.constraint.fix", json!({"entity": "c1.center"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [-10, 23], "p1": [16, 13]})),
|
||||
("sketch.line", json!({"ids": ["l22"], "points": [[-8, 21], [25, 14]]})),
|
||||
("sketch.circle.center", json!({"center": [6, 10], "radius": 10})),
|
||||
("sketch.circle.center", json!({"center": [8, -12], "radius": 11})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [-4, -7], "p1": [-5, -27]})),
|
||||
("sketch.constraint.concentric", json!({"a": "a1", "b": "c4"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [-12, -14], "p1": [-1, -17]})),
|
||||
("sketch.constraint.midpoint", json!({"line": "l12", "point": "c4.center"})),
|
||||
("sketch.constraint.midpoint", json!({"line": "l14", "point": "c4.center"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [23, 22], "p1": [-28, 8]})),
|
||||
("sketch.constraint.fix", json!({"entity": "p15"})),
|
||||
("sketch.dimension", json!({"entities": ["p17", "a1.start"], "value": 35})),
|
||||
("sketch.constraint.coincident", json!({"a": "p19", "b": "p12"})),
|
||||
("sketch.constraint.tangent", json!({"a": "c1", "b": "l11"})),
|
||||
("sketch.line", json!({"closed": true, "points": [[8, -29], [-5, 24], [-9, -6], [26, -8]]})),
|
||||
("sketch.constraint.perpendicular", json!({"a": "l15", "b": "l17"})),
|
||||
("sketch.dimension", json!({"entities": ["l12"], "value": 13})),
|
||||
("sketch.dimension", json!({"entities": ["l26", "l17"], "type": "angle", "value": 14})),
|
||||
("sketch.constraint.fix", json!({"entity": "p18"})),
|
||||
("sketch.constraint.tangent", json!({"a": "l23", "b": "c4"})),
|
||||
("sketch.constraint.coincident", json!({"a": "origin", "b": "l23.start"})),
|
||||
("sketch.constraint.midpoint", json!({"line": "l18", "point": "c4.center"})),
|
||||
] {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
let sk = sketch(&s);
|
||||
let far = sk.points.iter().map(|p| p.pos.len()).fold(0.0_f64, f64::max);
|
||||
assert!(far < 1e4, "a point went {far} mm away");
|
||||
}
|
||||
|
||||
/// A constraint that can only be met by shrinking curves to a point is refused, as one that
|
||||
/// throws the sketch far away is: making a rectangle's corner the midpoint of the opposite side,
|
||||
/// or two opposite corners coincident, squeezed the whole rectangle to a point while the sketch
|
||||
/// said solved (found by a local sketch fuzzer).
|
||||
#[test]
|
||||
fn a_constraint_that_would_shrink_a_rectangle_to_a_point_is_refused() {
|
||||
for (id, p) in
|
||||
[("sketch.constraint.midpoint", json!({"line": "l4", "point": "p2"})), ("sketch.constraint.coincident", json!({"a": "p1", "b": "p3"}))]
|
||||
{
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-30, 4], "p1": [11, 27]}));
|
||||
let before = sketch(&s);
|
||||
let e = s.execute(id, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("shrink") && e.contains("to a point"), "{id} {p}: {e}");
|
||||
assert_eq!(sketch(&s), before, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two rectangles, a corner of one made coincident with a corner of the other and then a second
|
||||
/// pair of corners made coincident: one solve squeezed a 49 mm side to about 1.2e-6 mm, just
|
||||
/// longer than the check took for a point, and the next took it the rest of the way, so the
|
||||
/// constraint was taken (found by a local sketch fuzzer).
|
||||
#[test]
|
||||
fn a_constraint_that_squeezes_a_side_to_a_hair_is_refused() {
|
||||
let mut s = new_sketch();
|
||||
for (id, p) in [
|
||||
("sketch.rectangle.two_point", json!({"p0": [27, 29], "p1": [1, 19]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [23, -8], "p1": [-21, -20]})),
|
||||
("sketch.constraint.perpendicular", json!({"a": "l5", "b": "l4"})),
|
||||
("sketch.constraint.coincident", json!({"a": "p5", "b": "p1"})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
let before = sketch(&s);
|
||||
let e = s.execute("sketch.constraint.coincident", &json!({"a": "p6", "b": "p3"})).unwrap_err().to_string();
|
||||
assert!(e.contains("shrink") && e.contains("to a point"), "{e}");
|
||||
assert_eq!(sketch(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_circles_lie_on_each_other() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
//! Equation curves (#8352): a sketch curve drawn from formulas, x(t) and y(t) over a range of t
|
||||
//! or y = f(x) over a range of x, in the design's expression language and parameters. The curve
|
||||
//! is a fit point spline through points worked out from the formulas, as many as keep it within
|
||||
//! a tolerance of them; it keeps its formulas, so it follows the parameters and can be edited.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::sketch_equation::{self, DEFAULT_TOLERANCE, EquationFit};
|
||||
use solidcraft_sketch::{EquationCurve, Sketch};
|
||||
|
||||
use super::sketch::{edit, mark_construction};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::bad;
|
||||
use crate::schema::sketch_equation::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_equation_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const CREATE_CONTRACT: TypedContract<EquationCurveParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<EquationEditParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.equation_curve", "Equation Curve", crate::checked!("sketch.equation_curve", create))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("equation_curve")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<EquationCurveResult>)
|
||||
.params(concat!(
|
||||
"x: x(t), y: y(t): formulas of t in the design's expression language and parameters (\"r * (cos(t) + t * sin(t))\"); or explicit: true and y: f(x) alone (no x); \
|
||||
start, end: the range of t (or of x): a number or an expression (\"2 * pi\", \"360 deg\" is 2·pi, a length is in the design's unit); \
|
||||
tolerance?: mm (default 0.01): how far the curve may stray from the formulas (0.0001 to 10); construction?: bool. \
|
||||
The variable is a plain number: sin(t) reads it in radians; a result without a unit is in the design's length unit. \
|
||||
Makes a fit point spline through fixed points, as many as the tolerance needs (up to 500), closed smoothly when it ends where it starts; \
|
||||
the points are placed again from the formulas whenever the parameters change. Returns curve, points (how many), closed, deviation (mm)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.equation_curve.edit", "Edit Equation Curve", crate::checked!("sketch.equation_curve.edit", edit_curve))
|
||||
.icon("equation_curve")
|
||||
.enabled(in_sketch)
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(output_schema_for::<EquationEditResult>)
|
||||
.params(
|
||||
"curve: an equation curve's id; x?, y?, explicit?, start?, end?, tolerance?: as in sketch.equation_curve, each kept when left out (explicit: true drops x; false needs x). \
|
||||
The points are chosen again for the new formulas; the curve's start point (and the end point of an open curve) keeps its id, so geometry joined there stays joined. Returns curve, points, closed, deviation",
|
||||
),
|
||||
];
|
||||
|
||||
/// A formula or range parameter: a string (an expression) or a number.
|
||||
fn text(p: &Value, cmd: &str, key: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(s.trim().to_string())),
|
||||
Some(Value::Number(n)) => n
|
||||
.as_f64()
|
||||
.and_then(crate::params::finite)
|
||||
.map(|v| Some(format!("{v}")))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` must be a formula (a string) or a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn tolerance(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get("tolerance") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|t| t.is_finite() && (sketch_equation::MIN_TOLERANCE..=sketch_equation::MAX_TOLERANCE).contains(t))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, "`tolerance` must be a number of mm from 0.0001 to 10")),
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit(p: &Value, cmd: &str) -> Result<Option<bool>> {
|
||||
match p.get("explicit") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad(cmd, "`explicit` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The equation a command's parameters give, over `old` (each left out kept).
|
||||
fn equation(p: &Value, cmd: &str, old: Option<&EquationCurve>) -> Result<EquationCurve> {
|
||||
let x = text(p, cmd, "x")?;
|
||||
let explicit = explicit(p, cmd)?;
|
||||
let x = match (explicit, x) {
|
||||
(Some(true), Some(_)) => return Err(bad(cmd, "an explicit curve takes y = f(x) alone: leave out `x`")),
|
||||
(Some(true), None) => None,
|
||||
(_, Some(x)) => Some(x),
|
||||
(Some(false), None) => match old.and_then(|o| o.x.clone()) {
|
||||
Some(x) => Some(x),
|
||||
None => return Err(bad(cmd, "a parametric curve needs `x`, a formula of t")),
|
||||
},
|
||||
(None, None) => match old {
|
||||
Some(o) => o.x.clone(),
|
||||
None => return Err(bad(cmd, "needs `x` and `y` (formulas of t), or explicit: true and `y` (a formula of x)")),
|
||||
},
|
||||
};
|
||||
let pick = |key: &str, kept: Option<&String>| -> Result<String> {
|
||||
match text(p, cmd, key)? {
|
||||
Some(v) => Ok(v),
|
||||
None => kept.cloned().ok_or_else(|| bad(cmd, format!("missing `{key}`"))),
|
||||
}
|
||||
};
|
||||
let eq = EquationCurve {
|
||||
curve: old.map(|o| o.curve.clone()).unwrap_or_default(),
|
||||
x,
|
||||
y: pick("y", old.map(|o| &o.y))?,
|
||||
start: pick("start", old.map(|o| &o.start))?,
|
||||
end: pick("end", old.map(|o| &o.end))?,
|
||||
tolerance: tolerance(p, cmd)?.or(old.map(|o| o.tolerance)).unwrap_or(DEFAULT_TOLERANCE),
|
||||
};
|
||||
sketch_equation::check(&eq).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(eq)
|
||||
}
|
||||
|
||||
/// The points of an equation, over the design's parameters as features are evaluated with them.
|
||||
fn fit(doc: &solidcraft_doc::Document, eq: &EquationCurve, cmd: &str) -> Result<EquationFit> {
|
||||
sketch_equation::fit(&doc.evaluation_values(), eq).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn reply(curve: String, fit: &EquationFit, info: Value) -> Value {
|
||||
// A closed curve's last point is its first one.
|
||||
let points = fit.points.len().saturating_sub(usize::from(fit.closed));
|
||||
json!({"curves": [curve.clone()], "curve": curve, "points": points, "closed": fit.closed, "deviation": fit.deviation, "sketch": info})
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.equation_curve";
|
||||
let eq = equation(p, cmd, None)?;
|
||||
let ((curve, fit), info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
|
||||
let fit = fit(doc, &eq, cmd)?;
|
||||
let c = sk.add_equation_curve(&fit.points, fit.closed, eq.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
mark_construction(sk, &[c], p);
|
||||
let id = sk.curves.get(c).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "the new curve is missing"))?;
|
||||
Ok((id, fit))
|
||||
})?;
|
||||
Ok(reply(curve, &fit, info))
|
||||
}
|
||||
|
||||
fn edit_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.equation_curve.edit";
|
||||
let id = p.get("curve").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`curve` must be an equation curve's id"))?.to_string();
|
||||
let (fit, info) = edit(s, p, cmd, false, |sk: &mut Sketch, doc| {
|
||||
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
let old = sk.equation_of(&id).cloned().ok_or_else(|| bad(cmd, format!("`{id}` is not an equation curve")))?;
|
||||
let eq = equation(p, cmd, Some(&old))?;
|
||||
let fit = fit(doc, &eq, cmd)?;
|
||||
sk.replace_equation_curve(c, &fit.points, fit.closed, eq).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(fit)
|
||||
})?;
|
||||
let mut out = reply(id, &fit, info);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.remove("curves");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
//! Equation curves (#8352): a fit point spline from formulas over the design's parameters.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::CurveKind;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn no_trouble(s: &Session) {
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
}
|
||||
|
||||
fn near(got: f64, want: f64, rel: f64) {
|
||||
assert!((got - want).abs() <= want.abs() * rel, "got {got}, want {want}");
|
||||
}
|
||||
|
||||
/// The evaluated sketch `S`.
|
||||
fn solved(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
s.model.state().sketch(f).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
/// The positions of curve `id`'s points in the evaluated sketch `S`.
|
||||
fn curve_points(s: &Session, id: &str) -> Vec<Vec2> {
|
||||
let sk = solved(s);
|
||||
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
|
||||
c.kind.point_ids().iter().map(|i| sk.points[*i].pos).collect()
|
||||
}
|
||||
|
||||
/// A wave over a strip: y = h + 5 sin(2 pi x / 100) over x from 0 to 100, closed by three
|
||||
/// lines down to the x axis. Its area is 100 h (the sine adds as much as it takes away).
|
||||
fn wave(s: &mut Session) -> String {
|
||||
run(s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(s, "sketch.equation_curve", json!({"explicit": true, "y": "h + 5 * sin(2 * pi * x / 100)", "start": 0, "end": "100 mm"}));
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
assert_eq!(r["closed"], false, "{r}");
|
||||
assert!(r["deviation"].as_f64().unwrap() <= 0.01, "{r}");
|
||||
run(s, "sketch.line", json!({"points": [format!("{id}.end"), [100, 0], [0, 0], format!("{id}.start")]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wave_from_a_formula_extrudes_and_follows_its_parameter() {
|
||||
let mut s = Session::default();
|
||||
let id = wave(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
|
||||
let before = curve_points(&s, &id);
|
||||
assert!(before.iter().all(|p| (p.y - (10.0 + 5.0 * (2.0 * std::f64::consts::PI * p.x / 100.0).sin())).abs() < 1e-9), "{before:?}");
|
||||
// The curve follows the parameter: the strip is twice as tall.
|
||||
run(&mut s, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
|
||||
let after = curve_points(&s, &id);
|
||||
assert_eq!(before.len(), after.len());
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| (b.y - a.y - 10.0).abs() < 1e-9 && (a.x - b.x).abs() < 1e-9));
|
||||
// The parameter knows the curve uses it, and a rename reaches the formula.
|
||||
let users = run(&mut s, "parameters.users", json!({"name": "h"}));
|
||||
assert!(users.to_string().contains("Curve Y"), "{users}");
|
||||
run(&mut s, "parameters.rename", json!({"name": "h", "new_name": "height"}));
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "height + 5 * sin(2 * pi * x / 100)");
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), 100.0 * 20.0 * 10.0, 1e-4);
|
||||
// The design file keeps the formulas, and a reopened design follows them.
|
||||
assert!(s.doc.to_json().contains("\"equations\""));
|
||||
let mut r = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
no_trouble(&r);
|
||||
near(volume(&mut r), 100.0 * 20.0 * 10.0, 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_hides_a_parameter_of_its_name_and_a_rename_leaves_it_alone() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "t", "expression": "1000 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t * t", "start": -2, "end": 2}));
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
let pts = curve_points(&s, &id);
|
||||
assert!(pts.iter().all(|p| (p.y - p.x * p.x).abs() < 1e-9 && p.x.abs() <= 2.0 + 1e-9), "{pts:?}");
|
||||
run(&mut s, "parameters.rename", json!({"name": "t", "new_name": "u"}));
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
let eq = s.doc.sketch(f).unwrap().equation_of(&id).unwrap().clone();
|
||||
assert_eq!((eq.x.as_deref(), eq.y.as_str()), (Some("t"), "t * t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_turn_closes_smoothly_and_extrudes_as_a_disc() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"x": "20 * cos(t)", "y": "20 * sin(t)", "start": 0, "end": "360 deg"}));
|
||||
assert_eq!(r["closed"], true, "{r}");
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
let sk = s.doc.sketch(f).unwrap();
|
||||
let Some(CurveKind::Spline { pts, periodic: true, control: false, .. }) = sk.curves.iter().find(|c| c.id == id).map(|c| &c.kind) else {
|
||||
panic!("not a closed fit spline: {:?}", sk.curves)
|
||||
};
|
||||
assert_eq!(pts.first(), pts.last());
|
||||
assert!(pts.iter().all(|p| sk.points[*p].fixed));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), std::f64::consts::PI * 400.0 * 5.0, 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_under_a_curve_revolves() {
|
||||
// y = 5 + 2 sin(x / 5) over x from 0 to 30, revolved about the x axis:
|
||||
// V = pi * integral of (5 + 2 sin(x/5))^2 = pi * (25 * 30 + 20 * 5 (1 - cos 6) + 4 (15 - 5/4 sin 12)).
|
||||
let want = std::f64::consts::PI * (750.0 + 100.0 * (1.0 - 6f64.cos()) + 4.0 * (15.0 - 1.25 * 12f64.sin()));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "5 + 2 * sin(x / 5)", "start": 0, "end": 30}));
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.line", json!({"points": [format!("{id}.end"), [30, 0], [0, 0], format!("{id}.start")]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "x"}));
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), want, 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_changes_the_formulas_and_keeps_the_joined_ends() {
|
||||
let mut s = Session::default();
|
||||
let id = wave(&mut s);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
let before = s.doc.sketch(f).unwrap().clone();
|
||||
let ends = |sk: &solidcraft_sketch::Sketch| {
|
||||
let c = sk.curves.iter().find(|c| c.id == id).unwrap();
|
||||
let (a, b) = c.kind.ends().unwrap();
|
||||
(sk.points[a].id.clone(), sk.points[b].id.clone())
|
||||
};
|
||||
// A tighter tolerance and a bigger wave: more points, the same ends.
|
||||
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "y": "h + 8 * sin(2 * pi * x / 100)", "tolerance": 0.002}));
|
||||
assert!(r["deviation"].as_f64().unwrap() <= 0.002, "{r}");
|
||||
let after = s.doc.sketch(f).unwrap().clone();
|
||||
assert_eq!(ends(&before), ends(&after));
|
||||
let count = |sk: &solidcraft_sketch::Sketch| sk.curves.iter().find(|c| c.id == id).unwrap().kind.point_ids().len();
|
||||
assert!(count(&after) > count(&before), "{} vs {}", count(&after), count(&before));
|
||||
assert_eq!(r["points"].as_u64(), Some(count(&after) as u64));
|
||||
let eq = after.equation_of(&id).unwrap();
|
||||
assert_eq!((eq.y.as_str(), eq.start.as_str(), eq.tolerance), ("h + 8 * sin(2 * pi * x / 100)", "0", 0.002));
|
||||
// No points left behind: the old in-between points went.
|
||||
assert_eq!(after.points.len(), before.points.len() - count(&before) + count(&after));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10}));
|
||||
no_trouble(&s);
|
||||
near(volume(&mut s), 100.0 * 10.0 * 10.0, 1e-4);
|
||||
// Undo puts the old formulas back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().y, "h + 5 * sin(2 * pi * x / 100)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_can_switch_between_the_two_forms() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x / 2", "start": 0, "end": 10}));
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
// To a closed parametric curve: the old end point goes, the start stays.
|
||||
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "x": "10 * cos(t)", "y": "10 * sin(t)", "start": 0, "end": "2 * pi"}));
|
||||
assert_eq!(r["closed"], true, "{r}");
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
let sk = s.doc.sketch(f).unwrap();
|
||||
assert_eq!(sk.points.len(), 1 + r["points"].as_u64().unwrap() as usize, "the origin and the curve's own points");
|
||||
// Back to the explicit form, open again.
|
||||
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "explicit": true, "y": "x * x / 10", "start": -10, "end": 10}));
|
||||
assert_eq!(r["closed"], false, "{r}");
|
||||
let sk = s.doc.sketch(f).unwrap();
|
||||
assert!(sk.equation_of(&id).unwrap().x.is_none());
|
||||
let (a, b) = sk.curves.iter().find(|c| c.id == id).unwrap().kind.ends().unwrap();
|
||||
assert_eq!((sk.points[a].pos, sk.points[b].pos), (Vec2::new(-10.0, 10.0), Vec2::new(10.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_curve_forgets_its_formulas() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sin(t)", "start": 0, "end": 6}));
|
||||
let id = r["curve"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.delete", json!({"entities": [id]}));
|
||||
let f = s.doc.find_feature("S").unwrap().id;
|
||||
let sk = s.doc.sketch(f).unwrap();
|
||||
assert!(sk.equations.is_empty() && sk.curves.is_empty() && sk.points.len() == 1, "{sk:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_formulas_ranges_and_values_are_refused_and_change_nothing() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let before = s.doc.to_json();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"y": "t", "start": 0, "end": 1}),
|
||||
json!({"x": "t", "y": "t", "start": 0}),
|
||||
json!({"x": "t", "explicit": true, "y": "x", "start": 0, "end": 1}),
|
||||
json!({"x": "t", "y": "sqrt(t)", "start": -1, "end": 1}),
|
||||
json!({"x": "t", "y": "1 / t", "start": -1, "end": 1}),
|
||||
json!({"x": "t", "y": "t", "start": 1, "end": 1}),
|
||||
json!({"x": "t", "y": "t", "start": 0, "end": "1e12"}),
|
||||
json!({"x": "t * 1 deg", "y": "t", "start": 0, "end": 1}),
|
||||
json!({"x": "nope", "y": "t", "start": 0, "end": 1}),
|
||||
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0}),
|
||||
json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"}),
|
||||
json!({"x": 5, "y": 5, "start": 0, "end": 1}),
|
||||
json!({"x": ["t"], "y": "t", "start": 0, "end": 1}),
|
||||
json!({"x": "t", "y": "sin(1e5 * t)", "start": 0, "end": 1000, "tolerance": 0.0001}),
|
||||
json!({"x": "1e300 * t", "y": "t", "start": 0, "end": 1}),
|
||||
] {
|
||||
assert!(s.execute("sketch.equation_curve", &p).is_err(), "{p} should be refused");
|
||||
assert_eq!(s.doc.to_json(), before, "{p} changed the design");
|
||||
}
|
||||
for p in [json!({"curve": "nope", "y": "t"}), json!({"y": "t"}), json!({"curve": 7})] {
|
||||
assert!(s.execute("sketch.equation_curve.edit", &p).is_err(), "{p} should be refused");
|
||||
}
|
||||
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 5]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
assert!(s.execute("sketch.equation_curve.edit", &json!({"curve": line, "y": "t"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_its_parameters_break_is_an_error_not_a_crash() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "k", "expression": "1 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "sqrt(k / 1 mm - t)", "start": 0, "end": 0.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
// Now the square root goes negative inside the range: the sketch reports it.
|
||||
run(&mut s, "parameters.change", json!({"name": "k", "expression": "0.25 mm"}));
|
||||
let r = s.model.results.iter().find(|r| r.name == "S").unwrap();
|
||||
assert!(r.error.as_deref().is_some_and(|e| e.contains("equation curve")), "{:?}", r.error);
|
||||
run(&mut s, "parameters.change", json!({"name": "k", "expression": "1 mm"}));
|
||||
no_trouble(&s);
|
||||
}
|
||||
@@ -321,14 +321,8 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
let (t0, t1) = sh.range();
|
||||
let full = sh.is_full();
|
||||
let span = t1 - t0;
|
||||
// A cut within rounding of the curve's own end is that end. A curve ending where another one
|
||||
// ends crosses it there only within the intersection's tolerance, and a cut a hair inside
|
||||
// left a piece of no length. Cuts closer than that to each other are already one (`add_cut`).
|
||||
let ends = if full { Vec::new() } else { vec![sh.point(t0), sh.point(t1)] };
|
||||
let mut cuts: Vec<(f64, Vec2, usize)> = cut_points(sk, ci)
|
||||
.into_iter()
|
||||
.filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span && ends.iter().all(|e| e.dist(sh.point(*t)) >= TOL * 10.0)))
|
||||
.collect();
|
||||
let mut cuts: Vec<(f64, Vec2, usize)> =
|
||||
cut_points(sk, ci).into_iter().filter(|(t, _, _)| full || (*t > t0 + 1e-9 * span && *t < t1 - 1e-9 * span)).collect();
|
||||
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if full {
|
||||
if cuts.len() < 2 {
|
||||
@@ -867,10 +861,9 @@ fn arc_aim(sk: &Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<Option<ArcAim>
|
||||
for piece in &pieces {
|
||||
for q in support::hits(&circle, piece, &mut budget)? {
|
||||
let travel = if at_b { (q - centre).angle() - end_angle } else { end_angle - (q - centre).angle() }.rem_euclid(TAU);
|
||||
// The growing end itself (within the intersection's tolerance: a line ending there
|
||||
// that touches the circle is found a hair beyond it), or past the arc's far end
|
||||
// (where the extension would run over the arc).
|
||||
if travel <= far_tol || travel > reach + far_tol {
|
||||
// The growing end itself, or past the arc's far end (where the extension would
|
||||
// run over the arc).
|
||||
if travel <= angle_tol || travel > reach + far_tol {
|
||||
continue;
|
||||
}
|
||||
keep_stop(&mut best, travel, q, oi, angle_tol);
|
||||
|
||||
@@ -171,57 +171,6 @@ fn trim_extend_break() {
|
||||
assert!(sketch(&s).curve_index(&lone).is_none());
|
||||
}
|
||||
|
||||
/// A line that ends where an arc ends crosses it there only within the intersection's
|
||||
/// tolerance: trimmed from its other end, it was cut a hair inside its own end and left a piece
|
||||
/// about 1e-7 mm long (found by a local trim fuzzer). Nothing else crosses it, so it trims away.
|
||||
#[test]
|
||||
fn trimming_a_line_that_ends_on_an_arc_leaves_no_piece_of_no_length() {
|
||||
let mut s = new_sketch();
|
||||
let l =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [[30, 20], [-6.10565359059288, -8.884522872474289]], "infer": false}))["curves"])[0].clone();
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.arc.three_point",
|
||||
json!({"start": [-8.972171771153333, -9.973571767012182], "end": format!("{l}.end"), "through": [-7.4533649085502365, -9.654220170934645]}),
|
||||
);
|
||||
run(&mut s, "sketch.trim", json!({"curve": l, "at": [30.001, 19.999]}));
|
||||
let sk = sketch(&s);
|
||||
for c in &sk.curves {
|
||||
if let Some((a, b)) = c.kind.ends().and_then(|(a, b)| sk.point(a).zip(sk.point(b))) {
|
||||
assert!(a.dist(b) > 1e-6, "{} is left {} mm long: {:?}", c.id, a.dist(b), c.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc rounded into a corner, whose end is also where a line ends that touches the arc's
|
||||
/// circle there: extended from that end, it stopped on the touch at its own end and added an arc
|
||||
/// from that point to itself (found by a local trim fuzzer). The touch is the growing end, not a
|
||||
/// stop.
|
||||
#[test]
|
||||
fn an_arc_extended_from_an_end_a_tangent_line_ends_on_adds_no_arc_of_no_length() {
|
||||
let mut s = new_sketch();
|
||||
for (id, p) in [
|
||||
("sketch.line", json!({"infer": false, "points": [[-20, -20], [-10, 20]]})),
|
||||
("sketch.arc.three_point", json!({"end": [0, 30], "start": [10, 20], "through": [-7, 7]})),
|
||||
("sketch.line", json!({"infer": false, "points": [[-10, -30], [10, 30]]})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [-10, 30], "p1": [20, 0]})),
|
||||
("sketch.arc.three_point", json!({"end": [0, 10], "start": [20, -30], "through": [3, 27]})),
|
||||
("sketch.trim", json!({"at": [2.829281375022314, 26.51870311540471], "curve": "a2"})),
|
||||
("sketch.trim", json!({"at": [-9.999, 29.999], "curve": "l6"})),
|
||||
("sketch.fillet", json!({"point": "p1", "radius": 5})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
let _ = s.execute("sketch.extend", &json!({"at": [-8.670612547178429, 1.6049962723352929], "curve": "a4"}));
|
||||
let sk = sketch(&s);
|
||||
for c in &sk.curves {
|
||||
if let CurveKind::Arc { a: p, b: q, .. } = c.kind {
|
||||
let (pa, pb) = (sk.point(p).unwrap(), sk.point(q).unwrap());
|
||||
assert!(p != q && pa.dist(pb) > 1e-6, "{} runs from {pa:?} to {pb:?}: {:?}", c.id, c.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_circle_makes_an_arc_and_a_profile() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -382,7 +382,8 @@ fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Any body's edges work, a mirrored or patterned copy's too; the link keeps the edge's
|
||||
/// persistent name, so the constraint follows the edge through rebuilds.
|
||||
pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&str]) -> Result<Value> {
|
||||
if !keys.iter().any(|k| p.get(*k).is_some_and(model_ref)) {
|
||||
let model = |v: &Value| v.get("edge").is_some() || v.get("vertex").is_some();
|
||||
if !keys.iter().any(|k| p.get(*k).is_some_and(model)) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
@@ -391,69 +392,22 @@ pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&s
|
||||
refresh(s, &doc, id, &mut sk);
|
||||
let mut out = p.clone();
|
||||
for key in keys {
|
||||
let Some(v) = p.get(*key).filter(|v| model_ref(v)) else { continue };
|
||||
out[*key] = json!(project_ref(s, &doc, id, &mut sk, v, cmd)?);
|
||||
let Some(v) = p.get(*key).filter(|v| model(v)) else { continue };
|
||||
let src = source(s, v, cmd)?;
|
||||
let what = src.describe();
|
||||
let link = add_link(s, &doc, id, &mut sk, LinkKind::Project, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
// An edge stands for its curve (a point when it is seen end on), a vertex for its point.
|
||||
let curve = if v.get("edge").is_some() { sk.link_curves(&link).first().and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone()) } else { None };
|
||||
let entity = curve
|
||||
.or_else(|| sk.link_points(&link).first().and_then(|q| sk.points.get(*q)).map(|q| q.id.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("{what} projects to nothing on this sketch")))?;
|
||||
out[*key] = json!(entity);
|
||||
}
|
||||
sketch_store::write(&mut doc, id, sk)?;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A model's reference among a command's operands (#6348): an `{edge: [x,y,z], body?}` or a
|
||||
/// `{vertex: [x,y,z], body?}`.
|
||||
fn model_ref(v: &Value) -> bool {
|
||||
v.get("edge").is_some() || v.get("vertex").is_some()
|
||||
}
|
||||
|
||||
/// Project one model reference into the sketch and report the entity it stands for: an edge's
|
||||
/// projected curve (a point when it is seen end on) or a vertex's projected point.
|
||||
fn project_ref(s: &mut Session, doc: &Document, id: u64, sk: &mut Sketch, v: &Value, cmd: &str) -> Result<String> {
|
||||
let src = source(s, v, cmd)?;
|
||||
let what = src.describe();
|
||||
let link = add_link(s, doc, id, sk, LinkKind::Project, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
// An edge stands for its curve (a point when it is seen end on), a vertex for its point.
|
||||
let curve = if v.get("edge").is_some() { sk.link_curves(&link).first().and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone()) } else { None };
|
||||
curve
|
||||
.or_else(|| sk.link_points(&link).first().and_then(|q| sk.points.get(*q)).map(|q| q.id.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("{what} projects to nothing on this sketch")))
|
||||
}
|
||||
|
||||
/// Most model references one call may project into a sketch: each one is a link and a resolve,
|
||||
/// so the count is capped before any of them is projected.
|
||||
const MAX_PROJECTED_ENTITIES: usize = 1_000;
|
||||
|
||||
/// A dimension's entities may be model geometry (#7912): an `{edge: [x,y,z], body?}` or
|
||||
/// `{vertex: [x,y,z], body?}` among them is projected into the sketch first, as Project does,
|
||||
/// and stands for the projected curve (or point) in the entities returned.
|
||||
pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let Some(Value::Array(entities)) = p.get("entities") else { return Ok(p.clone()) };
|
||||
// A long list is refused before anything is projected, so it costs nothing.
|
||||
if entities.len() > MAX_PROJECTED_ENTITIES {
|
||||
return Err(bad(cmd, format!("`entities` may list at most {MAX_PROJECTED_ENTITIES} entities")));
|
||||
}
|
||||
if !entities.iter().any(model_ref) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = sketch_store::read(&doc, id)?.clone();
|
||||
refresh(s, &doc, id, &mut sk);
|
||||
let mut out = entities.clone();
|
||||
for entity in out.iter_mut().take(MAX_PROJECTED_ENTITIES) {
|
||||
if !model_ref(entity) {
|
||||
continue;
|
||||
}
|
||||
*entity = json!(project_ref(s, &doc, id, &mut sk, entity, cmd)?);
|
||||
}
|
||||
let mut params = p.clone();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("entities".into(), Value::Array(out));
|
||||
}
|
||||
sketch_store::write(&mut doc, id, sk)?;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Add one link from `src` to the active sketch and report it.
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
|
||||
@@ -1983,50 +1983,3 @@ fn a_changed_length_dimension_keeps_its_first_point() {
|
||||
run(&mut s, "parameters.change", json!({"name": width, "expression": "60 mm"}));
|
||||
assert!((at(&s, "p1").x + 30.0).abs() < 1e-6 && (at(&s, "p2").x - 30.0).abs() < 1e-6, "{:?} {:?}", at(&s, "p1"), at(&s, "p2"));
|
||||
}
|
||||
|
||||
/// #7912: a dimension can measure a model edge: the edge is projected into the sketch and the
|
||||
/// dimension measures the distance from the sketch's point to it.
|
||||
#[test]
|
||||
fn a_dimension_measures_a_model_edge() {
|
||||
let mut s = Session::default();
|
||||
// A sheet-metal base flange: a 100 x 50 plate whose front edge runs along y = 0.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
|
||||
// A sketch with a point 25 above the flange's front edge.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
|
||||
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap().to_string();
|
||||
let out = run(&mut s, "sketch.dimension", json!({"entities": [point, {"edge": [50, 0, 0], "body": "Flange"}]}));
|
||||
assert!((out["measured"].as_f64().unwrap() - 25.0).abs() < 1e-6, "the distance to the edge: {out}");
|
||||
// The edge is projected into the sketch, and the dimension measures that projection.
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.links.len(), 1, "{:?}", sk.links);
|
||||
}
|
||||
|
||||
/// #7912: a dimension refuses a long list of entities before projecting any of them, so one
|
||||
/// request cannot make the engine resolve thousands of model edges.
|
||||
#[test]
|
||||
fn a_dimension_refuses_a_long_list_of_entities() {
|
||||
// The most entities one call may project (the engine's `MAX_PROJECTED_ENTITIES`).
|
||||
const MAX_PROJECTED: usize = 1_000;
|
||||
let mut s = Session::default();
|
||||
// A sheet-metal base flange: a 100 x 50 plate whose front edge runs along y = 0.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
|
||||
// A sketch with a point 25 above the flange's front edge.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
|
||||
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap().to_string();
|
||||
// More entities than a call may project, every one of them a model edge to project.
|
||||
let mut entities = vec![json!(point)];
|
||||
for _ in 0..MAX_PROJECTED {
|
||||
entities.push(json!({"edge": [50, 0, 0], "body": "Flange"}));
|
||||
}
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
let error = s.execute("sketch.dimension", &json!({"entities": entities})).unwrap_err();
|
||||
assert!(error.to_string().contains("at most"), "{error}");
|
||||
// The refused request leaves the design as it was.
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
} else {
|
||||
s.analysis = Some(a);
|
||||
}
|
||||
s.status_analysis_changed((!bool_(p, "clear").unwrap_or(false)).then_some(crate::status_items::StatusId::SurfaceAnalysis));
|
||||
s.revision += 1;
|
||||
Ok(json!({ "analysis": s.analysis }))
|
||||
}
|
||||
@@ -118,7 +117,6 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.continuity";
|
||||
if bool_(p, "clear").unwrap_or(false) {
|
||||
s.continuity.clear();
|
||||
s.status_analysis_changed(None);
|
||||
s.revision += 1;
|
||||
return Ok(json!({ "analysis": "continuity", "cleared": true }));
|
||||
}
|
||||
@@ -207,7 +205,6 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.continuity = seams;
|
||||
}
|
||||
s.status_analysis_changed(Some(crate::status_items::StatusId::Continuity));
|
||||
s.revision += 1;
|
||||
Ok(json!({
|
||||
"analysis": "continuity",
|
||||
@@ -316,327 +313,6 @@ mod tests {
|
||||
s.execute("solid.box", &json!({"length": size, "width": size, "height": size})).unwrap();
|
||||
}
|
||||
|
||||
fn status(s: &mut Session) -> Vec<crate::status_items::StatusItem> {
|
||||
serde_json::from_value::<crate::status_items::StatusItems>(s.execute("view.status_items", &json!({})).unwrap()).unwrap().items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_zero_seam_continuity_never_leaves_a_phantom_winner() {
|
||||
use crate::status_items::StatusId;
|
||||
for single in [false, true] {
|
||||
let mut empty = Session::default();
|
||||
empty.set_single_surface_analysis(single);
|
||||
empty.execute("inspect.zebra", &json!({"stripes":14.5})).unwrap();
|
||||
let before = (status(&mut empty), empty.latest_analysis, empty.revision);
|
||||
assert!(empty.execute("inspect.continuity", &json!({})).is_err());
|
||||
assert_eq!((status(&mut empty), empty.latest_analysis, empty.revision), before, "empty-model failure is atomic");
|
||||
for had_continuity in [false, true] {
|
||||
let mut s = Session::default();
|
||||
s.set_single_surface_analysis(single);
|
||||
boxed(&mut s, 10.0);
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY","name":"Boundary"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("surface.patch", json!({"sketch":"Boundary","body_name":"Sheet"})),
|
||||
("inspect.zebra", json!({"stripes":14.5})),
|
||||
("inspect.section", json!({"plane":"XY"})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
if had_continuity {
|
||||
s.execute("inspect.continuity", &json!({"body":"Body1","edges":[[5,0,0]],"samples":5})).unwrap();
|
||||
assert_eq!(s.continuity.len(), 1);
|
||||
if single {
|
||||
s.execute("inspect.zebra", &json!({"stripes":14.5})).unwrap();
|
||||
}
|
||||
}
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let result = s.execute("inspect.continuity", &json!({"body":"Sheet","samples":5})).unwrap();
|
||||
assert_eq!(result["summary"]["edges"], 0);
|
||||
assert_eq!(result["summary"]["skipped"], 4, "an actual one-face sheet has four open boundaries");
|
||||
assert!(s.continuity.is_empty());
|
||||
assert_eq!(s.latest_analysis, if single { None } else { Some(StatusId::SurfaceAnalysis) });
|
||||
if single {
|
||||
assert_eq!(s.analysis, None, "single-mode successful zero-result replacement retains its existing clear semantics");
|
||||
} else {
|
||||
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 14.5 }), "legacy zero result preserves shading");
|
||||
}
|
||||
let expected = if single { vec![StatusId::Section] } else { vec![StatusId::SurfaceAnalysis, StatusId::Section] };
|
||||
for policy in [true, false, true] {
|
||||
s.set_single_surface_analysis(policy);
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), expected);
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_legacy_policy_keeps_independent_effects_and_clears_in_both_orders() {
|
||||
use crate::status_items::StatusId::{Continuity, Section, SurfaceAnalysis};
|
||||
for continuity_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
s.set_single_surface_analysis(false);
|
||||
let shading = ("inspect.zebra", json!({"stripes": 14.5}));
|
||||
let continuity = ("inspect.continuity", json!({"edges": [[5, 0, 0]], "samples": 5}));
|
||||
for (command, params) in if continuity_first { [continuity, shading] } else { [shading, continuity] } {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
|
||||
let items = status(&mut s);
|
||||
let expected = if continuity_first { vec![Continuity, SurfaceAnalysis, Section] } else { vec![SurfaceAnalysis, Continuity, Section] };
|
||||
assert_eq!(items.iter().map(|item| item.id).collect::<Vec<_>>(), expected);
|
||||
assert_eq!(s.analysis, Some(crate::SurfaceAnalysis::Zebra { stripes: 14.5 }));
|
||||
assert_eq!(s.continuity.len(), 1);
|
||||
let before = (items.clone(), s.status_order.clone(), s.latest_analysis, s.revision, s.continuity.clone());
|
||||
for (command, params) in [
|
||||
("inspect.continuity", json!({"body": "missing"})),
|
||||
("inspect.draft", json!({"pull": [0, 0, 0]})),
|
||||
("inspect.zebra", json!({"stripes": -2})),
|
||||
] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert_eq!((status(&mut s), s.status_order.clone(), s.latest_analysis, s.revision, s.continuity.clone()), before);
|
||||
}
|
||||
// The legacy generic shading clear still ends Zebra, never Continuity or Section.
|
||||
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Continuity, Section]);
|
||||
let clear = items.iter().find(|item| item.id == Continuity).unwrap().clear.clone();
|
||||
for _ in 0..2 {
|
||||
s.execute(&clear.command, &serde_json::to_value(&clear.params).unwrap()).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section]);
|
||||
}
|
||||
// Clear in the opposite direction leaves the shading active too.
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
|
||||
s.execute("inspect.continuity", &json!({"clear": true})).unwrap();
|
||||
assert!(s.analysis.is_some());
|
||||
assert!(s.section.is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_policy_switch_keeps_latest_replacement_at_oldest_group_position() {
|
||||
use crate::status_items::{StatusId, StatusSetting};
|
||||
for continuity_first in [false, true] {
|
||||
for latest_continuity in [false, true] {
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
s.set_single_surface_analysis(false);
|
||||
let shading = ("inspect.zebra", json!({}));
|
||||
let continuity = ("inspect.continuity", json!({"edges": [[5, 0, 0]], "samples": 5}));
|
||||
let (first, last) = if continuity_first { (continuity, shading) } else { (shading, continuity) };
|
||||
s.execute(first.0, &first.1).unwrap();
|
||||
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute(last.0, &last.1).unwrap();
|
||||
// Replacement does not move the slot, but determines the policy-switch winner.
|
||||
if latest_continuity {
|
||||
s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap();
|
||||
} else {
|
||||
s.execute("inspect.draft", &json!({"angle": 7.5})).unwrap();
|
||||
}
|
||||
let before = status(&mut s);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(status(&mut s), before);
|
||||
}
|
||||
s.set_single_surface_analysis(true);
|
||||
let items = status(&mut s);
|
||||
assert_eq!(items.iter().map(|item| item.id).collect::<Vec<_>>(), vec![StatusId::SurfaceAnalysis, StatusId::Section]);
|
||||
assert_eq!(items[0].command, if latest_continuity { "inspect.continuity" } else { "inspect.draft" });
|
||||
if latest_continuity {
|
||||
assert_eq!(s.analysis, None);
|
||||
assert_eq!(items[0].setting, StatusSetting::Continuity { edge_count: 2 });
|
||||
} else {
|
||||
assert!(s.continuity.is_empty());
|
||||
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 7.5));
|
||||
}
|
||||
let revision = s.revision;
|
||||
s.set_single_surface_analysis(true);
|
||||
assert_eq!(s.revision, revision, "idempotent policy application");
|
||||
s.set_single_surface_analysis(false);
|
||||
assert_eq!(status(&mut s)[0].id, if latest_continuity { StatusId::Continuity } else { StatusId::SurfaceAnalysis });
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert!(!s.scratch().single_surface_analysis());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(!s.single_surface_analysis(), "new/open retain SDK session configuration");
|
||||
assert!(status(&mut s).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_describe_every_effect_and_execute_their_own_clear() {
|
||||
use crate::status_items::{StatusId, StatusKind, StatusSetting};
|
||||
let mut s = Session::default();
|
||||
assert!(status(&mut s).is_empty());
|
||||
for (command, params, setting, has_key) in [
|
||||
("inspect.zebra", json!({"stripes": 12.5}), StatusSetting::Zebra { stripes: 12.5 }, false),
|
||||
("inspect.draft", json!({"pull": [0, -2, 0], "angle": 3.25}), StatusSetting::Draft { pull: [0.0, -1.0, 0.0], angle_deg: 3.25 }, true),
|
||||
("inspect.environment_map", json!({}), StatusSetting::Environment, false),
|
||||
("inspect.accessibility", json!({"direction": [-3, 0, 0]}), StatusSetting::Accessibility { direction: [-1.0, 0.0, 0.0] }, true),
|
||||
("inspect.curvature_map", json!({"radius": 2.75}), StatusSetting::Curvature { radius_mm: 2.75 }, true),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
let items = status(&mut s);
|
||||
assert_eq!(items.len(), 1);
|
||||
let item = &items[0];
|
||||
assert_eq!(item.id, StatusId::SurfaceAnalysis);
|
||||
assert_eq!(item.kind, StatusKind::Mode);
|
||||
assert_eq!(item.command, command);
|
||||
assert_eq!(item.setting, setting);
|
||||
assert_eq!(item.has_key, has_key);
|
||||
assert_eq!(item.clear.command, command);
|
||||
let params = serde_json::to_value(&item.clear.params).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.execute(&item.clear.command, ¶ms).unwrap();
|
||||
assert!(status(&mut s).is_empty());
|
||||
}
|
||||
}
|
||||
for (params, origin, normal, hatch) in [
|
||||
(json!({"plane": "XY", "at": 20, "hatch": false}), [0.0, 0.0, 20.0], [0.0, 0.0, 1.0], false),
|
||||
(json!({"plane": {"origin": [1, 2, 3], "normal": [0, 3, 4]}}), [1.0, 2.0, 3.0], [0.0, 0.6, 0.8], true),
|
||||
] {
|
||||
s.execute("inspect.section", ¶ms).unwrap();
|
||||
let item = status(&mut s).remove(0);
|
||||
assert_eq!(item.id, StatusId::Section);
|
||||
assert_eq!(item.kind, StatusKind::Mode);
|
||||
assert_eq!(item.command, "inspect.section");
|
||||
assert_eq!(item.setting, StatusSetting::Section { origin_mm: origin, normal, hatch });
|
||||
assert!(!item.has_key);
|
||||
s.execute(&item.clear.command, &serde_json::to_value(item.clear.params).unwrap()).unwrap();
|
||||
assert!(status(&mut s).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_order_slots_by_activation_and_retain_replacement_position() {
|
||||
use crate::status_items::StatusId::{Section, SurfaceAnalysis};
|
||||
for section_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let section = ("inspect.section", json!({"plane": "XY"}));
|
||||
let zebra = ("inspect.zebra", json!({}));
|
||||
for (command, params) in if section_first { [section, zebra] } else { [zebra, section] } {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
let expected = if section_first { vec![Section, SurfaceAnalysis] } else { vec![SurfaceAnalysis, Section] };
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), expected);
|
||||
s.execute("inspect.draft", &json!({"angle": 7})).unwrap();
|
||||
s.execute("inspect.section", &json!({"plane": "YZ", "at": 9})).unwrap();
|
||||
assert_eq!(
|
||||
status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"analysis and Section replacements retain their slots"
|
||||
);
|
||||
s.execute("inspect.draft", &json!({"clear": true})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section]);
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![Section, SurfaceAnalysis]);
|
||||
s.execute("inspect.section", &json!({"clear": true})).unwrap();
|
||||
assert_eq!(status(&mut s).len(), 1);
|
||||
s.execute("inspect.section", &json!({"plane": "XZ"})).unwrap();
|
||||
assert_eq!(status(&mut s).iter().map(|item| item.id).collect::<Vec<_>>(), vec![SurfaceAnalysis, Section]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_continuity_shares_the_analysis_slot_and_failures_keep_effects() {
|
||||
use crate::status_items::{StatusId, StatusSetting};
|
||||
let mut s = Session::default();
|
||||
boxed(&mut s, 10.0);
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
s.execute("inspect.section", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
|
||||
assert_eq!(s.analysis, None);
|
||||
let continuity = status(&mut s)[0].clone();
|
||||
assert_eq!(continuity.id, StatusId::SurfaceAnalysis);
|
||||
assert_eq!(continuity.command, "inspect.continuity");
|
||||
assert_eq!(continuity.setting, StatusSetting::Continuity { edge_count: 1 });
|
||||
assert!(!continuity.has_key);
|
||||
s.execute("inspect.continuity", &json!({"edges": [[0, 5, 0]], "add": true})).unwrap();
|
||||
assert_eq!(status(&mut s)[0].setting, StatusSetting::Continuity { edge_count: 2 });
|
||||
let before = status(&mut s);
|
||||
let order = s.status_order.clone();
|
||||
let effects = (s.analysis, s.continuity.clone(), s.section, s.section_hatch, s.revision);
|
||||
for (command, params) in [
|
||||
("inspect.continuity", json!({"body": "missing"})),
|
||||
("inspect.continuity", json!({"samples": 2})),
|
||||
("inspect.draft", json!({"pull": [0, 0, 0]})),
|
||||
("inspect.zebra", json!({"stripes": -1})),
|
||||
("inspect.section", json!({"plane": {"normal": [0, 0, 0]}})),
|
||||
] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert_eq!(status(&mut s), before);
|
||||
assert_eq!(s.status_order, order);
|
||||
assert_eq!((s.analysis, s.continuity.clone(), s.section, s.section_hatch, s.revision), effects);
|
||||
}
|
||||
s.execute("inspect.draft", &json!({})).unwrap();
|
||||
assert!(s.continuity.is_empty());
|
||||
let before = status(&mut s);
|
||||
assert!(s.execute("inspect.continuity", &json!({"body": "missing"})).is_err());
|
||||
assert_eq!(status(&mut s), before, "failed Continuity activation keeps surface shading and ordering");
|
||||
s.execute(&continuity.clear.command, &serde_json::to_value(continuity.clear.params).unwrap()).unwrap();
|
||||
assert_eq!(status(&mut s)[0].command, "inspect.draft", "stale continuity clear keeps replacement");
|
||||
s.execute("inspect.continuity", &json!({"edges": [[5, 0, 0]]})).unwrap();
|
||||
let item = status(&mut s).remove(0);
|
||||
s.execute(&item.clear.command, &serde_json::to_value(item.clear.params).unwrap()).unwrap();
|
||||
assert_eq!(status(&mut s).len(), 1);
|
||||
assert_eq!(status(&mut s)[0].id, StatusId::Section);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_queries_are_read_only_even_during_a_stale_live_drag() {
|
||||
use std::sync::Arc;
|
||||
let mut s = Session::default();
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
|
||||
("sketch.dimension", json!({"entities":["l1"],"value":20})),
|
||||
("sketch.dimension", json!({"entities":["l2"],"value":15})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance":5})),
|
||||
("inspect.zebra", json!({"stripes": 10.5})),
|
||||
("inspect.section", json!({"plane":"XY", "at": 2})),
|
||||
("sketch.edit", json!({"sketch":"Sketch1"})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
let effects = status(&mut s);
|
||||
s.execute("sketch.drag", &json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})).unwrap();
|
||||
assert!(s.model.is_stale());
|
||||
let doc = s.doc.clone();
|
||||
let model = s.model.state();
|
||||
let selection = s.selection.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone());
|
||||
for _ in 0..3 {
|
||||
assert_eq!(status(&mut s), effects);
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone()), before);
|
||||
assert!(s.scratch().status_items().is_empty(), "scratch sessions have no persistent view effects");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(status(&mut s).is_empty());
|
||||
assert!(s.status_order.is_empty(), "new design resets transient chronology");
|
||||
}
|
||||
|
||||
/// The edges of the first body of a continuity reply.
|
||||
fn edges(v: &Value) -> &Vec<Value> {
|
||||
v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}"))
|
||||
@@ -700,7 +376,7 @@ mod tests {
|
||||
assert_eq!(v["summary"]["worst"], json!("sharp"));
|
||||
assert_eq!(s.continuity.len(), 12, "the seams are kept for the viewport");
|
||||
assert_eq!(s.undo.len(), undo, "no undo step");
|
||||
assert_eq!(s.analysis, None, "only the Continuity analysis is active");
|
||||
assert_eq!(s.analysis, None, "the other analyses are left alone");
|
||||
}
|
||||
|
||||
/// A rolling-ball fillet is tangent to the faces it joins but not curvature continuous: its
|
||||
|
||||
@@ -32,13 +32,8 @@ struct RenderBrowserControls {
|
||||
}
|
||||
|
||||
const RENDER_SELECT: TypedContract<RenderSelect> = TypedContract::new();
|
||||
const STATUS_ITEMS_CONTRACT: TypedContract<crate::schema::view_navigation::StatusItemsParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.status_items", "Viewport Status Items", crate::checked!("view.status_items", status_items))
|
||||
.icon("list").noundo().doc_only()
|
||||
.contract(&STATUS_ITEMS_CONTRACT).output(output_schema_for::<crate::status_items::StatusItems>)
|
||||
.params("no parameters → {items}; persistent viewport effects, oldest slot first, with structured settings and executable clear requests; read-only, no model evaluation"),
|
||||
CommandSpec::new("view.fit", "Fit", fit)
|
||||
.icon("fit").key("F6").noundo().doc_only().params("no parameters → frame the active canvas or selection; display only"),
|
||||
CommandSpec::new("view.home", "Home view", home)
|
||||
@@ -275,15 +270,6 @@ fn navigation_bar(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_toggle(p, "view.navigation_bar", "visible")
|
||||
}
|
||||
|
||||
fn status_items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !p.is_object() {
|
||||
return Err(bad("view.status_items", "parameters must be an empty object"));
|
||||
}
|
||||
let _: crate::schema::view_navigation::StatusItemsParams =
|
||||
serde_json::from_value(p.clone()).map_err(|e| bad("view.status_items", e.to_string()))?;
|
||||
Ok(json!(crate::status_items::StatusItems { items: s.status_items() }))
|
||||
}
|
||||
|
||||
fn render_focus(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderFocus = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_focus", e.to_string()))?;
|
||||
Ok(json!(request))
|
||||
|
||||
@@ -57,7 +57,6 @@ pub fn command_reads_only(id: &str) -> bool {
|
||||
|| id.ends_with(".inspect")
|
||||
|| id.ends_with(".list")
|
||||
|| id == "document.revision"
|
||||
|| id == "view.status_items"
|
||||
|| matches!(id, "document.inspect" | "engine.commands" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers")
|
||||
}
|
||||
|
||||
@@ -121,7 +120,6 @@ mod tests {
|
||||
("ui.screenshot", json!({})),
|
||||
("document.inspect", json!({"measure": true})),
|
||||
("engine.execute", json!({"command": "sketch.inspect"})),
|
||||
("engine.execute", json!({"command": "view.status_items", "params": {}})),
|
||||
("engine.execute", json!({"command": "inspect.measure"})),
|
||||
("engine.execute", json!({"command": "model.edges", "params": {"body": "Body1"}})),
|
||||
("engine.execute", json!({"command": "parameters.export", "params": {"format": "csv"}})),
|
||||
|
||||
@@ -33,7 +33,6 @@ pub mod slicer_files;
|
||||
mod slicer_icon;
|
||||
pub mod slicers;
|
||||
pub mod snap;
|
||||
pub mod status_items;
|
||||
pub mod timing;
|
||||
pub mod view;
|
||||
|
||||
@@ -44,7 +43,6 @@ use serde_json::Value;
|
||||
use solidcraft_doc::{Document, Model, ModelState};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
pub use cmd::settings::{MAX_PROFILE_NAME, profile_name};
|
||||
pub use cmd::{
|
||||
Addition, CommandInfo, CommandSpec, addition, auto_operation, command_specs, dimension_roles, extrude_reach, find_command, resolve_design_links,
|
||||
shown_scope, standard_view,
|
||||
@@ -185,52 +183,6 @@ struct BodyRename {
|
||||
names_only: bool,
|
||||
}
|
||||
|
||||
/// Bodies or component placements copied in one design, for pasting into another
|
||||
/// (`component.paste_from_design`): that design's placed model as it was when they were copied,
|
||||
/// so the copy is what was copied, whatever happens to that design (or its tab) after.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ForeignCopy {
|
||||
/// The design's name (it names what the paste adds).
|
||||
pub design: String,
|
||||
/// Its placed model: what its Browser and view showed.
|
||||
pub world: Arc<solidcraft_doc::ModelState>,
|
||||
/// The bodies copied, as its Browser names them (a body seen in another placement under the
|
||||
/// name it has there).
|
||||
pub bodies: Vec<String>,
|
||||
/// The name each of `bodies` has in its component (without the placement a body seen in
|
||||
/// another placement is named after): the name it pastes as.
|
||||
pub names: Vec<String>,
|
||||
/// The component placements copied, by path.
|
||||
pub placements: Vec<Vec<u64>>,
|
||||
/// Each of `placements`: the component placed, the occurrence and where it is in the world.
|
||||
pub placed: Vec<(u64, u64, solidcraft_doc::Mat)>,
|
||||
/// The design, its evaluated model and its placements moved but not yet applied: what a
|
||||
/// pasted component's history is copied from.
|
||||
pub doc: Arc<Document>,
|
||||
pub model: Model,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
}
|
||||
|
||||
impl ForeignCopy {
|
||||
/// What `session` copies when `bodies` and `placements` are copied in it now.
|
||||
pub fn of(session: &Session, bodies: Vec<String>, placements: Vec<Vec<u64>>) -> ForeignCopy {
|
||||
let frames = session.placed_document().body_frames_with_source(&session.model.state());
|
||||
let names = bodies.iter().map(|b| frames.iter().find(|f| f.0 == *b).map_or_else(|| b.clone(), |f| f.1.clone())).collect();
|
||||
let placed = placements.iter().filter_map(|p| session.occurrence_placement(p).ok()).map(|p| (p.component, p.occurrence, p.world)).collect();
|
||||
ForeignCopy {
|
||||
design: session.doc.name.clone(),
|
||||
world: session.world_state(),
|
||||
bodies,
|
||||
names,
|
||||
placements,
|
||||
placed,
|
||||
doc: session.doc.clone(),
|
||||
model: session.model.clone(),
|
||||
pending_moves: session.pending_moves.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
@@ -458,9 +410,6 @@ pub struct Session {
|
||||
pub sketch_clipboard: Option<cmd::sketch_clipboard::SketchClip>,
|
||||
/// Resolved appearance copied by appearance.copy; transient, outside design/history.
|
||||
pub appearance_clipboard: Option<solidcraft_doc::appearance::Look>,
|
||||
/// What was copied in another design, to paste here (`component.paste_from_design`). The
|
||||
/// host puts it here; each design's own copies stay in its own clipboard.
|
||||
pub foreign_copy: Option<Arc<ForeignCopy>>,
|
||||
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Whether the section's cut faces are hatched (`inspect.section`'s `hatch`; on by default).
|
||||
@@ -472,10 +421,6 @@ pub struct Session {
|
||||
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
|
||||
/// part of the design and not an undo step.
|
||||
pub continuity: Vec<ContinuitySeam>,
|
||||
/// Transient activation order only; effect settings remain in their existing fields.
|
||||
status_order: Vec<status_items::StatusId>,
|
||||
single_surface_analysis: bool,
|
||||
latest_analysis: Option<status_items::StatusId>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
|
||||
/// undo step of its own ([`Snapshot::visibility`]). A design brings its saved visibility when
|
||||
/// it opens (`Session::new`).
|
||||
@@ -649,15 +594,11 @@ impl Session {
|
||||
clipboard: Vec::new(),
|
||||
sketch_clipboard: None,
|
||||
appearance_clipboard: None,
|
||||
foreign_copy: None,
|
||||
section: None,
|
||||
section_hatch: true,
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
single_surface_analysis: true,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
replacements: 0,
|
||||
@@ -681,7 +622,6 @@ impl Session {
|
||||
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
|
||||
pub fn replace_design(&mut self, fresh: Session) {
|
||||
let auto_project = self.auto_project;
|
||||
let single_surface_analysis = self.single_surface_analysis;
|
||||
let param_check = self.param_check;
|
||||
let easyprint_upload = self.easyprint_upload.take();
|
||||
let render_viewport = self.render_viewport.clone();
|
||||
@@ -695,7 +635,6 @@ impl Session {
|
||||
*self = fresh;
|
||||
self.crash_journal = crash_journal;
|
||||
self.auto_project = auto_project;
|
||||
self.set_single_surface_analysis(single_surface_analysis);
|
||||
self.param_check = param_check;
|
||||
self.easyprint_upload = easyprint_upload;
|
||||
self.render_viewport = render_viewport;
|
||||
@@ -998,10 +937,9 @@ impl Session {
|
||||
/// Pin the name of every body the features made, as if the body had been renamed to it, and
|
||||
/// reserve the `Body<N>` names, so that no later command renames a body or hands its number
|
||||
/// to another. It is part of the command that made the bodies. A design saved before names
|
||||
/// were pinned is pinned as it opens ([`Self::opened_at`]), with the names it was saved
|
||||
/// with; one made in memory ([`Self::new`]) by its first command that changes it, with the
|
||||
/// names it has after that command (a delete, reorder, suppress or rollback renumbers the
|
||||
/// later counted bodies once).
|
||||
/// were pinned is pinned by its first command that changes it, with the names it has after
|
||||
/// that command: when that command is itself a delete, reorder, suppress or rollback, the
|
||||
/// later counted bodies are renumbered once, as they always were.
|
||||
fn pin_body_names(&mut self) {
|
||||
if self.model.is_stale() {
|
||||
return;
|
||||
@@ -1499,15 +1437,11 @@ impl Session {
|
||||
clipboard: self.clipboard.clone(),
|
||||
sketch_clipboard: self.sketch_clipboard.clone(),
|
||||
appearance_clipboard: self.appearance_clipboard.clone(),
|
||||
foreign_copy: self.foreign_copy.clone(),
|
||||
section: None,
|
||||
section_hatch: true,
|
||||
colored_components: None,
|
||||
analysis: None,
|
||||
continuity: Vec::new(),
|
||||
status_order: Vec::new(),
|
||||
single_surface_analysis: self.single_surface_analysis,
|
||||
latest_analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
replacements: 0,
|
||||
@@ -1810,10 +1744,9 @@ impl Session {
|
||||
|
||||
/// [`Session::run_script`], timing each command (#1833): the results and
|
||||
/// `{"timings": [{"index", "command", "ms", "profile"?}], "total_ms"}`, with `profile` the
|
||||
/// kernel's share of each command ([`timing`]). `each` is called as in
|
||||
/// [`Session::run_script_each`].
|
||||
pub fn run_script_timed(&mut self, script: &Value, profile: bool, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Value)> {
|
||||
let (results, timings) = self.run_script_with(script, Some(profile), each)?;
|
||||
/// kernel's share of each command ([`timing`]).
|
||||
pub fn run_script_timed(&mut self, script: &Value, profile: bool) -> Result<(Vec<Value>, Value)> {
|
||||
let (results, timings) = self.run_script_with(script, Some(profile), &mut |_, _| {})?;
|
||||
Ok((results, timings.unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
|
||||
@@ -349,82 +349,6 @@ pub struct OccurrenceAlignResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Move timeline features (sketches too) into a component. Their ids, names and timeline order
|
||||
/// stay, the geometry stays where it is in the world, and the bodies they made move with them.
|
||||
/// Nothing changes when the move would break a feature, while the timeline is rolled back, or
|
||||
/// for a component placed more than once.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MoveFeaturesParams {
|
||||
/// The features to move, by id or name.
|
||||
pub features: Vec<IdOrName>,
|
||||
/// The component they go to, by id or name. Default: the active component.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub component: Option<IdOrName>,
|
||||
}
|
||||
|
||||
/// What `component.move_features` returns.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct MoveFeaturesResult {
|
||||
/// The names of the features that moved.
|
||||
pub moved: Vec<String>,
|
||||
/// The component they went to.
|
||||
pub component: u64,
|
||||
/// The bodies that moved with them.
|
||||
pub bodies: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A placement: an occurrence id, or its path of occurrence ids from the top of the design.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum PlacementRef {
|
||||
Id(u64),
|
||||
Path(Vec<u64>),
|
||||
}
|
||||
|
||||
/// Move placements, timeline features and bodies into a component in one undo step, all or
|
||||
/// nothing. Placements move first and take what they hold, then features, which take the bodies
|
||||
/// they made, then the bodies still elsewhere (a body transfer, which leaves its features
|
||||
/// behind). Anything already in the component is left alone.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ReparentItemsParams {
|
||||
/// The placements to move.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub occurrences: Option<Vec<PlacementRef>>,
|
||||
/// The features to move, by id or name.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub features: Option<Vec<IdOrName>>,
|
||||
/// The bodies to move, by name.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
/// The component they go to, by id or name. Default: the active component.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub component: Option<IdOrName>,
|
||||
/// The placement of that component to place them by, as occurrence ids from the top.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub component_path: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
/// What `browser.reparent` returns.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
pub struct ReparentItemsResult {
|
||||
/// The component they went to.
|
||||
pub component: u64,
|
||||
/// The occurrence ids that moved.
|
||||
pub occurrences: Vec<u64>,
|
||||
/// The names of the features that moved.
|
||||
pub features: Vec<String>,
|
||||
/// The bodies that were transferred on their own.
|
||||
pub bodies: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{EditInPlaceParams, FinishEditParams, MoveFeaturesParams, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams};
|
||||
use super::{EditInPlaceParams, FinishEditParams, OccurrenceAlignParams, OccurrenceAlignResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -29,8 +29,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
"to_plane",
|
||||
],
|
||||
);
|
||||
assert_keys(input_schema_for::<MoveFeaturesParams>(), &["component", "features"]);
|
||||
assert_keys(input_schema_for::<ReparentItemsParams>(), &["bodies", "component", "component_path", "features", "occurrences"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -64,7 +64,6 @@ pub mod render_ui;
|
||||
pub mod sheet;
|
||||
pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_equation;
|
||||
pub mod view_navigation;
|
||||
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_equation.rs`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{SketchRef, present};
|
||||
|
||||
/// A formula or a range end: an expression (text) or a number.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Formula(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for Formula {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() => Ok(Formula(s)),
|
||||
Value::Number(n) => match n.as_f64().and_then(crate::params::finite) {
|
||||
Some(v) => Ok(Formula(format!("{v}"))),
|
||||
None => Err(D::Error::custom("must be a finite number")),
|
||||
},
|
||||
_ => Err(D::Error::custom("must be a formula (a string) or a number")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Formula {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"Formula".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An expression in the design's expression language and parameters, such as \"r * cos(t)\" or \"2 * pi\", or a number.",
|
||||
"anyOf": [
|
||||
{"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9},
|
||||
{"type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S"}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (mm) the curve may stray from its formulas.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tolerance(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for Tolerance {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?
|
||||
.as_f64()
|
||||
.filter(|t| t.is_finite() && (1e-4..=10.0).contains(t))
|
||||
.map(Tolerance)
|
||||
.ok_or_else(|| D::Error::custom("must be a number of mm from 0.0001 to 10"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for Tolerance {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EquationTolerance".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "How far (mm) the curve may stray from its formulas: 0.0001 to 10. Default 0.01.",
|
||||
"type": "number",
|
||||
"minimum": 0.0001,
|
||||
"maximum": 10.0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An equation curve's id.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CurveId(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for CurveId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(CurveId(s)),
|
||||
_ => Err(D::Error::custom("must be an equation curve's id")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CurveId {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EquationCurveId".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An equation curve's id (a curve id of the sketch).", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a curve from formulas: x(t) and y(t) over a range of t, or y = f(x) over a range of x.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct EquationCurveParams {
|
||||
/// x(t), a formula of t. Leave it out with `explicit: true`.
|
||||
#[serde(default)]
|
||||
pub x: Option<Formula>,
|
||||
/// y(t), a formula of t; or f(x), a formula of x, with `explicit: true`. Required.
|
||||
#[serde(default)]
|
||||
pub y: Option<Formula>,
|
||||
/// The explicit form, y = f(x): `y` is a formula of x and `x` is left out. Default false.
|
||||
#[serde(default)]
|
||||
pub explicit: Option<bool>,
|
||||
/// Where the variable starts: a number, an angle ("360 deg" is 2·pi) or a length (in the
|
||||
/// design's unit). Required.
|
||||
#[serde(default)]
|
||||
pub start: Option<Formula>,
|
||||
/// Where the variable ends, as `start`. Required.
|
||||
#[serde(default)]
|
||||
pub end: Option<Formula>,
|
||||
/// How far (mm) the curve may stray from its formulas. Default 0.01.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<Tolerance>,
|
||||
/// Make it construction geometry. Default false.
|
||||
#[serde(default)]
|
||||
pub construction: Option<bool>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub sketch: Option<SketchRef>,
|
||||
}
|
||||
|
||||
/// Change an equation curve's formulas, range or tolerance.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct EquationEditParams {
|
||||
/// The equation curve. Required.
|
||||
#[serde(default)]
|
||||
pub curve: Option<CurveId>,
|
||||
/// x(t). Kept when left out; `explicit: true` drops it.
|
||||
#[serde(default)]
|
||||
pub x: Option<Formula>,
|
||||
/// y(t), or f(x) for an explicit curve. Kept when left out.
|
||||
#[serde(default)]
|
||||
pub y: Option<Formula>,
|
||||
/// The explicit form, y = f(x). Kept when left out; false needs `x` (given or kept).
|
||||
#[serde(default)]
|
||||
pub explicit: Option<bool>,
|
||||
/// Where the variable starts. Kept when left out.
|
||||
#[serde(default)]
|
||||
pub start: Option<Formula>,
|
||||
/// Where the variable ends. Kept when left out.
|
||||
#[serde(default)]
|
||||
pub end: Option<Formula>,
|
||||
/// How far (mm) the curve may stray from its formulas. Kept when left out.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<Tolerance>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub sketch: Option<SketchRef>,
|
||||
}
|
||||
|
||||
/// What `sketch.equation_curve` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EquationCurveResult {
|
||||
/// The new curve's id, as a list (as other drawing commands return it).
|
||||
pub curves: Vec<String>,
|
||||
/// The new curve's id.
|
||||
pub curve: String,
|
||||
/// How many points the spline goes through (a closed curve's start counted once).
|
||||
pub points: usize,
|
||||
/// The curve ends where it starts and closes smoothly there.
|
||||
pub closed: bool,
|
||||
/// The largest distance (mm) found between the spline and the formulas.
|
||||
pub deviation: f64,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// What it added: `{points, curves, constraints, dimensions}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.equation_curve.edit` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EquationEditResult {
|
||||
/// The curve's id (unchanged).
|
||||
pub curve: String,
|
||||
/// How many points the spline now goes through (a closed curve's start counted once).
|
||||
pub points: usize,
|
||||
/// The curve ends where it starts and closes smoothly there.
|
||||
pub closed: bool,
|
||||
/// The largest distance (mm) found between the spline and the formulas.
|
||||
pub deviation: f64,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// What the edit added (the new points in between): `{points, curves, constraints, dimensions}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_equation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,46 +0,0 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::{EquationCurveParams, EquationCurveResult, EquationEditParams, EquationEditResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<EquationCurveParams>(), &["construction", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
|
||||
assert_keys(input_schema_for::<EquationEditParams>(), &["curve", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.equation_curve", json!({"x": ["t"], "y": "t", "start": 0, "end": 1})),
|
||||
("sketch.equation_curve", json!({"x": "t", "y": " ", "start": 0, "end": 1})),
|
||||
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": 0})),
|
||||
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "tolerance": "big"})),
|
||||
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "explicit": 1})),
|
||||
("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1e12})),
|
||||
("sketch.equation_curve", json!({"y": "t", "start": 0, "end": 1})),
|
||||
("sketch.equation_curve.edit", json!({"curve": 7})),
|
||||
("sketch.equation_curve.edit", json!({"curve": "nope", "y": "t"})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_their_schemas() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let run = |s: &mut Session, id: &str, p: serde_json::Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let created = output_schema_for::<EquationCurveResult>();
|
||||
let created = created.as_value();
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"x": "t * cos(t)", "y": "t * sin(t)", "start": 0, "end": "4 * pi"}));
|
||||
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
|
||||
let id = r["curve"].as_str().unwrap_or_else(|| panic!("{r}")).to_string();
|
||||
let r = run(&mut s, "sketch.equation_curve", json!({"explicit": true, "y": "x", "start": 0, "end": 1, "unknown": 1}));
|
||||
assert!(conforms(created, created, &r), "{r} doesn't match {created}");
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
let edited = output_schema_for::<EquationEditResult>();
|
||||
let edited = edited.as_value();
|
||||
let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "end": "6 * pi"}));
|
||||
assert!(conforms(edited, edited, &r), "{r} doesn't match {edited}");
|
||||
}
|
||||
@@ -39,11 +39,6 @@ pub struct RenderBrowser {
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
/// Empty input for the read-only viewport effect query.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StatusItemsParams {}
|
||||
|
||||
/// Show or hide the View angles readout under the orientation gizmo (#6032): the view's pitch, yaw
|
||||
/// and roll in degrees (the angles of `view.apply`'s camera, which takes them in radians) and Ortho
|
||||
/// or Persp for the projection. A display setting the app keeps: not saved with the design, not an
|
||||
|
||||
@@ -10,54 +10,6 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<AngleReadoutParams>(), &["enabled"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_has_strict_empty_input_and_a_typed_output_schema() {
|
||||
use crate::status_items::StatusItems;
|
||||
assert_keys(input_schema_for::<super::StatusItemsParams>(), &[]);
|
||||
let schema = output_schema_for::<StatusItems>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = Session::default();
|
||||
let described = s.execute("engine.describe", &json!({"command": "view.status_items"})).unwrap();
|
||||
assert_eq!(described["input"]["type"], "object");
|
||||
assert_eq!(described["input"]["additionalProperties"], false);
|
||||
assert_eq!(described["output"]["properties"]["items"]["type"], "array");
|
||||
let spec = crate::find_command("view.status_items").unwrap();
|
||||
assert!(!spec.undoable && !spec.reads_model);
|
||||
for mode in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
|
||||
s.param_check = mode;
|
||||
for input in [json!({}), Value::Null] {
|
||||
let reply = s.execute("view.status_items", &input).unwrap();
|
||||
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
|
||||
}
|
||||
for input in [json!({"extra": true}), json!({"clear": true}), json!([]), json!(false), json!(42), json!("status")] {
|
||||
assert!(s.execute("view.status_items", &input).is_err(), "accepted {input} in {mode:?}");
|
||||
}
|
||||
}
|
||||
for (command, params) in [
|
||||
("inspect.zebra", json!({"stripes": 13.5})),
|
||||
("inspect.draft", json!({"angle": 4.5})),
|
||||
("inspect.environment_map", json!({})),
|
||||
("inspect.accessibility", json!({})),
|
||||
("inspect.curvature_map", json!({"radius": 2})),
|
||||
("inspect.section", json!({"plane": "XY", "hatch": false})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
let reply = s.execute("view.status_items", &json!({})).unwrap();
|
||||
assert!(conforms(schema, schema, &reply), "{reply} doesn't match {schema}");
|
||||
let typed: StatusItems = serde_json::from_value(reply.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(typed).unwrap(), reply);
|
||||
}
|
||||
s.set_single_surface_analysis(false);
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
s.execute("inspect.continuity", &json!({"edges":[[5,0,0]],"samples":5})).unwrap();
|
||||
let reply = s.execute("view.status_items", &json!({})).unwrap();
|
||||
assert!(conforms(schema, schema, &reply), "legacy reply {reply} doesn't match {schema}");
|
||||
assert_eq!(reply["items"].as_array().unwrap().len(), 3);
|
||||
assert!(reply["items"].as_array().unwrap().iter().any(|item| item["id"] == "continuity"));
|
||||
let typed: StatusItems = serde_json::from_value(reply.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(typed).unwrap(), reply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
//! A read-only declaration of persistent viewport effects. Settings remain in the session's
|
||||
//! existing effect fields; only activation order is recorded separately, never in a design.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Session, SurfaceAnalysis};
|
||||
|
||||
/// Stable identity of an effect slot, retained when its analysis is replaced.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusId {
|
||||
SurfaceAnalysis,
|
||||
/// Independent Continuity slot when the compatibility policy is enabled.
|
||||
Continuity,
|
||||
Section,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusKind {
|
||||
Mode,
|
||||
Result,
|
||||
Attention,
|
||||
}
|
||||
|
||||
/// Structured settings, without unit formatting or translated text.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum StatusSetting {
|
||||
Zebra {
|
||||
stripes: f64,
|
||||
},
|
||||
Draft {
|
||||
/// Normalised pull direction.
|
||||
pull: [f64; 3],
|
||||
/// The stored Draft threshold is in degrees.
|
||||
angle_deg: f64,
|
||||
},
|
||||
Environment,
|
||||
Accessibility {
|
||||
/// Normalised direction from which the tool approaches.
|
||||
direction: [f64; 3],
|
||||
},
|
||||
Curvature {
|
||||
radius_mm: f64,
|
||||
},
|
||||
Continuity {
|
||||
/// Number of persistent seam records currently shaded.
|
||||
edge_count: usize,
|
||||
},
|
||||
Section {
|
||||
origin_mm: [f64; 3],
|
||||
/// Normalised world-space plane normal.
|
||||
normal: [f64; 3],
|
||||
hatch: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// An executable request that ends this effect, through the same command as other clients.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StatusClear {
|
||||
pub command: String,
|
||||
pub params: StatusClearParams,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StatusClearParams {
|
||||
pub clear: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StatusItem {
|
||||
pub id: StatusId,
|
||||
pub kind: StatusKind,
|
||||
/// Source command ID, whose catalog label consumers can translate.
|
||||
pub command: String,
|
||||
pub setting: StatusSetting,
|
||||
pub has_key: bool,
|
||||
pub clear: StatusClear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StatusItems {
|
||||
/// Oldest active slot first; replacing an analysis retains its position.
|
||||
pub items: Vec<StatusItem>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether shading and Continuity share one slot (the default). This is session
|
||||
/// configuration, never design data; SDK clients may choose the legacy independent policy.
|
||||
pub fn single_surface_analysis(&self) -> bool {
|
||||
self.single_surface_analysis
|
||||
}
|
||||
|
||||
/// Change the analysis policy without changing the design or undo history. Enabling a
|
||||
/// single slot keeps the latest successful analysis activation/replacement and retains the
|
||||
/// oldest analysis-group position relative to Section. Queries never perform this transition.
|
||||
pub fn set_single_surface_analysis(&mut self, single: bool) {
|
||||
if self.single_surface_analysis == single {
|
||||
return;
|
||||
}
|
||||
let before = self.status_items();
|
||||
if single && self.analysis.is_some() && !self.continuity.is_empty() {
|
||||
if self.latest_analysis == Some(StatusId::Continuity) {
|
||||
self.analysis = None;
|
||||
} else {
|
||||
self.continuity.clear();
|
||||
}
|
||||
}
|
||||
self.single_surface_analysis = single;
|
||||
self.status_order.clear();
|
||||
for item in &before {
|
||||
let id = match item.id {
|
||||
StatusId::SurfaceAnalysis | StatusId::Continuity if single => StatusId::SurfaceAnalysis,
|
||||
StatusId::SurfaceAnalysis if self.analysis.is_none() => StatusId::Continuity,
|
||||
id => id,
|
||||
};
|
||||
let active = match id {
|
||||
StatusId::SurfaceAnalysis => self.analysis.is_some() || (single && !self.continuity.is_empty()),
|
||||
StatusId::Continuity => !self.continuity.is_empty(),
|
||||
StatusId::Section => self.section.is_some(),
|
||||
};
|
||||
self.status_slot(id, active);
|
||||
}
|
||||
if self.status_items() != before {
|
||||
self.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful activation, or a clear (`None`), reconcile only chronology and the
|
||||
/// explicitly configured sharing policy. No duplicate effect settings are kept here.
|
||||
pub(crate) fn status_analysis_changed(&mut self, activated: Option<StatusId>) {
|
||||
if let Some(source) = activated
|
||||
&& self.single_surface_analysis
|
||||
{
|
||||
match source {
|
||||
StatusId::SurfaceAnalysis => self.continuity.clear(),
|
||||
StatusId::Continuity => self.analysis = None,
|
||||
StatusId::Section => {}
|
||||
}
|
||||
}
|
||||
// A successful Continuity measurement may find only open boundaries and no seams.
|
||||
// An ended/empty effect cannot become the winning source on the next policy change.
|
||||
let shading = self.analysis.is_some();
|
||||
let continuity = !self.continuity.is_empty();
|
||||
let active = |source: &StatusId| match source {
|
||||
StatusId::SurfaceAnalysis => shading,
|
||||
StatusId::Continuity => continuity,
|
||||
StatusId::Section => false,
|
||||
};
|
||||
let remaining = if shading {
|
||||
Some(StatusId::SurfaceAnalysis)
|
||||
} else if continuity {
|
||||
Some(StatusId::Continuity)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.latest_analysis = activated.filter(active).or(self.latest_analysis.filter(active)).or(remaining);
|
||||
self.status_slot(StatusId::SurfaceAnalysis, self.analysis.is_some() || (self.single_surface_analysis && !self.continuity.is_empty()));
|
||||
self.status_slot(StatusId::Continuity, !self.single_surface_analysis && !self.continuity.is_empty());
|
||||
}
|
||||
|
||||
/// Current effects, derived without evaluating the model or changing any session state.
|
||||
pub fn status_items(&self) -> Vec<StatusItem> {
|
||||
let analysis = self
|
||||
.analysis
|
||||
.map(|a| match a {
|
||||
SurfaceAnalysis::Zebra { stripes } => ("inspect.zebra", StatusSetting::Zebra { stripes }, false),
|
||||
SurfaceAnalysis::Draft { pull, angle } => ("inspect.draft", StatusSetting::Draft { pull: pull.into(), angle_deg: angle }, true),
|
||||
SurfaceAnalysis::Environment => ("inspect.environment_map", StatusSetting::Environment, false),
|
||||
SurfaceAnalysis::Access { dir } => ("inspect.accessibility", StatusSetting::Accessibility { direction: dir.into() }, true),
|
||||
SurfaceAnalysis::Curvature { radius } => ("inspect.curvature_map", StatusSetting::Curvature { radius_mm: radius }, true),
|
||||
})
|
||||
.or_else(|| {
|
||||
(self.single_surface_analysis && !self.continuity.is_empty()).then_some((
|
||||
"inspect.continuity",
|
||||
StatusSetting::Continuity { edge_count: self.continuity.len() },
|
||||
false,
|
||||
))
|
||||
});
|
||||
let analysis = analysis.map(|(command, setting, has_key)| item(StatusId::SurfaceAnalysis, command, setting, has_key));
|
||||
let continuity = (!self.single_surface_analysis && !self.continuity.is_empty())
|
||||
.then(|| item(StatusId::Continuity, "inspect.continuity", StatusSetting::Continuity { edge_count: self.continuity.len() }, false));
|
||||
let section = self.section.map(|(origin, normal)| {
|
||||
item(
|
||||
StatusId::Section,
|
||||
"inspect.section",
|
||||
StatusSetting::Section { origin_mm: origin.into(), normal: normal.into(), hatch: self.section_hatch },
|
||||
false,
|
||||
)
|
||||
});
|
||||
// Public effect fields can also be populated by embedders without command chronology.
|
||||
// Include those effects after recorded slots, without inventing or storing timestamps.
|
||||
let mut items = Vec::new();
|
||||
for id in self.status_order.iter().chain([StatusId::SurfaceAnalysis, StatusId::Continuity, StatusId::Section].iter()) {
|
||||
let effect = match id {
|
||||
StatusId::SurfaceAnalysis => &analysis,
|
||||
StatusId::Continuity => &continuity,
|
||||
StatusId::Section => §ion,
|
||||
};
|
||||
if let Some(effect) = effect
|
||||
&& !items.iter().any(|item: &StatusItem| item.id == *id)
|
||||
{
|
||||
items.push(effect.clone());
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(crate) fn status_slot(&mut self, id: StatusId, active: bool) {
|
||||
if !active {
|
||||
self.status_order.retain(|slot| *slot != id);
|
||||
} else if !self.status_order.contains(&id) {
|
||||
self.status_order.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item(id: StatusId, command: &str, setting: StatusSetting, has_key: bool) -> StatusItem {
|
||||
StatusItem {
|
||||
id,
|
||||
kind: StatusKind::Mode,
|
||||
command: command.into(),
|
||||
setting,
|
||||
has_key,
|
||||
clear: StatusClear { command: command.into(), params: StatusClearParams { clear: true } },
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ mod tests {
|
||||
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}},
|
||||
]});
|
||||
let mut s = crate::Session::default();
|
||||
let (results, t) = s.run_script_timed(&script, true, &mut |_, _| {}).unwrap();
|
||||
let (results, t) = s.run_script_timed(&script, true).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
let rows = t["timings"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
@@ -100,12 +100,12 @@ mod tests {
|
||||
let total = t["total_ms"].as_f64().unwrap();
|
||||
assert!(total + 0.01 >= rows.iter().map(|r| r["ms"].as_f64().unwrap()).sum::<f64>(), "{t}");
|
||||
// Without profile: the same rows, no kernel split; the plain run is unchanged.
|
||||
let (_, t) = crate::Session::default().run_script_timed(&script, false, &mut |_, _| {}).unwrap();
|
||||
let (_, t) = crate::Session::default().run_script_timed(&script, false).unwrap();
|
||||
assert!(t["timings"][0].get("profile").is_none());
|
||||
assert_eq!(crate::Session::default().run_script(&script).unwrap().len(), 2);
|
||||
// A failing step still fails the run, with its step number.
|
||||
let bad = json!([{"command": "solid.box", "params": {"length": -1}}]);
|
||||
let e = crate::Session::default().run_script_timed(&bad, true, &mut |_, _| {}).unwrap_err().to_string();
|
||||
let e = crate::Session::default().run_script_timed(&bad, true).unwrap_err().to_string();
|
||||
assert!(e.contains("step 1"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,42 +29,6 @@ fn angular_drag_keeps_the_horizontal_construction_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A line held at its midpoint, with an angle to a horizontal line from that point (#8944):
|
||||
/// dragging one of its ends off that angle slides the end along the line as far as it can go,
|
||||
/// and the horizontal line keeps its length. That line's far end is free only along it, which
|
||||
/// can't bring the cursor closer, yet the cursor correction moved it a whole step at a time on
|
||||
/// rounding: the line shrank, for the first target to nothing.
|
||||
#[test]
|
||||
fn a_midpoint_line_dragged_off_its_angle_leaves_the_horizontal_line_alone() {
|
||||
for live in [false, true] {
|
||||
for (end, to, reached) in
|
||||
[("l1.end", [14.0, 3.0], [11.617647, 6.970588]), ("l1.end", [5.0, 12.0], [8.970588, 5.382353]), ("l1.start", [-3.0, -12.0], [-7.5, -4.5])]
|
||||
{
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
for (id, params) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.line", json!({"points":[[-10,-6],[10,6]],"infer":false})),
|
||||
("sketch.constraint.midpoint", json!({"line":"l1","point":"origin"})),
|
||||
("sketch.line", json!({"points":["origin",[15,0]],"infer":false})),
|
||||
("sketch.constraint.horizontal_vertical", json!({"line":"l2"})),
|
||||
("sketch.dimension", json!({"entities":["l1","l2"],"type":"angle"})),
|
||||
] {
|
||||
s.execute(id, ¶ms).unwrap();
|
||||
}
|
||||
let from = four_bar::point(&s, end).unwrap();
|
||||
s.execute("sketch.drag", &json!({"entity":end,"from":[from.x,from.y],"to":to,"live":live})).unwrap();
|
||||
if live {
|
||||
s.execute("sketch.drag_end", &json!({})).unwrap();
|
||||
}
|
||||
let point = |id| four_bar::point(&s, id).unwrap();
|
||||
let at = point(end);
|
||||
assert!(at.dist(Vec2::new(reached[0], reached[1])) < 1e-4, "{end} to {to:?} (live {live}) went to {at:?}");
|
||||
let far = point("l2.end");
|
||||
assert!(far.dist(Vec2::new(15.0, 0.0)) < 1e-6, "{end} to {to:?} (live {live}) moved l2's end to {far:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drag(s: &mut solidcraft_engine::Session, target: Vec2) {
|
||||
let from = four_bar::point(s, "crank.end").unwrap();
|
||||
s.execute("sketch.drag", &json!({"entity":"crank.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
|
||||
|
||||
@@ -1,6 +1,258 @@
|
||||
use super::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
|
||||
mod interactive {
|
||||
use super::*;
|
||||
use crate::job::{RaytraceOptions, interactive::*};
|
||||
use std::time::Duration;
|
||||
|
||||
fn cpu() -> ImageRequest {
|
||||
ImageRequest { integrator: Integrator::Raytrace, raytrace: Some(RaytraceOptions::default()), ..request() }
|
||||
}
|
||||
fn revision(visual_revision: u64) -> VisualRevision {
|
||||
VisualRevision { scene_generation: 19, visual_revision }
|
||||
}
|
||||
fn ms(value: u64) -> Duration {
|
||||
Duration::from_millis(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_fits_landscape_portrait_square_and_small_outputs_without_mutating_final_intent() {
|
||||
for (width, height, expected) in
|
||||
[(3840, 2160, (1024, 576)), (2160, 3840, (576, 1024)), (4096, 4096, (1024, 1024)), (81, 53, (81, 53)), (u32::MAX, 1, (1024, 1))]
|
||||
{
|
||||
let mut source = cpu();
|
||||
source.width = width;
|
||||
source.height = height;
|
||||
source.camera.roll = 0.37;
|
||||
source.background = [19, 31, 97];
|
||||
source.studio_weights = Some([0.1, 0.2, 0.9]);
|
||||
source.raytrace = Some(RaytraceOptions { primary_samples: 16, exposure_ev: -2.0, shadows: false, ..Default::default() });
|
||||
let before = serde_json::to_value(&source).unwrap();
|
||||
let preview = cpu_request(&source).unwrap();
|
||||
assert_eq!((preview.width, preview.height), expected);
|
||||
assert_eq!(preview.camera, source.camera);
|
||||
assert_eq!(preview.background, source.background);
|
||||
assert_eq!(preview.studio_weights, source.studio_weights);
|
||||
let options = preview.raytrace.unwrap();
|
||||
assert_eq!(options.progressive, Some(crate::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 }));
|
||||
assert_eq!(options.primary_samples, 1);
|
||||
assert_eq!(options.exposure_ev, -2.0);
|
||||
assert!(!options.shadows);
|
||||
assert_eq!(serde_json::to_value(source).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_refuses_incompatible_transport_and_invalid_input_without_erasing_it() {
|
||||
let mut source = cpu();
|
||||
source.raytrace.as_mut().unwrap().area_lights = Some(vec![crate::job::RectangleLightOptions {
|
||||
center_mm: [0.0, 0.0, 10.0],
|
||||
edge_u_mm: [2.0, 0.0, 0.0],
|
||||
edge_v_mm: [0.0, 2.0, 0.0],
|
||||
color: [1.0; 3],
|
||||
intensity: 2.0,
|
||||
}]);
|
||||
let before = serde_json::to_value(&source).unwrap();
|
||||
assert!(cpu_request(&source).is_err());
|
||||
assert_eq!(serde_json::to_value(&source).unwrap(), before);
|
||||
for source in [
|
||||
ImageRequest { transparent_background: true, ..cpu() },
|
||||
ImageRequest { width: 0, ..cpu() },
|
||||
request(),
|
||||
ImageRequest { raytrace: Some(RaytraceOptions { primary_samples: 3, ..Default::default() }), ..cpu() },
|
||||
] {
|
||||
assert!(cpu_request(&source).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_enable_settles_edits_and_keeps_only_one_capture_even_without_cad_revision_changes() {
|
||||
let mut interactive = Interactive::new(9001);
|
||||
interactive.edit(revision(1), ms(0)).unwrap();
|
||||
assert_eq!(interactive.state().unwrap(), State::Off);
|
||||
assert!(interactive.capture(ms(1000)).unwrap().is_none());
|
||||
interactive.enable(revision(1), ms(1000)).unwrap();
|
||||
assert!(interactive.capture(ms(1199)).unwrap().is_none());
|
||||
interactive.edit(revision(2), ms(1199)).unwrap();
|
||||
assert!(interactive.capture(ms(1398)).unwrap().is_none());
|
||||
let b = interactive.capture(ms(1399)).unwrap().unwrap();
|
||||
assert_eq!(b.revision(), revision(2));
|
||||
assert!(interactive.capture(ms(9999)).unwrap().is_none());
|
||||
interactive.edit(revision(3), ms(1400)).unwrap();
|
||||
// Delayed failure of B cannot poison C.
|
||||
interactive.capture_failed(b, JobError::new(ErrorCode::InvalidScene, "old capture"));
|
||||
assert_eq!(interactive.state().unwrap(), State::Updating);
|
||||
let c = interactive.capture(ms(1600)).unwrap().unwrap();
|
||||
interactive.capture_failed(c, JobError::new(ErrorCode::InvalidScene, "current capture"));
|
||||
assert_eq!(interactive.state().unwrap(), State::Error);
|
||||
assert_eq!(interactive.error().unwrap().message, "current capture");
|
||||
assert!(interactive.capture(ms(9999)).unwrap().is_none(), "no automatic failure retry loop");
|
||||
interactive.resume(ms(2000)).unwrap();
|
||||
assert!(interactive.capture(ms(2200)).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_edits_final_priority_workspace_departure_and_clock_regression_do_not_start_jobs() {
|
||||
let mut interactive = Interactive::new(9002);
|
||||
interactive.enable(revision(1), ms(1000)).unwrap();
|
||||
assert!(interactive.capture(ms(999)).unwrap().is_none());
|
||||
interactive.pause().unwrap();
|
||||
interactive.edit(revision(2), ms(2000)).unwrap();
|
||||
assert_eq!(interactive.state().unwrap(), State::Paused);
|
||||
assert!(interactive.capture(ms(3000)).unwrap().is_none());
|
||||
interactive.set_final_active(true, ms(3000)).unwrap();
|
||||
interactive.resume(ms(3000)).unwrap();
|
||||
assert!(interactive.capture(ms(4000)).unwrap().is_none());
|
||||
interactive.set_final_active(false, ms(4000)).unwrap();
|
||||
assert!(interactive.capture(ms(4199)).unwrap().is_none());
|
||||
assert_eq!(interactive.capture(ms(4200)).unwrap().unwrap().revision(), revision(2));
|
||||
interactive.disable().unwrap();
|
||||
assert_eq!(interactive.state().unwrap(), State::Off);
|
||||
assert!(interactive.capture(ms(9999)).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
fn host() -> CpuHost {
|
||||
// Real native admission/worker steps, explicitly pumped like the existing
|
||||
// checkpoint tests. No test clock sleeps, mock renderer or OS race.
|
||||
ImageHost(Arc::new(Owner {
|
||||
executor: None,
|
||||
shared: CpuHost::shared(MEMORY_LIMIT),
|
||||
kind: HostKind::NativeWorker,
|
||||
running: Mutex::new(None),
|
||||
_worker: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn superseded_jobs_are_cancelled_and_foreign_jobs_remain_untouched() {
|
||||
let host = host();
|
||||
let mut interactive = Interactive::new(9003);
|
||||
interactive.enable(revision(1), ms(0)).unwrap();
|
||||
let a = interactive.capture(ms(200)).unwrap().unwrap();
|
||||
let first = host.submit(id(9003, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(interactive.attach(a, first.clone()).unwrap());
|
||||
interactive.edit(revision(2), ms(201)).unwrap();
|
||||
assert_eq!(first.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert!(interactive.current().is_none());
|
||||
let b = interactive.capture(ms(401)).unwrap().unwrap();
|
||||
let late = host.submit(id(9003, 2), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(!interactive.attach(a, late.clone()).unwrap());
|
||||
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
// Drain cancelled queue entries before admitting another real job.
|
||||
while let Some(work) = host.0.shared.queue.lock().unwrap().pop_front() {
|
||||
drop(work);
|
||||
}
|
||||
let foreign = host.submit(id(9999, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(!interactive.attach(b, foreign.clone()).unwrap());
|
||||
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Queued);
|
||||
assert_eq!(interactive.state().unwrap(), State::Error);
|
||||
interactive.disable().unwrap();
|
||||
drop(interactive);
|
||||
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
|
||||
let mut running = Running::begin(work).unwrap();
|
||||
for _ in 0..100_000 {
|
||||
if !running.advance(16).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(foreign.status().unwrap().progress.phase, Phase::Completed, "the foreign job can still finish after our controller is closed");
|
||||
assert!(foreign.frame().unwrap().is_some());
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn progressive_checkpoints_reach_target_and_final_suspension_releases_owned_work() {
|
||||
let host = host();
|
||||
let mut interactive = Interactive::new(9004);
|
||||
interactive.enable(revision(1), ms(0)).unwrap();
|
||||
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
|
||||
let job = host.submit(id(9004, 1), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(interactive.attach(ticket, job.clone()).unwrap());
|
||||
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
|
||||
let mut running = Running::begin(work).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..100_000 {
|
||||
let more = running.advance(16).unwrap();
|
||||
if let Some(preview) = interactive.current().unwrap().preview().unwrap() {
|
||||
let spp = preview.info.samples_per_pixel;
|
||||
if seen.last() != Some(&spp) {
|
||||
seen.push(spp);
|
||||
}
|
||||
}
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, [1, 2, 4, 8]);
|
||||
assert_eq!(interactive.state().unwrap(), State::TargetReached);
|
||||
assert!(job.frame().unwrap().is_some());
|
||||
assert!(!interactive.attach(ticket, job.clone()).unwrap(), "duplicate acknowledgement cannot restart or cancel the accepted job");
|
||||
interactive.set_final_active(true, ms(300)).unwrap();
|
||||
assert_eq!(interactive.state().unwrap(), State::Paused);
|
||||
assert!(interactive.current().is_none());
|
||||
assert!(interactive.capture(ms(9999)).unwrap().is_none());
|
||||
drop(running);
|
||||
drop(job);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn same_scene_stale_serial_generation_and_delayed_success_after_failure_are_rejected() {
|
||||
for mismatch in 0..3 {
|
||||
let host = host();
|
||||
let mut interactive = Interactive::new(9005);
|
||||
interactive.enable(revision(1), ms(0)).unwrap();
|
||||
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
|
||||
let mut job_id = ticket.job_id();
|
||||
let mut scene_generation = ticket.revision().scene_generation;
|
||||
match mismatch {
|
||||
0 => job_id.serial += 1,
|
||||
1 => job_id.generation += 1,
|
||||
_ => scene_generation += 1,
|
||||
}
|
||||
let job = host.submit(job_id, scene_generation, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(!interactive.attach(ticket, job.clone()).unwrap());
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert_eq!(interactive.state().unwrap(), State::Error);
|
||||
}
|
||||
let host = host();
|
||||
let mut interactive = Interactive::new(9006);
|
||||
interactive.enable(revision(1), ms(0)).unwrap();
|
||||
let ticket = interactive.capture(ms(200)).unwrap().unwrap();
|
||||
interactive.capture_failed(ticket, JobError::new(ErrorCode::InvalidScene, "capture failed"));
|
||||
let late = host.submit(ticket.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(!interactive.attach(ticket, late.clone()).unwrap());
|
||||
assert_eq!(late.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert!(interactive.current().is_none());
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn delayed_ticket_cannot_cancel_the_current_handle_but_still_cancels_its_obsolete_job() {
|
||||
let host = host();
|
||||
let mut interactive = Interactive::new(9007);
|
||||
interactive.enable(revision(1), ms(0)).unwrap();
|
||||
let old = interactive.capture(ms(200)).unwrap().unwrap();
|
||||
interactive.edit(revision(2), ms(201)).unwrap();
|
||||
let current = interactive.capture(ms(401)).unwrap().unwrap();
|
||||
let job = host.submit(current.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(interactive.attach(current, job.clone()).unwrap());
|
||||
assert!(!interactive.attach(old, job.clone()).unwrap());
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
|
||||
let obsolete = host.submit(old.job_id(), 19, empty(), cpu_request(&cpu()).unwrap()).unwrap();
|
||||
assert!(!interactive.attach(old, obsolete.clone()).unwrap());
|
||||
assert_eq!(obsolete.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
|
||||
assert_eq!(interactive.current().unwrap().id(), current.job_id());
|
||||
drop(interactive);
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
mod progressive_checkpoints {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Transient CPU Interactive scheduling. The UI adapter owns visual revisions;
|
||||
//! this controller owns cancellation, not document settings or final-image export.
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{CpuProgressiveOptions, ErrorCode, ImageRequest, Integrator, JobError, JobHandle, JobId, Phase, Result};
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(200);
|
||||
const MAX_EDGE: u32 = 1024;
|
||||
|
||||
/// Prepare a separate low-resolution request. Never edits the final request,
|
||||
/// changes renderer, removes optics/lights, or promises geometry admission.
|
||||
pub fn cpu_request(final_request: &ImageRequest) -> Result<ImageRequest> {
|
||||
if final_request.integrator != Integrator::Raytrace || final_request.transparent_background {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive CPU requires opaque ray tracing"));
|
||||
}
|
||||
let (width, height) = (final_request.width, final_request.height);
|
||||
if width == 0 || height == 0 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive output dimensions must be positive"));
|
||||
}
|
||||
if let Some(options) = &final_request.raytrace {
|
||||
options.validate()?;
|
||||
}
|
||||
let edge = width.max(height);
|
||||
let fit = |side: u32| {
|
||||
if edge <= MAX_EDGE { side } else { ((u64::from(side) * u64::from(MAX_EDGE) + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
|
||||
};
|
||||
let mut request = final_request.clone();
|
||||
request.width = fit(width);
|
||||
request.height = fit(height);
|
||||
let options = request.raytrace.get_or_insert_default();
|
||||
options.primary_samples = 1;
|
||||
// These are preview sampling choices only. Incompatible transport remains an
|
||||
// explicit error, rather than silently removing the user's requested effects.
|
||||
options.progressive = Some(CpuProgressiveOptions::default());
|
||||
if let Some(reflections) = &mut options.reflections {
|
||||
reflections.samples = 1;
|
||||
}
|
||||
options.validate()?;
|
||||
options.budget.validate_image(request.width, request.height, options.progressive)?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Caller increments visual_revision for every camera/draft/appearance/light/HDR
|
||||
/// or output edit, even if scene_generation did not change. Never use only a CAD
|
||||
/// revision, a body name, or the selected setup name as the visual identity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct VisualRevision {
|
||||
pub scene_generation: u64,
|
||||
pub visual_revision: u64,
|
||||
}
|
||||
|
||||
/// Issued only after the edit group settles. Private fields prevent fabricating
|
||||
/// a ticket for a newer request from a delayed capture.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CaptureTicket {
|
||||
session: u64,
|
||||
serial: u64,
|
||||
revision: VisualRevision,
|
||||
}
|
||||
|
||||
impl CaptureTicket {
|
||||
pub fn revision(self) -> VisualRevision {
|
||||
self.revision
|
||||
}
|
||||
|
||||
/// Submit this identity to the preview host, outside the final-job registry.
|
||||
pub fn job_id(self) -> JobId {
|
||||
JobId { session: self.session, serial: self.serial, generation: self.serial }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Off,
|
||||
Updating,
|
||||
Refining,
|
||||
Paused,
|
||||
TargetReached,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// One controller per document session. No serialization, clocks, threads, scene
|
||||
/// extraction or new renderer here. Supply monotonic elapsed time from the host;
|
||||
/// this keeps both browser integration and deterministic event tests possible.
|
||||
pub struct Interactive {
|
||||
session: u64,
|
||||
serial: u64,
|
||||
revision: Option<VisualRevision>,
|
||||
changed_at: Duration,
|
||||
enabled: bool,
|
||||
paused: bool,
|
||||
final_active: bool,
|
||||
ticket: Option<CaptureTicket>,
|
||||
active: Option<JobHandle>,
|
||||
error: Option<JobError>,
|
||||
}
|
||||
|
||||
impl Interactive {
|
||||
pub fn new(session: u64) -> Self {
|
||||
Self {
|
||||
session,
|
||||
serial: 0,
|
||||
revision: None,
|
||||
changed_at: Duration::ZERO,
|
||||
enabled: false,
|
||||
paused: false,
|
||||
final_active: false,
|
||||
ticket: None,
|
||||
active: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self) -> Result<()> {
|
||||
// Invalidate first, including when a poisoned host refuses cancellation.
|
||||
self.ticket = None;
|
||||
if let Some(job) = self.active.take() {
|
||||
job.cancel()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explicit user activation. Merely entering Render must not call this.
|
||||
pub fn enable(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
|
||||
self.cancel()?;
|
||||
self.enabled = true;
|
||||
self.paused = false;
|
||||
self.error = None;
|
||||
self.revision = Some(revision);
|
||||
self.changed_at = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Workspace/document departure uses disable; manually started final jobs
|
||||
/// are deliberately outside this controller and are never cancelled here.
|
||||
pub fn disable(&mut self) -> Result<()> {
|
||||
self.enabled = false;
|
||||
self.paused = false;
|
||||
self.error = None;
|
||||
self.cancel()
|
||||
}
|
||||
|
||||
pub fn edit(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
|
||||
if self.revision != Some(revision) {
|
||||
self.revision = Some(revision);
|
||||
self.changed_at = now;
|
||||
self.error = None;
|
||||
self.cancel()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A view may retain an identified immutable PNG before pausing, but must
|
||||
/// label it paused/stale. It is never a current or exportable final image.
|
||||
pub fn pause(&mut self) -> Result<()> {
|
||||
self.paused = true;
|
||||
self.cancel()
|
||||
}
|
||||
|
||||
pub fn resume(&mut self, now: Duration) -> Result<()> {
|
||||
self.cancel()?;
|
||||
self.paused = false;
|
||||
self.error = None;
|
||||
self.changed_at = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_final_active(&mut self, active: bool, now: Duration) -> Result<()> {
|
||||
if self.final_active != active {
|
||||
self.final_active = active;
|
||||
self.changed_at = now;
|
||||
self.cancel()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// At most one unacknowledged capture; no queue of obsolete requests.
|
||||
pub fn capture(&mut self, now: Duration) -> Result<Option<CaptureTicket>> {
|
||||
if !self.enabled
|
||||
|| self.paused
|
||||
|| self.final_active
|
||||
|| self.ticket.is_some()
|
||||
|| self.error.is_some()
|
||||
|| now.checked_sub(self.changed_at).is_none_or(|elapsed| elapsed < SETTLE)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(revision) = self.revision else { return Ok(None) };
|
||||
self.serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Interactive capture identity exhausted"))?;
|
||||
let ticket = CaptureTicket { session: self.session, serial: self.serial, revision };
|
||||
self.ticket = Some(ticket);
|
||||
Ok(Some(ticket))
|
||||
}
|
||||
|
||||
/// Attach an admitted preview-only job after immutable capture. Delayed jobs
|
||||
/// are cancelled, including a previous ticket for the same visual revision.
|
||||
/// Use a separate engine job session/registry from final-image export.
|
||||
pub fn attach(&mut self, ticket: CaptureTicket, job: JobHandle) -> Result<bool> {
|
||||
// A misrouted callback must never cancel another document's work or a
|
||||
// manual final job. Only our dedicated preview session is ours to stop.
|
||||
if job.id().session != self.session {
|
||||
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job belongs to another session"));
|
||||
return Ok(false);
|
||||
}
|
||||
if self.ticket != Some(ticket) || ticket.session != self.session {
|
||||
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
|
||||
job.cancel()?;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let status = job.status()?;
|
||||
if status.job != ticket.job_id() || status.scene_generation != ticket.revision.scene_generation {
|
||||
job.cancel()?;
|
||||
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job capture identity mismatch"));
|
||||
return Ok(false);
|
||||
}
|
||||
if status.request.integrator != Integrator::Raytrace || status.request.raytrace.as_ref().is_none_or(|options| options.progressive.is_none()) {
|
||||
job.cancel()?;
|
||||
self.capture_failed(ticket, JobError::new(ErrorCode::InvalidRequest, "Interactive requires a CPU progressive job"));
|
||||
return Ok(false);
|
||||
}
|
||||
// A ticket is acknowledged exactly once, even if the same capture is
|
||||
// delivered twice while an earlier attached job is still running.
|
||||
if self.active.is_some() {
|
||||
if self.active.as_ref().is_none_or(|active| active.id() != job.id()) {
|
||||
job.cancel()?;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
self.active = Some(job);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn capture_failed(&mut self, ticket: CaptureTicket, error: JobError) {
|
||||
if self.ticket == Some(ticket) && self.active.is_none() {
|
||||
self.ticket = None;
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Access only the current capture. The adapter reads checkpoint/final bytes
|
||||
/// through this handle, displaying both as Interactive/provisional content.
|
||||
pub fn current(&self) -> Option<&JobHandle> {
|
||||
self.active.as_ref()
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<&JobError> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<State> {
|
||||
if !self.enabled {
|
||||
return Ok(State::Off);
|
||||
}
|
||||
if self.paused || self.final_active {
|
||||
return Ok(State::Paused);
|
||||
}
|
||||
if self.error.is_some() {
|
||||
return Ok(State::Error);
|
||||
}
|
||||
if let Some(job) = &self.active {
|
||||
return Ok(match job.status()?.progress.phase {
|
||||
Phase::Completed => State::TargetReached,
|
||||
Phase::Cancelled | Phase::Failed => State::Error,
|
||||
_ => State::Refining,
|
||||
});
|
||||
}
|
||||
Ok(State::Updating)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Interactive {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cancel();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod encoding;
|
||||
pub mod finishing;
|
||||
mod host;
|
||||
pub(crate) mod image;
|
||||
pub mod interactive;
|
||||
pub mod led_strip;
|
||||
mod lens;
|
||||
#[cfg(test)]
|
||||
|
||||