mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 03:25:58 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d92923a964 | |||
| 71598c0354 | |||
| 06a8a9b7d2 | |||
| 4d2691e636 | |||
| 6e17874cb9 | |||
| bf22dad8e8 | |||
| 731029cd87 | |||
| 6b5c5dc6e8 | |||
| e7ce6c282a | |||
| 67c58cbd28 | |||
| 7c89f7445f | |||
| 26adcfa997 | |||
| 1e41dddae2 | |||
| a9b219bc9f | |||
| 32dc8750df | |||
| ceeff58a7f | |||
| a55286953a | |||
| 9a0846ef68 | |||
| 06749ac464 | |||
| a6e7126bf1 | |||
| 4a906699ee | |||
| a2a7102859 | |||
| a137f2d459 | |||
| f87dd73661 | |||
| af5644aee6 | |||
| abeb232f17 | |||
| 1a27c8399d | |||
| 5ab59e7c1b | |||
| 9a274f91df | |||
| 08fa042039 | |||
| 09bb8aaaf5 | |||
| 46edd8d749 | |||
| c5d3dabd1b | |||
| 92171bd79b | |||
| 2ce55baba1 | |||
| 6870f9539c | |||
| 2998d412b6 | |||
| 2396b6be05 | |||
| 3c1074cfc6 | |||
| c4a63a1d43 | |||
| 2fdcc5ae49 | |||
| 4678a04827 | |||
| 63ffadfebf | |||
| aee947f0f9 | |||
| ad56dd6a93 | |||
| cafe015ef9 | |||
| 155387bac1 | |||
| de1db6fb8c | |||
| c47e848f4a | |||
| 8b4fe10995 | |||
| 8fb046aaa8 | |||
| 7b31c0567b | |||
| 5efdd52854 | |||
| 61ada52984 | |||
| 6578d591ac | |||
| d9421bf2ca | |||
| 774c45a512 | |||
| 315d8cfeb7 | |||
| 4ab1640dc8 | |||
| 3aee35fe01 | |||
| 5562d9c6c8 | |||
| d16496e057 |
@@ -1,6 +1,6 @@
|
||||
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
|
||||
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
|
||||
# answer (CONTRIBUTING.md, "Kernel work goes to solidcore"). The logic is in scripts/kernel-paused/ (tested
|
||||
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
|
||||
# in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
|
||||
|
||||
## Start here
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see.
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Local `plan/` notes are optional.
|
||||
|
||||
## Hard rules (what enforces each)
|
||||
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
|
||||
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
|
||||
- Kernel ops and math go to solidcore; features call its API: `cargo xtask lists`.
|
||||
- New kernel cases go to solidcore: `cargo xtask lists`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
@@ -21,17 +21,19 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
|
||||
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
|
||||
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
|
||||
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
|
||||
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
|
||||
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
|
||||
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
|
||||
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
|
||||
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
|
||||
- Code, comments, commit messages and docs are in English, always: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
|
||||
- Independent work runs in parallel, dependent work stays sequential: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
|
||||
- Show observable changes with real screenshots or a recording, captured yourself.
|
||||
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
|
||||
- Run `cargo xtask ci` before every commit. `cargo xtask skills` keeps this file and `skills/` honest.
|
||||
|
||||
## Working on X: read the skill first
|
||||
| Working on | Read |
|
||||
|---|---|
|
||||
| A feature, PR, claim, review, CI, evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
|
||||
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
|
||||
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
|
||||
|
||||
@@ -798,6 +798,10 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/sketch-tool-hints.json | carlymx | Original sketch tool hints and engine error translations | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/primitive-pick-field-tips.json | SolidCraft contributors | Original primitive pick-field tooltip translations | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/ribbon-panel-hover.json | SolidCraft contributors | Original ribbon panel menu preference labels and translations | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/box-insert-designer.gif | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (2.5× playback, 800 px wide) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/box-insert-designer.mp4 | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (1280×720) | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
+13
-26
@@ -17,27 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work goes to solidcore
|
||||
## Kernel work is paused
|
||||
|
||||
Since 0.40, SolidCraft's geometry kernel is [solidcore](https://github.com/tangency-labs/solidcore),
|
||||
and the kernel operations still in this repository are migrating there. The split:
|
||||
|
||||
- **SolidCraft** is the application: commands, the timeline, sketches, the UI, file handling. A new
|
||||
feature calls the solidcore API and builds on the operations it offers.
|
||||
- **solidcore** holds the geometry: evaluators, intersections, booleans, blends, shells, sweeps,
|
||||
tessellation and the rest of the math. If a feature needs kernel functionality solidcore does not
|
||||
have yet, add it to solidcore first (its own issue and PR), then use it here.
|
||||
|
||||
So pull requests that add geometry math to SolidCraft (`crates/kernel/`, `crates/geom/`, or a new
|
||||
evaluator or solver anywhere else) are not taken; they get the `kernel-paused` label
|
||||
(`.github/workflows/kernel-paused.yml`) and a maintainer points them to solidcore. Crashes and data
|
||||
loss are still taken as issues here, and the dialogs and tools for kernel features carry on as
|
||||
normal.
|
||||
|
||||
Work started before 0.40 was written against the earlier kernel. It is still welcome: port it onto
|
||||
today's code, calling solidcore for the geometry, before it can land. Expect a substantial rework
|
||||
for anything that touched the kernel. Designs keep opening and rebuilding on the new kernel; where a
|
||||
result changes, the PR says so and why.
|
||||
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
|
||||
change `crates/kernel/`, `crates/geom/` or `vendor/truck*`, or issues and discussions about kernel
|
||||
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
|
||||
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
|
||||
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
|
||||
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
|
||||
including the dialogs and tools for these features.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -100,6 +88,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
|
||||
|
||||
- `crates/geom`: vectors, planes, meshes, measures.
|
||||
- `crates/sketch`: our own constraint solver, plus profile finding.
|
||||
- `crates/dynamics`: multibody dynamics: rigid bodies, joint constraints, gravity and springs,
|
||||
integrated in time (RFC #9719). It depends only on `geom`.
|
||||
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
|
||||
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
|
||||
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
|
||||
@@ -341,11 +331,8 @@ and a fresh CI run:
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, discuss it on [Discord](https://discord.gg/FtU4NQNVDH)
|
||||
first, then open an issue. Before you start, search **all** issues and pull requests, open and
|
||||
closed, for similar work (`gh issue list -R tangency-labs/solidcraft --state all --search "<words>"`,
|
||||
and the same with `gh pr list`). **Claim the issue with a draft PR** before you start, so two
|
||||
people don't build the same thing:
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
@@ -458,7 +445,7 @@ correct them freely and it won't put them back.
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; the kernel is [solidcore](https://github.com/tangency-labs/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
|
||||
|
||||
Generated
+11
@@ -4153,6 +4153,17 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-dynamics"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.40.0-rc.2"
|
||||
|
||||
@@ -22,6 +22,7 @@ solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-smlib = { path = "crates/smlib" }
|
||||
solidcraft-step = { path = "crates/step" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
solidcraft-dynamics = { path = "crates/dynamics" }
|
||||
solidcraft-gpu = { path = "crates/gpu" }
|
||||
solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::process::{Command, ExitCode};
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
solidcraft_engine::note!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
@@ -23,7 +23,7 @@ fn main() -> ExitCode {
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
solidcraft_engine::note!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
eprintln!("design {seed}");
|
||||
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
|
||||
solidcraft_engine::note!("design {seed}");
|
||||
let run = fuzz::run_with(seed, steps, &mut |st| solidcraft_engine::note!("step {}", serde_json::to_string(st).unwrap_or_default()));
|
||||
let mut design = json!({
|
||||
"outcome": run.outcome,
|
||||
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
||||
@@ -45,13 +45,13 @@ pub fn one(args: &[String]) -> Result<(), String> {
|
||||
if run.outcome.is_bug() {
|
||||
design["script"] = run.script();
|
||||
if minimise {
|
||||
eprintln!("minimise");
|
||||
solidcraft_engine::note!("minimise");
|
||||
let m = fuzz::minimise(&run);
|
||||
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
|
||||
}
|
||||
}
|
||||
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
|
||||
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
|
||||
solidcraft_engine::note!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
|
||||
let b = boolean_case(seed);
|
||||
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
|
||||
@@ -162,11 +162,14 @@ fn record(v: &Value, out_dir: Option<&str>) {
|
||||
let d = &v["design"];
|
||||
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
|
||||
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
|
||||
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
|
||||
solidcraft_engine::note!(
|
||||
"seed {seed}: {}",
|
||||
if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") }
|
||||
);
|
||||
}
|
||||
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
|
||||
if bk != "ok" {
|
||||
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
|
||||
solidcraft_engine::note!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
|
||||
}
|
||||
if let Some(dir) = out_dir {
|
||||
use std::io::Write;
|
||||
|
||||
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
|
||||
}
|
||||
};
|
||||
if let Err(e) = max_file_size(&mut args) {
|
||||
eprintln!("error: {e}");
|
||||
solidcraft_engine::note!("error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let r = match args.first().map(String::as_str) {
|
||||
@@ -235,7 +235,7 @@ fn main() -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
solidcraft_engine::note!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec
|
||||
/// 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());
|
||||
solidcraft_engine::note!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,10 +598,10 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out");
|
||||
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
eprintln!("note: the script leaves out {what}");
|
||||
solidcraft_engine::note!("note: the script leaves out {what}");
|
||||
}
|
||||
match out {
|
||||
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
|
||||
Some(out) => solidcraft_engine::note!("{out}: {} steps", r["steps"]),
|
||||
None => println!("{}", pretty(&r["script"])),
|
||||
}
|
||||
Ok(())
|
||||
@@ -632,7 +632,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
|
||||
);
|
||||
match s.lands_on {
|
||||
Some(solidcraft_gpu::select::Landing::Cpu) => {
|
||||
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
|
||||
solidcraft_engine::note!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
|
||||
Ok(())
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
@@ -709,7 +709,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
|
||||
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
|
||||
eprintln!("warning: {w}");
|
||||
solidcraft_engine::note!("warning: {w}");
|
||||
reply["warning"] = json!(w);
|
||||
}
|
||||
println!("{}", pretty(&reply));
|
||||
@@ -796,7 +796,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
|
||||
if let Some(s) = &scope {
|
||||
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
|
||||
eprintln!(
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
|
||||
roots.join(", ")
|
||||
);
|
||||
@@ -804,7 +804,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
|
||||
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
solidcraft_engine::note!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How calls that break a command's parameter contract are treated, headless or bridged.
|
||||
@@ -818,7 +818,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
|
||||
if let Err(e) = remote.try_connect() {
|
||||
eprintln!(
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
|
||||
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
|
||||
);
|
||||
@@ -835,7 +835,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
(None, None) => {
|
||||
let auto = !args.iter().any(|a| a == "--headless");
|
||||
if auto && let Some(port) = discover_app() {
|
||||
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
solidcraft_engine::note!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
|
||||
return serve_mcp(Box::new(remote), scope);
|
||||
}
|
||||
|
||||
@@ -188,3 +188,26 @@ fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
|
||||
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #10111: a line for a stderr whose reader has gone (the pipe closed before it, as an agent may
|
||||
/// leave the MCP server's, and the app that one starts) is dropped, and the command ends as it
|
||||
/// would with stderr open: here a script that isn't there fails with 1, where `eprintln!`
|
||||
/// panicked with "failed printing to stderr" (101).
|
||||
#[test]
|
||||
fn a_closed_stderr_does_not_stop_the_cli() {
|
||||
for _ in 0..5 {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["run", "no-such-script.json"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
drop(child.stderr.take());
|
||||
assert_eq!(child.wait().unwrap().code(), Some(1));
|
||||
}
|
||||
// The old name's alias writes its deprecation notice first: an agent still configured with
|
||||
// `solvecraft-cli mcp` must get the server, not that panic.
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--help").stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stderr.take());
|
||||
assert_eq!(child.wait().unwrap().code(), Some(0));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
|
||||
@@ -33,7 +34,6 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemGetDirectoryOptions", "FileSystemWritableFileStream",
|
||||
|
||||
@@ -553,7 +553,7 @@ mod web {
|
||||
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
|
||||
/// for access to the file (call in the click), read it into the engine's files and queue it
|
||||
/// to open. A file that is gone is forgotten and dropped from the recent designs.
|
||||
pub fn restore(files: &Rc<Files>, path: &str) {
|
||||
pub fn restore(files: &Rc<Files>, path: &str, on_opened: Option<Box<dyn FnOnce()>>) {
|
||||
let Some((id, name)) = parse(path) else { return };
|
||||
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
|
||||
spawn_local(async move {
|
||||
@@ -568,6 +568,9 @@ mod web {
|
||||
files.opened.borrow_mut().push(path);
|
||||
files.wake();
|
||||
touch(&id).await;
|
||||
if let Some(done) = on_opened {
|
||||
done();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! and go out as downloads (exports, and "Download design"). Where the File System Access API
|
||||
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
|
||||
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
|
||||
//! reload or a crash.
|
||||
//! reload or a crash. With "keep unsaved work in this browser" on, each open design's unsaved
|
||||
//! changes are kept there instead, one copy per design (`keep.rs`, #10220).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
@@ -13,7 +14,7 @@ use std::rc::Rc;
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
|
||||
use crate::disk;
|
||||
use crate::{disk, keep};
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
@@ -79,6 +80,16 @@ pub struct Files {
|
||||
pub recovery: RefCell<Option<(String, Option<String>)>>,
|
||||
/// The app's context, to wake it when a browser call finishes.
|
||||
pub ctx: RefCell<Option<egui::Context>>,
|
||||
/// This page's id: the copies it keeps are named after it (`keep::entry_id`).
|
||||
pub keep_page: RefCell<String>,
|
||||
/// Kept copies stored by this page, and the design revision each holds once its write ended.
|
||||
pub kept: RefCell<std::collections::HashMap<String, Option<u64>>>,
|
||||
/// Kept copies being written or removed now (one browser call at a time per copy).
|
||||
pub keeping: RefCell<std::collections::HashSet<String>>,
|
||||
/// When (ms) the last write of a copy failed, to wait before trying again (`keep::may_write`).
|
||||
pub kept_failed: RefCell<std::collections::HashMap<String, f64>>,
|
||||
/// Copies earlier pages left, newer than their design's last save: offered at start.
|
||||
pub keep_offered: RefCell<Vec<keep::Entry>>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
@@ -288,6 +299,9 @@ pub fn start(files: &Rc<Files>) {
|
||||
let _ = JsFuture::from(p).await;
|
||||
});
|
||||
}
|
||||
let page = keep::page_id(js_sys::Date::now(), js_sys::Math::random());
|
||||
hold_page_lock(&page);
|
||||
*files.keep_page.borrow_mut() = page;
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
match disk::known_ids().await {
|
||||
@@ -300,6 +314,7 @@ pub fn start(files: &Rc<Files>) {
|
||||
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let kept = kept_copies(&all);
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
|
||||
for (name, bytes, modified) in all {
|
||||
@@ -315,7 +330,7 @@ pub fn start(files: &Rc<Files>) {
|
||||
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
|
||||
continue;
|
||||
}
|
||||
if name != RECOVERY {
|
||||
if name != RECOVERY && keep::parse_name(&name).is_none() {
|
||||
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
|
||||
}
|
||||
vfs::insert(&format!("{OPFS}{name}"), bytes);
|
||||
@@ -326,6 +341,7 @@ pub fn start(files: &Rc<Files>) {
|
||||
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
|
||||
*files.recovery.borrow_mut() = None;
|
||||
}
|
||||
sort_kept(&files, kept, &live_pages().await);
|
||||
}
|
||||
Err(e) => files.say(format!("browser storage: {e}"), true),
|
||||
}
|
||||
@@ -343,7 +359,7 @@ pub fn flush(files: &Rc<Files>) {
|
||||
{
|
||||
let mut e = files.entries.borrow_mut();
|
||||
e.retain(|x| x.name != name);
|
||||
if name != RECOVERY && name != RECOVERY_META {
|
||||
if name != RECOVERY && name != RECOVERY_META && keep::parse_name(&name).is_none() {
|
||||
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
|
||||
e.sort_by_key(|a| a.name.to_lowercase());
|
||||
}
|
||||
@@ -473,7 +489,15 @@ pub fn recovery_path() -> String {
|
||||
/// A recovery file lives in OPFS, but its relative links belong to the original design's
|
||||
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
|
||||
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
|
||||
let path = recovery_path();
|
||||
prepare_copy(recovery_path(), original_path)
|
||||
}
|
||||
|
||||
/// The same for a kept copy (#10220).
|
||||
pub fn prepare_kept(id: &str, original_path: Option<&str>) -> Result<String, String> {
|
||||
prepare_copy(format!("{OPFS}{}", keep::design_file(id)), original_path)
|
||||
}
|
||||
|
||||
fn prepare_copy(path: String, original_path: Option<&str>) -> Result<String, String> {
|
||||
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
|
||||
solidcraft_engine::resolve_design_links(&mut doc, original_path);
|
||||
@@ -646,3 +670,149 @@ pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold this page's browser lock while it runs, so other tabs know its kept copies are in use
|
||||
/// (`keep.rs`). Browsers without the Web Locks API hold none; their copies are then offered by
|
||||
/// any later page.
|
||||
fn hold_page_lock(page: &str) {
|
||||
let Some(locks) = web_locks() else { return };
|
||||
let Ok(request) = Reflect::get(&locks, &"request".into()).and_then(|f| f.dyn_into::<Function>().map_err(JsValue::from)) else { return };
|
||||
// A promise that never settles: the lock is let go only when the page goes.
|
||||
let hold = Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(|_lock: JsValue| js_sys::Promise::new(&mut |_, _| {}));
|
||||
let name = format!("{}{page}", keep::LOCK_PREFIX);
|
||||
if let Err(e) = request.call2(&locks, &name.into(), hold.as_ref().unchecked_ref()) {
|
||||
log::info!("keep: no page lock: {}", err(e));
|
||||
}
|
||||
// The callback lives as long as the page.
|
||||
hold.forget();
|
||||
}
|
||||
|
||||
fn web_locks() -> Option<JsValue> {
|
||||
let nav = web_sys::window()?.navigator();
|
||||
Reflect::get(&nav, &"locks".into()).ok().filter(|l| l.is_object())
|
||||
}
|
||||
|
||||
/// The pages still open (in this browser profile) that hold their lock.
|
||||
async fn live_pages() -> Vec<String> {
|
||||
let query = async {
|
||||
let locks = web_locks().ok_or("no Web Locks")?;
|
||||
let f: Function = Reflect::get(&locks, &"query".into()).map_err(err)?.dyn_into().map_err(|_| "no query".to_string())?;
|
||||
let p: js_sys::Promise = f.call0(&locks).map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
|
||||
let state = JsFuture::from(p).await.map_err(err)?;
|
||||
let held: Array = Reflect::get(&state, &"held".into()).map_err(err)?.dyn_into().map_err(|_| "no list".to_string())?;
|
||||
Ok::<_, String>(
|
||||
held.iter()
|
||||
.take(10_000)
|
||||
.filter_map(|l| Reflect::get(&l, &"name".into()).ok()?.as_string())
|
||||
.filter_map(|n| n.strip_prefix(keep::LOCK_PREFIX).map(str::to_string))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
query.await.unwrap_or_else(|e| {
|
||||
log::info!("keep: open pages unknown ({e})");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// The kept copies among the stored files: (copy, when its design's file was last written), and
|
||||
/// the ids of designs stored without their metadata.
|
||||
struct Found {
|
||||
copies: Vec<(keep::Entry, Option<f64>)>,
|
||||
orphans: Vec<String>,
|
||||
}
|
||||
|
||||
fn kept_copies(all: &[(String, Vec<u8>, f64)]) -> Found {
|
||||
let modified = |path: &str| path.strip_prefix(OPFS).and_then(|n| all.iter().find(|(name, _, _)| name == n)).map(|(_, _, m)| *m);
|
||||
let mut copies = Vec::new();
|
||||
let mut designs = Vec::new();
|
||||
for (name, bytes, _) in all {
|
||||
match keep::parse_name(name) {
|
||||
Some((id, keep::Part::Meta)) => {
|
||||
if let Some(e) = keep::Entry::parse(id, bytes) {
|
||||
let saved = e.path.as_deref().and_then(modified);
|
||||
copies.push((e, saved));
|
||||
}
|
||||
}
|
||||
Some((id, keep::Part::Design)) => designs.push(id.to_string()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// A copy is whole only with its design.
|
||||
copies.retain(|(e, _)| designs.contains(&e.id));
|
||||
let orphans = designs.into_iter().filter(|id| !copies.iter().any(|(e, _)| &e.id == id)).collect();
|
||||
Found { copies, orphans }
|
||||
}
|
||||
|
||||
/// Offer the copies earlier pages left that hold unsaved work, and remove those their design's
|
||||
/// later save made stale (and parts without the rest), leaving open pages' copies alone.
|
||||
fn sort_kept(files: &Rc<Files>, found: Found, live: &[String]) {
|
||||
let mut offered = Vec::new();
|
||||
let mut gone = Vec::new();
|
||||
for (e, saved) in found.copies {
|
||||
match keep::fate(&e, live, saved) {
|
||||
keep::Fate::Offer => offered.push(e),
|
||||
keep::Fate::Drop => gone.push(e.id),
|
||||
keep::Fate::Leave => {}
|
||||
}
|
||||
}
|
||||
gone.extend(found.orphans.into_iter().filter(|id| !keep::page_of(id).is_some_and(|p| live.iter().any(|l| l == p))));
|
||||
keep::order_offered(&mut offered);
|
||||
*files.keep_offered.borrow_mut() = offered;
|
||||
for id in gone {
|
||||
remove_kept(files, &id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The path (in the engine's files) a kept copy opens from.
|
||||
pub fn kept_path(id: &str) -> String {
|
||||
format!("{OPFS}{}", keep::design_file(id))
|
||||
}
|
||||
|
||||
/// Write the copy of a design with unsaved changes (`bytes`, described by `entry`) that holds
|
||||
/// its revision `revision`. Its metadata goes last, so a copy is never offered half written.
|
||||
pub fn write_kept(files: &Rc<Files>, entry: &keep::Entry, bytes: Vec<u8>, revision: u64) {
|
||||
let id = entry.id.clone();
|
||||
if !files.keeping.borrow_mut().insert(id.clone()) {
|
||||
return;
|
||||
}
|
||||
files.kept.borrow_mut().entry(id.clone()).or_insert(None);
|
||||
let meta = entry.to_json();
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
write_file(&keep::design_file(&id), &bytes).await?;
|
||||
write_file(&keep::meta_file(&id), meta.as_bytes()).await
|
||||
};
|
||||
match r.await {
|
||||
Ok(()) => {
|
||||
files.kept.borrow_mut().insert(id.clone(), Some(revision));
|
||||
files.kept_failed.borrow_mut().remove(&id);
|
||||
}
|
||||
Err(e) => {
|
||||
files.kept_failed.borrow_mut().insert(id.clone(), js_sys::Date::now());
|
||||
files.say(format!("keeping unsaved work in this browser: {e}"), true)
|
||||
}
|
||||
}
|
||||
files.keeping.borrow_mut().remove(&id);
|
||||
files.wake();
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove kept copy `id` (its design was saved, closed, discarded or restored).
|
||||
pub fn remove_kept(files: &Rc<Files>, id: &str) {
|
||||
let id = id.to_string();
|
||||
if !files.keeping.borrow_mut().insert(id.clone()) {
|
||||
return;
|
||||
}
|
||||
let _ = vfs::remove(&kept_path(&id));
|
||||
files.kept_failed.borrow_mut().remove(&id);
|
||||
let files = files.clone();
|
||||
spawn_local(async move {
|
||||
// The metadata first: what's left without it is never offered.
|
||||
let _ = remove_file(&keep::meta_file(&id)).await;
|
||||
let _ = remove_file(&keep::design_file(&id)).await;
|
||||
files.kept.borrow_mut().remove(&id);
|
||||
files.keeping.borrow_mut().remove(&id);
|
||||
files.wake();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Keep unsaved work in this browser (#10220): the decisions, in plain Rust (tested natively).
|
||||
//!
|
||||
//! With Preferences › General › "Keep unsaved work in this browser instead of asking when the tab
|
||||
//! closes" on, each open design with unsaved changes has a copy in the browser's private storage:
|
||||
//! `keep-<id>.sc` (the design) and `keep-<id>.json` (its name, its file and when it last
|
||||
//! changed). The copy is written shortly after each change ([`due`]) and again when the page is
|
||||
//! hidden, so it is already current when the tab closes; the page then doesn't ask through the
|
||||
//! browser's "leave site?" box ([`asks_before_unload`]). A design that is saved, closed or
|
||||
//! discarded has its copy removed. The next visit offers the copies newer than their design's
|
||||
//! last save ([`fate`]) in SolidCraft's own window, answered with [`plan`].
|
||||
//!
|
||||
//! An id is `<page>-<n>`: the page that wrote it (each page load has its own) and the design's
|
||||
//! number in that page. A page holds a browser lock named after it while it runs, so another tab
|
||||
//! never offers, or removes, the copies of a page that is still open.
|
||||
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The start of every kept file's name.
|
||||
pub const PREFIX: &str = "keep-";
|
||||
/// The browser lock a page holds while it runs: this, then its page id.
|
||||
pub const LOCK_PREFIX: &str = "solidcraft.keep.";
|
||||
/// A copy is written once the design has been still this long (ms)…
|
||||
pub const QUIET_MS: f64 = 800.0;
|
||||
/// …or at the latest this long after the first change it doesn't hold yet (ms).
|
||||
pub const MAX_WAIT_MS: f64 = 4000.0;
|
||||
/// The most copies offered at once (the rest wait for the next visit).
|
||||
pub const MAX_OFFERED: usize = 32;
|
||||
const MAX_ID: usize = 64;
|
||||
const MAX_NAME: usize = 256;
|
||||
const MAX_PATH: usize = 4096;
|
||||
/// Copies' metadata is small: a damaged or hostile one is not read.
|
||||
pub const MAX_META_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Ids are short and safe in a file name: ASCII letters, digits and dashes, with a page part.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= MAX_ID && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && page_of(id).is_some()
|
||||
}
|
||||
|
||||
/// The page that wrote copy `id`.
|
||||
pub fn page_of(id: &str) -> Option<&str> {
|
||||
id.rsplit_once('-').map(|(page, _)| page).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// This page's id, from the time it started (ms since 1970) and a random number in 0..1:
|
||||
/// letters and digits only.
|
||||
pub fn page_id(now_ms: f64, random: f64) -> String {
|
||||
let now = if now_ms.is_finite() && now_ms > 0.0 { now_ms.min(1e15) as u64 } else { 0 };
|
||||
let random = if random.is_finite() { (random.clamp(0.0, 1.0) * f64::from(0xff_ffff)) as u64 } else { 0 };
|
||||
format!("{now:x}{random:06x}")
|
||||
}
|
||||
|
||||
/// The copy of design `uid` (`Session::uid`) open in page `page`.
|
||||
pub fn entry_id(page: &str, uid: u64) -> String {
|
||||
format!("{page}-{uid}")
|
||||
}
|
||||
|
||||
/// The stored file names of copy `id`: its design and its metadata.
|
||||
pub fn design_file(id: &str) -> String {
|
||||
format!("{PREFIX}{id}.{}", solidcraft_engine::io::DESIGN_EXT)
|
||||
}
|
||||
pub fn meta_file(id: &str) -> String {
|
||||
format!("{PREFIX}{id}.json")
|
||||
}
|
||||
|
||||
/// Which part of a copy a stored file is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Part {
|
||||
Design,
|
||||
Meta,
|
||||
}
|
||||
|
||||
/// A stored file's copy id and part; `None` for every other file.
|
||||
pub fn parse_name(name: &str) -> Option<(&str, Part)> {
|
||||
let rest = name.strip_prefix(PREFIX)?;
|
||||
let (id, part) = if let Some(id) = rest.strip_suffix(".json") {
|
||||
(id, Part::Meta)
|
||||
} else {
|
||||
(rest.strip_suffix(solidcraft_engine::io::DESIGN_EXT)?.strip_suffix('.')?, Part::Design)
|
||||
};
|
||||
valid_id(id).then_some((id, part))
|
||||
}
|
||||
|
||||
/// A kept copy, as its metadata describes it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
/// The design's name.
|
||||
pub name: String,
|
||||
/// Its file (`None`: never saved).
|
||||
pub path: Option<String>,
|
||||
/// When the design last changed (ms since 1970).
|
||||
pub changed_at: f64,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn to_json(&self) -> String {
|
||||
json!({"name": self.name, "path": self.path, "changed_at": self.changed_at}).to_string()
|
||||
}
|
||||
|
||||
/// Read copy `id`'s metadata. Anything malformed, oversized or not finite is no copy.
|
||||
pub fn parse(id: &str, bytes: &[u8]) -> Option<Entry> {
|
||||
if !valid_id(id) || bytes.len() > MAX_META_BYTES {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_slice(bytes).ok()?;
|
||||
let name = v.get("name")?.as_str()?.chars().take(MAX_NAME).collect::<String>();
|
||||
let path = match v.get("path") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(p) => Some(p.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_PATH)?.to_string()),
|
||||
};
|
||||
let changed_at = v.get("changed_at")?.as_f64().filter(|t| t.is_finite() && *t >= 0.0)?;
|
||||
Some(Entry { id: id.to_string(), name, path, changed_at })
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a copy that last changed at `changed_at` holds work its design's file doesn't:
|
||||
/// the file was last written at `saved_at` (ms since 1970; `None` or unknown: never saved, or
|
||||
/// the time can't be read, so the copy is kept rather than lost).
|
||||
pub fn newer_than_save(changed_at: f64, saved_at: Option<f64>) -> bool {
|
||||
match saved_at {
|
||||
Some(saved) if saved.is_finite() && saved > 0.0 => changed_at > saved,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the next visit does with a copy it finds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Fate {
|
||||
/// Offer it: "Restore unsaved design…?".
|
||||
Offer,
|
||||
/// Its design was saved after it: remove it without asking.
|
||||
Drop,
|
||||
/// Its page is still open (another tab): leave it alone.
|
||||
Leave,
|
||||
}
|
||||
|
||||
/// The copy `entry`, whose design's file was last written at `saved_at`, while the pages
|
||||
/// `live` are open.
|
||||
pub fn fate(entry: &Entry, live: &[String], saved_at: Option<f64>) -> Fate {
|
||||
if page_of(&entry.id).is_some_and(|p| live.iter().any(|l| l == p)) {
|
||||
Fate::Leave
|
||||
} else if newer_than_save(entry.changed_at, saved_at) {
|
||||
Fate::Offer
|
||||
} else {
|
||||
Fate::Drop
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies to offer first: the most recently changed, at most [`MAX_OFFERED`].
|
||||
pub fn order_offered(entries: &mut Vec<Entry>) {
|
||||
entries.sort_by(|a, b| b.changed_at.total_cmp(&a.changed_at).then_with(|| a.id.cmp(&b.id)));
|
||||
entries.truncate(MAX_OFFERED);
|
||||
}
|
||||
|
||||
/// Whether a design's copy is due to be written at `now`: the design last changed at
|
||||
/// `last_change`, and the copy has lacked a change since `first_unwritten` (all ms).
|
||||
pub fn due(now: f64, first_unwritten: f64, last_change: f64) -> bool {
|
||||
now - last_change >= QUIET_MS || now - first_unwritten >= MAX_WAIT_MS
|
||||
}
|
||||
|
||||
/// How long after a failed write of a design's copy it isn't tried again (ms).
|
||||
pub const RETRY_MS: f64 = 30_000.0;
|
||||
|
||||
/// Whether a copy whose write last failed at `failed_at` (ms; `None`: it hasn't) may be written
|
||||
/// at `now`: not before [`RETRY_MS`] has passed, so a full or denied store isn't hit every tick.
|
||||
pub fn may_write(now: f64, failed_at: Option<f64>) -> bool {
|
||||
failed_at.is_none_or(|t| !(now >= t) || now - t >= RETRY_MS)
|
||||
}
|
||||
|
||||
/// Whether `wanted` is the design now open, given the open design's path.
|
||||
pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
|
||||
open == Some(wanted)
|
||||
}
|
||||
|
||||
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
|
||||
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
|
||||
/// keep preference off it never is, so there the box always stays).
|
||||
pub fn asks_before_unload(persists: bool, keep: bool, also_ask: bool, any_unsaved: bool, all_kept: bool) -> bool {
|
||||
persists && any_unsaved && (also_ask || !(keep && all_kept))
|
||||
}
|
||||
|
||||
/// An answer in the "Restore unsaved design?" window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// Open the copy, with its unsaved changes, as the design it was (saving goes to its file).
|
||||
Restore,
|
||||
/// Open the design's file as last saved; the copy goes.
|
||||
OpenSaved,
|
||||
/// The copy goes.
|
||||
Discard,
|
||||
}
|
||||
|
||||
/// What an answer does.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
/// Open the copy (and, once it is open, give it back its name and file).
|
||||
pub open_copy: bool,
|
||||
/// Open this saved file.
|
||||
pub open_saved: Option<String>,
|
||||
/// Remove the copy.
|
||||
pub drop_copy: bool,
|
||||
}
|
||||
|
||||
/// What `answer` does to copy `entry`; `saved_exists`: its file can still be opened. `None`: the
|
||||
/// answer isn't offered for it (Open saved version of a design never saved, or gone).
|
||||
pub fn plan(answer: Answer, entry: &Entry, saved_exists: bool) -> Option<Plan> {
|
||||
match answer {
|
||||
Answer::Restore => Some(Plan { open_copy: true, open_saved: None, drop_copy: true }),
|
||||
Answer::OpenSaved => {
|
||||
let path = entry.path.clone().filter(|_| saved_exists)?;
|
||||
Some(Plan { open_copy: false, open_saved: Some(path), drop_copy: true })
|
||||
}
|
||||
Answer::Discard => Some(Plan { open_copy: false, open_saved: None, drop_copy: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The answers the window offers for `entry`, in order.
|
||||
pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
|
||||
[Answer::Restore, Answer::OpenSaved, Answer::Discard].into_iter().filter(|a| plan(*a, entry, saved_exists).is_some()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
|
||||
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_waits_before_the_next_try() {
|
||||
assert!(may_write(1000.0, None));
|
||||
assert!(!may_write(1000.0 + RETRY_MS - 1.0, Some(1000.0)));
|
||||
assert!(may_write(1000.0 + RETRY_MS, Some(1000.0)));
|
||||
// A clock that went back doesn't block for good.
|
||||
assert!(may_write(10.0, Some(1000.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_counts_as_opened_only_at_its_own_path() {
|
||||
assert!(is_open(Some("a.sc"), "a.sc"));
|
||||
assert!(!is_open(Some("b.sc"), "a.sc"));
|
||||
assert!(!is_open(None, "a.sc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_are_named_by_page_and_design_and_nothing_else_reads_as_one() {
|
||||
let page = page_id(1_700_000_000_000.0, 0.5);
|
||||
assert!(page.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
let id = entry_id(&page, 7);
|
||||
assert!(valid_id(&id));
|
||||
assert_eq!(page_of(&id), Some(page.as_str()));
|
||||
assert_eq!(parse_name(&design_file(&id)), Some((id.as_str(), Part::Design)));
|
||||
assert_eq!(parse_name(&meta_file(&id)), Some((id.as_str(), Part::Meta)));
|
||||
for other in ["part.sc", "recovery.sc", "recovery.json", "keep-.sc", "keep-x.sc", "keep-a/b-1.sc", "keep-a-1.step", "keep-a-1sc"] {
|
||||
assert_eq!(parse_name(other), None, "{other}");
|
||||
}
|
||||
// Hostile clocks still give a usable id.
|
||||
for (now, random) in [(f64::NAN, f64::NAN), (f64::INFINITY, -1.0), (-5.0, 9.0)] {
|
||||
assert!(valid_id(&entry_id(&page_id(now, random), 1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_round_trips_and_hostile_metadata_is_no_copy() {
|
||||
let e = entry("a1-3", 1234.0, Some("/opfs/Bracket.sc"));
|
||||
assert_eq!(Entry::parse("a1-3", e.to_json().as_bytes()), Some(e.clone()));
|
||||
let never_saved = entry("a1-4", 99.0, None);
|
||||
assert_eq!(Entry::parse("a1-4", never_saved.to_json().as_bytes()), Some(never_saved));
|
||||
for bad in [
|
||||
"",
|
||||
"[]",
|
||||
r#"{"name":"x","changed_at":"soon"}"#,
|
||||
r#"{"name":"x","changed_at":-1}"#,
|
||||
r#"{"name":"x","changed_at":1e999}"#,
|
||||
r#"{"name":"x","path":7,"changed_at":1}"#,
|
||||
r#"{"name":"x","path":"","changed_at":1}"#,
|
||||
r#"{"changed_at":1}"#,
|
||||
] {
|
||||
assert_eq!(Entry::parse("a1-3", bad.as_bytes()), None, "{bad}");
|
||||
}
|
||||
assert_eq!(Entry::parse("../x", e.to_json().as_bytes()), None);
|
||||
assert_eq!(Entry::parse("a1-3", &vec![b' '; MAX_META_BYTES + 1]), None);
|
||||
let long = json!({"name": "n".repeat(10_000), "changed_at": 1}).to_string();
|
||||
assert_eq!(Entry::parse("a1-3", long.as_bytes()).map(|e| e.name.len()), Some(MAX_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_newer_than_the_last_save_is_offered_an_older_one_dropped_an_open_pages_left() {
|
||||
let e = entry("p1-1", 2000.0, Some("/opfs/Bracket.sc"));
|
||||
// Changed after the file was last written: offered.
|
||||
assert_eq!(fate(&e, &[], Some(1000.0)), Fate::Offer);
|
||||
// Saved since (in this tab or another): nothing to offer.
|
||||
assert_eq!(fate(&e, &[], Some(2000.0)), Fate::Drop);
|
||||
assert_eq!(fate(&e, &[], Some(3000.0)), Fate::Drop);
|
||||
// Never saved, or the save time can't be read: kept rather than lost.
|
||||
for saved in [None, Some(0.0), Some(f64::NAN), Some(f64::INFINITY), Some(-4.0)] {
|
||||
assert_eq!(fate(&e, &[], saved), Fate::Offer, "{saved:?}");
|
||||
}
|
||||
// Its page is still open in another tab: neither offered nor removed.
|
||||
assert_eq!(fate(&e, &["p1".into()], Some(1000.0)), Fate::Leave);
|
||||
assert_eq!(fate(&e, &["p1".into()], Some(3000.0)), Fate::Leave);
|
||||
assert_eq!(fate(&e, &["p2".into()], Some(1000.0)), Fate::Offer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_copies_are_offered_first_and_at_most_a_windowful() {
|
||||
let mut list: Vec<Entry> = (0..40).map(|i| entry(&format!("p-{i}"), f64::from(i), None)).collect();
|
||||
order_offered(&mut list);
|
||||
assert_eq!(list.len(), MAX_OFFERED);
|
||||
assert_eq!(list.first().map(|e| e.id.as_str()), Some("p-39"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_is_written_once_the_design_is_still_or_after_the_longest_wait() {
|
||||
// Still changing, not waited long: not yet.
|
||||
assert!(!due(1000.0, 900.0, 900.0));
|
||||
// Still for QUIET_MS: due.
|
||||
assert!(due(900.0 + QUIET_MS, 900.0, 900.0));
|
||||
// Changing all the time: due after MAX_WAIT_MS anyway.
|
||||
assert!(!due(5000.0, 2000.0, 4900.0));
|
||||
assert!(due(2000.0 + MAX_WAIT_MS, 2000.0, 2000.0 + MAX_WAIT_MS - 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_asks_before_unload_unless_told_not_to_and_every_copy_is_current() {
|
||||
// Keeping off: exactly as before, unsaved work asks, whatever the second option says.
|
||||
assert!(asks_before_unload(true, false, true, true, true));
|
||||
assert!(asks_before_unload(true, false, true, true, false));
|
||||
assert!(asks_before_unload(true, false, false, true, true));
|
||||
// Keeping on, asking on (the default): both, the box and the copy.
|
||||
assert!(asks_before_unload(true, true, true, true, true));
|
||||
// Keeping on, asking off, every unsaved design kept: no browser box.
|
||||
assert!(!asks_before_unload(true, true, false, true, true));
|
||||
// Keeping on, asking off, a copy still being written (or failed): the browser still asks.
|
||||
assert!(asks_before_unload(true, true, false, true, false));
|
||||
// Nothing unsaved, or an embedded page that keeps nothing: never.
|
||||
assert!(!asks_before_unload(true, true, true, false, false));
|
||||
assert!(!asks_before_unload(true, false, true, false, false));
|
||||
assert!(!asks_before_unload(false, false, true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_open_saved_and_discard_each_do_one_thing_and_always_remove_the_copy() {
|
||||
let saved = entry("p-1", 5.0, Some("/opfs/Bracket.sc"));
|
||||
assert_eq!(plan(Answer::Restore, &saved, true), Some(Plan { open_copy: true, open_saved: None, drop_copy: true }));
|
||||
assert_eq!(
|
||||
plan(Answer::OpenSaved, &saved, true),
|
||||
Some(Plan { open_copy: false, open_saved: Some("/opfs/Bracket.sc".into()), drop_copy: true })
|
||||
);
|
||||
assert_eq!(plan(Answer::Discard, &saved, true), Some(Plan { open_copy: false, open_saved: None, drop_copy: true }));
|
||||
assert_eq!(answers(&saved, true), [Answer::Restore, Answer::OpenSaved, Answer::Discard]);
|
||||
// A design never saved, or whose file is gone, has no saved version to open.
|
||||
let never = entry("p-2", 5.0, None);
|
||||
assert_eq!(plan(Answer::OpenSaved, &never, true), None);
|
||||
assert_eq!(plan(Answer::OpenSaved, &saved, false), None);
|
||||
assert_eq!(answers(&never, false), [Answer::Restore, Answer::Discard]);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ mod easyprint;
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod keep;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod linked;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -184,6 +186,7 @@ mod web {
|
||||
use crate::api::Api;
|
||||
use crate::disk::{self, SaveTarget};
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::keep;
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
@@ -243,7 +246,9 @@ mod web {
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed). With "keep
|
||||
/// unsaved work in this browser" on and "still ask" off, there is nothing to ask once every
|
||||
/// unsaved design's copy is current (`keep::asks_before_unload`, #10220).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
@@ -256,6 +261,50 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// With "keep unsaved work in this browser" on, the page brings every unsaved design's copy up
|
||||
/// to date when it is hidden or goes (`pagehide`, `visibilitychange`): the browser may not
|
||||
/// wait for that write, so the copies are written shortly after each change as well
|
||||
/// (`Shell::keep_tick`, #10220).
|
||||
fn keep_when_hidden(window: &web_sys::Window, runner: &eframe::WebRunner) {
|
||||
let keep_now = {
|
||||
let runner = runner.clone();
|
||||
move || {
|
||||
if let Some(mut shell) = runner.app_mut::<Shell>() {
|
||||
shell.keep_tick(None, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
let on_hide = {
|
||||
let keep_now = keep_now.clone();
|
||||
wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| keep_now())
|
||||
};
|
||||
let on_visibility = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
|
||||
let hidden = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| js_sys::Reflect::get(&d, &"visibilityState".into()).ok())
|
||||
.and_then(|v| v.as_string())
|
||||
.is_some_and(|v| v == "hidden");
|
||||
if hidden {
|
||||
keep_now();
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref());
|
||||
if let Some(document) = window.document() {
|
||||
let _ = document.add_event_listener_with_callback("visibilitychange", on_visibility.as_ref().unchecked_ref());
|
||||
}
|
||||
// The listeners live as long as the page.
|
||||
on_hide.forget();
|
||||
on_visibility.forget();
|
||||
}
|
||||
|
||||
/// One open design followed for its kept copy: its revision, when that last changed, and since
|
||||
/// when its copy has lacked a change (`None`: current, or being written).
|
||||
struct Track {
|
||||
revision: u64,
|
||||
last_change: f64,
|
||||
first_unwritten: Option<f64>,
|
||||
}
|
||||
|
||||
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
|
||||
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
|
||||
/// the canvas, before eframe sees them; the browser then acts on them as usual.
|
||||
@@ -328,6 +377,10 @@ mod web {
|
||||
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
|
||||
prefs_writer: crate::PrefsWriter,
|
||||
other_tab_prefs: PrefsInbox,
|
||||
/// The open designs followed for their kept copy, by `Session::uid` (#10220).
|
||||
keep_track: std::collections::HashMap<u64, Track>,
|
||||
/// Every unsaved design's kept copy is current (and the preference is on).
|
||||
all_kept: bool,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -442,8 +495,14 @@ mod web {
|
||||
self.sync_recents();
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// With "keep unsaved work in this browser" on, each design's own copy takes over from
|
||||
// the single autosave (#10220).
|
||||
if self.app.preferences.keep_unsaved_in_browser && self.autosaved.is_some() {
|
||||
files::autosave(&self.files, None);
|
||||
self.autosaved = None;
|
||||
}
|
||||
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() && !self.app.preferences.keep_unsaved_in_browser {
|
||||
let s = &self.app.session;
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
@@ -462,6 +521,175 @@ mod web {
|
||||
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.all_kept = self.keep_tick(Some(ctx), false);
|
||||
}
|
||||
|
||||
/// Keep each open design's unsaved changes in the browser, when the preference asks for
|
||||
/// it: a design's copy is written once it has been still a moment (`keep::due`), or at
|
||||
/// once with `now` (the page is being hidden); a copy whose design is saved or closed, or
|
||||
/// with the preference off, goes. Answers whether every unsaved design's copy is current.
|
||||
fn keep_tick(&mut self, ctx: Option<&egui::Context>, now: bool) -> bool {
|
||||
if !self.launch.persists() || !self.files.ready.get() {
|
||||
return false;
|
||||
}
|
||||
let Shell { app, files, keep_track, .. } = self;
|
||||
let on = app.preferences.keep_unsaved_in_browser;
|
||||
let time = js_sys::Date::now();
|
||||
let page = files.keep_page.borrow().clone();
|
||||
let mut wanted = std::collections::HashSet::new();
|
||||
let mut all_kept = true;
|
||||
let mut wake: Option<f64> = None;
|
||||
let mut error = None;
|
||||
let mut soon = |ms: f64| wake = Some(wake.map_or(ms, |w: f64| w.min(ms)));
|
||||
if on {
|
||||
for s in solidcraft_ui_egui::documents::open_sessions(app).into_iter().filter(|s| s.is_dirty()) {
|
||||
let id = keep::entry_id(&page, s.uid);
|
||||
wanted.insert(s.uid);
|
||||
let t = keep_track.entry(s.uid).or_insert(Track { revision: s.revision, last_change: time, first_unwritten: None });
|
||||
if t.revision != s.revision {
|
||||
t.revision = s.revision;
|
||||
t.last_change = time;
|
||||
}
|
||||
if files.kept.borrow().get(&id).copied().flatten() == Some(s.revision) {
|
||||
t.first_unwritten = None;
|
||||
continue;
|
||||
}
|
||||
all_kept = false;
|
||||
if files.keeping.borrow().contains(&id) {
|
||||
soon(100.0);
|
||||
continue;
|
||||
}
|
||||
let first = *t.first_unwritten.get_or_insert(time);
|
||||
let failed = files.kept_failed.borrow().get(&id).copied();
|
||||
if !keep::may_write(time, failed) {
|
||||
// The last write failed: try again after a while.
|
||||
soon(keep::RETRY_MS);
|
||||
continue;
|
||||
}
|
||||
if now || keep::due(time, first, t.last_change) {
|
||||
match solidcraft_engine::io::write_design_container(&s.design()) {
|
||||
Ok(bytes) => {
|
||||
let entry = keep::Entry { id, name: s.doc.name.clone(), path: s.path.clone(), changed_at: t.last_change };
|
||||
files::write_kept(files, &entry, bytes, s.revision);
|
||||
t.first_unwritten = None;
|
||||
}
|
||||
Err(e) => {
|
||||
files.kept_failed.borrow_mut().insert(id, time);
|
||||
error = Some(format!("keeping unsaved work in this browser: {e}"));
|
||||
}
|
||||
}
|
||||
soon(100.0);
|
||||
} else {
|
||||
soon((keep::QUIET_MS - (time - t.last_change)).min(keep::MAX_WAIT_MS - (time - first)).max(16.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// This page's copies no design needs any more: saved, closed, or the preference off.
|
||||
let stale: Vec<String> = files
|
||||
.kept
|
||||
.borrow()
|
||||
.keys()
|
||||
.filter(|id| keep::page_of(id) == Some(page.as_str()))
|
||||
.filter(|id| !wanted.iter().any(|uid| keep::entry_id(&page, *uid) == **id))
|
||||
.cloned()
|
||||
.collect();
|
||||
for id in stale {
|
||||
files::remove_kept(files, &id);
|
||||
soon(100.0);
|
||||
}
|
||||
keep_track.retain(|uid, _| wanted.contains(uid));
|
||||
if let Some(e) = error {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
if let (Some(ctx), Some(ms)) = (ctx, wake) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(ms / 1000.0));
|
||||
}
|
||||
on && all_kept
|
||||
}
|
||||
|
||||
/// Offer the copies an earlier visit kept (#10220): "Restore unsaved design: <name>,
|
||||
/// changed <time>?", one window for all of them, each with Restore, Open saved version
|
||||
/// (when it has a file) and Discard.
|
||||
fn keep_prompt(&mut self, ctx: &egui::Context) {
|
||||
let offered = self.files.keep_offered.borrow().clone();
|
||||
if offered.is_empty() || !self.files.ready.get() {
|
||||
return;
|
||||
}
|
||||
let now = (js_sys::Date::now() / 1000.0) as u64;
|
||||
let rows: Vec<_> = offered
|
||||
.iter()
|
||||
.map(|e| solidcraft_ui_egui::recovery_ui::BrowserCopy {
|
||||
name: &e.name,
|
||||
when: solidcraft_ui_egui::recovery_ui::ago_between(now, (e.changed_at / 1000.0) as u64),
|
||||
open_saved: keep::answers(e, self.saved_exists(e)).contains(&keep::Answer::OpenSaved),
|
||||
})
|
||||
.collect();
|
||||
let act = solidcraft_ui_egui::recovery_ui::browser_copies_window(ctx, &self.app.preferences.language, &rows);
|
||||
let Some((index, answer)) = act else { return };
|
||||
let Some(entry) = offered.get(index) else { return };
|
||||
let answer = match answer {
|
||||
solidcraft_ui_egui::recovery_ui::CopyAnswer::Restore => keep::Answer::Restore,
|
||||
solidcraft_ui_egui::recovery_ui::CopyAnswer::OpenSaved => keep::Answer::OpenSaved,
|
||||
solidcraft_ui_egui::recovery_ui::CopyAnswer::Discard => keep::Answer::Discard,
|
||||
};
|
||||
self.keep_answer(entry, answer);
|
||||
}
|
||||
|
||||
/// Whether a kept copy's design file can still be opened: in the browser's storage, or a
|
||||
/// file on the computer this browser remembers.
|
||||
fn saved_exists(&self, e: &keep::Entry) -> bool {
|
||||
e.path.as_deref().is_some_and(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| self.files.disk_known.borrow().contains(id)))
|
||||
}
|
||||
|
||||
/// Act on an answer in the "Restore unsaved design?" window (`keep::plan`).
|
||||
fn keep_answer(&mut self, entry: &keep::Entry, answer: keep::Answer) {
|
||||
let Some(plan) = keep::plan(answer, entry, self.saved_exists(entry)) else { return };
|
||||
if plan.open_copy {
|
||||
let copy = match files::prepare_kept(&entry.id, entry.path.as_deref()) {
|
||||
Ok(copy) => copy,
|
||||
Err(error) => {
|
||||
self.app.set_status(error, true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(©);
|
||||
if self.app.session.path.as_deref() != Some(copy.as_str()) {
|
||||
// Not opened (the status says why): it stays offered.
|
||||
return;
|
||||
}
|
||||
// It is its own design again (saving goes to its file), with its changes unsaved.
|
||||
self.app.session.path = entry.path.clone();
|
||||
self.app.session.doc_mut().name = entry.name.clone();
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
}
|
||||
if let Some(path) = &plan.open_saved {
|
||||
if vfs::exists(path) {
|
||||
self.app.open_path(path);
|
||||
if !keep::is_open(self.app.session.path.as_deref(), path) {
|
||||
// Not opened (the status says why): the copy stays offered.
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// A file on the computer: the browser asks for access again, in this click.
|
||||
// The copy goes once the file has opened, not before: if access is refused,
|
||||
// the picker is dismissed or the file is gone, it stays offered.
|
||||
let (files, id) = (self.files.clone(), entry.id.clone());
|
||||
disk::restore(
|
||||
&self.files,
|
||||
path,
|
||||
Some(Box::new(move || {
|
||||
files.keep_offered.borrow_mut().retain(|e| e.id != id);
|
||||
files::remove_kept(&files, &id);
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if plan.drop_copy {
|
||||
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
|
||||
files::remove_kept(&self.files, &entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The start page's recent designs are the stored ones: the remembered order first (those
|
||||
@@ -698,6 +926,13 @@ mod web {
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Whether leaving the page asks through the browser's own box (`keep::asks_before_unload`).
|
||||
fn asks_before_unload(&self) -> bool {
|
||||
let any = solidcraft_ui_egui::documents::any_dirty(&self.app);
|
||||
let prefs = &self.app.preferences;
|
||||
keep::asks_before_unload(self.launch.persists(), prefs.keep_unsaved_in_browser, prefs.keep_unsaved_also_asks, any, self.all_kept)
|
||||
}
|
||||
|
||||
/// Whether the shell already has the on-demand face the live language needs.
|
||||
fn script_font_ready(&self) -> bool {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
|
||||
@@ -809,7 +1044,7 @@ mod web {
|
||||
self.api.poll();
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -829,7 +1064,7 @@ mod web {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.linked.tick(&mut self.app, ui.ctx());
|
||||
UNSAVED.with(|u| u.set(self.launch.persists() && solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
if self.launch.embed {
|
||||
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
@@ -844,10 +1079,15 @@ mod web {
|
||||
if self.launch.persists() {
|
||||
self.file_panel(&ctx);
|
||||
self.recovery_prompt(&ctx);
|
||||
self.keep_prompt(&ctx);
|
||||
}
|
||||
self.watch_text_for_font(&ctx);
|
||||
self.store_prefs(&ctx);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||||
Some(&mut *self)
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
// eframe keeps this under one fixed key: only stable uses it, so a pre-release never rewrites it.
|
||||
self.launch.persists() && crate::channel::current().is_empty()
|
||||
@@ -922,7 +1162,8 @@ mod web {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
let runner = eframe::WebRunner::new();
|
||||
let result = runner
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
@@ -966,7 +1207,7 @@ mod web {
|
||||
can_restore: Some(Box::new(move |path: &str| {
|
||||
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
|
||||
})),
|
||||
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
|
||||
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path, None))),
|
||||
download_design: Some(Box::new(files::download)),
|
||||
easyprint_upload: Some(std::sync::Arc::new(crate::easyprint::upload)),
|
||||
browser_files: Some(Box::new(move || shown.panel.set(true))),
|
||||
@@ -1033,10 +1274,18 @@ mod web {
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
prefs_writer: crate::PrefsWriter::default(),
|
||||
other_tab_prefs,
|
||||
keep_track: Default::default(),
|
||||
all_kept: false,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if result.is_ok()
|
||||
&& crate::Launch::parse(&query()).persists()
|
||||
&& let Some(window) = web_sys::window()
|
||||
{
|
||||
keep_when_hidden(&window, &runner);
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => dismiss_loading(el),
|
||||
|
||||
@@ -68,7 +68,9 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
|
||||
Ok(l) => {
|
||||
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
|
||||
if matches!(listen_on, control_auth::Listen::Default) {
|
||||
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
|
||||
);
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match control_auth::write_current_port(port) {
|
||||
@@ -77,14 +79,14 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
|
||||
*p = Some((port, token));
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
|
||||
}
|
||||
}
|
||||
None => eprintln!("solidcraft: control server not started: no random token available"),
|
||||
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
|
||||
}
|
||||
rx
|
||||
}
|
||||
@@ -95,13 +97,13 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
|
||||
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
|
||||
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
|
||||
let Some(token) = new_token() else {
|
||||
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
|
||||
solidcraft_engine::note!("solidcraft: agent chat endpoint not started: no random token available");
|
||||
return None;
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", 0)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}");
|
||||
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -115,19 +117,21 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
|
||||
match control_auth::token_path(port) {
|
||||
Some(path) => match control_auth::write_token_file(&path, &token) {
|
||||
Ok(()) => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
solidcraft_engine::note!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.push((path, token.clone()));
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
Err(e) => solidcraft_engine::note!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
clients need SOLIDCRAFT_CONTROL_TOKEN",
|
||||
path.display()
|
||||
),
|
||||
},
|
||||
None => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN"
|
||||
)
|
||||
}
|
||||
}
|
||||
let token = std::sync::Arc::new(token);
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
|
||||
pub fn end() -> ! {
|
||||
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
|
||||
std::thread::sleep(END_GRACE);
|
||||
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
|
||||
solidcraft_engine::note!("SolidCraft graphics: still closing after the device was lost; aborting");
|
||||
std::process::abort();
|
||||
});
|
||||
if let Err(e) = watchdog {
|
||||
|
||||
+15
-12
@@ -79,7 +79,7 @@ fn mark(step: &str) {
|
||||
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
|
||||
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
|
||||
if *on {
|
||||
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
|
||||
solidcraft_engine::note!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,8 @@ impl eframe::App for App {
|
||||
log::warn!("a picture was still being drawn as the app closed");
|
||||
}
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
// A GPU path tracer setup still running did not stop the app (#9236).
|
||||
solidcraft_ui_egui::render_workspace::close(&mut self.solve);
|
||||
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
|
||||
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
|
||||
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
|
||||
@@ -366,7 +368,7 @@ impl App {
|
||||
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
|
||||
let reason = gpu.lost.reason().unwrap_or_default();
|
||||
log::error!("graphics: the device was lost ({reason}); closing to start again");
|
||||
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
|
||||
solidcraft_engine::note!("SolidCraft graphics: the device was lost ({reason}); starting again");
|
||||
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
|
||||
gpu.lost_at = Some(device_lost::now_secs());
|
||||
gpu.reason = reason;
|
||||
@@ -991,7 +993,7 @@ fn main() -> eframe::Result {
|
||||
if let Some(value) = max_file_size {
|
||||
let applied = startup::max_file_size(value.as_deref()).and_then(|bytes| solidcraft_engine::io::limits::set_override(Some(bytes)));
|
||||
if let Err(error) = applied {
|
||||
eprintln!("SolidCraft: {error}");
|
||||
solidcraft_engine::note!("SolidCraft: {error}");
|
||||
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
|
||||
}
|
||||
}
|
||||
@@ -1023,7 +1025,7 @@ fn main() -> eframe::Result {
|
||||
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
|
||||
if let Some(note) = &fallback_note {
|
||||
log::warn!("{note}");
|
||||
eprintln!("SolidCraft: {note}");
|
||||
solidcraft_engine::note!("SolidCraft: {note}");
|
||||
notes.push(note.clone());
|
||||
}
|
||||
let saved_samples = if safe_mode { None } else { msaa() };
|
||||
@@ -1136,13 +1138,13 @@ fn main() -> eframe::Result {
|
||||
// Windows: the taskbar button shows the mark even when slow first frames keep the
|
||||
// taskbar waiting for the window's icon.
|
||||
if !native_frame::set_class_icon(cc) {
|
||||
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
|
||||
solidcraft_engine::note!("SolidCraft window: no class icon; the taskbar may show a generic one");
|
||||
}
|
||||
// The OS hit test offers the resize border, at the window's DPI, from the first
|
||||
// message (#1381).
|
||||
app.native_resize_border = custom_titlebar && native_frame::install(cc);
|
||||
if custom_titlebar && !app.native_resize_border {
|
||||
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
|
||||
solidcraft_engine::note!("SolidCraft window: no native resize border; egui edge zones instead");
|
||||
}
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
@@ -1173,9 +1175,10 @@ fn main() -> eframe::Result {
|
||||
.collect();
|
||||
let info = rs.adapter.get_info();
|
||||
if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) {
|
||||
eprintln!(
|
||||
solidcraft_engine::note!(
|
||||
"SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead",
|
||||
app.preferences.graphics_adapter, info.name
|
||||
app.preferences.graphics_adapter,
|
||||
info.name
|
||||
);
|
||||
}
|
||||
let samples = scene_msaa_request(saved_samples);
|
||||
@@ -1183,7 +1186,7 @@ fn main() -> eframe::Result {
|
||||
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
|
||||
});
|
||||
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::note!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
|
||||
app.msaa_supported = supported;
|
||||
@@ -1344,7 +1347,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
|
||||
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
|
||||
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
|
||||
eprintln!("SolidCraft graphics: starting {backends:?}");
|
||||
solidcraft_engine::note!("SolidCraft graphics: starting {backends:?}");
|
||||
setup.instance_descriptor.backends = backends;
|
||||
}
|
||||
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
|
||||
@@ -1370,7 +1373,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
/// event loop or a failed device; each retry advances through the APIs, then software once.
|
||||
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
|
||||
log::error!("the window could not open: {error}");
|
||||
eprintln!("SolidCraft: the window could not open: {error}");
|
||||
solidcraft_engine::note!("SolidCraft: the window could not open: {error}");
|
||||
// This failure was handled, so neither the marker nor the journal should call it a crash.
|
||||
if let Some(dir) = settings {
|
||||
startup::started(dir);
|
||||
@@ -1383,7 +1386,7 @@ fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backen
|
||||
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
|
||||
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
|
||||
log::warn!("{note}");
|
||||
eprintln!("SolidCraft: {note}");
|
||||
solidcraft_engine::note!("SolidCraft: {note}");
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
|
||||
if next == "cpu" {
|
||||
|
||||
@@ -40,10 +40,10 @@ fn connect_to(path: &Path) -> Option<UnixStream> {
|
||||
|
||||
/// The axes of one record, or `None` for a record that is not motion.
|
||||
///
|
||||
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
|
||||
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
|
||||
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
|
||||
/// the user swaps or inverts there then carries over.
|
||||
/// The daemon reports x to the right, y up and z away from the user; our raw HID frame is x
|
||||
/// right, y towards the user and z down. Swap and negate y/z and ry/rz to match that frame.
|
||||
/// With nothing set in `spnavcfg` the two readers give the same motion; what the user swaps
|
||||
/// or inverts there then carries over.
|
||||
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
|
||||
let mut data = [0_i32; 8];
|
||||
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
|
||||
@@ -51,8 +51,8 @@ fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
|
||||
}
|
||||
let [kind, x, y, z, rx, ry, rz, _period] = data;
|
||||
(kind == 0).then(|| Axes {
|
||||
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
|
||||
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
|
||||
translation: [unit(x), unit(-i64::from(z)), unit(-i64::from(y))],
|
||||
rotation: [unit(rx), unit(-i64::from(rz)), unit(-i64::from(ry))],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,22 +149,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn motion_records_become_axes_in_the_devices_raw_frame() {
|
||||
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
|
||||
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
|
||||
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
|
||||
// Each daemon axis reaches only its matching raw HID axis, in both directions.
|
||||
for (daemon_axis, raw_axis, sign) in [(0, 0, 1), (1, 2, -1), (2, 1, -1), (3, 3, 1), (4, 5, -1), (5, 4, -1)] {
|
||||
for value in [-500, 500] {
|
||||
let mut data = [0; 8];
|
||||
data[daemon_axis + 1] = value;
|
||||
let mut expected = [0; 6];
|
||||
expected[raw_axis] = sign * (value * 350 / 500) as i16;
|
||||
assert_eq!(decode(&record(data)), Some(Axes::from(expected)), "daemon axis {daemon_axis}, value {value}");
|
||||
}
|
||||
}
|
||||
// Rest is a motion record of zeros.
|
||||
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_spnavcfg_swaps_reaches_the_view() {
|
||||
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
|
||||
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
|
||||
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
|
||||
assert_eq!(before, Some([0, -350, 0]));
|
||||
assert_eq!(after, Some([0, 0, -350]));
|
||||
// A push away is daemon z (raw y); swapping Y/Z makes it a lift (raw z).
|
||||
// The same swap changes a sideways tilt (daemon rz, raw ry) into a twist (raw rz).
|
||||
let before = decode(&record([0, 0, 0, 500, 0, 0, 250, 8]));
|
||||
let after = decode(&record([0, 0, 500, 0, 0, 250, 0, 8]));
|
||||
assert_eq!(before, Some(Axes { translation: [0, -350, 0], rotation: [0, -175, 0] }));
|
||||
assert_eq!(after, Some(Axes { translation: [0, 0, -350], rotation: [0, 0, -175] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -179,7 +185,7 @@ mod tests {
|
||||
// Nothing overflows on the extremes of `i32`.
|
||||
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
|
||||
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
|
||||
assert_eq!(extreme.map(|axes| axes.translation), Some([i16::MIN, i16::MIN, i16::MAX]));
|
||||
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
|
||||
}
|
||||
|
||||
|
||||
@@ -311,13 +311,18 @@ pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
|
||||
|
||||
/// Where macOS keeps its `nvram` tool.
|
||||
#[cfg(target_os = "macos")]
|
||||
const NVRAM_TOOL: &str = "/usr/sbin/nvram";
|
||||
|
||||
/// `true` if the OCLP NVRAM key is present on this system.
|
||||
///
|
||||
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
|
||||
/// permission denied, key absent) returns `false` — this is a best-effort probe.
|
||||
/// Runs `/usr/sbin/nvram <key>` and treats exit 0 as present. The full path keeps a `PATH` that
|
||||
/// lacks `/usr/sbin`, or lists a look-alike first, from changing the answer. Any error (missing
|
||||
/// binary, permission denied, key absent) returns `false`: this is a best-effort probe.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn oclp_nvram_present() -> bool {
|
||||
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
|
||||
std::process::Command::new(NVRAM_TOOL).arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
|
||||
@@ -334,8 +339,8 @@ fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
|
||||
///
|
||||
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
|
||||
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
|
||||
/// already queried the OS version by then. Re-launching is the only safe approach that
|
||||
/// works without `unsafe` (a true `execve` replacement would require it).
|
||||
/// already queried the OS version by then, so the program starts itself again with the variable
|
||||
/// set and waits for that child.
|
||||
///
|
||||
/// Returns `Ok(false)` immediately when:
|
||||
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
|
||||
@@ -352,7 +357,7 @@ pub fn relaunch_with_version_compat() -> Result<bool, String> {
|
||||
return Ok(false);
|
||||
}
|
||||
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
|
||||
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
|
||||
solidcraft_engine::note!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
|
||||
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
|
||||
let status = std::process::Command::new(&exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
|
||||
@@ -79,17 +79,6 @@ pub(crate) fn snap_hint(painter: &egui::Painter, c: Pos2, kind: SnapKind, col: C
|
||||
painter.circle_filled(c, 1.3, col);
|
||||
}
|
||||
|
||||
/// A placed hole's drag handle: a ring round the hole's centre with a cross through it, filled
|
||||
/// while it is hovered or dragged.
|
||||
pub(crate) fn hole_handle(painter: &egui::Painter, c: Pos2, col: Color32, hot: bool) {
|
||||
let ic = Tokens::get().icons;
|
||||
let r = 6.0;
|
||||
painter.circle_filled(c, r + 3.5, ic.marker_halo);
|
||||
painter.circle(c, r, if hot { col } else { col.gamma_multiply(0.35) }, Stroke::new(1.6, col));
|
||||
let ink = Stroke::new(1.3, if hot { ic.highlight } else { col });
|
||||
painter.line_segment([c - vec2(r + 3.0, 0.0), c + vec2(r + 3.0, 0.0)], ink);
|
||||
painter.line_segment([c - vec2(0.0, r + 3.0), c + vec2(0.0, r + 3.0)], ink);
|
||||
}
|
||||
pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"Coincident" => "c_coincident",
|
||||
|
||||
+68
-28
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "تعطيل المجلدات المحددة",
|
||||
"Suppressed": "مُعطّل",
|
||||
"Surface": "السطح",
|
||||
"Surface or face": "سطح أو وجه",
|
||||
"Surface or faces": "سطح أو أوجه",
|
||||
"Surface, or faces to turn over": "السطح، أو الأوجه المراد قلبها",
|
||||
"Surfaces": "الأسطح",
|
||||
"Swap Ends": "تبديل الطرفين",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "الهدف",
|
||||
"Target Body": "الجسم الهدف",
|
||||
"Target Face (optional)": "الوجه الهدف (اختياري)",
|
||||
"Target edges": "الحواف المستهدفة",
|
||||
"Tear": "تمزيق",
|
||||
"Technical details": "التفاصيل التقنية",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "الرسم المتقطع دليل مستوى محفوظ. حدد وجهًا جديدًا لإصلاح الارتباط.",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "يجب أن يكون الاتجاه محورًا أو منحنى رسم مستقيمًا يقع في مستوى الرسم",
|
||||
"The mirror line cannot also be mirrored": "لا يمكن عكس خط المرآة نفسه",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "يجب أن يكون خط المرآة منحنى مستقيمًا من هذا الرسم، وليس أحد محاور نقطة الأصل",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "محور الأصل عمودي على مستوى الرسم، لذا ليس له خط في المستوى",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "نسخة المؤلف المحفوظة مفقودة. حدد موضع مؤلف صالحًا قبل إعادة الربط.",
|
||||
"Then": "ثم",
|
||||
"These features will be deleted:": "ستُحذف هذه الميزات:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "تتبّع الكاميرا",
|
||||
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
|
||||
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
|
||||
"ai.prefs.instructions": "تعليمات مخصصة",
|
||||
"ai.prefs.instructions_note": "تُرسَل إلى الوكيل في بداية كل محادثة، بعد تعليمات SolidCraft الخاصة: طريقتك المفضلة في العمل والقواعد التي يجب الالتزام بها. يسري أي تغيير من المحادثة التالية (اتصال أو محادثة جديدة).",
|
||||
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
|
||||
"ai.prefs.make_default": "تعيين افتراضيًا",
|
||||
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "جارٍ الحساب… {seconds} ث",
|
||||
"dialog.could_not_be_checked": "تعذّر فحصه",
|
||||
"dialog.custom_axis": "↖ محور مخصص",
|
||||
"dialog.cylinder_face_tip": "وجه النموذج أو رأسه الذي تقوم عليه الأسطوانة: انقر هنا ثم انقر عليه في العرض. إذا تُرك فارغًا، تحدد X وY وZ موضعها.",
|
||||
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
|
||||
"dialog.dimension_name": "البُعد: {name}",
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "تعديل {title}",
|
||||
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
|
||||
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
|
||||
"dialog.primitive_point_tip": "نقطة تملأ X وY وZ: انقر هنا ثم على رأس أو حافة أو وجه من النموذج، أو على نقطة من رسم، أو على نقطة الأصل.",
|
||||
"dialog.reference_edge_tip": "حافة مستقيمة من الوجه المختار تُقاس منها المسافة أدناه: انقر هنا ثم على الحافة.",
|
||||
"dialog.select": "↖ تحديد",
|
||||
"dialog.selected_count": "↖ {count} محدد",
|
||||
"dialog.selection_constraint": "قيد {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
|
||||
"prefs.axes": "المحاور",
|
||||
"prefs.axis": "المحور",
|
||||
"prefs.background_choose": "اختيار صورة…",
|
||||
"prefs.background_image": "الصورة",
|
||||
"prefs.background_unreadable": "تعذرت قراءة صورة الخلفية",
|
||||
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
|
||||
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
|
||||
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
"prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار/الإخفاء بجانب سهم الطي كما كانت من قبل، بدلًا من عمود عند نهاية الصفوف.",
|
||||
"prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر",
|
||||
"prefs.browser_rows_start_closed": "صفوف المستعرض تبدأ مغلقة",
|
||||
"prefs.browser_rows_start_closed_note": "تبدأ المواضع والمجلدات في المستعرض مغلقة، فيعرض التصميم الذي يضم مكونات كثيرة صفوفه العليا. يُفتح كل صف بسهمه أو بـ«توسيع الكل» أو بـ«البحث في المستعرض» الذي يفتح الصفوف المؤدية إلى الجسم فقط، ويغلق من جديد الصفوف التي فتحها البحث السابق. اختيار جسم في العرض ثلاثي الأبعاد يترك المستعرض كما هو. عند إيقافه تبدأ المجلدات مفتوحة، وتبدأ المواضع كما يحدّد خيار «المكوّنات مفتوحة من البداية»، ويفتح الاختيار في العرض ثلاثي الأبعاد المستعرضَ حتى صف الجسم، كما كان من قبل.",
|
||||
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
|
||||
"prefs.browser_shown": "ظاهر",
|
||||
"prefs.built_in": "مدمج",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "سهما التدوير المنحنيان عند زاوية المكعب يدوران العرض داخل مستوى الشاشة. عند التشغيل: سهم التدوير مع عقارب الساعة يدور عكسها، وسهم العكس يدور معها. عند الإيقاف: يدور كل سهم بالعرض حسب انحنائه.",
|
||||
"prefs.invert_navigator_arrows": "عكس أسهم تدوير مكعب العرض",
|
||||
"prefs.invert_navigator_arrows_note": "تدوّر الأسهم المحيطة بالمكعب الكاميرا في الاتجاه الذي تشير إليه، كما في Fusion وInventor: السهم الموجود على اليسار يشير إلى اليمين ويدوّر الكاميرا إلى اليمين. عند الإيقاف: يدوّر السهم المكعب نحو الجهة التي يقع فيها، فيدوّر السهم الأيسر الكاميرا إلى اليسار. لا يتأثر سهما الدوران المنحنيان.",
|
||||
"prefs.keep_unsaved_also_asks": "السؤال أيضًا عند إغلاق علامة التبويب",
|
||||
"prefs.keep_unsaved_also_asks_tip": "عند إيقاف هذا الخيار، تُغلق علامة التبويب دون مربع المتصفح بعد تحديث نسخة كل تصميم غير محفوظ.",
|
||||
"prefs.keep_unsaved_in_browser": "الاحتفاظ بالعمل غير المحفوظ في هذا المتصفح",
|
||||
"prefs.keep_unsaved_in_browser_tip": "تُحدَّث التغييرات غير المحفوظة لكل تصميم مفتوح باستمرار في التخزين الخاص بهذا المتصفح. وتعرض الزيارة التالية استعادتها.",
|
||||
"prefs.keep_work_on_tab_switch": "إبقاء مربعات الحوار والأدوات مفتوحة عند التبديل بين التصاميم",
|
||||
"prefs.keep_work_on_tab_switch_note": "تحتفظ كل علامة تبويب تصميم بمربع الحوار أو الأداة المفتوحة، بما حددته وكتبته فيها، وبعلامة تبويب شريط الأدوات. وتجدها كما هي عند العودة إلى التصميم. عند الإيقاف: يؤدي التبديل بين التصاميم إلى إلغائها وعرض علامة التبويب «النموذج».",
|
||||
"prefs.labels": "التسميات",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "بطاقة",
|
||||
"prefs.layout_page.column": "عمود",
|
||||
"prefs.layout_page.hover": "عند مرور المؤشر",
|
||||
"prefs.layout_page.hover_panel_menus": "تبديل قوائم اللوحات عند مرور المؤشر",
|
||||
"prefs.layout_page.hover_panel_menus_help": "انقر على اسم لوحة في الشريط لفتح قائمتها. مرّر المؤشر فوق اسم لوحة أخرى للتبديل. حرّك المؤشر خارج الشريط والقائمة المفتوحة لإغلاقها.",
|
||||
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
|
||||
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
|
||||
"prefs.layout_page.on_hover": "عند مرور المؤشر",
|
||||
@@ -2560,8 +2580,8 @@
|
||||
"prefs.section_hatch_by_part": "حسب الجزء",
|
||||
"prefs.section_hatch_one": "نمط واحد",
|
||||
"prefs.select": "تحديد",
|
||||
"prefs.select_hidden_edges": "تحديد الحواف المخفية في العرض المظلل مع الحواف المخفية",
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأسطح عند تفعيل العرض المظلل مع الحواف المخفية، بما في ذلك استخدام أداة Fillet. يحافظ إيقافه على سلوك التحديد في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_hidden_edges": "تحديد الحواف المخفية في نمط «مظلل مع الحواف المخفية»",
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "الآن",
|
||||
"recovery.min_ago": "قبل {count} دقيقة",
|
||||
"recovery.never_saved": "لم يُحفظ قط",
|
||||
"recovery.open_saved": "فتح النسخة المحفوظة",
|
||||
"recovery.restore": "استعادة",
|
||||
"recovery.restore_intro": "التغييرات التي لم تُحفظ عند آخر إغلاق لهذه الصفحة محفوظة في هذا المتصفح.",
|
||||
"recovery.restore_row": "{name}، تغيّر {when}",
|
||||
"recovery.restore_title": "استعادة التصاميم غير المحفوظة؟",
|
||||
"recovery.restore_title_one": "استعادة التصميم غير المحفوظ: {name}، تغيّر {when}؟",
|
||||
"refused.change": "تعذّر تغيير {body}.",
|
||||
"refused.cut": "تعذّر قطع {body}.",
|
||||
"refused.face_curved": "أحد أوجهه المنحنية لا يمكن استخدامه في النمذجة.",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · عريض",
|
||||
"render.gpu_depth": "أقصى عمق للمسار",
|
||||
"render.gpu_options_help": "تُحفظ العيّنات والعمق والبذرة والتعريض مع هذا الإعداد. تُحفظ طريقة الصورة النشطة فقط.",
|
||||
"render.gpu_path_crashed": "أُغلق SolidCraft أثناء إعداده على محوّل الرسوميات هذا في المرة السابقة، على الأرجح داخل برنامج تشغيل الرسوميات، لذا لن يُعاد إعداده تلقائيًا. استخدم تتبع الأشعة على المعالج المركزي بدلًا منه، أو حاول مرة أخرى (يعيد تحديث برنامج التشغيل المحاولة أيضًا).",
|
||||
"render.gpu_path_failed": "متتبع المسارات على GPU غير متاح",
|
||||
"render.gpu_path_first_setup": "قد يستغرق الإعداد الأول على وحدة GPU هذه عدة دقائق؛ ويبقى SolidCraft قابلًا للاستخدام أثناءها. تعيد المرات اللاحقة استخدام المظللات المترجمة حيث يحتفظ بها برنامج التشغيل.",
|
||||
"render.gpu_path_preparing": "متتبع المسارات على GPU: ترجمة المظللات…",
|
||||
"render.gpu_path_retry": "حاول مرة أخرى",
|
||||
"render.gpu_seed": "البذرة العشوائية",
|
||||
"render.gpu_unavailable": "تتبع المسارات على GPU غير جاهز على هذا الجهاز.",
|
||||
"render.ground_apply": "ملاءمة الأرضية وحفظها",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "لوح نموذجي",
|
||||
"sample.vents.about": "شقوق في نمط مستطيلي: الصفوف والأعمدة والخطوة معاملات",
|
||||
"sample.vents.name": "لوح تهوية",
|
||||
"scale.per_axis": "لكل محور",
|
||||
"scale.uniform": "موحّد",
|
||||
"search.command_keys": "Enter تشغيل Shift+Enter تشغيل مع الإبقاء مفتوحًا Esc إغلاق",
|
||||
"search.commands": "الأوامر",
|
||||
"search.design": "في هذا التصميم",
|
||||
@@ -4178,32 +4208,32 @@
|
||||
"sketch.dof_left": "تبقّت {count} درجات حرية",
|
||||
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
|
||||
"sketch.draw_curves": "الرسم كـ {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.error.closed_curve_two_crossings": "يحتاج المنحنى المغلق إلى نقطتي تقاطع لقصّه",
|
||||
"sketch.error.nothing_to_break": "لا يوجد هنا ما يمكن كسره",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` منحنى إزاحة، وزواياه تتبع منحنياته المصدر: قوّس زوايا المنحنيات المصدر أو اشطفها قبل الإزاحة، أو فك ارتباط الإزاحة أولًا",
|
||||
"sketch.error.offset_trim": "`{id}` منحنى إزاحة، وأطرافه تتبع منحنياته المصدر: عدّل المنحنيات المصدر بدلًا من ذلك، أو فك ارتباط الإزاحة أولًا",
|
||||
"sketch.hint.arc.center_point": "قوس: انقر المركز ثم البداية، ودُر في أي اتجاه وانقر النهاية، أو اكتب زاوية القوس بإشارة موجبة أو سالبة",
|
||||
"sketch.hint.arc.three_point": "قوس: انقر البداية ثم النهاية، ثم نقطة على القوس",
|
||||
"sketch.hint.arc3d": "قوس ثلاثي الأبعاد: انقر البداية ثم النهاية، ثم نقطة على القوس (يمكن أن تقع النقاط خارج مستوى الرسم)",
|
||||
"sketch.hint.break": "كسر: انقر على منحنى في الموضع الذي يُقسم عنده",
|
||||
"sketch.hint.circle.center": "دائرة: انقر المركز، ثم نقطة على الدائرة",
|
||||
"sketch.hint.constraint.generic": "قيد: اختر عناصر الرسم",
|
||||
"sketch.hint.constraint.horizontal_vertical": "أفقي/رأسي: اختر خطًا، أو نقطتين، أو نقطة وخطًا؛ اضغط Shift مع الاستمرار لاختيار منتصف خط",
|
||||
"sketch.hint.constraint.midpoint": "نقطة المنتصف: اختر نقطة وخطًا، أو خطين لتوسيط الثاني على الأول",
|
||||
"sketch.hint.dimension": "البُعد: اختر عنصرًا أو عنصرين واكتب القيمة (أو انقر لوضعه أولًا)؛ انقر على بُعد لتعديله؛ اضغط Enter للتطبيق",
|
||||
"sketch.hint.extend": "تمديد: انقر على منحنى قرب الطرف المراد تمديده (Shift: انقر منحنيين لتمديدهما معًا حتى نقطة التقائهما)",
|
||||
"sketch.hint.fallback": "انقر في الرسم",
|
||||
"sketch.hint.line": "خط: انقر النقاط؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء",
|
||||
"sketch.hint.line3d": "خط ثلاثي الأبعاد: انقر نقاطًا في الفضاء؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء (يمكن أن تقع النقاط خارج مستوى الرسم)",
|
||||
"sketch.hint.open_done": "تم الفتح",
|
||||
"sketch.hint.point3d": "نقطة ثلاثية الأبعاد: انقر حيث توضع النقطة ثلاثية الأبعاد",
|
||||
"sketch.hint.rectangle.center": "مستطيل من المركز: انقر المركز، ثم زاوية",
|
||||
"sketch.hint.rectangle.rounded": "مستطيل مستدير الزوايا: انقر زاويتين متقابلتين (اضغط Tab للانتقال إلى خانة نصف القطر وكتابة نصف قطر الزوايا)",
|
||||
"sketch.hint.rectangle.three_point": "مستطيل بثلاث نقاط: انقر نقطتين على أحد الأضلاع، ثم العرض",
|
||||
"sketch.hint.rectangle.two_point": "مستطيل: انقر زاويتين متقابلتين",
|
||||
"sketch.hint.slot": "شق: انقر الطرفين، ثم العرض",
|
||||
"sketch.hint.spline3d": "منحنى شريحي ثلاثي الأبعاد: انقر نقاط الملاءمة في الفضاء ثلاثي الأبعاد؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء",
|
||||
"sketch.hint.trim": "قص: انقر على الجزء المراد إزالته من المنحنى، أو اسحب عبر الأجزاء المراد إزالتها",
|
||||
"sketch.make_selection": "تحويل التحديد إلى {linetype}",
|
||||
"sketch.open_ends": "مفتوحة {count}",
|
||||
"sketch.status_conflict": "تعارض القيود",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "رسم ثلاثي الأبعاد",
|
||||
"ui.activate": "تنشيط",
|
||||
"ui.active_component": "المكوّن النشط",
|
||||
"ui.addToQuickAccess": "إضافة إلى الوصول السريع",
|
||||
"ui.add_configuration": "إضافة تكوين",
|
||||
"ui.add_parameter": "إضافة معامل",
|
||||
"ui.agent_chat": "محادثة الوكيل",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "المعامل",
|
||||
"ui.fade": "تلاشٍ",
|
||||
"ui.fade_length": "طول التلاشي",
|
||||
"ui.fair_iterations": "عدد التكرارات",
|
||||
"ui.fair_keep_edges": "الحفاظ على الحواف",
|
||||
"ui.fair_strength": "شدة التنعيم",
|
||||
"ui.fair_surface_title": "تنعيم السطح",
|
||||
"ui.fair_tolerance_default": "0.001 من حجم الجسم",
|
||||
"ui.feature_history": "سجل الميزات",
|
||||
"ui.feature_options": "خيارات الميزة",
|
||||
"ui.features": "الميزات",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "السحب الحر للمُعالج أيضًا مع Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "عند التفعيل: Alt/Option أو Ctrl/Cmd على أسهم البثق / أداة التحويل يعطّل التقاط القيم. عند الإيقاف: Ctrl/Cmd فقط.",
|
||||
"ui.manufacture": "التصنيع",
|
||||
"ui.match_surface_title": "مطابقة السطح",
|
||||
"ui.material_review.axes": "محاور محلية للمادة؛ لم يُحدد اتجاه للجسم.",
|
||||
"ui.material_review.claims": "المصادر والتراخيص ادعاءات غير موثّقة. القيم المفقودة تبقى مجهولة.",
|
||||
"ui.material_review.compressive_strength": "مقاومة الانضغاط",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "الخيارات",
|
||||
"ui.orbit": "تدوير العرض",
|
||||
"ui.orbit_around": "التدوير حول",
|
||||
"ui.organize": "تنظيم",
|
||||
"ui.orientation": "الاتجاه",
|
||||
"ui.origin": "نقطة الأصل",
|
||||
"ui.palette_construction_mode": "وضع الخطوط المساعدة (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "الجودة",
|
||||
"ui.quantity": "الكمية",
|
||||
"ui.quantity_2": "الكمية 2",
|
||||
"ui.quick_access_full": "يتسع شريط الوصول السريع لـ {count} أمرًا؛ أزل أحدها لإضافة آخر",
|
||||
"ui.quit": "خروج",
|
||||
"ui.radial_context_menu": "قائمة سياقية دائرية",
|
||||
"ui.radius": "نصف القطر",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "هذا المكوّن غير مرتبط بتصميم مصدر.",
|
||||
"ui.relink_select_one": "حدد مكوّنًا مرتبطًا واحدًا أو إحدى نسخه أولًا.",
|
||||
"ui.relink_source_all": "إعادة ربط المصدر (كل النسخ)…",
|
||||
"ui.removeFromQuickAccess": "إزالة من الوصول السريع",
|
||||
"ui.removeFromToolbar": "إزالة من شريط الأدوات",
|
||||
"ui.removeSection": "إزالة المقطع",
|
||||
"ui.render": "التصيير",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Ukázková deska",
|
||||
"sample.vents.about": "Drážky v obdélníkovém poli: řádky, sloupce a rozteč jsou parametry",
|
||||
"sample.vents.name": "Větrací deska",
|
||||
"scale.per_axis": "Podle os",
|
||||
"scale.uniform": "Rovnoměrně",
|
||||
"search.command_keys": "Enter spustit Shift+Enter spustit a ponechat otevřené Esc zavřít",
|
||||
"search.commands": "Příkazy",
|
||||
"search.design": "V tomto návrhu",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Eksempelplade",
|
||||
"sample.vents.about": "Slidser i et rektangulært mønster: Rækker, kolonner og deling er parametre",
|
||||
"sample.vents.name": "Ventilationsplade",
|
||||
"scale.per_axis": "Per akse",
|
||||
"scale.uniform": "Ensartet",
|
||||
"search.command_keys": "Enter kør Shift+Enter kør og hold åben Esc luk",
|
||||
"search.commands": "Kommandoer",
|
||||
"search.design": "I dette design",
|
||||
|
||||
@@ -628,6 +628,8 @@
|
||||
"Suppress Selected Folders": "Ausgewählte Ordner unterdrücken",
|
||||
"Suppressed": "Unterdrückt",
|
||||
"Surface": "Flächenkörper",
|
||||
"Surface or face": "Flächenkörper oder Fläche",
|
||||
"Surface or faces": "Flächenkörper oder Flächen",
|
||||
"Surface, or faces to turn over": "Fläche oder umzudrehende Flächen",
|
||||
"Surfaces": "Flächen",
|
||||
"Swap Ends": "Enden vertauschen",
|
||||
@@ -641,6 +643,7 @@
|
||||
"Target": "Ziel",
|
||||
"Target Body": "Zielkörper",
|
||||
"Target Face (optional)": "Zielfläche (optional)",
|
||||
"Target edges": "Zielkanten",
|
||||
"Tear": "Reißen",
|
||||
"Technical details": "Technische Details",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Die gestrichelte Skizze ist eine gespeicherte Ebenenführung. Wählen Sie eine neue Fläche, um die Anbindung zu reparieren.",
|
||||
@@ -648,6 +651,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Die Richtung muss eine Achse oder eine gerade Skizzenkurve in der Skizzenebene sein",
|
||||
"The mirror line cannot also be mirrored": "Die Spiegellinie kann nicht gleichzeitig gespiegelt werden",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "Die Spiegellinie muss eine gerade Kurve dieser Skizze sein, keine Ursprungsachse",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Die Ursprungsachse steht senkrecht auf der Skizzenebene und hat daher keine Linie in ihr",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Das gespeicherte Autorvorkommen fehlt. Wählen Sie vor dem erneuten Anbinden eine gültige Autorplatzierung.",
|
||||
"Then": "Then",
|
||||
"These features will be deleted:": "Diese Merkmale werden gelöscht:",
|
||||
@@ -1388,6 +1392,7 @@
|
||||
"dialog.computing": "Wird berechnet… {seconds} s",
|
||||
"dialog.could_not_be_checked": "nicht prüfbar",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.cylinder_face_tip": "Die Fläche oder der Eckpunkt des Modells, auf dem der Zylinder steht: hier klicken, dann in der Ansicht darauf klicken. Leer gelassen platzieren ihn X, Y und Z.",
|
||||
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
|
||||
"dialog.dimension_name": "Bemaßung: {name}",
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
@@ -1396,6 +1401,8 @@
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
|
||||
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
|
||||
"dialog.primitive_point_tip": "Ein Punkt, der X, Y und Z füllt: hier klicken, dann einen Eckpunkt, eine Kante oder Fläche des Modells, einen Punkt einer Skizze oder den Ursprung anklicken.",
|
||||
"dialog.reference_edge_tip": "Eine gerade Kante der gewählten Fläche, von der aus der Abstand darunter gemessen wird: hier klicken, dann die Kante anklicken.",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
"dialog.selected_count": "↖ {count} ausgewählt",
|
||||
"dialog.selection_constraint": "Bedingung {id}",
|
||||
@@ -2278,6 +2285,8 @@
|
||||
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
|
||||
"prefs.browser_eye_left_note": "Browserzeilen zeigen ihr Auge zum Ein- und Ausblenden wie bisher neben dem Aufklapppfeil statt in einer Spalte am rechten Zeilenende.",
|
||||
"prefs.browser_reveal_on_hover": "Beim Überfahren einblenden",
|
||||
"prefs.browser_rows_start_closed": "Browser-Zeilen beginnen geschlossen",
|
||||
"prefs.browser_rows_start_closed_note": "Platzierungen und Ordner im Browser beginnen geschlossen, sodass ein Entwurf mit vielen Komponenten seine obersten Zeilen zeigt. Jede öffnet sich mit ihrem Pfeil, „Alle ausklappen“ oder „Im Browser suchen“, das nur die Zeilen bis zu seinem Körper öffnet und die von der letzten Suche geöffneten wieder schließt. Ein in der 3D-Ansicht gewählter Körper lässt den Browser, wie er ist. Ist es aus, beginnen die Ordner geöffnet, Platzierungen so, wie „Komponenten zu Beginn aufgeklappt“ es festlegt, und eine Auswahl in der 3D-Ansicht öffnet den Browser bis zu ihrem Körper, wie bisher.",
|
||||
"prefs.browser_show_note": "Angezeigt lässt den Browser auf dem Bildschirm. Beim Überfahren einblenden blendet ihn aus, bis der Zeiger über seinen Platz oder die schmale Lasche an seinem linken Rand kommt; er bleibt offen, solange der Zeiger darin ist oder ein Menü daraus offen ist.",
|
||||
"prefs.browser_shown": "Angezeigt",
|
||||
"prefs.built_in": "Integriert",
|
||||
@@ -2418,6 +2427,8 @@
|
||||
"prefs.layout_page.card": "Karte",
|
||||
"prefs.layout_page.column": "Spalte",
|
||||
"prefs.layout_page.hover": "Bei Mauszeiger",
|
||||
"prefs.layout_page.hover_panel_menus": "Bereichsmenüs beim Darüberfahren wechseln",
|
||||
"prefs.layout_page.hover_panel_menus_help": "Klicken Sie auf einen Bereichsnamen, um das Menü zu öffnen. Bewegen Sie den Zeiger auf einen anderen Bereichsnamen, um das Menü zu wechseln. Außerhalb des Menübands und des geöffneten Menüs schließt es sich.",
|
||||
"prefs.layout_page.icons_labels": "Symbole + Beschriftungen",
|
||||
"prefs.layout_page.ok_cancel": "OK und Abbrechen",
|
||||
"prefs.layout_page.on_hover": "Bei Mauszeiger",
|
||||
@@ -3253,9 +3264,11 @@
|
||||
"render.frame_wide": "16:9 · Breitbild",
|
||||
"render.gpu_depth": "Maximale Pfadtiefe",
|
||||
"render.gpu_options_help": "Samples, Tiefe, Startwert und Belichtung werden mit diesem Setup gespeichert. Es wird nur die aktive Bildmethode gespeichert.",
|
||||
"render.gpu_path_crashed": "SolidCraft wurde beim letzten Einrichten auf diesem Grafikadapter beendet, wahrscheinlich im Grafiktreiber, und richtet ihn deshalb nicht von selbst erneut ein. Verwenden Sie stattdessen Raytracing auf der CPU oder versuchen Sie es erneut (nach einem Treiberupdate wird es ebenfalls erneut versucht).",
|
||||
"render.gpu_path_failed": "GPU-Pathtracer nicht verfügbar",
|
||||
"render.gpu_path_first_setup": "Das erste Setup auf dieser GPU kann mehrere Minuten dauern; SolidCraft bleibt währenddessen bedienbar. Spätere Starts verwenden die kompilierten Shader erneut, sofern der Treiber sie behält.",
|
||||
"render.gpu_path_preparing": "GPU-Pathtracer: Shader werden kompiliert…",
|
||||
"render.gpu_path_retry": "Erneut versuchen",
|
||||
"render.gpu_seed": "Zufallsstartwert",
|
||||
"render.gpu_unavailable": "GPU-Pathtracing ist auf diesem Gerät nicht bereit.",
|
||||
"render.ground_apply": "Boden anpassen und speichern",
|
||||
@@ -4063,6 +4076,8 @@
|
||||
"sample.plate.name": "Beispielplatte",
|
||||
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
|
||||
"sample.vents.name": "Lüftungsplatte",
|
||||
"scale.per_axis": "Je Achse",
|
||||
"scale.uniform": "Gleichmäßig",
|
||||
"search.command_keys": "Eingabe ausführen Umschalt+Eingabe ausführen und offen lassen Esc schließen",
|
||||
"search.commands": "Befehle",
|
||||
"search.design": "In diesem Design",
|
||||
@@ -4920,6 +4935,11 @@
|
||||
"ui.factor": "Faktor",
|
||||
"ui.fade": "Auslauf",
|
||||
"ui.fade_length": "Auslauflänge",
|
||||
"ui.fair_iterations": "Iterationen",
|
||||
"ui.fair_keep_edges": "Kanten behalten",
|
||||
"ui.fair_strength": "Stärke",
|
||||
"ui.fair_surface_title": "FLÄCHENBEGRADIGUNG",
|
||||
"ui.fair_tolerance_default": "0,001 der Körpergröße",
|
||||
"ui.feature_history": "Feature-Verlauf",
|
||||
"ui.feature_options": "Funktionsoptionen",
|
||||
"ui.features": "Funktionen",
|
||||
@@ -5129,6 +5149,7 @@
|
||||
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
|
||||
"ui.manufacture": "Fertigung",
|
||||
"ui.match_surface_title": "FLÄCHENANPASSUNG",
|
||||
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
|
||||
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
|
||||
"ui.material_review.compressive_strength": "Druckfestigkeit",
|
||||
@@ -5192,6 +5213,7 @@
|
||||
"ui.options": "Optionen",
|
||||
"ui.orbit": "Drehen",
|
||||
"ui.orbit_around": "Umkreisen um",
|
||||
"ui.organize": "Organisieren",
|
||||
"ui.orientation": "Ausrichtung",
|
||||
"ui.origin": "Ursprung",
|
||||
"ui.palette_construction_mode": "Konstruktionsmodus (X)",
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"ui.layout_compact": "Compact",
|
||||
"ui.layout_floating": "Floating",
|
||||
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
|
||||
"ui.organize": "Organise",
|
||||
"ui.point_cloud_color_by": "Colour By",
|
||||
"ui.point_cloud_file_colors": "File Colours",
|
||||
"ui.point_cloud_no_file_colors": "The file has no colours",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "Suppress Selected Folders",
|
||||
"Suppressed": "Suppressed",
|
||||
"Surface": "Surface",
|
||||
"Surface or face": "Surface or face",
|
||||
"Surface or faces": "Surface or faces",
|
||||
"Surface, or faces to turn over": "Surface, or faces to turn over",
|
||||
"Surfaces": "Surfaces",
|
||||
"Swap Ends": "Swap Ends",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "Target",
|
||||
"Target Body": "Target Body",
|
||||
"Target Face (optional)": "Target Face (optional)",
|
||||
"Target edges": "Target edges",
|
||||
"Tear": "Tear",
|
||||
"Technical details": "Technical details",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "The direction must be an axis or a straight sketch curve that lies in the sketch plane",
|
||||
"The mirror line cannot also be mirrored": "The mirror line cannot also be mirrored",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "The mirror line must be a straight curve of this sketch, not an origin axis",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "The origin axis is perpendicular to the sketch plane, so it has no line in the plane",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "The saved author occurrence is missing. Select a valid author placement before rebinding.",
|
||||
"Then": "Then",
|
||||
"These features will be deleted:": "These features will be deleted:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "Follow the camera",
|
||||
"ai.prefs.follow_camera_off": "Automatic camera following is off.",
|
||||
"ai.prefs.follow_camera_on": "The camera turns to face sketches the agent creates.",
|
||||
"ai.prefs.instructions": "Custom instructions",
|
||||
"ai.prefs.instructions_note": "Sent to the agent at the start of every chat, after SolidCraft's own instructions: how you like to work, rules to keep to. A change applies from the next chat (Connect or New chat).",
|
||||
"ai.prefs.intro": "AI agents in SolidCraft.",
|
||||
"ai.prefs.make_default": "Make default",
|
||||
"ai.prefs.no_agents": "No agents yet. Add an agent or refresh to find installed agents.",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "Computing… {seconds} s",
|
||||
"dialog.could_not_be_checked": "could not be checked",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.cylinder_face_tip": "The face or vertex of the model the cylinder stands on: click here, then click it in the view. Left empty, X, Y and Z place it.",
|
||||
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
|
||||
"dialog.dimension_name": "Dimension: {name}",
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
|
||||
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
|
||||
"dialog.primitive_point_tip": "A point that fills X, Y and Z: click here, then click a vertex, edge or face of the model, a point of a sketch, or the origin.",
|
||||
"dialog.reference_edge_tip": "A straight edge of the picked face to measure the distance below from: click here, then click the edge.",
|
||||
"dialog.select": "↖ Select",
|
||||
"dialog.selected_count": "↖ {count} selected",
|
||||
"dialog.selection_constraint": "Constraint {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
|
||||
"prefs.axes": "Axes",
|
||||
"prefs.axis": "Axis",
|
||||
"prefs.background_choose": "Choose picture…",
|
||||
"prefs.background_image": "Picture",
|
||||
"prefs.background_unreadable": "Could not read the background picture",
|
||||
"prefs.blur_cost": "Relative rendering cost. More bars usually mean more work.",
|
||||
"prefs.blur_device": "Unavailable on this graphics device or rendering path",
|
||||
"prefs.blur_guard": "Blur quality was reduced to keep frames responsive. The radius is unchanged.",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "Eye beside the fold arrow",
|
||||
"prefs.browser_eye_left_note": "Browser rows show their show/hide eye next to the fold arrow, as before, instead of in a column at the right end of the rows.",
|
||||
"prefs.browser_reveal_on_hover": "Reveal on hover",
|
||||
"prefs.browser_rows_start_closed": "Browser rows start closed",
|
||||
"prefs.browser_rows_start_closed_note": "Placements and folders in the Browser start closed, so a design with many components shows its top-level rows. Each opens with its arrow, Expand All or Find in Browser, which opens just the rows down to its body and closes again the ones the last find opened. A body picked in the 3D view leaves the Browser as it is. When off, folders start open, placements start as Components start open says, and a pick in the 3D view opens the Browser down to its body, as before.",
|
||||
"prefs.browser_show_note": "Shown keeps the browser on screen. Reveal on hover keeps it out of sight until the pointer comes over its place or the thin tab at its left edge, and it stays open while the pointer is inside it or a menu from it is open.",
|
||||
"prefs.browser_shown": "Shown",
|
||||
"prefs.built_in": "Built in",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "The two curved arrows at the cube's corner roll the view within the screen plane. On: the clockwise arrow rolls counterclockwise and the counterclockwise arrow rolls clockwise. Off: each arrow rolls the view the way it curls.",
|
||||
"prefs.invert_navigator_arrows": "Invert the view cube rotation arrows",
|
||||
"prefs.invert_navigator_arrows_note": "The arrows around the cube orbit the camera the way they point, as in Fusion and Inventor: the arrow on the left points right and swings the camera right. Off: an arrow rotates the cube toward the side it sits on, so the left arrow swings the camera left. The curved roll arrows are not affected.",
|
||||
"prefs.keep_unsaved_also_asks": "Still ask when the tab closes",
|
||||
"prefs.keep_unsaved_also_asks_tip": "With this off, the tab closes without the browser's box once every unsaved design's copy is current.",
|
||||
"prefs.keep_unsaved_in_browser": "Keep unsaved work in this browser",
|
||||
"prefs.keep_unsaved_in_browser_tip": "Each open design's unsaved changes are kept up to date in this browser's private storage. The next visit offers to restore them.",
|
||||
"prefs.keep_work_on_tab_switch": "Keep dialogs and tools open when switching designs",
|
||||
"prefs.keep_work_on_tab_switch_note": "Each design tab keeps its open dialog or tool, with what you picked and typed in it, and its toolbar tab. They are there again when you come back to the design. Off: switching designs cancels them and shows the Model tab.",
|
||||
"prefs.labels": "Labels",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "Card",
|
||||
"prefs.layout_page.column": "Column",
|
||||
"prefs.layout_page.hover": "Hover",
|
||||
"prefs.layout_page.hover_panel_menus": "Switch panel menus on hover",
|
||||
"prefs.layout_page.hover_panel_menus_help": "Click a ribbon panel name to open its menu. Hover another panel name to switch. Move outside the ribbon and open menu to close it.",
|
||||
"prefs.layout_page.icons_labels": "Icons + labels",
|
||||
"prefs.layout_page.ok_cancel": "OK and Cancel",
|
||||
"prefs.layout_page.on_hover": "On hover",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "just now",
|
||||
"recovery.min_ago": "{count} min ago",
|
||||
"recovery.never_saved": "never saved",
|
||||
"recovery.open_saved": "Open saved version",
|
||||
"recovery.restore": "Restore",
|
||||
"recovery.restore_intro": "Changes that weren't saved when this page last closed are kept in this browser.",
|
||||
"recovery.restore_row": "{name}, changed {when}",
|
||||
"recovery.restore_title": "Restore unsaved designs?",
|
||||
"recovery.restore_title_one": "Restore unsaved design: {name}, changed {when}?",
|
||||
"refused.change": "Couldn't change {body}.",
|
||||
"refused.cut": "Couldn't cut {body}.",
|
||||
"refused.face_curved": "One of its curved faces can't be used for modelling.",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · Widescreen",
|
||||
"render.gpu_depth": "Maximum path depth",
|
||||
"render.gpu_options_help": "Samples, depth, seed and exposure are saved with this setup. Only the active image method is saved.",
|
||||
"render.gpu_path_crashed": "SolidCraft closed while setting it up on this graphics adapter last time, most likely inside the graphics driver, so it is not set up again by itself. Choose CPU ray tracing instead, or try again (a driver update also tries again).",
|
||||
"render.gpu_path_failed": "GPU path tracer unavailable",
|
||||
"render.gpu_path_first_setup": "The first setup on this GPU can take several minutes; SolidCraft stays usable meanwhile. Later starts reuse the compiled shaders where the driver keeps them.",
|
||||
"render.gpu_path_preparing": "GPU path tracer: compiling shaders…",
|
||||
"render.gpu_path_retry": "Try again",
|
||||
"render.gpu_seed": "Random seed",
|
||||
"render.gpu_unavailable": "GPU path tracing is not ready on this device.",
|
||||
"render.ground_apply": "Fit and save ground",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "Sample Plate",
|
||||
"sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters",
|
||||
"sample.vents.name": "Vent Plate",
|
||||
"scale.per_axis": "Per axis",
|
||||
"scale.uniform": "Uniform",
|
||||
"search.command_keys": "Enter run Shift+Enter run and keep open Esc close",
|
||||
"search.commands": "Commands",
|
||||
"search.design": "In this design",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "3D Sketch",
|
||||
"ui.activate": "Activate",
|
||||
"ui.active_component": "Active component",
|
||||
"ui.addToQuickAccess": "Add to Quick Access",
|
||||
"ui.add_configuration": "Add configuration",
|
||||
"ui.add_parameter": "Add parameter",
|
||||
"ui.agent_chat": "Agent chat",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "Factor",
|
||||
"ui.fade": "Fade",
|
||||
"ui.fade_length": "Fade Length",
|
||||
"ui.fair_iterations": "Iterations",
|
||||
"ui.fair_keep_edges": "Keep edges",
|
||||
"ui.fair_strength": "Strength",
|
||||
"ui.fair_surface_title": "FAIR SURFACE",
|
||||
"ui.fair_tolerance_default": "0.001 of body size",
|
||||
"ui.feature_history": "Feature history",
|
||||
"ui.feature_options": "Feature Options",
|
||||
"ui.features": "Features",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
|
||||
"ui.manufacture": "Manufacture",
|
||||
"ui.match_surface_title": "MATCH SURFACE",
|
||||
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
|
||||
"ui.material_review.claims": "Sources and licenses are unverified claims. Missing values remain unknown.",
|
||||
"ui.material_review.compressive_strength": "Compressive strength",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "Options",
|
||||
"ui.orbit": "Orbit",
|
||||
"ui.orbit_around": "Orbit around",
|
||||
"ui.organize": "Organize",
|
||||
"ui.orientation": "Orientation",
|
||||
"ui.origin": "Origin",
|
||||
"ui.palette_construction_mode": "Construction mode (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "Quality",
|
||||
"ui.quantity": "Quantity",
|
||||
"ui.quantity_2": "Quantity 2",
|
||||
"ui.quick_access_full": "The quick-access bar holds {count} commands; remove one to add another",
|
||||
"ui.quit": "Quit",
|
||||
"ui.radial_context_menu": "Radial context menu",
|
||||
"ui.radius": "Radius",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "This component is not linked to a source design.",
|
||||
"ui.relink_select_one": "Select one linked component or one of its instances first.",
|
||||
"ui.relink_source_all": "Relink Source (All Instances)…",
|
||||
"ui.removeFromQuickAccess": "Remove from Quick Access",
|
||||
"ui.removeFromToolbar": "Remove from Toolbar",
|
||||
"ui.removeSection": "Remove Section",
|
||||
"ui.render": "Render",
|
||||
|
||||
@@ -1392,6 +1392,7 @@
|
||||
"dialog.computing": "Calculando… {seconds} s",
|
||||
"dialog.could_not_be_checked": "no se pudo comprobar",
|
||||
"dialog.custom_axis": "↖ Eje personalizado",
|
||||
"dialog.cylinder_face_tip": "La cara o el vértice del modelo sobre el que se apoya el cilindro: haz clic aquí y después haz clic en él en la vista. Si se deja vacío, lo colocan X, Y y Z.",
|
||||
"dialog.dimension_insert_hint": "Haga doble clic en otra cota de control para insertar su nombre",
|
||||
"dialog.dimension_name": "Cota: {name}",
|
||||
"dialog.dimension_step_down": "Bajar {step}",
|
||||
@@ -1400,6 +1401,8 @@
|
||||
"dialog.edit_title": "EDITAR {title}",
|
||||
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
|
||||
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
|
||||
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
|
||||
"dialog.reference_edge_tip": "Una arista recta de la cara elegida desde la que se mide la distancia de abajo: haz clic aquí y después en la arista.",
|
||||
"dialog.select": "↖ Seleccionar",
|
||||
"dialog.selected_count": "↖ {count} seleccionados",
|
||||
"dialog.selection_constraint": "Restricción {id}",
|
||||
@@ -4076,6 +4079,8 @@
|
||||
"sample.plate.name": "Placa de ejemplo",
|
||||
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
|
||||
"sample.vents.name": "Placa de ventilación",
|
||||
"scale.per_axis": "Por eje",
|
||||
"scale.uniform": "Uniforme",
|
||||
"search.command_keys": "Intro ejecutar Mayús+Intro ejecutar y mantener abierto Esc cerrar",
|
||||
"search.commands": "Comandos",
|
||||
"search.design": "En este diseño",
|
||||
|
||||
@@ -4077,6 +4077,8 @@
|
||||
"sample.plate.name": "Esimerkkilevy",
|
||||
"sample.vents.about": "Urat suorakulmaisessa kuviossa: rivit, sarakkeet ja jako ovat parametreja",
|
||||
"sample.vents.name": "Tuuletuslevy",
|
||||
"scale.per_axis": "Akseleittain",
|
||||
"scale.uniform": "Tasainen",
|
||||
"search.command_keys": "Enter suorita Shift+Enter suorita ja pidä auki Esc sulje",
|
||||
"search.commands": "Komennot",
|
||||
"search.design": "Tässä mallissa",
|
||||
|
||||
@@ -1363,11 +1363,14 @@
|
||||
"dialog.computing": "Calcul… {seconds} s",
|
||||
"dialog.could_not_be_checked": "non vérifiable",
|
||||
"dialog.custom_axis": "↖ Axe personnalisé",
|
||||
"dialog.cylinder_face_tip": "La face ou le sommet du modèle sur lequel le cylindre est posé : cliquer ici, puis dessus dans la vue. Laissé vide, X, Y et Z le placent.",
|
||||
"dialog.dimension_insert_hint": "Double-cliquez sur une autre cote pilotante pour insérer son nom",
|
||||
"dialog.dimension_name": "Cote : {name}",
|
||||
"dialog.edit_title": "MODIFIER {title}",
|
||||
"dialog.no_shown_body": "Aucun corps affiché à modifier : affichez-en un ou choisissez Nouveau corps",
|
||||
"dialog.pattern_sketch_not_object": "Choisissez des corps ou des fonctions : une esquisse n'est ni l'un ni l'autre. Répétez ses courbes avec la Répétition ou la Symétrie de l'esquisse, ou extrudez-la d'abord.",
|
||||
"dialog.primitive_point_tip": "Un point qui remplit X, Y et Z : cliquer ici, puis sur un sommet, une arête ou une face du modèle, sur un point d'une esquisse ou sur l'origine.",
|
||||
"dialog.reference_edge_tip": "Une arête droite de la face choisie, depuis laquelle la distance ci-dessous est mesurée : cliquer ici, puis sur l'arête.",
|
||||
"dialog.select": "↖ Sélectionner",
|
||||
"dialog.selected_count": "↖ {count} sélectionné(s)",
|
||||
"dialog.selection_constraint": "Contrainte {id}",
|
||||
@@ -2999,9 +3002,11 @@
|
||||
"render.frame_wide": "16:9 · Écran large",
|
||||
"render.gpu_depth": "Profondeur maximale des chemins",
|
||||
"render.gpu_options_help": "Les échantillons, la profondeur, la graine et l'exposition sont enregistrés avec ce préréglage. Seule la méthode d'image active est enregistrée.",
|
||||
"render.gpu_path_crashed": "SolidCraft s’est fermé pendant sa dernière préparation sur cet adaptateur graphique, très probablement dans le pilote graphique ; il n’est donc plus préparé de lui-même. Utilisez le lancer de rayons sur le CPU, ou réessayez (une mise à jour du pilote réessaie aussi).",
|
||||
"render.gpu_path_failed": "Traceur de chemins GPU indisponible",
|
||||
"render.gpu_path_first_setup": "La première initialisation sur ce GPU peut prendre plusieurs minutes ; SolidCraft reste utilisable pendant ce temps. Les démarrages suivants réutilisent les shaders compilés lorsque le pilote les conserve.",
|
||||
"render.gpu_path_preparing": "Traceur de chemins GPU : compilation des shaders…",
|
||||
"render.gpu_path_retry": "Réessayer",
|
||||
"render.gpu_seed": "Graine aléatoire",
|
||||
"render.gpu_unavailable": "Le tracé de chemins GPU n'est pas prêt sur cet appareil.",
|
||||
"render.ground_apply": "Ajuster et enregistrer le sol",
|
||||
@@ -3809,6 +3814,8 @@
|
||||
"sample.plate.name": "Plaque d'exemple",
|
||||
"sample.vents.about": "Rainures en répétition rectangulaire : lignes, colonnes et pas sont des paramètres",
|
||||
"sample.vents.name": "Plaque d'aération",
|
||||
"scale.per_axis": "Par axe",
|
||||
"scale.uniform": "Uniforme",
|
||||
"search.command_keys": "Entrée exécuter Maj+Entrée exécuter et garder ouvert Échap fermer",
|
||||
"search.commands": "Commandes",
|
||||
"search.design": "Dans cette conception",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Placa de exemplo",
|
||||
"sample.vents.about": "Ranuras nun patrón rectangular: filas, columnas e paso son parámetros",
|
||||
"sample.vents.name": "Placa de ventilación",
|
||||
"scale.per_axis": "Por eixo",
|
||||
"scale.uniform": "Uniforme",
|
||||
"search.command_keys": "Intro executar Maiús+Intro executar e manter aberto Esc pechar",
|
||||
"search.commands": "Ordes",
|
||||
"search.design": "Neste deseño",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "השבתת התיקיות שנבחרו",
|
||||
"Suppressed": "מושבת",
|
||||
"Surface": "משטח",
|
||||
"Surface or face": "משטח או פאה",
|
||||
"Surface or faces": "משטח או פאות",
|
||||
"Surface, or faces to turn over": "משטח, או פאות להיפוך",
|
||||
"Surfaces": "משטחים",
|
||||
"Swap Ends": "החלפת קצוות",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "יעד",
|
||||
"Target Body": "גוף יעד",
|
||||
"Target Face (optional)": "פאת יעד (אופציונלי)",
|
||||
"Target edges": "מקצועות יעד",
|
||||
"Tear": "קריעה",
|
||||
"Technical details": "פרטים טכניים",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "הסקיצה המקווקוות מסמנת מישור שמור. יש לבחור פאה חדשה כדי לתקן את ההצמדה.",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "הכיוון חייב להיות ציר או עקומת סקיצה ישרה הנמצאת במישור הסקיצה",
|
||||
"The mirror line cannot also be mirrored": "אי אפשר לשקף גם את קו השיקוף עצמו",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "קו השיקוף חייב להיות עקומה ישרה של הסקיצה הזו, ולא אחד מצירי הראשית",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "ציר הראשית ניצב למישור הסקיצה, ולכן אין לו קו במישור",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "מופע היצירה השמור חסר. יש לבחור מיקום יצירה תקין לפני הקישור מחדש.",
|
||||
"Then": "אז",
|
||||
"These features will be deleted:": "הפיצ'רים הבאים יימחקו:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "לעקוב אחר המצלמה",
|
||||
"ai.prefs.follow_camera_off": "מעקב המצלמה האוטומטי כבוי.",
|
||||
"ai.prefs.follow_camera_on": "המצלמה פונה אל הסקיצות שהסוכן יוצר.",
|
||||
"ai.prefs.instructions": "הוראות מותאמות אישית",
|
||||
"ai.prefs.instructions_note": "נשלחות לסוכן בתחילת כל צ'אט, אחרי ההוראות של SolidCraft עצמה: דרך העבודה המועדפת עליך וכללים שיש להקפיד עליהם. שינוי חל מהצ'אט הבא (התחברות או צ'אט חדש).",
|
||||
"ai.prefs.intro": "סוכני AI ב־SolidCraft.",
|
||||
"ai.prefs.make_default": "לקבוע כברירת מחדל",
|
||||
"ai.prefs.no_agents": "אין עדיין סוכנים. אפשר להוסיף סוכן או לרענן כדי למצוא סוכנים מותקנים.",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "מחשב… {seconds} שנ'",
|
||||
"dialog.could_not_be_checked": "לא ניתן היה לבדוק",
|
||||
"dialog.custom_axis": "↖ ציר מותאם",
|
||||
"dialog.cylinder_face_tip": "הפאה או הקודקוד של המודל שעליהם הגליל ניצב: יש ללחוץ כאן ואז עליהם בתצוגה. אם נשאר ריק, X, Y ו-Z ממקמים אותו.",
|
||||
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
|
||||
"dialog.dimension_name": "מידה: {name}",
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "עריכת {title}",
|
||||
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
|
||||
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
|
||||
"dialog.primitive_point_tip": "נקודה שממלאת את X, Y ו-Z: יש ללחוץ כאן ואז על קודקוד, מקצוע או פאה של המודל, על נקודה של סקיצה, או על הראשית.",
|
||||
"dialog.reference_edge_tip": "מקצוע ישר של הפאה שנבחרה שממנו נמדד המרחק שלמטה: יש ללחוץ כאן ואז על המקצוע.",
|
||||
"dialog.select": "↖ בחירה",
|
||||
"dialog.selected_count": "↖ {count} נבחרו",
|
||||
"dialog.selection_constraint": "אילוץ {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
|
||||
"prefs.axes": "צירים",
|
||||
"prefs.axis": "ציר",
|
||||
"prefs.background_choose": "בחירת תמונה…",
|
||||
"prefs.background_image": "תמונה",
|
||||
"prefs.background_unreadable": "לא ניתן לקרוא את תמונת הרקע",
|
||||
"prefs.blur_cost": "עלות עיבוד יחסית. יותר פסים מציינים בדרך כלל יותר עבודה.",
|
||||
"prefs.blur_device": "לא זמין בהתקן הגרפי או בנתיב העיבוד הזה",
|
||||
"prefs.blur_guard": "איכות הטשטוש הופחתה כדי לשמור על תגובתיות הפריימים. הרדיוס לא השתנה.",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
|
||||
"prefs.browser_eye_left_note": "שורות עץ התכן מציגות את סמל העין ליד חץ הקיפול, כמו קודם, במקום בעמודה בקצה הימני של השורות.",
|
||||
"prefs.browser_reveal_on_hover": "הצגה בריחוף",
|
||||
"prefs.browser_rows_start_closed": "שורות עץ התכן מוצגות סגורות",
|
||||
"prefs.browser_rows_start_closed_note": "מיקומים ותיקיות בעץ התכן מוצגים סגורים, כך שתכן עם רכיבים רבים מציג את השורות העליונות שלו. כל שורה נפתחת בחץ שלה, ב„הרחבת הכול” או ב„איתור בעץ התכן”, שפותח רק את השורות שמובילות לגוף שלו וסוגר שוב את אלה שהאיתור הקודם פתח. גוף שנבחר בתצוגה התלת־ממדית משאיר את עץ התכן כפי שהוא. כשהאפשרות כבויה התיקיות מוצגות פתוחות, המיקומים מוצגים כפי שקובעת האפשרות „רכיבים פתוחים מההתחלה”, ובחירה בתצוגה התלת־ממדית פותחת את עץ התכן עד לגוף שלה, כמו קודם.",
|
||||
"prefs.browser_show_note": "״מוצג״ משאיר את הדפדפן על המסך. ״הצגה בריחוף״ מסתיר אותו עד שהמצביע מגיע למקומו או ללשונית הדקה בקצהו השמאלי, והוא נשאר פתוח כל עוד המצביע בתוכו או שתפריט ממנו פתוח.",
|
||||
"prefs.browser_shown": "מוצג",
|
||||
"prefs.built_in": "מובנה",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "שני החצים המעוגלים בפינת הקובייה מסובבים את התצוגה במישור המסך. פועל: החץ עם כיוון השעון מסובב נגד כיוון השעון, והחץ נגד כיוון השעון מסובב עם כיוון השעון. כבוי: כל חץ מסובב את התצוגה לפי העקום שלו.",
|
||||
"prefs.invert_navigator_arrows": "היפוך חצי סיבוב קוביית התצוגה",
|
||||
"prefs.invert_navigator_arrows_note": "החצים סביב הקובייה מחוויים את המצלמה לכיוון שהחץ מצביע, כמו ב-Fusion ו-Inventor: החץ שמשמאל מצביע ימימה ומניע את המצלמה ימימה. כבוי: החץ מסובב את הקובייה אל הצד שבו הוא יושב, כך שחץ שמאל מניע את המצלמה שמאלה. חצי הגלילה המעוגלים אינם מושפעים.",
|
||||
"prefs.keep_unsaved_also_asks": "לשאול גם כשהכרטיסייה נסגרת",
|
||||
"prefs.keep_unsaved_also_asks_tip": "כשאפשרות זו כבויה, הכרטיסייה נסגרת ללא חלון הדפדפן לאחר שהעותק של כל תכן שלא נשמר עודכן.",
|
||||
"prefs.keep_unsaved_in_browser": "לשמור עבודה שלא נשמרה בדפדפן זה",
|
||||
"prefs.keep_unsaved_in_browser_tip": "השינויים שלא נשמרו בכל תכן פתוח נשמרים מעודכנים באחסון הפרטי של דפדפן זה. בביקור הבא תוצע שחזורם.",
|
||||
"prefs.keep_work_on_tab_switch": "השארת תיבות דו־שיח וכלים פתוחים במעבר בין עיצובים",
|
||||
"prefs.keep_work_on_tab_switch_note": "כל לשונית עיצוב שומרת את תיבת הדו־שיח או הכלי הפתוחים בה, עם מה שבחרת והקלדת בהם, ואת לשונית סרגל הכלים שלה. הם מחכים לך כשתחזור לעיצוב. כבוי: מעבר בין עיצובים מבטל אותם ומציג את הלשונית מודל.",
|
||||
"prefs.labels": "תוויות",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "כרטיס",
|
||||
"prefs.layout_page.column": "עמודה",
|
||||
"prefs.layout_page.hover": "מעבר עכבר",
|
||||
"prefs.layout_page.hover_panel_menus": "מעבר בין תפריטי לוחות בריחוף",
|
||||
"prefs.layout_page.hover_panel_menus_help": "לחצו על שם לוח ברצועת הכלים כדי לפתוח את התפריט שלו. העבירו את הסמן מעל שם של לוח אחר כדי לעבור לתפריט שלו. הזיזו את הסמן אל מחוץ לרצועת הכלים ולתפריט הפתוח כדי לסגור אותו.",
|
||||
"prefs.layout_page.icons_labels": "סמלים + תוויות",
|
||||
"prefs.layout_page.ok_cancel": "אישור וביטול",
|
||||
"prefs.layout_page.on_hover": "בעת מעבר עכבר",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "זה עתה",
|
||||
"recovery.min_ago": "לפני {count} דק'",
|
||||
"recovery.never_saved": "לא נשמר מעולם",
|
||||
"recovery.open_saved": "פתיחת הגרסה השמורה",
|
||||
"recovery.restore": "שחזור",
|
||||
"recovery.restore_intro": "שינויים שלא נשמרו כשדף זה נסגר לאחרונה שמורים בדפדפן זה.",
|
||||
"recovery.restore_row": "{name}, שונה {when}",
|
||||
"recovery.restore_title": "לשחזר תכנים שלא נשמרו?",
|
||||
"recovery.restore_title_one": "לשחזר תכן שלא נשמר: {name}, שונה {when}?",
|
||||
"refused.change": "לא ניתן היה לשנות את {body}.",
|
||||
"refused.cut": "לא ניתן היה לחתוך את {body}.",
|
||||
"refused.face_curved": "אחת הפאות המעוקלות שלו אינה ניתנת לשימוש במידול.",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · רחב",
|
||||
"render.gpu_depth": "עומק מסלול מרבי",
|
||||
"render.gpu_options_help": "הדגימות, העומק, הזרע והחשיפה נשמרים עם ההגדרה הזאת. נשמרת רק שיטת התמונה הפעילה.",
|
||||
"render.gpu_path_crashed": "SolidCraft נסגר בזמן ההכנה על מתאם גרפי זה בפעם הקודמת, ככל הנראה בתוך מנהל ההתקן הגרפי, ולכן ההכנה לא תתבצע שוב מעצמה. יש להשתמש במעקב קרניים על המעבד במקום זאת, או לנסות שוב (עדכון מנהל ההתקן גם מנסה שוב).",
|
||||
"render.gpu_path_failed": "מעקב המסלולים ב־GPU אינו זמין",
|
||||
"render.gpu_path_first_setup": "ההגדרה הראשונה ב־GPU הזה עשויה להימשך כמה דקות; SolidCraft נשאר זמין לשימוש בינתיים. הפעלות מאוחרות יותר משתמשות שוב בשיידרים שהודרו, כשמנהל ההתקן שומר אותן.",
|
||||
"render.gpu_path_preparing": "מעקב מסלולים ב־GPU: הידור שיידרים…",
|
||||
"render.gpu_path_retry": "לנסות שוב",
|
||||
"render.gpu_seed": "זרע אקראי",
|
||||
"render.gpu_unavailable": "מעקב מסלולים ב־GPU אינו מוכן במכשיר הזה.",
|
||||
"render.ground_apply": "התאמת הקרקע ושמירתה",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "לוח לדוגמה",
|
||||
"sample.vents.about": "חריצים במערך מלבני: מספר השורות, מספר העמודות והפסיעה הם פרמטרים",
|
||||
"sample.vents.name": "לוח אוורור",
|
||||
"scale.per_axis": "לפי ציר",
|
||||
"scale.uniform": "אחיד",
|
||||
"search.command_keys": "Enter הרצה Shift+Enter הרצה והשארה פתוח Esc סגירה",
|
||||
"search.commands": "פקודות",
|
||||
"search.design": "בתכן זה",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "סקיצה תלת־ממדית",
|
||||
"ui.activate": "הפעלה",
|
||||
"ui.active_component": "רכיב פעיל",
|
||||
"ui.addToQuickAccess": "הוספה לגישה מהירה",
|
||||
"ui.add_configuration": "הוספת תצורה",
|
||||
"ui.add_parameter": "הוספת פרמטר",
|
||||
"ui.agent_chat": "צ'אט עם סוכן",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "מקדם",
|
||||
"ui.fade": "דעיכה",
|
||||
"ui.fade_length": "אורך דעיכה",
|
||||
"ui.fair_iterations": "מספר איטרציות",
|
||||
"ui.fair_keep_edges": "שמירה על המקצועות",
|
||||
"ui.fair_strength": "עוצמה",
|
||||
"ui.fair_surface_title": "החלקת משטח",
|
||||
"ui.fair_tolerance_default": "0.001 מגודל הגוף",
|
||||
"ui.feature_history": "ציר הזמן",
|
||||
"ui.feature_options": "אפשרויות פיצ'ר",
|
||||
"ui.features": "פיצ'רים",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "גרירה חופשית של הידית גם עם Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "פעיל: Alt/Option או Ctrl/Cmd על חיצי השליפה / הידית מבטלים הצמדת ערכים. כבוי: Ctrl/Cmd בלבד.",
|
||||
"ui.manufacture": "ייצור",
|
||||
"ui.match_surface_title": "התאמת משטח",
|
||||
"ui.material_review.axes": "צירים מקומיים של החומר; לא הוגדרה אוריינטציה לגוף.",
|
||||
"ui.material_review.claims": "המקורות והרישיונות הם טענות שלא אומתו. ערכים חסרים נשארים לא ידועים.",
|
||||
"ui.material_review.compressive_strength": "חוזק לחיצה",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "אפשרויות",
|
||||
"ui.orbit": "סיבוב מבט",
|
||||
"ui.orbit_around": "סיבוב מבט סביב",
|
||||
"ui.organize": "ארגון",
|
||||
"ui.orientation": "אוריינטציה",
|
||||
"ui.origin": "ראשית",
|
||||
"ui.palette_construction_mode": "מצב עזר (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "איכות",
|
||||
"ui.quantity": "כמות",
|
||||
"ui.quantity_2": "כמות 2",
|
||||
"ui.quick_access_full": "סרגל הגישה המהירה מכיל {count} פקודות; יש להסיר אחת כדי להוסיף אחרת",
|
||||
"ui.quit": "יציאה",
|
||||
"ui.radial_context_menu": "תפריט הקשר מעגלי",
|
||||
"ui.radius": "רדיוס",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "רכיב זה אינו מקושר לתכן מקור.",
|
||||
"ui.relink_select_one": "יש לבחור תחילה רכיב מקושר אחד או אחד המופעים שלו.",
|
||||
"ui.relink_source_all": "קישור מחדש למקור (כל המופעים)…",
|
||||
"ui.removeFromQuickAccess": "הסרה מהגישה המהירה",
|
||||
"ui.removeFromToolbar": "הסרה מסרגל הכלים",
|
||||
"ui.removeSection": "הסרת מקטע",
|
||||
"ui.render": "הדמיה",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Mintalemez",
|
||||
"sample.vents.about": "Hornyok téglalap mintában: a sorok, oszlopok és osztás paraméterek",
|
||||
"sample.vents.name": "Szellőzőlemez",
|
||||
"scale.per_axis": "Tengelyenként",
|
||||
"scale.uniform": "Egyenletes",
|
||||
"search.command_keys": "Enter futtatás Shift+Enter futtatás és nyitva tartás Esc bezárás",
|
||||
"search.commands": "Parancsok",
|
||||
"search.design": "Ebben a tervben",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Pelat Contoh",
|
||||
"sample.vents.about": "Slot dalam pola persegi panjang: baris, kolom, dan pitch adalah parameter",
|
||||
"sample.vents.name": "Pelat Ventilasi",
|
||||
"scale.per_axis": "Per sumbu",
|
||||
"scale.uniform": "Seragam",
|
||||
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan tetap buka Esc tutup",
|
||||
"search.commands": "Perintah",
|
||||
"search.design": "Di desain ini",
|
||||
|
||||
@@ -1364,11 +1364,14 @@
|
||||
"dialog.computing": "Calcolo in corso… {seconds} s",
|
||||
"dialog.could_not_be_checked": "impossibile verificare",
|
||||
"dialog.custom_axis": "↖ Asse personalizzato",
|
||||
"dialog.cylinder_face_tip": "La faccia o il vertice del modello su cui poggia il cilindro: fai clic qui, poi su di esso nella vista. Se resta vuoto, lo posizionano X, Y e Z.",
|
||||
"dialog.dimension_insert_hint": "Fai doppio clic su un’altra quota di controllo per inserirne il nome",
|
||||
"dialog.dimension_name": "Quota: {name}",
|
||||
"dialog.edit_title": "MODIFICA {title}",
|
||||
"dialog.no_shown_body": "Nessun corpo visibile da modificare: mostrane uno o scegli Nuovo corpo",
|
||||
"dialog.pattern_sketch_not_object": "Seleziona corpi o lavorazioni: uno schizzo non è nessuno dei due. Ripeti le sue curve con Ripetizione o Specchia dello schizzo stesso, oppure estrudilo prima.",
|
||||
"dialog.primitive_point_tip": "Un punto che compila X, Y e Z: fai clic qui, poi su un vertice, uno spigolo o una faccia del modello, su un punto di uno schizzo oppure sull'origine.",
|
||||
"dialog.reference_edge_tip": "Uno spigolo rettilineo della faccia scelta da cui si misura la distanza sotto: fai clic qui, poi sullo spigolo.",
|
||||
"dialog.select": "↖ Seleziona",
|
||||
"dialog.selected_count": "↖ Selezionati: {count}",
|
||||
"dialog.selection_constraint": "Vincolo {id}",
|
||||
@@ -3810,6 +3813,8 @@
|
||||
"sample.plate.name": "Piastra di esempio",
|
||||
"sample.vents.about": "Asole in ripetizione rettangolare: righe, colonne e passo sono parametri",
|
||||
"sample.vents.name": "Piastra di ventilazione",
|
||||
"scale.per_axis": "Per asse",
|
||||
"scale.uniform": "Uniforme",
|
||||
"search.command_keys": "Invio esegui Maiusc+Invio esegui e tieni aperto Esc chiudi",
|
||||
"search.commands": "Comandi",
|
||||
"search.design": "In questo progetto",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "選択したフォルダーを抑制",
|
||||
"Suppressed": "抑制",
|
||||
"Surface": "サーフェス",
|
||||
"Surface or face": "サーフェスまたは面",
|
||||
"Surface or faces": "サーフェスまたは面",
|
||||
"Surface, or faces to turn over": "サーフェス、または裏返す面",
|
||||
"Surfaces": "サーフェス",
|
||||
"Swap Ends": "端点を入れ替え",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "ターゲット",
|
||||
"Target Body": "ターゲットボディ",
|
||||
"Target Face (optional)": "対象面 (オプション)",
|
||||
"Target edges": "ターゲットのエッジ",
|
||||
"Tear": "引き裂き",
|
||||
"Technical details": "技術情報",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "破線のスケッチは保存された平面ガイドです。新しい面を選択してアタッチメントを修復してください。",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "方向は、軸、またはスケッチ平面上にある直線のスケッチ曲線にしてください",
|
||||
"The mirror line cannot also be mirrored": "ミラー線自体をミラーすることはできません",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "ミラー線は、原点の軸ではなく、このスケッチの直線にしてください",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "原点軸はスケッチ平面に垂直なため、平面内に線がありません",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "保存された作成元のオカレンスが見つかりません。再バインドする前に有効な作成元配置を選択してください。",
|
||||
"Then": "次を実行 (Then)",
|
||||
"These features will be deleted:": "以下のフィーチャーが削除されます:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "カメラを追従",
|
||||
"ai.prefs.follow_camera_off": "カメラの自動追従はオフです。",
|
||||
"ai.prefs.follow_camera_on": "エージェントが作成するスケッチにカメラを向けます。",
|
||||
"ai.prefs.instructions": "カスタム指示",
|
||||
"ai.prefs.instructions_note": "各チャットの開始時に、SolidCraft 自身の指示の後に続けてエージェントへ送られます。作業の進め方や守ってほしいルールを書いてください。変更は次のチャット(接続または新しいチャット)から適用されます。",
|
||||
"ai.prefs.intro": "SolidCraftのAIエージェント。",
|
||||
"ai.prefs.make_default": "既定にする",
|
||||
"ai.prefs.no_agents": "エージェントはまだありません。追加するか、更新してインストール済みのエージェントを検索してください。",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "チェックできません",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.cylinder_face_tip": "円柱が立つモデルの面または頂点: ここをクリックしてから、ビューでそれをクリック。空のままなら X、Y、Z で配置します。",
|
||||
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
|
||||
"dialog.dimension_name": "寸法: {name}",
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
|
||||
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
|
||||
"dialog.primitive_point_tip": "X、Y、Z を埋める点: ここをクリックしてから、モデルの頂点、エッジ、面、スケッチの点、または原点をクリック。",
|
||||
"dialog.reference_edge_tip": "下の距離を測る基準となる、選んだ面の直線エッジ: ここをクリックしてからエッジをクリック。",
|
||||
"dialog.select": "↖ 選択",
|
||||
"dialog.selected_count": "↖ {count} 個選択中",
|
||||
"dialog.selection_constraint": "拘束 {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
|
||||
"prefs.axes": "軸",
|
||||
"prefs.axis": "軸",
|
||||
"prefs.background_choose": "画像を選択…",
|
||||
"prefs.background_image": "画像",
|
||||
"prefs.background_unreadable": "背景画像を読み込めませんでした",
|
||||
"prefs.blur_cost": "相対的な描画負荷です。バーが多いほど、通常は処理量が増えます。",
|
||||
"prefs.blur_device": "このグラフィックスデバイスまたは描画経路では利用できません",
|
||||
"prefs.blur_guard": "画面の応答性を保つため、ぼかしの品質を下げました。半径は変更されていません。",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
|
||||
"prefs.browser_eye_left_note": "ブラウザーの各行の表示切り替えを、行の右端の列ではなく、以前のように折りたたみ矢印の隣に表示します。",
|
||||
"prefs.browser_reveal_on_hover": "ホバーで表示",
|
||||
"prefs.browser_rows_start_closed": "ブラウザーの行を閉じた状態で始める",
|
||||
"prefs.browser_rows_start_closed_note": "ブラウザーの配置とフォルダーを閉じた状態で表示するため、多数のコンポーネントを含むデザインでは最上位の行だけが表示されます。各行は矢印、「すべて展開」または「ブラウザで検索」で開きます。「ブラウザで検索」はボディまでの行だけを開き、前回の検索で開いた行を再び閉じます。3Dビューでボディを選んでもブラウザーはそのままです。オフのときはフォルダーが開いた状態で始まり、配置は「コンポーネントを最初から展開」の設定に従い、3Dビューで選ぶとブラウザーがそのボディまで開きます(以前と同じ)。",
|
||||
"prefs.browser_show_note": "「表示」はブラウザーを常に画面に表示します。「ホバーで表示」はポインターがその位置か左端の細いタブに来るまで隠し、ポインターが中にある間やそのメニューが開いている間は表示したままにします。",
|
||||
"prefs.browser_shown": "表示",
|
||||
"prefs.built_in": "組み込み",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "キューブの隅にある2つのカーブした矢印は、画面内でビューをロールします。オン: 時計回りの矢印が反時計回りに、反時計回りの矢印が時計回りにロールします。オフ: 各矢印は、そのカーブの向きにビューをロールします。",
|
||||
"prefs.invert_navigator_arrows": "ビューキューブの回転矢印を反転",
|
||||
"prefs.invert_navigator_arrows_note": "キューブの周りの矢印が、矢印の指す方向にカメラを回します(Fusion や Inventor と同じ動き):左の矢印は右を指し、カメラを右に振ります。オフ: 矢印は、そのある面の方へキューブを回すため、左の矢印はカメラを左に振ります。カーブしたロール矢印は影響を受けません。",
|
||||
"prefs.keep_unsaved_also_asks": "タブを閉じるときにも確認する",
|
||||
"prefs.keep_unsaved_also_asks_tip": "オフにすると、未保存の各デザインのコピーが最新であれば、ブラウザーの確認なしでタブが閉じます。",
|
||||
"prefs.keep_unsaved_in_browser": "未保存の作業をこのブラウザーに保持する",
|
||||
"prefs.keep_unsaved_in_browser_tip": "開いている各デザインの未保存の変更を、このブラウザーのプライベートストレージに常に最新の状態で保持します。次回のアクセス時に復元を提案します。",
|
||||
"prefs.keep_work_on_tab_switch": "デザインを切り替えてもダイアログとツールを開いたままにする",
|
||||
"prefs.keep_work_on_tab_switch_note": "各デザインのタブが、開いているダイアログまたはツール(選択や入力した内容を含む)とツールバーのタブを保持します。デザインに戻ると元のまま表示されます。オフ: デザインを切り替えるとそれらはキャンセルされ、モデル タブが表示されます。",
|
||||
"prefs.labels": "ラベル",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "カード",
|
||||
"prefs.layout_page.column": "列",
|
||||
"prefs.layout_page.hover": "ホバー時",
|
||||
"prefs.layout_page.hover_panel_menus": "ポイントしてパネルメニューを切り替える",
|
||||
"prefs.layout_page.hover_panel_menus_help": "リボンのパネル名をクリックしてメニューを開きます。別のパネル名にポインターを合わせると切り替わります。リボンと開いているメニューの外に移動すると閉じます。",
|
||||
"prefs.layout_page.icons_labels": "アイコン+ラベル",
|
||||
"prefs.layout_page.ok_cancel": "OK とキャンセル",
|
||||
"prefs.layout_page.on_hover": "ホバー時",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "たった今",
|
||||
"recovery.min_ago": "{count}分前",
|
||||
"recovery.never_saved": "未保存",
|
||||
"recovery.open_saved": "保存済みのバージョンを開く",
|
||||
"recovery.restore": "復元",
|
||||
"recovery.restore_intro": "このページを前回閉じたときに保存されていなかった変更は、このブラウザーに保持されています。",
|
||||
"recovery.restore_row": "{name}、{when}に変更",
|
||||
"recovery.restore_title": "未保存のデザインを復元しますか?",
|
||||
"recovery.restore_title_one": "未保存のデザイン {name}({when}に変更)を復元しますか?",
|
||||
"refused.change": "{body} を変更できませんでした。",
|
||||
"refused.cut": "{body} をカットできませんでした。",
|
||||
"refused.face_curved": "曲面の 1 つがモデリングに使えません。",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · ワイドスクリーン",
|
||||
"render.gpu_depth": "最大パス深度",
|
||||
"render.gpu_options_help": "サンプル数、深度、シード、露出をこの設定に保存します。保存するのは選択中の画像方式だけです。",
|
||||
"render.gpu_path_crashed": "前回このグラフィックアダプターでの準備中に SolidCraft が終了しました。グラフィックドライバー内で終了した可能性が高いため、自動では再度準備しません。代わりに CPU レイトレーシングを使うか、もう一度試してください(ドライバーを更新した場合も再試行されます)。",
|
||||
"render.gpu_path_failed": "GPUパストレーサーを利用できません",
|
||||
"render.gpu_path_first_setup": "このGPUでの初回セットアップには数分かかることがあります。その間もSolidCraftは使用できます。以降の起動では、ドライバーが保持していればコンパイル済みのシェーダーを再利用します。",
|
||||
"render.gpu_path_preparing": "GPUパストレーサー:シェーダーをコンパイル中…",
|
||||
"render.gpu_path_retry": "もう一度試す",
|
||||
"render.gpu_seed": "乱数シード",
|
||||
"render.gpu_unavailable": "このデバイスでは GPU パストレーシングの準備ができていません。",
|
||||
"render.ground_apply": "床をフィットして保存",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "サンプル プレート",
|
||||
"sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ",
|
||||
"sample.vents.name": "通気プレート",
|
||||
"scale.per_axis": "軸ごと",
|
||||
"scale.uniform": "均等",
|
||||
"search.command_keys": "Enter 実行 Shift+Enter 実行して開いたままにする Esc 閉じる",
|
||||
"search.commands": "コマンド",
|
||||
"search.design": "このデザイン内",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "3D スケッチ",
|
||||
"ui.activate": "アクティブ化",
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.addToQuickAccess": "クイックアクセスに追加",
|
||||
"ui.add_configuration": "構成を追加",
|
||||
"ui.add_parameter": "パラメーターを追加",
|
||||
"ui.agent_chat": "エージェントチャット",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "倍率",
|
||||
"ui.fade": "切り上げ",
|
||||
"ui.fade_length": "切り上げ長さ",
|
||||
"ui.fair_iterations": "反復回数",
|
||||
"ui.fair_keep_edges": "エッジを保持",
|
||||
"ui.fair_strength": "強度",
|
||||
"ui.fair_surface_title": "サーフェスフェアリング",
|
||||
"ui.fair_tolerance_default": "ボディサイズの 0.001 倍",
|
||||
"ui.feature_history": "フィーチャー履歴",
|
||||
"ui.feature_options": "フィーチャーオプション",
|
||||
"ui.features": "フィーチャー",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
|
||||
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
|
||||
"ui.manufacture": "製造",
|
||||
"ui.match_surface_title": "サーフェス一致",
|
||||
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
|
||||
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
|
||||
"ui.material_review.compressive_strength": "圧縮強度",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "オプション",
|
||||
"ui.orbit": "回転",
|
||||
"ui.orbit_around": "回転中心",
|
||||
"ui.organize": "整理",
|
||||
"ui.orientation": "向き",
|
||||
"ui.origin": "原点",
|
||||
"ui.palette_construction_mode": "補助線モード (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "品質",
|
||||
"ui.quantity": "数量",
|
||||
"ui.quantity_2": "数量 2",
|
||||
"ui.quick_access_full": "クイックアクセスバーに置けるコマンドは {count} 個までです。追加するには 1 つ削除してください",
|
||||
"ui.quit": "終了",
|
||||
"ui.radial_context_menu": "パイ メニュー",
|
||||
"ui.radius": "半径",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
|
||||
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
|
||||
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
|
||||
"ui.removeFromQuickAccess": "クイックアクセスから削除",
|
||||
"ui.removeFromToolbar": "ツールバーから削除",
|
||||
"ui.removeSection": "セクションを解除",
|
||||
"ui.render": "レンダリング",
|
||||
|
||||
@@ -544,6 +544,8 @@
|
||||
"Suppress": "비활성화",
|
||||
"Suppressed": "억제됨",
|
||||
"Surface": "곡면",
|
||||
"Surface or face": "곡면 또는 면",
|
||||
"Surface or faces": "곡면 또는 면",
|
||||
"Surface, or faces to turn over": "곡면 또는 뒤집을 면",
|
||||
"Surfaces": "곡면",
|
||||
"Swap Ends": "양 끝 바꾸기",
|
||||
@@ -555,6 +557,7 @@
|
||||
"Target": "대상",
|
||||
"Target Body": "대상 바디",
|
||||
"Target Face (optional)": "대상 면 (선택 사항)",
|
||||
"Target edges": "대상 모서리",
|
||||
"Tear": "찢김",
|
||||
"Technical details": "기술 세부 정보",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "점선 스케치는 저장된 평면 가이드입니다. 새 면을 선택하여 부착을 복구하세요.",
|
||||
@@ -1206,6 +1209,7 @@
|
||||
"dialog.computing": "계산 중… {seconds}초",
|
||||
"dialog.could_not_be_checked": "확인할 수 없음",
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.cylinder_face_tip": "원기둥이 놓일 모델의 면 또는 꼭짓점: 여기를 클릭한 다음 뷰에서 그것을 클릭. 비워 두면 X, Y, Z로 배치합니다.",
|
||||
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
|
||||
"dialog.dimension_name": "치수: {name}",
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
@@ -1213,6 +1217,8 @@
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
|
||||
"dialog.primitive_point_tip": "X, Y, Z를 채우는 점: 여기를 클릭한 다음 모델의 꼭짓점, 모서리, 면, 스케치의 점 또는 원점을 클릭.",
|
||||
"dialog.reference_edge_tip": "아래 거리를 재는 기준이 되는, 선택한 면의 직선 모서리: 여기를 클릭한 다음 모서리를 클릭.",
|
||||
"dialog.select": "↖ 선택",
|
||||
"dialog.selected_count": "↖ {count}개 선택됨",
|
||||
"dialog.selection_constraint": "구속 조건 {id}",
|
||||
@@ -1687,6 +1693,8 @@
|
||||
"prefs.browser_components_open_note": "브라우저의 모든 구성요소가 이전처럼 펼쳐진 상태로 시작합니다. 끄면 직접 펼칠 때까지 접혀 있습니다.",
|
||||
"prefs.browser_eye_left": "접기 화살표 옆의 눈 아이콘",
|
||||
"prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다.",
|
||||
"prefs.browser_rows_start_closed": "브라우저 행을 접힌 상태로 시작",
|
||||
"prefs.browser_rows_start_closed_note": "브라우저의 배치와 폴더가 접힌 상태로 시작하므로, 컴포넌트가 많은 디자인은 최상위 행만 보여 줍니다. 각 행은 화살표, '모두 펼치기' 또는 '브라우저에서 찾기'로 엽니다. '브라우저에서 찾기'는 바디까지의 행만 열고, 지난번 찾기로 연 행은 다시 접습니다. 3D 뷰에서 바디를 골라도 브라우저는 그대로입니다. 끄면 폴더가 펼친 상태로 시작하고, 배치는 '구성요소를 펼친 상태로 시작' 설정을 따르며, 3D 뷰에서 고르면 브라우저가 그 바디까지 열립니다(이전과 같음).",
|
||||
"prefs.built_in": "기본 제공",
|
||||
"prefs.button_left": "왼쪽 드래그",
|
||||
"prefs.button_middle": "가운데 드래그",
|
||||
@@ -1810,6 +1818,8 @@
|
||||
"prefs.layout_page.card": "카드",
|
||||
"prefs.layout_page.column": "열",
|
||||
"prefs.layout_page.hover": "마우스를 올릴 때",
|
||||
"prefs.layout_page.hover_panel_menus": "포인터를 올려 패널 메뉴 전환",
|
||||
"prefs.layout_page.hover_panel_menus_help": "리본의 패널 이름을 클릭하여 메뉴를 엽니다. 다른 패널 이름에 포인터를 올리면 메뉴가 전환됩니다. 리본과 열린 메뉴 밖으로 포인터를 이동하면 메뉴가 닫힙니다.",
|
||||
"prefs.layout_page.icons_labels": "아이콘 + 레이블",
|
||||
"prefs.layout_page.ok_cancel": "확인과 취소",
|
||||
"prefs.layout_page.on_hover": "마우스를 올릴 때",
|
||||
@@ -2954,6 +2964,8 @@
|
||||
"sample.plate.name": "샘플 플레이트",
|
||||
"sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수",
|
||||
"sample.vents.name": "통풍 플레이트",
|
||||
"scale.per_axis": "축별",
|
||||
"scale.uniform": "균일",
|
||||
"search.command_keys": "Enter 실행 Shift+Enter 실행 후 열어 두기 Esc 닫기",
|
||||
"search.commands": "명령",
|
||||
"search.design": "이 디자인에서",
|
||||
@@ -3719,6 +3731,11 @@
|
||||
"ui.factor": "배율",
|
||||
"ui.fade": "페이드",
|
||||
"ui.fade_length": "페이드 길이",
|
||||
"ui.fair_iterations": "반복 횟수",
|
||||
"ui.fair_keep_edges": "모서리 유지",
|
||||
"ui.fair_strength": "강도",
|
||||
"ui.fair_surface_title": "서피스 페어링",
|
||||
"ui.fair_tolerance_default": "바디 크기의 0.001배",
|
||||
"ui.feature_history": "피처 기록",
|
||||
"ui.feature_options": "피처 옵션",
|
||||
"ui.features": "피처",
|
||||
@@ -3918,6 +3935,7 @@
|
||||
"ui.manipulator_free_alt": "Alt/Option으로도 매니퓰레이터 자유 드래그",
|
||||
"ui.manipulator_free_alt_tip": "켬: 돌출 / 기즈모 화살표에서 Alt/Option 또는 Ctrl/Cmd를 누르면 값 스냅이 해제됩니다. 끔: Ctrl/Cmd만 해당.",
|
||||
"ui.manufacture": "제조",
|
||||
"ui.match_surface_title": "서피스 일치",
|
||||
"ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.",
|
||||
"ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.",
|
||||
"ui.material_review.compressive_strength": "압축 강도",
|
||||
@@ -3977,6 +3995,7 @@
|
||||
"ui.options": "옵션",
|
||||
"ui.orbit": "회전",
|
||||
"ui.orbit_around": "궤도 회전 기준",
|
||||
"ui.organize": "정리",
|
||||
"ui.orientation": "배향",
|
||||
"ui.origin": "원점",
|
||||
"ui.palette_construction_mode": "보조선 모드 (X)",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Plat Contoh",
|
||||
"sample.vents.about": "Corak slot segi empat tepat: baris, lajur dan pic sebagai parameter",
|
||||
"sample.vents.name": "Plat Pengudaraan",
|
||||
"scale.per_axis": "Mengikut paksi",
|
||||
"scale.uniform": "Seragam",
|
||||
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan kekalkan terbuka Esc tutup",
|
||||
"search.commands": "Arahan",
|
||||
"search.design": "Dalam reka bentuk ini",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Eksempelplate",
|
||||
"sample.vents.about": "Spor i et rektangulært mønster: rader, kolonner og stigning er parametere",
|
||||
"sample.vents.name": "Ventilasjonsplate",
|
||||
"scale.per_axis": "Per akse",
|
||||
"scale.uniform": "Jevn",
|
||||
"search.command_keys": "Enter kjør Shift+Enter kjør og behold åpen Esc lukk",
|
||||
"search.commands": "Kommandoer",
|
||||
"search.design": "I dette designet",
|
||||
|
||||
@@ -3818,6 +3818,8 @@
|
||||
"sample.plate.name": "Voorbeeldplaat",
|
||||
"sample.vents.about": "Sleuven in een rechthoekig patroon: rijen, kolommen en spoed zijn parameters",
|
||||
"sample.vents.name": "Ventilatieplaat",
|
||||
"scale.per_axis": "Per as",
|
||||
"scale.uniform": "Uniform",
|
||||
"search.command_keys": "Enter uitvoeren Shift+Enter uitvoeren en open houden Esc sluiten",
|
||||
"search.commands": "Opdrachten",
|
||||
"search.design": "In dit ontwerp",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"en": {
|
||||
"ai.http.allow": "Allow insecure HTTP",
|
||||
"ai.http.confirm": "Allow for this endpoint",
|
||||
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust."
|
||||
},
|
||||
"ar": {
|
||||
"ai.http.allow": "السماح باتصال HTTP غير آمن",
|
||||
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
|
||||
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها."
|
||||
},
|
||||
"de": {
|
||||
"ai.http.allow": "Unsicheres HTTP erlauben",
|
||||
"ai.http.confirm": "Für diesen Endpunkt erlauben",
|
||||
"ai.http.warning": "Der API-Schlüssel und die Unterhaltung werden unverschlüsselt gesendet. Jeder mit Zugriff auf das Netzwerk kann sie lesen oder verändern. Verwenden Sie dies nur in einem vertrauenswürdigen Netzwerk."
|
||||
},
|
||||
"he": {
|
||||
"ai.http.allow": "אפשר HTTP לא מאובטח",
|
||||
"ai.http.confirm": "אפשר עבור נקודת קצה זו",
|
||||
"ai.http.warning": "מפתח ה־API והשיחה יישלחו ללא הצפנה. כל מי שיש לו גישה לרשת יכול לקרוא או לשנות אותם. יש להשתמש באפשרות זו רק ברשת מהימנה."
|
||||
},
|
||||
"ja": {
|
||||
"ai.http.allow": "安全でない HTTP を許可",
|
||||
"ai.http.confirm": "このエンドポイントで許可",
|
||||
"ai.http.warning": "API キーと会話は暗号化されずに送信されます。ネットワークにアクセスできる人は、内容を読み取ったり改ざんしたりする可能性があります。信頼できるネットワークでのみ使用してください。"
|
||||
},
|
||||
"ko": {
|
||||
"ai.http.allow": "보안되지 않은 HTTP 허용",
|
||||
"ai.http.confirm": "이 엔드포인트에 허용",
|
||||
"ai.http.warning": "API 키와 대화가 암호화되지 않은 상태로 전송됩니다. 네트워크에 접근할 수 있는 누구나 이를 읽거나 변경할 수 있습니다. 신뢰하는 네트워크에서만 사용하세요."
|
||||
},
|
||||
"vi": {
|
||||
"ai.http.allow": "Cho phép HTTP không bảo mật",
|
||||
"ai.http.confirm": "Cho phép với điểm cuối này",
|
||||
"ai.http.warning": "Khóa API và cuộc trò chuyện sẽ được gửi mà không mã hóa. Bất kỳ ai có quyền truy cập mạng đều có thể đọc hoặc thay đổi chúng. Chỉ sử dụng trên mạng mà bạn tin cậy."
|
||||
},
|
||||
"zh": {
|
||||
"ai.http.allow": "允许不安全的 HTTP",
|
||||
"ai.http.confirm": "允许此端点",
|
||||
"ai.http.warning": "API 密钥和对话将以未加密的方式发送。任何能够访问该网络的人都可能读取或篡改它们。请仅在可信网络上使用。"
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1408,8 @@
|
||||
"sample.plate.name": "Placa de exemplo",
|
||||
"sample.vents.about": "Rasgos em padrão retangular: linhas, colunas e passo são parâmetros",
|
||||
"sample.vents.name": "Placa de ventilação",
|
||||
"scale.per_axis": "Por eixo",
|
||||
"scale.uniform": "Uniforme",
|
||||
"search.command_keys": "Enter executar Shift+Enter executar e manter aberto Esc fechar",
|
||||
"search.commands": "Comandos",
|
||||
"search.design": "Neste projeto",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Placă exemplu",
|
||||
"sample.vents.about": "Canale într-o matrice dreptunghiulară: rândurile, coloanele și pasul sunt parametri",
|
||||
"sample.vents.name": "Placă de ventilație",
|
||||
"scale.per_axis": "Pe axe",
|
||||
"scale.uniform": "Uniform",
|
||||
"search.command_keys": "Enter execută Shift+Enter execută și păstrează deschis Esc închide",
|
||||
"search.commands": "Comenzi",
|
||||
"search.design": "În acest proiect",
|
||||
|
||||
@@ -647,6 +647,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Направление должно быть осью или прямолинейной кривой эскиза, лежащей в плоскости эскиза",
|
||||
"The mirror line cannot also be mirrored": "Линию зеркала нельзя одновременно отражать",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "Линия зеркала должна быть прямолинейной кривой этого эскиза, а не осью начала координат",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Ось начала координат перпендикулярна плоскости эскиза, поэтому линии в этой плоскости нет",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Сохранённое исходное вхождение отсутствует. Выберите корректное исходное положение перед перепривязкой.",
|
||||
"Then": "Затем",
|
||||
"These features will be deleted:": "Эти операции будут удалены:",
|
||||
@@ -3750,6 +3751,8 @@
|
||||
"sample.plate.name": "Образец пластины",
|
||||
"sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами",
|
||||
"sample.vents.name": "Вентиляционная пластина",
|
||||
"scale.per_axis": "По осям",
|
||||
"scale.uniform": "Равномерно",
|
||||
"search.command_keys": "Enter выполнить Shift+Enter выполнить и оставить открытым Esc закрыть",
|
||||
"search.commands": "Команды",
|
||||
"search.design": "В этом дизайне",
|
||||
|
||||
@@ -3808,6 +3808,8 @@
|
||||
"sample.plate.name": "Exempelplatta",
|
||||
"sample.vents.about": "Spår i ett rektangulärt mönster: rader, kolumner och delning är parametrar",
|
||||
"sample.vents.name": "Ventilationsplatta",
|
||||
"scale.per_axis": "Per axel",
|
||||
"scale.uniform": "Likformig",
|
||||
"search.command_keys": "Enter kör Shift+Enter kör och behåll öppen Esc stäng",
|
||||
"search.commands": "Kommandon",
|
||||
"search.design": "I denna konstruktion",
|
||||
|
||||
@@ -3807,6 +3807,8 @@
|
||||
"sample.plate.name": "Örnek plaka",
|
||||
"sample.vents.about": "Dikdörtgen desende yuvalar: satırlar, sütunlar ve aralık parametredir",
|
||||
"sample.vents.name": "Havalandırma plakası",
|
||||
"scale.per_axis": "Eksen başına",
|
||||
"scale.uniform": "Tekdüze",
|
||||
"search.command_keys": "Enter çalıştır Shift+Enter çalıştır ve açık tut Esc kapat",
|
||||
"search.commands": "Komutlar",
|
||||
"search.design": "Bu tasarımda",
|
||||
|
||||
@@ -4074,6 +4074,8 @@
|
||||
"sample.plate.name": "Зразкова пластина",
|
||||
"sample.vents.about": "Пази в прямокутному масиві: рядки, стовпці та крок є параметрами",
|
||||
"sample.vents.name": "Вентиляційна пластина",
|
||||
"scale.per_axis": "По осях",
|
||||
"scale.uniform": "Рівномірно",
|
||||
"search.command_keys": "Enter виконати Shift+Enter виконати й залишити відкритим Esc закрити",
|
||||
"search.commands": "Команди",
|
||||
"search.design": "У цьому проєкті",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "Bỏ kích hoạt các thư mục đã chọn",
|
||||
"Suppressed": "Bị tạm nén",
|
||||
"Surface": "Bề mặt",
|
||||
"Surface or face": "Bề mặt hoặc mặt",
|
||||
"Surface or faces": "Bề mặt hoặc các mặt",
|
||||
"Surface, or faces to turn over": "Mặt cong, hoặc các mặt cần lật hướng",
|
||||
"Surfaces": "Các bề mặt",
|
||||
"Swap Ends": "Hoán đổi hai đầu",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "Mục tiêu",
|
||||
"Target Body": "Khối mục tiêu",
|
||||
"Target Face (optional)": "Mặt đích (tùy chọn)",
|
||||
"Target edges": "Các cạnh đích",
|
||||
"Tear": "Xé rách mép chấn",
|
||||
"Technical details": "Chi tiết kỹ thuật",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Phác thảo nét đứt là đường dẫn mặt phẳng đã lưu. Hãy chọn một bề mặt mới để sửa lại liên kết.",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Hướng phải là một trục hoặc một đường cong phác thảo thẳng nằm trong mặt phẳng phác thảo",
|
||||
"The mirror line cannot also be mirrored": "Đường đối xứng không thể đồng thời được lấy đối xứng",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "Đường đối xứng phải là một đường cong thẳng của phác thảo này, không phải trục gốc",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Trục gốc vuông góc với mặt phẳng phác thảo nên không có đường thẳng trong mặt phẳng này",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Bản thể tác giả đã lưu bị thiếu. Hãy chọn một vị trí tác giả hợp lệ trước khi gán lại.",
|
||||
"Then": "Thì",
|
||||
"These features will be deleted:": "Các tính năng sau sẽ bị xóa:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "Theo camera",
|
||||
"ai.prefs.follow_camera_off": "Tự động xoay camera theo phác thảo đang tắt.",
|
||||
"ai.prefs.follow_camera_on": "Camera sẽ tự động xoay vuông góc với phác thảo do agent tạo.",
|
||||
"ai.prefs.instructions": "Hướng dẫn tùy chỉnh",
|
||||
"ai.prefs.instructions_note": "Được gửi cho agent khi bắt đầu mỗi cuộc trò chuyện, sau hướng dẫn riêng của SolidCraft: cách bạn muốn làm việc, các quy tắc cần tuân theo. Thay đổi có hiệu lực từ cuộc trò chuyện tiếp theo (Kết nối hoặc Cuộc trò chuyện mới).",
|
||||
"ai.prefs.intro": "Tác nhân AI trong SolidCraft.",
|
||||
"ai.prefs.make_default": "Đặt làm mặc định",
|
||||
"ai.prefs.no_agents": "Chưa có agent nào. Thêm một agent hoặc làm mới để tìm các agent đã cài đặt.",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "Đang tính toán… {seconds} s",
|
||||
"dialog.could_not_be_checked": "không thể kiểm tra",
|
||||
"dialog.custom_axis": "↖ Trục tùy chỉnh",
|
||||
"dialog.cylinder_face_tip": "Bề mặt hoặc đỉnh của mô hình mà khối trụ đứng trên đó: nhấp vào đây, sau đó nhấp vào nó trong khung nhìn. Để trống thì X, Y và Z đặt vị trí.",
|
||||
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
|
||||
"dialog.dimension_name": "Kích thước: {name}",
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "CHỈNH SỬA {title}",
|
||||
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
|
||||
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
|
||||
"dialog.primitive_point_tip": "Một điểm điền vào X, Y và Z: nhấp vào đây, sau đó nhấp vào một đỉnh, cạnh hoặc bề mặt của mô hình, một điểm của bản phác thảo, hoặc gốc tọa độ.",
|
||||
"dialog.reference_edge_tip": "Một cạnh thẳng của bề mặt đã chọn, từ đó đo khoảng cách bên dưới: nhấp vào đây, sau đó nhấp vào cạnh.",
|
||||
"dialog.select": "↖ Chọn",
|
||||
"dialog.selected_count": "↖ {count} đã chọn",
|
||||
"dialog.selection_constraint": "Ràng buộc {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "Nối, Vật thể mới hoặc Cắt, tùy vào phần vật liệu tiếp xúc.",
|
||||
"prefs.axes": "Các trục tọa độ",
|
||||
"prefs.axis": "Trục tọa độ",
|
||||
"prefs.background_choose": "Chọn ảnh…",
|
||||
"prefs.background_image": "Ảnh",
|
||||
"prefs.background_unreadable": "Không đọc được ảnh nền",
|
||||
"prefs.blur_cost": "Chi phí kết xuất tương đối. Nhiều thanh hơn thường có nghĩa là cần xử lý nhiều hơn.",
|
||||
"prefs.blur_device": "Không khả dụng trên thiết bị đồ họa hoặc đường kết xuất này",
|
||||
"prefs.blur_guard": "Chất lượng làm mờ đã giảm để duy trì độ phản hồi của khung hình. Bán kính không thay đổi.",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
|
||||
"prefs.browser_eye_left_note": "Các hàng trong cây thư mục hiển thị biểu tượng mắt bật/tắt cạnh mũi tên thu gọn như trước đây, thay vì trong một cột ở đầu bên phải của các hàng.",
|
||||
"prefs.browser_reveal_on_hover": "Hiện khi di chuột",
|
||||
"prefs.browser_rows_start_closed": "Các dòng trong cây thư mục đóng sẵn",
|
||||
"prefs.browser_rows_start_closed_note": "Vị trí đặt và thư mục trong cây thư mục bắt đầu ở trạng thái đóng, nên thiết kế có nhiều thành phần chỉ hiện các dòng cấp trên cùng. Mỗi dòng mở bằng mũi tên của nó, “Mở rộng tất cả” hoặc “Tìm trong cây đối tượng”, lệnh này chỉ mở các dòng dẫn tới thân khối và đóng lại các dòng mà lần tìm trước đã mở. Chọn một thân khối trong khung nhìn 3D không làm thay đổi cây thư mục. Khi tắt, các thư mục mở sẵn, vị trí đặt theo tùy chọn “Thành phần mở sẵn”, và việc chọn trong khung nhìn 3D mở cây thư mục tới thân khối đó, như trước đây.",
|
||||
"prefs.browser_show_note": "Hiển thị giữ trình duyệt trên màn hình. Hiện khi di chuột ẩn nó cho đến khi con trỏ đến vị trí của nó hoặc thẻ mảnh ở cạnh trái, và nó vẫn mở khi con trỏ ở bên trong hoặc một menu của nó đang mở.",
|
||||
"prefs.browser_shown": "Hiển thị",
|
||||
"prefs.built_in": "Mặc định tích hợp",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "Hai mũi tên cong ở góc khối cuộn khung nhìn trong mặt phẳng màn hình. Khi bật: mũi tên theo chiều kim đồng hồ cuộn ngược chiều kim đồng hồ và ngược lại. Khi tắt: mỗi mũi tên cuộn khung nhìn theo chiều cong của nó.",
|
||||
"prefs.invert_navigator_arrows": "Đảo ngược mũi tên xoay của khối điều hướng",
|
||||
"prefs.invert_navigator_arrows_note": "Các mũi tên quanh khối xoay camera theo hướng mũi tên chỉ, như trong Fusion và Inventor: mũi tên bên trái chỉ sang phải và đưa camera sang phải. Khi tắt: mũi tên xoay khối về phía cạnh chứa nó, nên mũi tên bên trái đưa camera sang trái. Hai mũi tên cuộn cong không bị ảnh hưởng.",
|
||||
"prefs.keep_unsaved_also_asks": "Vẫn hỏi khi đóng thẻ",
|
||||
"prefs.keep_unsaved_also_asks_tip": "Khi tắt, thẻ sẽ đóng mà không có hộp thoại của trình duyệt khi bản sao của mọi thiết kế chưa lưu đã được cập nhật.",
|
||||
"prefs.keep_unsaved_in_browser": "Giữ công việc chưa lưu trong trình duyệt này",
|
||||
"prefs.keep_unsaved_in_browser_tip": "Các thay đổi chưa lưu của mỗi thiết kế đang mở được cập nhật liên tục trong bộ nhớ riêng của trình duyệt này. Lần truy cập sau sẽ đề nghị khôi phục chúng.",
|
||||
"prefs.keep_work_on_tab_switch": "Giữ hộp thoại và công cụ mở khi chuyển giữa các thiết kế",
|
||||
"prefs.keep_work_on_tab_switch_note": "Mỗi thẻ thiết kế giữ hộp thoại hoặc công cụ đang mở, cùng những gì bạn đã chọn và nhập trong đó, và thẻ thanh công cụ của nó. Chúng vẫn còn đó khi bạn quay lại thiết kế. Tắt: chuyển thiết kế sẽ hủy chúng và hiển thị thẻ Mô hình.",
|
||||
"prefs.labels": "Nhãn chữ",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "Thẻ",
|
||||
"prefs.layout_page.column": "Cột",
|
||||
"prefs.layout_page.hover": "Rê chuột",
|
||||
"prefs.layout_page.hover_panel_menus": "Chuyển menu nhóm khi di chuột",
|
||||
"prefs.layout_page.hover_panel_menus_help": "Nhấp vào tên nhóm trên dải công cụ để mở menu. Di chuột lên tên nhóm khác để chuyển menu. Di chuyển con trỏ ra ngoài dải công cụ và menu đang mở để đóng menu.",
|
||||
"prefs.layout_page.icons_labels": "Biểu tượng + nhãn",
|
||||
"prefs.layout_page.ok_cancel": "Đồng ý và Hủy bỏ",
|
||||
"prefs.layout_page.on_hover": "Khi rê chuột",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "vừa xong",
|
||||
"recovery.min_ago": "{count} phút trước",
|
||||
"recovery.never_saved": "chưa từng lưu",
|
||||
"recovery.open_saved": "Mở phiên bản đã lưu",
|
||||
"recovery.restore": "Khôi phục",
|
||||
"recovery.restore_intro": "Các thay đổi chưa được lưu khi trang này đóng lần trước được giữ trong trình duyệt này.",
|
||||
"recovery.restore_row": "{name}, thay đổi {when}",
|
||||
"recovery.restore_title": "Khôi phục các thiết kế chưa lưu?",
|
||||
"recovery.restore_title_one": "Khôi phục thiết kế chưa lưu: {name}, thay đổi {when}?",
|
||||
"refused.change": "Không thể thay đổi {body}.",
|
||||
"refused.cut": "Không thể cắt {body}.",
|
||||
"refused.face_curved": "Một trong các mặt cong của nó không dùng được để mô hình hóa.",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · Màn ảnh rộng",
|
||||
"render.gpu_depth": "Độ sâu đường truyền tia tối đa",
|
||||
"render.gpu_options_help": "Số mẫu, độ sâu, hạt giống và độ phơi sáng được lưu cùng thiết lập này. Chỉ lưu phương thức tạo ảnh đang hoạt động.",
|
||||
"render.gpu_path_crashed": "Lần trước SolidCraft đã đóng khi đang thiết lập nó trên bộ điều hợp đồ họa này, nhiều khả năng là trong trình điều khiển đồ họa, nên nó sẽ không tự thiết lập lại. Hãy dùng dò tia trên CPU, hoặc thử lại (cập nhật trình điều khiển cũng sẽ thử lại).",
|
||||
"render.gpu_path_failed": "Bộ dò đường truyền tia GPU không khả dụng",
|
||||
"render.gpu_path_first_setup": "Lần thiết lập đầu tiên trên GPU này có thể mất vài phút; SolidCraft vẫn sử dụng được bình thường. Các lần khởi động sau sẽ tái sử dụng mã đổ bóng đã biên dịch nếu trình điều khiển lưu giữ chúng.",
|
||||
"render.gpu_path_preparing": "Bộ dò đường truyền tia GPU: đang biên dịch mã đổ bóng…",
|
||||
"render.gpu_path_retry": "Thử lại",
|
||||
"render.gpu_seed": "Hạt giống ngẫu nhiên",
|
||||
"render.gpu_unavailable": "Dò đường truyền tia GPU chưa sẵn sàng trên thiết bị này.",
|
||||
"render.ground_apply": "Khớp và lưu mặt sàn",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "Tấm mẫu",
|
||||
"sample.vents.about": "Các rãnh then trong mảng chữ nhật: số hàng, số cột và bước rãnh là các tham số",
|
||||
"sample.vents.name": "Tấm khe thông gió",
|
||||
"scale.per_axis": "Theo từng trục",
|
||||
"scale.uniform": "Đồng đều",
|
||||
"search.command_keys": "Enter chạy Shift+Enter chạy và giữ mở Esc đóng",
|
||||
"search.commands": "Lệnh",
|
||||
"search.design": "Trong thiết kế này",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "3D Phác thảo",
|
||||
"ui.activate": "Kích hoạt",
|
||||
"ui.active_component": "Cụm chi tiết đang kích hoạt",
|
||||
"ui.addToQuickAccess": "Thêm vào Truy cập nhanh",
|
||||
"ui.add_configuration": "Thêm cấu hình",
|
||||
"ui.add_parameter": "Thêm tham số",
|
||||
"ui.agent_chat": "Trò chuyện với AI Agent",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "Hệ số",
|
||||
"ui.fade": "Thoát ren",
|
||||
"ui.fade_length": "Chiều dài thoát ren",
|
||||
"ui.fair_iterations": "Số lần lặp",
|
||||
"ui.fair_keep_edges": "Giữ các cạnh",
|
||||
"ui.fair_strength": "Cường độ",
|
||||
"ui.fair_surface_title": "LÀM MƯỢT BỀ MẶT",
|
||||
"ui.fair_tolerance_default": "0,001 lần kích thước thân khối",
|
||||
"ui.feature_history": "Lịch sử tính năng",
|
||||
"ui.feature_options": "Tùy chọn tính năng",
|
||||
"ui.features": "Các tính năng",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "Kéo tự do bộ thao tác cũng bằng Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Bật: Alt/Option hoặc Ctrl/Cmd trên các mũi tên đùn / gizmo sẽ tắt bước nhảy giá trị. Tắt: chỉ Ctrl/Cmd.",
|
||||
"ui.manufacture": "Gia công chế tạo",
|
||||
"ui.match_surface_title": "KHỚP BỀ MẶT",
|
||||
"ui.material_review.axes": "Các trục cục bộ của vật liệu; chưa gán hướng cho thân khối.",
|
||||
"ui.material_review.claims": "Nguồn gốc và giấy phép là thông tin chưa được kiểm chứng. Các giá trị thiếu vẫn ở dạng chưa biết.",
|
||||
"ui.material_review.compressive_strength": "Độ bền nén",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "Tùy chọn",
|
||||
"ui.orbit": "Xoay quanh tâm (Orbit)",
|
||||
"ui.orbit_around": "Xoay quanh",
|
||||
"ui.organize": "Sắp xếp",
|
||||
"ui.orientation": "Định hướng",
|
||||
"ui.origin": "Gốc tọa độ",
|
||||
"ui.palette_construction_mode": "Chế độ đường dựng (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "Chất lượng",
|
||||
"ui.quantity": "Số lượng",
|
||||
"ui.quantity_2": "Số lượng 2",
|
||||
"ui.quick_access_full": "Thanh truy cập nhanh chứa tối đa {count} lệnh; hãy gỡ một lệnh để thêm lệnh khác",
|
||||
"ui.quit": "Thoát ứng dụng",
|
||||
"ui.radial_context_menu": "Menu ngữ cảnh dạng vòng (Radial)",
|
||||
"ui.radius": "Bán kính",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "Chi tiết này chưa được liên kết với thiết kế nguồn nào.",
|
||||
"ui.relink_select_one": "Hãy chọn trước một chi tiết được liên kết hoặc một thể hiện của nó.",
|
||||
"ui.relink_source_all": "Liên kết lại nguồn (tất cả thể hiện)…",
|
||||
"ui.removeFromQuickAccess": "Gỡ khỏi Truy cập nhanh",
|
||||
"ui.removeFromToolbar": "Xóa khỏi thanh công cụ",
|
||||
"ui.removeSection": "Xóa mặt cắt",
|
||||
"ui.render": "Kết xuất hình ảnh",
|
||||
|
||||
@@ -1364,11 +1364,14 @@
|
||||
"dialog.computing": "正在計算… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "無法檢查",
|
||||
"dialog.custom_axis": "↖ 自訂軸",
|
||||
"dialog.cylinder_face_tip": "圓柱體所立的模型面或頂點:先點擊此處,再在視圖中點擊它。留空時由 X、Y、Z 放置。",
|
||||
"dialog.dimension_insert_hint": "點擊其他驅動尺寸以插入其名稱",
|
||||
"dialog.dimension_name": "尺寸:{name}",
|
||||
"dialog.edit_title": "編輯 {title}",
|
||||
"dialog.no_shown_body": "沒有可變更的已顯示實體:請顯示一個,或選擇「新建實體」",
|
||||
"dialog.pattern_sketch_not_object": "請選取實體或特徵:草圖兩者皆非。請用草圖自己的「陣列」或「鏡射」重複其曲線,或先將它擠出。",
|
||||
"dialog.primitive_point_tip": "填入 X、Y、Z 的點:先點擊此處,再點擊模型的頂點、邊或面、草圖中的點,或原點。",
|
||||
"dialog.reference_edge_tip": "所選面上的一條直邊,下方的距離從它量起:先點擊此處,再點擊該邊。",
|
||||
"dialog.select": "↖ 選擇",
|
||||
"dialog.selected_count": "↖ 已選擇 {count} 個",
|
||||
"dialog.selection_constraint": "拘束 {id}",
|
||||
@@ -3810,6 +3813,8 @@
|
||||
"sample.plate.name": "範例板",
|
||||
"sample.vents.about": "依矩形陣列排列的槽:列數、欄數和間距均為參數",
|
||||
"sample.vents.name": "通風板",
|
||||
"scale.per_axis": "依軸",
|
||||
"scale.uniform": "等比",
|
||||
"search.command_keys": "Enter 執行 Shift+Enter 執行並保持開啟 Esc 關閉",
|
||||
"search.commands": "命令",
|
||||
"search.design": "此設計中",
|
||||
|
||||
@@ -629,6 +629,8 @@
|
||||
"Suppress Selected Folders": "抑制所选文件夹",
|
||||
"Suppressed": "已抑制",
|
||||
"Surface": "曲面",
|
||||
"Surface or face": "曲面或面",
|
||||
"Surface or faces": "曲面或面",
|
||||
"Surface, or faces to turn over": "曲面,或要翻转的面",
|
||||
"Surfaces": "曲面",
|
||||
"Swap Ends": "交换端点",
|
||||
@@ -642,6 +644,7 @@
|
||||
"Target": "目标",
|
||||
"Target Body": "目标实体",
|
||||
"Target Face (optional)": "目标面(可选)",
|
||||
"Target edges": "目标边",
|
||||
"Tear": "撕裂",
|
||||
"Technical details": "技术详情",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "虚线草图是已保存的平面引导。选择一个新面以修复附着。",
|
||||
@@ -649,6 +652,7 @@
|
||||
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "方向必须是轴,或位于草图平面内的直线草图曲线",
|
||||
"The mirror line cannot also be mirrored": "镜像线本身不能再被镜像",
|
||||
"The mirror line must be a straight curve of this sketch, not an origin axis": "镜像线必须是此草图中的直线曲线,不能是原点轴",
|
||||
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "原点轴垂直于草图平面,因此在该平面内没有直线",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "保存的作者实例缺失。重新绑定前请选择有效的作者放置。",
|
||||
"Then": "操作",
|
||||
"These features will be deleted:": "以下特征将被删除:",
|
||||
@@ -751,6 +755,8 @@
|
||||
"ai.prefs.follow_camera": "跟随相机",
|
||||
"ai.prefs.follow_camera_off": "相机自动跟随已关闭。",
|
||||
"ai.prefs.follow_camera_on": "相机将转向代理创建的草图。",
|
||||
"ai.prefs.instructions": "自定义指令",
|
||||
"ai.prefs.instructions_note": "在每次对话开始时发送给代理,位于 SolidCraft 自身的指令之后:你喜欢的工作方式、需要遵守的规则。更改从下一次对话(连接或新对话)起生效。",
|
||||
"ai.prefs.intro": "SolidCraft中的AI代理。",
|
||||
"ai.prefs.make_default": "设为默认",
|
||||
"ai.prefs.no_agents": "尚无代理。添加代理或刷新以查找已安装的代理。",
|
||||
@@ -1395,6 +1401,7 @@
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "无法检查",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.cylinder_face_tip": "圆柱体所立的模型面或顶点:先单击此处,再在视图中单击它。留空时由 X、Y、Z 放置。",
|
||||
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
|
||||
"dialog.dimension_name": "尺寸:{name}",
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
@@ -1403,6 +1410,8 @@
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
|
||||
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
|
||||
"dialog.primitive_point_tip": "填入 X、Y、Z 的点:先单击此处,再单击模型的顶点、边或面、草图中的点,或原点。",
|
||||
"dialog.reference_edge_tip": "所选面上的一条直边,下方的距离从它量起:先单击此处,再单击该边。",
|
||||
"dialog.select": "↖ 选择",
|
||||
"dialog.selected_count": "↖ 已选择 {count} 个",
|
||||
"dialog.selection_constraint": "约束 {id}",
|
||||
@@ -2277,6 +2286,9 @@
|
||||
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
|
||||
"prefs.axes": "坐标轴",
|
||||
"prefs.axis": "轴",
|
||||
"prefs.background_choose": "选择图片…",
|
||||
"prefs.background_image": "图片",
|
||||
"prefs.background_unreadable": "无法读取背景图片",
|
||||
"prefs.blur_cost": "相对渲染开销。条形越多,通常意味着处理工作越多。",
|
||||
"prefs.blur_device": "此图形设备或渲染路径不支持此功能",
|
||||
"prefs.blur_guard": "已降低模糊质量,以保持画面响应。模糊半径未改变。",
|
||||
@@ -2298,6 +2310,8 @@
|
||||
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
|
||||
"prefs.browser_eye_left_note": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。",
|
||||
"prefs.browser_reveal_on_hover": "悬停时显示",
|
||||
"prefs.browser_rows_start_closed": "浏览器中的行默认折叠",
|
||||
"prefs.browser_rows_start_closed_note": "浏览器中的放置和文件夹默认折叠,因此包含大量组件的设计只显示顶层行。每一行可通过其箭头、“全部展开”或“在浏览器中查找”打开;“在浏览器中查找”只展开通往该实体的行,并重新折叠上次查找展开的行。在 3D 视图中选取实体不会改变浏览器。关闭时文件夹默认展开,放置按“组件初始展开”的设置显示,在 3D 视图中选取会使浏览器展开到该实体,与以前相同。",
|
||||
"prefs.browser_show_note": "“显示”让浏览器始终留在屏幕上。“悬停时显示”会将其隐藏,直到指针移到它的位置或其左边缘的细标签上;指针在其中或其菜单打开时,它保持打开。",
|
||||
"prefs.browser_shown": "显示",
|
||||
"prefs.built_in": "内置",
|
||||
@@ -2429,6 +2443,10 @@
|
||||
"prefs.invert_curved_roll_arrows_note": "立方体角落的两个弯曲箭头在屏幕平面内翻滚视图。开启:顺时针箭头逆时针翻滚,逆时针箭头顺时针翻滚。关闭:每个箭头按其弯曲方向翻滚视图。",
|
||||
"prefs.invert_navigator_arrows": "反转视图立方体的旋转箭头",
|
||||
"prefs.invert_navigator_arrows_note": "立方体周围的箭头按箭头所指的方向转动相机,与 Fusion 和 Inventor 相同:左侧箭头指向右,相机向右转动。关闭:箭头把立方体转向它所在的那一面,因此左侧箭头使相机向左转动。弯曲的翻滚箭头不受影响。",
|
||||
"prefs.keep_unsaved_also_asks": "关闭标签页时仍然询问",
|
||||
"prefs.keep_unsaved_also_asks_tip": "关闭此项后,只要每个未保存设计的副本都是最新的,标签页就会直接关闭,不显示浏览器的提示框。",
|
||||
"prefs.keep_unsaved_in_browser": "在此浏览器中保留未保存的工作",
|
||||
"prefs.keep_unsaved_in_browser_tip": "每个打开的设计的未保存更改都会在此浏览器的私有存储中保持最新。下次访问时会提示恢复。",
|
||||
"prefs.keep_work_on_tab_switch": "切换设计时保持对话框和工具打开",
|
||||
"prefs.keep_work_on_tab_switch_note": "每个设计标签页保留其打开的对话框或工具(包括其中已选择和输入的内容)以及工具栏选项卡。回到该设计时它们仍在原处。关闭时:切换设计会取消它们并显示“模型”选项卡。",
|
||||
"prefs.labels": "标签",
|
||||
@@ -2440,6 +2458,8 @@
|
||||
"prefs.layout_page.card": "卡片",
|
||||
"prefs.layout_page.column": "列",
|
||||
"prefs.layout_page.hover": "悬停",
|
||||
"prefs.layout_page.hover_panel_menus": "悬停切换面板菜单",
|
||||
"prefs.layout_page.hover_panel_menus_help": "点击功能区的面板名称打开菜单。将指针悬停在另一面板名称上即可切换菜单。将指针移到功能区和已打开菜单之外即可关闭菜单。",
|
||||
"prefs.layout_page.icons_labels": "图标+标签",
|
||||
"prefs.layout_page.ok_cancel": "确定和取消",
|
||||
"prefs.layout_page.on_hover": "悬停时",
|
||||
@@ -2757,6 +2777,12 @@
|
||||
"recovery.just_now": "刚刚",
|
||||
"recovery.min_ago": "{count} 分钟前",
|
||||
"recovery.never_saved": "从未保存",
|
||||
"recovery.open_saved": "打开已保存的版本",
|
||||
"recovery.restore": "恢复",
|
||||
"recovery.restore_intro": "此页面上次关闭时未保存的更改保留在此浏览器中。",
|
||||
"recovery.restore_row": "{name},更改于{when}",
|
||||
"recovery.restore_title": "恢复未保存的设计?",
|
||||
"recovery.restore_title_one": "恢复未保存的设计:{name},更改于{when}?",
|
||||
"refused.change": "无法更改 {body}。",
|
||||
"refused.cut": "无法切割 {body}。",
|
||||
"refused.face_curved": "它的一个曲面无法用于建模。",
|
||||
@@ -3278,9 +3304,11 @@
|
||||
"render.frame_wide": "16:9 · 宽屏",
|
||||
"render.gpu_depth": "最大路径深度",
|
||||
"render.gpu_options_help": "采样数、深度、种子和曝光会随此配置保存。仅保存当前图像方法。",
|
||||
"render.gpu_path_crashed": "上次在此图形适配器上设置时 SolidCraft 关闭了,很可能是在图形驱动程序内部,因此不会再自动设置。请改用 CPU 光线追踪,或重试(更新驱动程序后也会重试)。",
|
||||
"render.gpu_path_failed": "GPU 路径追踪器不可用",
|
||||
"render.gpu_path_first_setup": "首次在此 GPU 上设置可能需要几分钟,期间 SolidCraft 仍可正常使用。之后启动时,如果驱动程序保留了已编译的着色器,将直接复用。",
|
||||
"render.gpu_path_preparing": "GPU 路径追踪器:正在编译着色器…",
|
||||
"render.gpu_path_retry": "重试",
|
||||
"render.gpu_seed": "随机种子",
|
||||
"render.gpu_unavailable": "此设备上的 GPU 路径追踪尚未就绪。",
|
||||
"render.ground_apply": "适配并保存地面",
|
||||
@@ -4088,6 +4116,8 @@
|
||||
"sample.plate.name": "示例板",
|
||||
"sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数",
|
||||
"sample.vents.name": "通风板",
|
||||
"scale.per_axis": "按轴",
|
||||
"scale.uniform": "等比",
|
||||
"search.command_keys": "Enter 运行 Shift+Enter 运行并保持打开 Esc 关闭",
|
||||
"search.commands": "命令",
|
||||
"search.design": "此设计中",
|
||||
@@ -4425,6 +4455,7 @@
|
||||
"ui.3d_sketch": "3D 草图",
|
||||
"ui.activate": "激活",
|
||||
"ui.active_component": "活动组件",
|
||||
"ui.addToQuickAccess": "添加到快速访问",
|
||||
"ui.add_configuration": "添加配置",
|
||||
"ui.add_parameter": "添加参数",
|
||||
"ui.agent_chat": "智能体聊天",
|
||||
@@ -4976,6 +5007,11 @@
|
||||
"ui.factor": "系数",
|
||||
"ui.fade": "收尾",
|
||||
"ui.fade_length": "收尾长度",
|
||||
"ui.fair_iterations": "迭代次数",
|
||||
"ui.fair_keep_edges": "保持边",
|
||||
"ui.fair_strength": "强度",
|
||||
"ui.fair_surface_title": "曲面光顺",
|
||||
"ui.fair_tolerance_default": "实体尺寸的 0.001 倍",
|
||||
"ui.feature_history": "特征历史",
|
||||
"ui.feature_options": "特征选项",
|
||||
"ui.features": "特征",
|
||||
@@ -5189,6 +5225,7 @@
|
||||
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
|
||||
"ui.manufacture": "制造",
|
||||
"ui.match_surface_title": "曲面匹配",
|
||||
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
|
||||
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
|
||||
"ui.material_review.compressive_strength": "抗压强度",
|
||||
@@ -5252,6 +5289,7 @@
|
||||
"ui.options": "选项",
|
||||
"ui.orbit": "环绕",
|
||||
"ui.orbit_around": "环绕",
|
||||
"ui.organize": "整理",
|
||||
"ui.orientation": "定向",
|
||||
"ui.origin": "原点",
|
||||
"ui.palette_construction_mode": "构造模式 (X)",
|
||||
@@ -5340,6 +5378,7 @@
|
||||
"ui.quality": "质量",
|
||||
"ui.quantity": "数量",
|
||||
"ui.quantity_2": "数量 2",
|
||||
"ui.quick_access_full": "快速访问栏最多容纳 {count} 个命令;请先移除一个再添加",
|
||||
"ui.quit": "退出",
|
||||
"ui.radial_context_menu": "环形上下文菜单",
|
||||
"ui.radius": "半径",
|
||||
@@ -5357,6 +5396,7 @@
|
||||
"ui.relink_not_linked": "此组件未链接到源设计。",
|
||||
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
|
||||
"ui.relink_source_all": "重新链接源文件(所有实例)…",
|
||||
"ui.removeFromQuickAccess": "从快速访问中移除",
|
||||
"ui.removeFromToolbar": "从工具栏移除",
|
||||
"ui.removeSection": "移除剖面",
|
||||
"ui.render": "渲染",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
| assets/locales/pending/agent-http-consent.json | SolidCraft contributors | Original endpoint-specific HTTP consent messages and translations | MIT OR Apache-2.0 |
|
||||
@@ -452,8 +452,14 @@ pub enum AxisRef {
|
||||
SketchAxis { axis: String },
|
||||
/// World X / Y / Z axis through the origin.
|
||||
World { axis: String },
|
||||
/// Any line in the sketch plane, in world coordinates.
|
||||
Line { origin: Position, dir: Dir },
|
||||
/// Any line in the sketch plane, in world coordinates. The origin's coordinates are length
|
||||
/// expressions, so the axis follows parameter changes.
|
||||
Line {
|
||||
#[serde(with = "point_expr")]
|
||||
#[walk(placement)]
|
||||
origin: PointExpr,
|
||||
dir: Dir,
|
||||
},
|
||||
/// An edge or round face of the model (a straight or circular edge, a cylinder, cone or
|
||||
/// torus face), found again on each evaluation: [`crate::construct::model_axis`].
|
||||
Model { geo: GeoRef },
|
||||
@@ -1044,6 +1050,8 @@ pub enum FeatureKind {
|
||||
Split {
|
||||
body: BodyName,
|
||||
plane: PlaneRef,
|
||||
// The tool face is picked (and named), so the split follows it (#11674).
|
||||
#[walk(picks_faces)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tool: Option<FaceAt>,
|
||||
/// A straight line of this sketch as the tool (#1552): the plane through it along the
|
||||
|
||||
+252
-60
@@ -659,7 +659,7 @@ impl ModelState {
|
||||
}
|
||||
|
||||
/// A digest of everything later features can read: bodies (by content identity), sketches,
|
||||
/// the body counter, threads and sheet bodies.
|
||||
/// the body counter, threads, sheet bodies and construction geometry.
|
||||
fn digest(&self) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for b in &self.bodies {
|
||||
@@ -701,12 +701,19 @@ impl ModelState {
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
// Construction axes, planes and points so far: features find them here by name, and a
|
||||
// result taken from the cache brings back the ones it was made with, so one moved past
|
||||
// a feature must change the key (#9544). A state without any hashes as before.
|
||||
if !self.construct.is_empty() {
|
||||
serde_json::to_string(&self.construct).unwrap_or_default().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||||
/// made or changed take that key, sketches it made a digest of their solved geometry (see
|
||||
/// [`quantize`]); the rest keep theirs.
|
||||
/// `f` must have gone through [`naming_feature`]: face naming reads no parameter values.
|
||||
fn stamp(&mut self, doc: &Document, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
|
||||
@@ -1084,8 +1091,8 @@ pub struct Model {
|
||||
pub no_cache: bool,
|
||||
/// Results kept besides the cache ([`Model::keep`]).
|
||||
kept: KeptSlot,
|
||||
/// The imported texts' digests the last `evaluate` fingerprinted the features with.
|
||||
step_digests: StepDigests,
|
||||
/// Embedded imports' content digests from the last evaluation.
|
||||
import_digests: ImportDigests,
|
||||
/// The address of the shared document whose fingerprints the results were last given in
|
||||
/// full, with [`fingerprints_digest`] of them then ([`Model::pinned_prefix`]). Only an
|
||||
/// address: a reference to the document would make every in-place edit of it a copy.
|
||||
@@ -1124,7 +1131,7 @@ fn param_names(f: &Feature) -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &StepDigests) -> u64 {
|
||||
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &ImportDigests) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
@@ -1145,34 +1152,73 @@ fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, V
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The digests of the Import features' STEP texts, by feature id, with the text each was taken
|
||||
/// from. Every evaluation fingerprints every feature, and writing out a design's imported files
|
||||
/// each time made every command as slow as the files are large: a text is shared (`Arc`), so
|
||||
/// its digest is taken once and kept while the feature holds that same text.
|
||||
/// Content digests for embedded imports. Shared, unchanged arrays/text are read once;
|
||||
/// feature metadata is still fingerprinted on every evaluation. Rebuilding this map prunes
|
||||
/// removed features and never trusts an address without retaining and checking its Arc.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct StepDigests(std::collections::HashMap<u64, (Arc<str>, u64)>);
|
||||
struct ImportDigests {
|
||||
steps: std::collections::HashMap<u64, (Arc<str>, u64)>,
|
||||
meshes: std::collections::HashMap<u64, Vec<MeshDigest>>,
|
||||
}
|
||||
|
||||
impl StepDigests {
|
||||
/// The digests of `doc`'s imported texts, reusing `known` for texts it has seen.
|
||||
fn of(doc: &Document, known: &StepDigests) -> StepDigests {
|
||||
let texts = doc.features.iter().filter_map(|f| match &f.kind {
|
||||
FeatureKind::Import { step, .. } => Some((f.id, step)),
|
||||
_ => None,
|
||||
});
|
||||
StepDigests(
|
||||
texts
|
||||
.map(|(id, step)| {
|
||||
let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step));
|
||||
(id, (step.clone(), digest))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
#[derive(Clone, Debug)]
|
||||
struct MeshDigest {
|
||||
positions: Arc<[f64]>,
|
||||
triangles: Arc<[u32]>,
|
||||
digest: u64,
|
||||
}
|
||||
|
||||
impl MeshDigest {
|
||||
fn matches(&self, mesh: &crate::MeshData) -> bool {
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportDigests {
|
||||
fn of(doc: &Document, known: &ImportDigests) -> ImportDigests {
|
||||
let mut out = ImportDigests::default();
|
||||
for f in &doc.features {
|
||||
match &f.kind {
|
||||
FeatureKind::Import { step, .. } => {
|
||||
let digest = known.get(f.id, step).unwrap_or_else(|| step_text_digest(step));
|
||||
out.steps.insert(f.id, (step.clone(), digest));
|
||||
}
|
||||
FeatureKind::MeshImport { meshes, .. } => {
|
||||
let digests = meshes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, mesh)| {
|
||||
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
|
||||
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
|
||||
})
|
||||
.collect();
|
||||
out.meshes.insert(f.id, digests);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The digest of `step`, when it is the text feature `id` was last seen with.
|
||||
fn get(&self, id: u64, step: &Arc<str>) -> Option<u64> {
|
||||
self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
|
||||
self.steps.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
|
||||
}
|
||||
|
||||
fn mesh(&self, id: u64, index: usize, mesh: &crate::MeshData) -> Option<u64> {
|
||||
self.meshes.get(&id)?.get(index).filter(|cached| cached.matches(mesh)).map(|cached| cached.digest)
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.mesh_digested += 1);
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
mesh.positions.len().hash(&mut h);
|
||||
for coordinate in mesh.positions.iter() {
|
||||
coordinate.to_bits().hash(&mut h);
|
||||
}
|
||||
mesh.triangles.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn step_text_digest(text: &str) -> u64 {
|
||||
@@ -1208,12 +1254,12 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
|
||||
}
|
||||
|
||||
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported
|
||||
/// file's text by its digest.
|
||||
/// file's text or mesh payload by its content digest.
|
||||
/// Hash [`feature_json`] of `f` into `h`. Every evaluation fingerprints every feature, and in a
|
||||
/// design built command by command almost all of them are the same as last time: the text of a
|
||||
/// feature is kept (by id) with the feature it was written from, and written again only when the
|
||||
/// feature differs. An Import's text also depends on its STEP digest, so it is written each time
|
||||
/// as before.
|
||||
/// feature differs. Embedded imports also depend on their content digests, so their small
|
||||
/// metadata-and-digest representation is written each time without comparing large arrays.
|
||||
///
|
||||
/// The texts are shared by every thread: a command evaluated ahead runs on a new worker thread
|
||||
/// each time (the desktop's deferred commands and delete checks, the control channel's ahead
|
||||
@@ -1221,10 +1267,10 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
|
||||
/// feature again. The lock is held only to look up or store an entry, never while a feature is
|
||||
/// compared or written; an entry is the same whichever thread wrote it, since the text is a
|
||||
/// function of the feature alone.
|
||||
fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
|
||||
fn hash_feature_json(f: &Feature, steps: &ImportDigests, h: &mut impl Hasher) {
|
||||
type Written = std::collections::HashMap<u64, Arc<(Feature, String)>>;
|
||||
static WRITTEN: std::sync::LazyLock<std::sync::Mutex<Written>> = std::sync::LazyLock::new(Default::default);
|
||||
if matches!(f.kind, FeatureKind::Import { .. }) {
|
||||
if matches!(f.kind, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
|
||||
feature_json(f, steps).hash(h);
|
||||
return;
|
||||
}
|
||||
@@ -1247,7 +1293,21 @@ fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
|
||||
written.insert(f.id, entry);
|
||||
}
|
||||
|
||||
fn feature_json(f: &Feature, steps: &StepDigests) -> String {
|
||||
fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
|
||||
if matches!(&f.kind, FeatureKind::MeshImport { .. }) {
|
||||
let mut g = f.clone();
|
||||
let mut digests = Vec::new();
|
||||
if let FeatureKind::MeshImport { meshes, .. } = &mut g.kind {
|
||||
for (index, mesh) in meshes.iter_mut().enumerate() {
|
||||
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
|
||||
mesh.positions = Arc::from([]);
|
||||
mesh.triangles = Arc::from([]);
|
||||
}
|
||||
}
|
||||
// Keep the complete definition (including mesh order, names and colors) without
|
||||
// writing the large numeric arrays out again. Equal content in fresh Arcs hashes alike.
|
||||
return serde_json::to_string(&(g, digests)).unwrap_or_default();
|
||||
}
|
||||
if let FeatureKind::Import { step, .. } = &f.kind {
|
||||
let mut g = f.clone();
|
||||
if let FeatureKind::Import { step: text, .. } = &mut g.kind {
|
||||
@@ -1322,6 +1382,12 @@ impl Document {
|
||||
pub fn evaluation_values(&self) -> BTreeMap<String, Value> {
|
||||
evaluation_values(self).0
|
||||
}
|
||||
|
||||
/// A placement point read as the features read it: the parameters, and a bare number
|
||||
/// beside them in the design's length unit (#9015).
|
||||
pub fn eval_point(&self, p: &crate::PointExpr) -> Result<Vec3> {
|
||||
point(&self.evaluation_values(), p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||||
@@ -1344,7 +1410,7 @@ fn chain_fingerprint(
|
||||
f: &Feature,
|
||||
prev: u64,
|
||||
rolled_back: bool,
|
||||
steps: &StepDigests,
|
||||
steps: &ImportDigests,
|
||||
st: Option<&ModelState>,
|
||||
) -> u64 {
|
||||
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
|
||||
@@ -1422,7 +1488,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||||
for f in doc.features.iter().filter(|f| {
|
||||
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||||
}) {
|
||||
fingerprint(doc, 0, f, vals, false, &StepDigests::default()).hash(&mut h);
|
||||
fingerprint(doc, 0, f, vals, false, &ImportDigests::default()).hash(&mut h);
|
||||
}
|
||||
doc.units.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
@@ -1504,6 +1570,9 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
||||
#[derive(Default)]
|
||||
struct PlacementReads {
|
||||
all: bool,
|
||||
/// A body named that `st` lacks: a further placement's (`Block (Part:2)`), found through
|
||||
/// its occurrence (#9957), or gone.
|
||||
unlisted: bool,
|
||||
components: std::collections::BTreeSet<u64>,
|
||||
paths: Vec<Vec<u64>>,
|
||||
bodies: std::collections::BTreeSet<String>,
|
||||
@@ -1542,14 +1611,17 @@ impl PlacementReads {
|
||||
}
|
||||
}
|
||||
|
||||
/// The component of body `name` in `st`; every component when the body is not there (a
|
||||
/// projection then takes the body nearest its point, wherever that is).
|
||||
/// The component of body `name` in `st`; every component and every placement when the body
|
||||
/// is not there (a further placement's body, or the body nearest its point, wherever that is).
|
||||
fn body(&mut self, doc: &Document, st: Option<&ModelState>, name: &str) {
|
||||
match st.and_then(|st| st.body(name)) {
|
||||
Some(b) => {
|
||||
self.components.insert(doc.model_body_component(b));
|
||||
}
|
||||
None => self.all = true,
|
||||
None => {
|
||||
self.all = true;
|
||||
self.unlisted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1785,6 +1857,9 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
|
||||
}
|
||||
}
|
||||
hash_placed_bodies(doc, st, &reads.bodies, &inverse, &mut h);
|
||||
if reads.unlisted {
|
||||
placements_hash(doc).hash(&mut h);
|
||||
}
|
||||
// Legacy and component-wide reads still need the conservative offset dependency. Body
|
||||
// reads above already include just their own offsets, in the frame that evaluates them.
|
||||
if reads.all || !reads.components.is_empty() || !reads.paths.is_empty() {
|
||||
@@ -1922,7 +1997,7 @@ impl Model {
|
||||
}
|
||||
// The fingerprints from the first stored sketch on.
|
||||
let vals = evaluation_values(doc).0;
|
||||
let steps = StepDigests::of(doc, &self.step_digests);
|
||||
let steps = ImportDigests::of(doc, &self.import_digests);
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
|
||||
for (i, f) in doc.features.iter().enumerate().skip(first) {
|
||||
@@ -1972,7 +2047,7 @@ impl Model {
|
||||
return false;
|
||||
}
|
||||
let vals = evaluation_values(doc).0;
|
||||
let steps = StepDigests::of(doc, &self.step_digests);
|
||||
let steps = ImportDigests::of(doc, &self.import_digests);
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let mut prev = 0;
|
||||
// The model each feature starts from (which components the bodies it names are in).
|
||||
@@ -2037,7 +2112,7 @@ impl Model {
|
||||
let previous_context = self.context;
|
||||
let mut rolled_results = std::mem::take(&mut self.rolled_results);
|
||||
self.context = ctx;
|
||||
self.step_digests = StepDigests::of(doc, &self.step_digests);
|
||||
self.import_digests = ImportDigests::of(doc, &self.import_digests);
|
||||
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
|
||||
// Each feature's component, for `Document::legacy_body_component` on every body after
|
||||
// every feature without searching the timeline for each (the first feature with an id).
|
||||
@@ -2072,7 +2147,7 @@ impl Model {
|
||||
};
|
||||
// Opening was cancelled: the rest is skipped and the result discarded.
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
|
||||
prev_fp = fp;
|
||||
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
|
||||
// A state the saved model did not keep is evaluated again from the one before it; the
|
||||
@@ -2148,7 +2223,7 @@ impl Model {
|
||||
}
|
||||
// The same feature on the same inputs: its earlier result.
|
||||
let key =
|
||||
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
|
||||
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.import_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
|
||||
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
|
||||
if !self.no_cache
|
||||
&& let Some(c) = self
|
||||
@@ -2283,7 +2358,9 @@ impl Model {
|
||||
}
|
||||
_ => mats,
|
||||
};
|
||||
next.stamp(doc, &state, f, key, mats);
|
||||
// A revolve axis origin that follows parameters names its faces from its values.
|
||||
let named = naming_feature(&vals, f);
|
||||
next.stamp(doc, &state, &named, key, mats);
|
||||
// Evaluated again only for what came before it (a mirror moved by a drag).
|
||||
if let Some(old) = self.results.get(i).filter(|old| old.id == f.id) {
|
||||
let made = old.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| (b.name.as_str(), b));
|
||||
@@ -2502,7 +2579,7 @@ impl Model {
|
||||
pub(crate) fn refingerprint_from(&mut self, doc: &Document, start: usize) {
|
||||
self.placement_inputs = None;
|
||||
self.fingerprinted = None;
|
||||
self.step_digests = StepDigests::of(doc, &self.step_digests);
|
||||
self.import_digests = ImportDigests::of(doc, &self.import_digests);
|
||||
let start = start.min(self.results.len()).min(doc.features.len());
|
||||
if start == self.results.len().min(doc.features.len()) {
|
||||
return;
|
||||
@@ -2514,7 +2591,7 @@ impl Model {
|
||||
// Each feature's fingerprint reads the model it starts from, as evaluating does.
|
||||
let mut before = last.map_or_else(|| Arc::new(ModelState::default()), |r| r.state.clone());
|
||||
for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate().skip(start) {
|
||||
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests, Some(&before));
|
||||
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.import_digests, Some(&before));
|
||||
r.fingerprint = prev;
|
||||
before = r.state.clone();
|
||||
}
|
||||
@@ -3600,10 +3677,11 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
|
||||
/// The faces of the model near `p`: for each, how far `p` is from it, the outward normal there
|
||||
/// (the surface's own normal for planes, cylinders and spheres, turned the way the face looks out
|
||||
/// of its body; the nearest mesh facet's otherwise) and, for a plane, how far `p` is from the
|
||||
/// plane itself. Only faces within `reach` of `p`, nearest first.
|
||||
fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option<f64>)> {
|
||||
/// plane itself. Only faces within `reach` of `p`, nearest first, of the bodies in `scope`
|
||||
/// (every body when it is empty).
|
||||
fn faces_through(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
|
||||
let mut out: Vec<(f64, Vec3, Option<f64>)> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh() && in_hole_scope(scope, b)) {
|
||||
let m = b.mesh();
|
||||
// The nearest facet of each face of this body.
|
||||
let mut near: BTreeMap<u32, (f64, Vec3)> = BTreeMap::new();
|
||||
@@ -3649,13 +3727,29 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
|
||||
out
|
||||
}
|
||||
|
||||
/// The faces near `p` a hole placed there goes by (as [`faces_through`]): those of the bodies in
|
||||
/// `scope`, the hole's own, when one of them passes within `on` of `p`, else those of every body.
|
||||
/// Where a plate's face touches another body both faces pass through `p`, and a hole that drills
|
||||
/// the plate goes by the plate's face, never the other body's (#12066). A hole placed on another
|
||||
/// body's face, to drill through it into its own body behind, still goes by that face.
|
||||
fn hole_faces_through(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
|
||||
if !scope.is_empty() {
|
||||
let own = faces_through(st, p, reach, scope);
|
||||
if own.first().is_some_and(|(d, _, _)| *d <= on) {
|
||||
return own;
|
||||
}
|
||||
}
|
||||
faces_through(st, p, reach, &[])
|
||||
}
|
||||
|
||||
/// The way a hole placed at `p` drills: into the material, against the outward normal of the
|
||||
/// face `p` lies on. A point on no face (a planar face's centroid outside it, say) takes the
|
||||
/// nearest planar face whose plane it lies in, else the face nearest it within its body's pick
|
||||
/// tolerance. None when no face is near.
|
||||
pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
|
||||
/// tolerance. None when no face is near. Where the faces of several bodies pass through `p`, those
|
||||
/// of the bodies in `scope` (the bodies the hole drills; empty for every body) decide.
|
||||
pub fn hole_direction_at(st: &ModelState, p: Vec3, scope: &[String]) -> Option<Vec3> {
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let faces = faces_through(st, p, f64::INFINITY);
|
||||
let faces = hole_faces_through(st, p, f64::INFINITY, size * 1e-4, scope);
|
||||
if let Some((d, n, _)) = faces.first()
|
||||
&& *d <= size * 1e-4
|
||||
{
|
||||
@@ -3672,10 +3766,13 @@ pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
|
||||
/// starts on (as designs saved before format 18 can say: their direction was taken from any face
|
||||
/// whose plane passed near the point, such as the other side of a thin plate or a face of another
|
||||
/// body at the same height), it is mirrored in the face it starts on, so it goes in at the same
|
||||
/// angle. A direction that enters a face there, or a spot on no face, is kept.
|
||||
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 {
|
||||
/// angle. A direction that enters a face there, or a spot on no face, is kept. Where the faces of
|
||||
/// the hole's own bodies (`scope`, see [`hole_drill_scope`]) pass through the spot, only they
|
||||
/// count: a hole that names the plate it drills, saved with the direction into a body touching
|
||||
/// the plate there, drills the plate (#12066).
|
||||
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 {
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let faces = faces_through(st, p, size * 1e-5);
|
||||
let faces = hole_faces_through(st, p, size * 1e-5, size * 1e-5, scope);
|
||||
if faces.is_empty() || faces.iter().any(|(_, n, _)| n.dot(dir) < -1e-9) {
|
||||
return dir;
|
||||
}
|
||||
@@ -4147,10 +4244,26 @@ fn blend_body(
|
||||
Ok((Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]), used, tapered))
|
||||
}
|
||||
|
||||
/// `f` as the naming of its faces reads it: a revolve's explicit axis origin written as the values
|
||||
/// it has now (the naming has no parameter values to read an expression with).
|
||||
fn naming_feature<'a>(vals: &BTreeMap<String, Value>, f: &'a Feature) -> std::borrow::Cow<'a, Feature> {
|
||||
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &f.kind
|
||||
&& let Ok(o) = point(vals, origin)
|
||||
{
|
||||
let mut g = f.clone();
|
||||
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &mut g.kind {
|
||||
*origin = [format!("{} mm", o.x).into(), format!("{} mm", o.y).into(), format!("{} mm", o.z).into()];
|
||||
}
|
||||
return std::borrow::Cow::Owned(g);
|
||||
}
|
||||
std::borrow::Cow::Borrowed(f)
|
||||
}
|
||||
|
||||
/// The revolve axis in the sketch's plane coordinates (a point and a direction). `st` is the model
|
||||
/// an axis on an edge or face of it is found in; `construct` gives a construction axis, by name, in
|
||||
/// the sketch's 3D frame.
|
||||
/// the sketch's 3D frame; `vals` are the parameter values an explicit axis's origin is read with.
|
||||
pub(crate) fn revolve_axis(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
ss: &SolvedSketch,
|
||||
axis: &AxisRef,
|
||||
st: &ModelState,
|
||||
@@ -4185,7 +4298,7 @@ pub(crate) fn revolve_axis(
|
||||
}
|
||||
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, origin.0, dir.0),
|
||||
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, point(vals, origin)?, dir.0),
|
||||
AxisRef::Model { geo } => {
|
||||
let (o, d) = crate::construct::model_axis(st, geo)?;
|
||||
line_in_sketch_plane(ss, o, d)
|
||||
@@ -4294,6 +4407,13 @@ fn hole_scope(f: &Feature) -> &[String] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies whose faces decide which way a hole placed at a position drills, where they pass
|
||||
/// through it: the bodies it cuts when it names them, so a body touching the face there can't
|
||||
/// turn it round (#12066). Empty (every body) otherwise, and for `["*"]`.
|
||||
fn hole_drill_scope(f: &Feature) -> &[String] {
|
||||
if f.participants.iter().any(|b| b == "*") { &[] } else { &f.participants }
|
||||
}
|
||||
|
||||
fn in_hole_scope(scope: &[String], b: &ModelBody) -> bool {
|
||||
scope.is_empty() || scope.contains(&b.name)
|
||||
}
|
||||
@@ -4319,7 +4439,7 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir, &[]);
|
||||
let at = with_references(vals, st, f, at, dir, true)?;
|
||||
let dir = hole_dir_into_face(st, at, dir);
|
||||
let dir = hole_dir_into_face(st, at, dir, hole_drill_scope(f));
|
||||
return Ok((vec![hole_on_surface(st, at, dir, &[])], dir));
|
||||
}
|
||||
};
|
||||
@@ -4367,7 +4487,7 @@ fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature)
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let p = single_hole_position(vals, st, position, at, points)?;
|
||||
let p = if hole_references(f).is_empty() { p } else { with_references(vals, st, f, hole_on_surface(st, p, dir, &[]), dir, false)? };
|
||||
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir)))
|
||||
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir, hole_drill_scope(f))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5291,8 +5411,9 @@ fn feature_tools_with_constructor(
|
||||
f,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) =
|
||||
revolve_axis(&ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
|
||||
let (o, d) = revolve_axis(vals, &ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| {
|
||||
construction_axis_in(Some(doc), st, name, f.component)
|
||||
})?;
|
||||
// To Object is in this feature's component frame: the model as seen from there.
|
||||
let tst = if to.is_some() { state_in_frame(doc, st, f.component)? } else { std::borrow::Cow::Borrowed(st) };
|
||||
let angles = if to.is_some() {
|
||||
@@ -6614,6 +6735,24 @@ fn replay(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A moved copy is copied again as a body: each instance is the copy the Move made,
|
||||
// placed by the pattern (#5931).
|
||||
if let FeatureKind::Move { copy: true, .. } = &src.kind {
|
||||
let made: Vec<ModelBody> = st.bodies.iter().filter(|b| b.feature == src.id).cloned().collect();
|
||||
if made.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{} made no copy to repeat", src.name)));
|
||||
}
|
||||
let mut output = 0;
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
for mb in &made {
|
||||
let moved = moved_copy(mb, kernel::transform_matrix(&mb.body, *m)?, first + k + 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::traced_new(name, moved, f.id));
|
||||
output += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (op, targets) = feature_op(src);
|
||||
// A primitive's or a hole's targets are its participants (#1108): the copies change
|
||||
// those bodies only, like the feature itself (#2755).
|
||||
@@ -7131,6 +7270,14 @@ fn eval_feature(
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
@@ -8368,6 +8515,51 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
Ok(Cow::Owned(out))
|
||||
}
|
||||
|
||||
/// `framed` (the model `st` in the frame that `frame` places in the world) with the further
|
||||
/// placements `names` names (`Block (Part:2)`), where they are in the world. A component's model
|
||||
/// holds its first placement only, so a sketch projecting a copy's face found no body of that
|
||||
/// name and took the first placement's (#9957). Only the named ones are placed: placing every
|
||||
/// body of an assembly for each sketch made sketch edits as slow as the design is big.
|
||||
pub fn with_placed_copies<'a>(
|
||||
doc: &Document,
|
||||
st: &ModelState,
|
||||
framed: std::borrow::Cow<'a, ModelState>,
|
||||
frame: &Mat,
|
||||
names: &[&str],
|
||||
) -> std::borrow::Cow<'a, ModelState> {
|
||||
let missing: Vec<&str> = names.iter().copied().filter(|n| !n.is_empty() && framed.body(n).is_none()).collect();
|
||||
if missing.is_empty() || doc.occurrences.is_empty() {
|
||||
return framed;
|
||||
}
|
||||
let Some(inv) = crate::mat_inverse(frame) else { return framed };
|
||||
let mut by_component: std::collections::HashMap<u64, Vec<&ModelBody>> = std::collections::HashMap::new();
|
||||
for (b, owner) in st.bodies.iter().zip(doc.model_body_components(st)) {
|
||||
by_component.entry(owner).or_default().push(b);
|
||||
}
|
||||
let world = crate::WorldNames::new(doc, st);
|
||||
let mut placed: Vec<ModelBody> = Vec::new();
|
||||
for (comp, path, m) in doc.placements() {
|
||||
for b in by_component.get(&comp).into_iter().flatten() {
|
||||
let name = world.name(&b.name, comp, &path);
|
||||
if !missing.contains(&name.as_str()) || placed.iter().any(|p| p.name == name) {
|
||||
continue;
|
||||
}
|
||||
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
if let Ok(mut copy) = st.body_in_frame(b, &crate::mat_mul(&inv, &bm)) {
|
||||
copy.name = name;
|
||||
copy.source_name = Some(b.definition_name().to_string());
|
||||
placed.push(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
if placed.is_empty() {
|
||||
return framed;
|
||||
}
|
||||
let mut out = framed.into_owned();
|
||||
out.bodies.extend(placed);
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The model placed in the world: each component's bodies, sketches, threads and construction
|
||||
/// geometry through every occurrence of it (bodies of the first placement keep their names;
|
||||
/// further instances get the occurrence path in brackets). Without components this is the model
|
||||
|
||||
@@ -16,7 +16,7 @@ use solidcraft_sketch::Sketch;
|
||||
use crate::eval::{PlacementReads, body_world, hash_placement, placement_digest};
|
||||
use crate::*;
|
||||
|
||||
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
|
||||
/// Work counted on this thread: bodies moved, STEP/mesh contents digested, joint faces and bodies
|
||||
/// measured, bodies searched for by name, features searched for by id, bodies a cut (or
|
||||
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on,
|
||||
/// bodies whose faces a tapped hole read for its wall, intersects run to find a pattern's copies
|
||||
@@ -26,6 +26,7 @@ use crate::*;
|
||||
pub(super) struct Counts {
|
||||
pub moved: usize,
|
||||
pub digested: usize,
|
||||
pub mesh_digested: usize,
|
||||
pub joint_faces: usize,
|
||||
pub measures: usize,
|
||||
pub body_searches: usize,
|
||||
@@ -42,6 +43,7 @@ thread_local! {
|
||||
Cell::new(Counts {
|
||||
moved: 0,
|
||||
digested: 0,
|
||||
mesh_digested: 0,
|
||||
joint_faces: 0,
|
||||
measures: 0,
|
||||
body_searches: 0,
|
||||
@@ -1637,3 +1639,123 @@ fn tools_are_apart_only_when_they_neither_meet_nor_nest() {
|
||||
// A rod in the tube's bore, clear of its wall, is apart from it.
|
||||
assert!(super::tools_clearly_apart(&[tube, rod(0.0, 0.0, 3.0)]));
|
||||
}
|
||||
|
||||
fn imported_meshes() -> Document {
|
||||
let mut doc = Document::new("reference meshes");
|
||||
let comp = doc.add_component(Some("Part"), 0).unwrap();
|
||||
let meshes = (0..2)
|
||||
.map(|i| MeshData {
|
||||
name: format!("Mesh{i}"),
|
||||
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
|
||||
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
|
||||
color: Some([0.2, 0.4, 0.6]),
|
||||
})
|
||||
.collect();
|
||||
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
|
||||
doc.feature_mut(id).unwrap().component = comp;
|
||||
doc
|
||||
}
|
||||
|
||||
fn meshes_mut(doc: &mut Document) -> &mut Vec<MeshData> {
|
||||
let FeatureKind::MeshImport { meshes, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
|
||||
meshes
|
||||
}
|
||||
|
||||
fn same_as_fresh(doc: &Document, model: &Model) {
|
||||
let mut fresh = Model::new();
|
||||
fresh.no_cache = true;
|
||||
fresh.evaluate(doc);
|
||||
assert_eq!(model.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>(), fresh.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>());
|
||||
let (actual, expected) = (model.state(), fresh.state());
|
||||
assert_eq!(actual.bodies.len(), expected.bodies.len());
|
||||
for (a, b) in actual.bodies.iter().zip(&expected.bodies) {
|
||||
assert_eq!(a.name, b.name);
|
||||
assert_eq!(a.mesh().positions, b.mesh().positions);
|
||||
assert_eq!(a.mesh().triangles, b.mesh().triangles);
|
||||
assert_eq!(a.body.color(), b.body.color());
|
||||
}
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
|
||||
let mut doc = imported_meshes();
|
||||
let mut model = Model::new();
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
|
||||
for k in 1..5 {
|
||||
doc = doc.clone(); // Undo/preview copies share the arrays, not the document itself.
|
||||
doc.occurrences[0].transform = rigid(Vec3::new(f64::from(k), 2.0, 3.0), Vec3::ZERO, Vec3::Z, 0.1 * f64::from(k));
|
||||
assert_eq!(
|
||||
counted(|| {
|
||||
model.evaluate(&doc);
|
||||
assert!(model.is_current(&doc));
|
||||
})
|
||||
.mesh_digested,
|
||||
0
|
||||
);
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
same_as_fresh(&doc, &model);
|
||||
}
|
||||
let saved = serde_json::to_string(&doc).unwrap();
|
||||
let reopened: Document = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(counted(|| model.evaluate(&reopened)).mesh_digested, 2, "new array identities must be checked");
|
||||
assert_eq!(model.last_recomputed, 0, "equal content has equal fingerprints");
|
||||
same_as_fresh(&reopened, &model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_payload_edits_invalidate_the_cache_and_undo_restores_the_geometry() {
|
||||
let mut doc = imported_meshes();
|
||||
let original = doc.clone();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let fingerprint = model.results[0].fingerprint;
|
||||
Arc::make_mut(&mut meshes_mut(&mut doc)[0].positions)[3] = 5.0;
|
||||
assert!(!model.is_current(&doc));
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
|
||||
assert_ne!(model.results[0].fingerprint, fingerprint);
|
||||
same_as_fresh(&doc, &model);
|
||||
let edited = doc.clone();
|
||||
Arc::make_mut(&mut meshes_mut(&mut doc)[0].triangles).swap(0, 1);
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
|
||||
same_as_fresh(&doc, &model);
|
||||
for restored in [&original, &edited, &original] {
|
||||
model.evaluate(restored);
|
||||
same_as_fresh(restored, &model);
|
||||
}
|
||||
assert_eq!(model.results[0].fingerprint, fingerprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_metadata_and_order_remain_part_of_the_fingerprint() {
|
||||
let mut doc = imported_meshes();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
for edit in 0..7 {
|
||||
let before = model.results[0].fingerprint;
|
||||
match edit {
|
||||
0 => meshes_mut(&mut doc)[0].name = "Renamed".into(),
|
||||
1 => meshes_mut(&mut doc)[0].color = Some([0.8, 0.1, 0.3]),
|
||||
2 => doc.features[0].name = "Renamed feature".into(),
|
||||
3 => meshes_mut(&mut doc).swap(0, 1),
|
||||
4 => {
|
||||
meshes_mut(&mut doc).pop();
|
||||
}
|
||||
5 => {
|
||||
let mesh = meshes_mut(&mut doc)[0].clone();
|
||||
meshes_mut(&mut doc).push(MeshData { name: "Added".into(), ..mesh });
|
||||
}
|
||||
_ => {
|
||||
let FeatureKind::MeshImport { file, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
|
||||
*file = "renamed.stl".into();
|
||||
}
|
||||
}
|
||||
assert!(!model.is_current(&doc));
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
if edit < 3 || edit == 6 {
|
||||
assert_eq!(work.mesh_digested, 0, "metadata alone: {edit}");
|
||||
}
|
||||
assert_ne!(model.results[0].fingerprint, before, "edit {edit}");
|
||||
same_as_fresh(&doc, &model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
let mut tools = Vec::new();
|
||||
for n in bodies {
|
||||
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
|
||||
let b = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
// Kept face, offset and edge walls. Carry that history so a sketch can attach
|
||||
// to the solid's faces and follow the thickness (#7641).
|
||||
let thickened = kernel::thicken(&b.body, t, *symmetric)?;
|
||||
tools.push(Traced::step(thickened, &[&b.traced()]));
|
||||
}
|
||||
if tools.is_empty() {
|
||||
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
|
||||
}
|
||||
apply_op(doc, st, f, tools, *operation, &crate::plain(targets))
|
||||
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep, keep_more, curves, with, keep_with, stitch, extend } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
@@ -308,7 +311,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let (o, d) = revolve_axis(&ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
|
||||
let (o, d) = revolve_axis(vals, &ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
|
||||
construction_axis_in(Some(doc), st, name, f.component)
|
||||
})?;
|
||||
let a = val(vals, angle, Kind::Angle)?;
|
||||
|
||||
@@ -55,7 +55,8 @@ impl FeatureKind {
|
||||
|
||||
/// The body a feature that works on one body's faces names (shell, draft, split, offset,
|
||||
/// delete and replace face, surface offset, the placed plastic features, sheet metal
|
||||
/// conversion, ruled surface, untrim): its face points pick faces of that body.
|
||||
/// conversion, ruled surface, untrim, Split Body's tool face): its face points pick faces of
|
||||
/// that body.
|
||||
pub fn face_body(&self) -> Option<&str> {
|
||||
match self {
|
||||
FeatureKind::Shell { body, .. }
|
||||
@@ -69,7 +70,7 @@ impl FeatureKind {
|
||||
| FeatureKind::SnapFit { body, .. }
|
||||
| FeatureKind::Rest { body, .. } => body.as_deref(),
|
||||
FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceRuled { body, .. } => Some(body.as_ref()),
|
||||
FeatureKind::SurfaceUntrim { face, .. } => Some(face.body.as_ref()),
|
||||
FeatureKind::SurfaceUntrim { face, .. } | FeatureKind::Split { tool: Some(face), .. } => Some(face.body.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2]
|
||||
face points: [3, 3.5, 3.25]
|
||||
profiles: 1 all
|
||||
placement: 9, 9.5, 9.25
|
||||
fillet_constant
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
fillet_chord
|
||||
@@ -86,6 +87,7 @@ construction_plane
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
split
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
|
||||
face points: [0, 0, 0]
|
||||
split_line
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
construction_axis
|
||||
@@ -166,6 +168,7 @@ surface_extrude
|
||||
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
|
||||
surface_revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
|
||||
placement: 9, 9.5, 9.25
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
surface_loft
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Nothing new is saved for holes, but a hole placed on a face that names the bodies it drills
|
||||
//! (`participants`: its `targets`, or the body of another component it was picked on) now finds
|
||||
//! the face it starts on among those bodies' faces, where one of them passes through its position
|
||||
//! (#12066). Where another body touched that face at the hole, older designs could save a
|
||||
//! `direction` taken from the other body's face, pointing out of the drilled body; such a hole cut
|
||||
//! only a shallow pocket. It is now mirrored into the drilled body's face when the design is
|
||||
//! evaluated, as format 18 does for a direction taken from the far side of a thin plate, and
|
||||
//! drills through. Holes whose direction enters one of their own bodies' faces there, holes whose
|
||||
//! own bodies have no face at their position (placed on another body's face to drill through it),
|
||||
//! holes that name no bodies, and holes at several sketch points (already limited to their bodies,
|
||||
//! #2882) build as before. No rewrite is needed. The last release refuses a design with this
|
||||
//! change (the newer format number) instead of building the pocket again.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A revolve's explicit axis `{origin, dir}` can save its origin's coordinates as length
|
||||
//! expressions (`["W / 2", 0, "groove_floor"]`) so the axis follows the parameters; a plain number
|
||||
//! stays a number. An older design has numbers only and reads as before. The last release refuses
|
||||
//! a design with this change and asks for an update, instead of opening it and reading an
|
||||
//! expression as the origin.
|
||||
@@ -92,8 +92,8 @@ pub use eval::{
|
||||
blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at,
|
||||
hole_on_surface, import_body_names, join_pieces_added, map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices,
|
||||
revolve_extent, revolve_extent_for_handle, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, world_state,
|
||||
world_state_reusing,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, with_placed_copies,
|
||||
world_state, world_state_reusing,
|
||||
};
|
||||
pub use eval::{EvalCache, KeptResults};
|
||||
pub use expr::Expr;
|
||||
|
||||
@@ -760,7 +760,7 @@ impl Model {
|
||||
return Err(bad("saved model: features"));
|
||||
}
|
||||
let (vals, param_errors) = evaluation_values(doc);
|
||||
let step_digests = StepDigests::of(doc, &StepDigests::default());
|
||||
let import_digests = ImportDigests::of(doc, &ImportDigests::default());
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
// As `evaluate` digests the context under the mode, so the next evaluation reuses these.
|
||||
let ctx = context_digest(doc, &vals) ^ validation_digest(validation);
|
||||
@@ -776,7 +776,7 @@ impl Model {
|
||||
// A fingerprint reads the state before its feature (where the bodies it takes lie).
|
||||
// After a state the file left out, the saved one is taken: `evaluate` works that state
|
||||
// out, checks the fingerprints after it, and evaluates again from one that differs.
|
||||
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before)) } else { saved };
|
||||
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &import_digests, Some(&before)) } else { saved };
|
||||
if r.get("id").and_then(Json::as_u64) != Some(f.id) || saved != prev {
|
||||
return Err(bad("saved model: the document changed"));
|
||||
}
|
||||
@@ -826,7 +826,7 @@ impl Model {
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
let mut model = Model { results, param_errors, step_digests, context: ctx, frame_bodies, validation, ..Model::default() };
|
||||
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
|
||||
let unkept = model.results.iter().filter(|r| r.unkept).count();
|
||||
if unkept > 0 {
|
||||
// The features whose state the file did not keep, each evaluated again from the state
|
||||
@@ -844,7 +844,7 @@ impl Model {
|
||||
let mut before = Arc::new(ModelState::default());
|
||||
if let Ok(mut cache) = model.cache.lock() {
|
||||
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
|
||||
let local = fingerprint(doc, 0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
cache.put(
|
||||
cache_key(f, r.fingerprint, local, ctx, &before),
|
||||
CachedEval {
|
||||
|
||||
@@ -835,7 +835,7 @@ pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
|
||||
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
|
||||
}
|
||||
|
||||
/// The name of a face new to `f`.
|
||||
/// The name of a face new to `f` (a feature that went through `eval::naming_feature`).
|
||||
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64, curves: &std::cell::OnceCell<Curves>) -> String {
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
match &f.kind {
|
||||
@@ -858,7 +858,10 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
}
|
||||
FeatureKind::Revolve { sketch, axis, .. } => {
|
||||
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
|
||||
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
|
||||
// The origin was written as its values when the feature was evaluated (`naming_feature`).
|
||||
let Ok((o2, d2)) =
|
||||
crate::eval::revolve_axis(&Default::default(), ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0))
|
||||
else {
|
||||
return format!("F{fid}:face:{fi}");
|
||||
};
|
||||
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Expr, Kind, ParamDef, Value};
|
||||
use crate::sheet::rule_references;
|
||||
use crate::walk::Walk;
|
||||
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
|
||||
|
||||
@@ -797,13 +798,14 @@ impl Document {
|
||||
}
|
||||
}
|
||||
let uses = |e: &str| expr::references(e).iter().any(|r| r == name);
|
||||
let rule_uses = |field: &str, e: &str| rule_references(field, e).iter().any(|r| r == name);
|
||||
for r in &self.sheet.rules {
|
||||
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
|
||||
for (field, _, _) in r.fields().into_iter().filter(|(f, e, _)| rule_uses(f, e)) {
|
||||
out.push(ParamUser::Rule { kind: "sheet metal".into(), name: r.name.clone(), field: field.into() });
|
||||
}
|
||||
}
|
||||
for r in &self.plastic.rules {
|
||||
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
|
||||
for (field, _, _) in r.fields().into_iter().filter(|(f, e, _)| rule_uses(f, e)) {
|
||||
out.push(ParamUser::Rule { kind: "plastic".into(), name: r.name.clone(), field: field.into() });
|
||||
}
|
||||
}
|
||||
@@ -855,6 +857,21 @@ impl Document {
|
||||
if self.name_taken(new) {
|
||||
return Err(DocError::Invalid(format!("there is already a parameter `{new}`")));
|
||||
}
|
||||
// Outside a rule's own thickness, `Thickness` is the rule's thickness (#9623): renaming to
|
||||
// it would turn those references into the rule's thickness and change their values.
|
||||
if new == "Thickness" {
|
||||
let uses_old = |field: &str, e: &str| field != "thickness" && expr::references(e).iter().any(|r| r == old);
|
||||
let sheet = self.sheet.rules.iter().flat_map(|r| r.fields().into_iter().map(move |(f, e, _)| ("sheet metal", &r.name, f, e)));
|
||||
let plastic = self.plastic.rules.iter().flat_map(|r| r.fields().into_iter().map(move |(f, e, _)| ("plastic", &r.name, f, e)));
|
||||
let list: Vec<String> =
|
||||
sheet.chain(plastic).filter(|(_, _, f, e)| uses_old(f, e)).map(|(kind, name, f, _)| format!("{kind} rule {name} ({f})")).collect();
|
||||
if !list.is_empty() {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"`{old}` can't be renamed to `Thickness`: {} would read the rule's thickness instead",
|
||||
list.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut n = 0;
|
||||
let text = self.text_param(old).is_some();
|
||||
for p in &mut self.text_params {
|
||||
@@ -964,7 +981,8 @@ impl Document {
|
||||
}
|
||||
// Sheet metal and plastic rules and canvas planes hold expressions too (#4420).
|
||||
let rules = self.sheet.rules.iter_mut().flat_map(|r| r.fields_mut()).chain(self.plastic.rules.iter_mut().flat_map(|r| r.fields_mut()));
|
||||
for (_, e) in rules {
|
||||
// `Thickness` outside a rule's own thickness is the rule's, not the parameter (#9623).
|
||||
for (_, e) in rules.filter(|(field, _)| old != "Thickness" || *field == "thickness") {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
@@ -1025,10 +1043,10 @@ impl Document {
|
||||
}
|
||||
// Sheet metal and plastic features rebuild when their rules' parameters change (#4420).
|
||||
if f.kind.is_sheet() {
|
||||
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
|
||||
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
if f.kind.is_plastic() {
|
||||
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
|
||||
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
@@ -1052,9 +1070,9 @@ impl Document {
|
||||
}
|
||||
// A rule that uses it changes every sheet metal (or plastic) feature: the evaluation
|
||||
// hashes the rules into their fingerprint.
|
||||
let uses = |e: &String| expr::references(e).iter().any(|r| affected.contains(r));
|
||||
let sheet = self.sheet.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
|
||||
let plastic = self.plastic.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
|
||||
let uses = |field: &str, e: &str| rule_references(field, e).iter().any(|r| affected.contains(r));
|
||||
let sheet = self.sheet.rules.iter().any(|r| r.fields().iter().any(|(f, e, _)| uses(f, e)));
|
||||
let plastic = self.plastic.rules.iter().any(|r| r.fields().iter().any(|(f, e, _)| uses(f, e)));
|
||||
let mut out = Vec::new();
|
||||
for f in &self.features {
|
||||
let mut hit = f.param_names.iter().any(|n| affected.contains(n));
|
||||
|
||||
@@ -167,7 +167,7 @@ impl Document {
|
||||
return Err(DocError::Invalid("the plastic thickness must be positive".into()));
|
||||
}
|
||||
let with_t = |e: &str, k: Kind| -> Result<f64> {
|
||||
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
|
||||
let f = |n: &str| if n == "Thickness" { Ok(Value::length(t)) } else { look(n) };
|
||||
let v = expr::eval_with(e, &f)?.to_kind(k)?;
|
||||
if !v.is_finite() || v < 0.0 {
|
||||
return Err(DocError::Invalid(format!("plastic rule `{}`: `{e}` must not be negative", r.name)));
|
||||
|
||||
@@ -278,7 +278,7 @@ fn selection_indices(ss: &SolvedSketch, selection: &ProfileSel, mut notes: Optio
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, profile)| profile.region.contains(*point))
|
||||
.filter(|(_, profile)| profile.contains(*point))
|
||||
.min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area))
|
||||
.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?;
|
||||
indices.push(index);
|
||||
|
||||
@@ -1619,7 +1619,36 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
// where they are) are kept when they take no more curves than the loose fit.
|
||||
let close = refine(fit_segments(&segs, size, true));
|
||||
let plain = |v: &[LinkGeom]| v.iter().all(|g| matches!(g, LinkGeom::Line(..) | LinkGeom::Arc { .. } | LinkGeom::Circle(..)));
|
||||
if plain(&close) && close.len() <= lossy.len() { close } else { lossy }
|
||||
// A loose fit with fewer curves can still be wrong: short faces beside a small round (2 mm
|
||||
// walls each side of an R2 corner) fit within its tolerance as one larger arc. The exact
|
||||
// crossings (on planes and rulings) show it: one of them off every curve of the fit.
|
||||
// Any other curve counts as near, and past a work limit nothing is checked: the loose fit
|
||||
// stands, as before.
|
||||
let tight = size * 1e-4;
|
||||
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
|
||||
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
|
||||
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
|
||||
}
|
||||
|
||||
/// How far `p` lies from a line, arc or circle; None for any other curve.
|
||||
fn plain_distance(g: &LinkGeom, p: Vec2) -> Option<f64> {
|
||||
match *g {
|
||||
LinkGeom::Line(a, b) => {
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 0.0 { ((p - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
Some(p.dist(a.lerp(b, t)))
|
||||
}
|
||||
LinkGeom::Circle(c, r) => Some((p.dist(c) - r).abs()),
|
||||
LinkGeom::Arc { c, a, b } => {
|
||||
let (s0, sw) = ((a - c).angle(), ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU));
|
||||
if ((p - c).angle() - s0).rem_euclid(std::f64::consts::TAU) <= sw + 1e-9 {
|
||||
Some((p.dist(c) - c.dist(a)).abs())
|
||||
} else {
|
||||
Some(p.dist(a).min(p.dist(b)))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
|
||||
|
||||
+13
-2
@@ -284,6 +284,16 @@ impl SheetRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// The design parameters a rule's `field` expression `e` reads: outside the rule's own
|
||||
/// `thickness`, `Thickness` is the rule's thickness (sheet metal and plastic rules alike).
|
||||
pub fn rule_references(field: &str, e: &str) -> Vec<String> {
|
||||
let mut names = expr::references(e);
|
||||
if field != "thickness" {
|
||||
names.retain(|n| n != "Thickness");
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn rule_gap() -> String {
|
||||
"Thickness".into()
|
||||
}
|
||||
@@ -464,7 +474,8 @@ impl Document {
|
||||
.unwrap_or_else(SheetRule::steel)
|
||||
}
|
||||
|
||||
/// Evaluate a rule: `Thickness` in its expressions is the rule's thickness.
|
||||
/// Evaluate a rule: `Thickness` in its expressions is the rule's thickness, even where the
|
||||
/// design has a parameter of that name (#9623); only the rule's `thickness` itself reads it.
|
||||
pub fn rule_values(&self, vals: &std::collections::BTreeMap<String, Value>, r: &SheetRule) -> Result<RuleValues> {
|
||||
self.rule_values_at(vals, r, None)
|
||||
}
|
||||
@@ -481,7 +492,7 @@ impl Document {
|
||||
return Err(DocError::Invalid("the sheet thickness must be more than 0 and less than 10000 mm".into()));
|
||||
}
|
||||
let with_t = |e: &str, k: Kind| -> Result<f64> {
|
||||
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
|
||||
let f = |n: &str| if n == "Thickness" { Ok(Value::length(t)) } else { look(n) };
|
||||
expr::eval_with(e, &f)?.to_kind(k)
|
||||
};
|
||||
let v = RuleValues {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "solidcraft-dynamics"
|
||||
description = "SolidCraft multibody dynamics: rigid bodies, joint constraints, gravity and springs, integrated in time"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Time stepping: classical Runge–Kutta 4 with a fixed step, or Dormand–Prince 5(4) with an
|
||||
//! embedded error estimate, each followed by projection onto the joints (positions, then
|
||||
//! velocities), landing on every output sample.
|
||||
|
||||
use crate::model::{Integrator, MAX_MAGNITUDE, MAX_STEPS, Model, Settings};
|
||||
use crate::system::System;
|
||||
use crate::{DynamicsError, Result, Sample, Stats, Trajectory};
|
||||
|
||||
/// Dormand–Prince 5(4) tableau (the system is autonomous, so the nodes are not needed): stage
|
||||
/// weights, fifth-order weights and the difference between the fifth- and fourth-order weights
|
||||
/// (the error estimate).
|
||||
const DP_A: [[f64; 6]; 7] = [
|
||||
[0.0; 6],
|
||||
[1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[3.0 / 40.0, 9.0 / 40.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0, 0.0, 0.0, 0.0],
|
||||
[19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0, 0.0, 0.0],
|
||||
[9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, -5103.0 / 18656.0, 0.0],
|
||||
[35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0],
|
||||
];
|
||||
const DP_B: [f64; 7] = [35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0, 0.0];
|
||||
const DP_E: [f64; 7] = [
|
||||
35.0 / 384.0 - 5179.0 / 57600.0,
|
||||
0.0,
|
||||
500.0 / 1113.0 - 7571.0 / 16695.0,
|
||||
125.0 / 192.0 - 393.0 / 640.0,
|
||||
-2187.0 / 6784.0 + 92097.0 / 339200.0,
|
||||
11.0 / 84.0 - 187.0 / 2100.0,
|
||||
-1.0 / 40.0,
|
||||
];
|
||||
|
||||
/// `y + Σ k_i · w_i · h`.
|
||||
fn combine(y: &[f64], ks: &[Vec<f64>], weights: &[f64], h: f64) -> Vec<f64> {
|
||||
let mut out = y.to_vec();
|
||||
for (k, w) in ks.iter().zip(weights) {
|
||||
if *w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, d) in out.iter_mut().zip(k) {
|
||||
*o += h * w * d;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn at_time(e: DynamicsError, t: f64) -> DynamicsError {
|
||||
match e {
|
||||
DynamicsError::Singular { .. } => DynamicsError::Singular { time: t },
|
||||
DynamicsError::Diverged { .. } => DynamicsError::Diverged { time: t },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
struct Run<'a> {
|
||||
sys: &'a System,
|
||||
settings: &'a Settings,
|
||||
stats: Stats,
|
||||
}
|
||||
|
||||
impl Run<'_> {
|
||||
fn deriv(&self, y: &[f64]) -> Result<Vec<f64>> {
|
||||
self.sys.accelerations(y).map(|a| a.ydot)
|
||||
}
|
||||
|
||||
/// Normalise, project and check a new state.
|
||||
fn finish_step(&mut self, y: &mut [f64], t: f64) -> Result<()> {
|
||||
self.sys.normalize(y).map_err(|e| at_time(e, t))?;
|
||||
if self.settings.projection {
|
||||
self.sys.project_positions(y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_velocities(y).map_err(|e| at_time(e, t))?;
|
||||
}
|
||||
if !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
|
||||
return Err(DynamicsError::Diverged { time: t });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_step(&mut self, t: f64) -> Result<()> {
|
||||
if self.stats.steps + self.stats.rejected >= MAX_STEPS {
|
||||
return Err(DynamicsError::StepLimit { time: t });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rk4(&mut self, y: &[f64], h: f64) -> Result<Vec<f64>> {
|
||||
let k1 = self.deriv(y)?;
|
||||
let k2 = self.deriv(&combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
|
||||
let k3 = self.deriv(&combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
|
||||
let k4 = self.deriv(&combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
|
||||
Ok(combine(y, &[k1, k2, k3, k4], &[1.0 / 6.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 6.0], h))
|
||||
}
|
||||
|
||||
/// One Dormand–Prince step: the fifth-order state and the scaled error norm.
|
||||
fn dopri(&mut self, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
|
||||
let mut ks: Vec<Vec<f64>> = Vec::with_capacity(7);
|
||||
for a in DP_A.iter().take(6) {
|
||||
let stage = combine(y, &ks, a, h);
|
||||
ks.push(self.deriv(&stage)?);
|
||||
}
|
||||
let y5 = combine(y, &ks, &DP_B, h);
|
||||
ks.push(self.deriv(&y5)?);
|
||||
let err = combine(&vec![0.0; y.len()], &ks, &DP_E, h);
|
||||
let n = y.len().max(1) as f64;
|
||||
let sum: f64 = err
|
||||
.iter()
|
||||
.zip(y.iter().zip(&y5))
|
||||
.map(|(e, (a, b))| {
|
||||
let scale = abs_tol + rel_tol * a.abs().max(b.abs());
|
||||
(e / scale).powi(2)
|
||||
})
|
||||
.sum();
|
||||
Ok((y5, (sum / n).sqrt()))
|
||||
}
|
||||
|
||||
/// Advance `y` from `t` to `t_end` exactly.
|
||||
fn advance(&mut self, y: &mut Vec<f64>, t: &mut f64, t_end: f64, h_next: &mut f64) -> Result<()> {
|
||||
let span = t_end - *t;
|
||||
let eps = 1e-12 * t_end.abs().max(1.0);
|
||||
match self.settings.integrator {
|
||||
Integrator::Rk4 { step } => {
|
||||
// Equal steps no longer than `step` that land on t_end.
|
||||
let n = (span / step).ceil().max(1.0);
|
||||
let h = span / n;
|
||||
for _ in 0..n as usize {
|
||||
self.count_step(*t)?;
|
||||
let mut next = self.rk4(y, h).map_err(|e| at_time(e, *t))?;
|
||||
*t += h;
|
||||
self.finish_step(&mut next, *t)?;
|
||||
*y = next;
|
||||
self.stats.steps += 1;
|
||||
}
|
||||
*t = t_end;
|
||||
}
|
||||
Integrator::Adaptive { rel_tol, abs_tol, min_step, max_step } => {
|
||||
while t_end - *t > eps {
|
||||
self.count_step(*t)?;
|
||||
let remaining = t_end - *t;
|
||||
let h = h_next.clamp(min_step, max_step).min(remaining);
|
||||
let (mut next, err) = self.dopri(y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
|
||||
// The usual controller: 0.9·err^(−1/5), within [0.2, 5].
|
||||
let factor = if err > 0.0 { (0.9 * err.powf(-0.2)).clamp(0.2, 5.0) } else { 5.0 };
|
||||
if err <= 1.0 {
|
||||
let landed = h >= remaining;
|
||||
*t = if landed { t_end } else { *t + h };
|
||||
self.finish_step(&mut next, *t)?;
|
||||
*y = next;
|
||||
self.stats.steps += 1;
|
||||
// Keep the step chosen before a shortened landing step.
|
||||
if !landed || factor < 1.0 {
|
||||
*h_next = h * factor;
|
||||
}
|
||||
} else {
|
||||
self.stats.rejected += 1;
|
||||
if h <= min_step * (1.0 + 1e-9) {
|
||||
return Err(DynamicsError::StepTooSmall { time: *t });
|
||||
}
|
||||
*h_next = (h * factor).max(min_step);
|
||||
}
|
||||
}
|
||||
*t = t_end;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sample(&mut self, y: &[f64], t: f64) -> Result<Sample> {
|
||||
let acc = self.sys.accelerations(y).map_err(|e| at_time(e, t))?;
|
||||
let constraint_error = self.sys.constraint_error(y);
|
||||
self.stats.max_constraint_error = self.stats.max_constraint_error.max(constraint_error);
|
||||
self.stats.redundant_equations = self.stats.redundant_equations.max(acc.row_count().saturating_sub(acc.rank));
|
||||
Ok(Sample { time: t, bodies: self.sys.body_states(y), joints: self.sys.joint_results(y, &acc), energy: self.sys.energy(y), constraint_error })
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulates `model` for `settings.duration`, returning the states at every output interval
|
||||
/// (the first at t = 0, after the start has been projected onto the joints).
|
||||
pub fn simulate(model: &Model, settings: &Settings) -> Result<Trajectory> {
|
||||
settings.validate()?;
|
||||
let sys = System::new(model, settings.baumgarte)?;
|
||||
let mut y = System::initial_state(model)?;
|
||||
// An inconsistent start (frames a little apart, velocities the joints don't allow) is
|
||||
// brought onto the joints first, whether or not later steps project.
|
||||
sys.project_positions(&mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.project_velocities(&mut y).map_err(|e| at_time(e, 0.0))?;
|
||||
let mut run = Run { sys: &sys, settings, stats: Stats::default() };
|
||||
let count = (settings.duration / settings.output_interval).ceil() as usize;
|
||||
let mut samples = Vec::with_capacity(count + 1);
|
||||
samples.push(run.sample(&y, 0.0)?);
|
||||
let mut t = 0.0;
|
||||
let mut h_next = match settings.integrator {
|
||||
Integrator::Rk4 { step } => step,
|
||||
Integrator::Adaptive { min_step, max_step, .. } => (settings.output_interval * 0.1).clamp(min_step, max_step),
|
||||
};
|
||||
for k in 1..=count {
|
||||
let t_end = (k as f64 * settings.output_interval).min(settings.duration);
|
||||
if t_end <= t {
|
||||
continue;
|
||||
}
|
||||
run.advance(&mut y, &mut t, t_end, &mut h_next)?;
|
||||
samples.push(run.sample(&y, t)?);
|
||||
}
|
||||
Ok(Trajectory { samples, stats: run.stats })
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Multibody dynamics for SolidCraft (RFC #9719): rigid bodies with masses and inertias, held
|
||||
//! together by joints, moved by gravity, springs, dampers and loads, integrated in time.
|
||||
//!
|
||||
//! The crate knows nothing of documents or occurrences: a [`Model`] lists bodies (mass,
|
||||
//! inertia about the centre of mass, starting pose and velocities), joints between frames on
|
||||
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball) and force elements;
|
||||
//! [`simulate`] returns a [`Trajectory`] of poses, joint values and rates, joint reactions and
|
||||
//! energies at a fixed output interval. The assembly layer maps occurrences and joints to it.
|
||||
//!
|
||||
//! The formulation is the textbook one for constrained rigid bodies in maximal coordinates
|
||||
//! (Lagrange multipliers, index-1 form, see `system.rs`), stepped by Runge–Kutta 4 or
|
||||
//! Dormand–Prince 5(4) and projected back onto the joints after every step (`integrate.rs`).
|
||||
//!
|
||||
//! Units are the design's: mm, kg, s and radians, so forces are in mN (kg·mm/s²), torques in
|
||||
//! mN·mm, stiffness in N/m (kg/s²) and gravity in mm/s² ([`STANDARD_GRAVITY`]).
|
||||
//!
|
||||
//! Every input is checked ([`Model::validate`], [`Settings::validate`]) and every size and loop
|
||||
//! is capped ([`MAX_BODIES`], [`MAX_STEPS`], …): a bad model is an error, never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod integrate;
|
||||
mod linalg;
|
||||
pub mod math;
|
||||
mod model;
|
||||
mod system;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
pub use integrate::simulate;
|
||||
pub use math::{Mat3, Quat};
|
||||
pub use model::{
|
||||
Anchor, Body, Force, Integrator, Joint, JointKind, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
|
||||
MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, STANDARD_GRAVITY, Settings,
|
||||
};
|
||||
|
||||
/// Why a simulation could not run or stopped.
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum DynamicsError {
|
||||
#[error("invalid model: {0}")]
|
||||
InvalidModel(String),
|
||||
#[error("invalid settings: {0}")]
|
||||
InvalidSettings(String),
|
||||
#[error("too large: {0}")]
|
||||
TooLarge(String),
|
||||
#[error("the joint equations could not be solved at t = {time} s")]
|
||||
Singular { time: f64 },
|
||||
#[error("the motion diverged at t = {time} s")]
|
||||
Diverged { time: f64 },
|
||||
#[error("the step needed at t = {time} s is below the smallest allowed")]
|
||||
StepTooSmall { time: f64 },
|
||||
#[error("the run needs more than the step limit (stopped at t = {time} s)")]
|
||||
StepLimit { time: f64 },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DynamicsError>;
|
||||
|
||||
/// A body's state at one time (world).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BodyState {
|
||||
/// Centre of mass (mm).
|
||||
pub position: Vec3,
|
||||
/// The body's axes (body → world).
|
||||
pub orientation: Quat,
|
||||
/// Velocity of the centre of mass (mm/s).
|
||||
pub velocity: Vec3,
|
||||
/// Angular velocity (rad/s).
|
||||
pub angular_velocity: Vec3,
|
||||
}
|
||||
|
||||
/// A joint's state at one time.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointResult {
|
||||
/// The joint's values ([`JointKind::values`]; radians and mm). A ball joint gives B's turn
|
||||
/// from A as a rotation vector in A's frame.
|
||||
pub values: Vec<f64>,
|
||||
/// Their rates (rad/s, mm/s); a ball joint gives the relative angular velocity in A's frame.
|
||||
pub rates: Vec<f64>,
|
||||
/// The force the joint puts on `b`'s side (world, mN). Joints whose equations repeat
|
||||
/// others' (a planar linkage of revolutes) share their reactions in one of many valid ways.
|
||||
pub force: Vec3,
|
||||
/// The torque the joint puts on `b`'s side about its frame's origin (world, mN·mm).
|
||||
pub torque: Vec3,
|
||||
}
|
||||
|
||||
/// Energies at one time (kg·mm²/s² = µJ).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Energy {
|
||||
pub kinetic: f64,
|
||||
/// Potential energy of gravity, zero at the world origin.
|
||||
pub gravity: f64,
|
||||
/// Energy stored in springs (preload included).
|
||||
pub spring: f64,
|
||||
}
|
||||
|
||||
impl Energy {
|
||||
pub fn total(&self) -> f64 {
|
||||
self.kinetic + self.gravity + self.spring
|
||||
}
|
||||
}
|
||||
|
||||
/// The system at one output time.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sample {
|
||||
/// Time (s).
|
||||
pub time: f64,
|
||||
pub bodies: Vec<BodyState>,
|
||||
pub joints: Vec<JointResult>,
|
||||
pub energy: Energy,
|
||||
/// The largest joint equation error (mm for points; directions are unitless).
|
||||
pub constraint_error: f64,
|
||||
}
|
||||
|
||||
/// What a run did.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Stats {
|
||||
/// Accepted steps.
|
||||
pub steps: usize,
|
||||
/// Rejected steps (adaptive integration).
|
||||
pub rejected: usize,
|
||||
/// The largest joint equation error at an output sample.
|
||||
pub max_constraint_error: f64,
|
||||
/// Joint equations that repeat others (the most at any sample).
|
||||
pub redundant_equations: usize,
|
||||
}
|
||||
|
||||
/// A run's result: the samples at t = 0, one output interval, two, … up to the duration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Trajectory {
|
||||
pub samples: Vec<Sample>,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Dense symmetric positive semi-definite solves for the constraint equations.
|
||||
//!
|
||||
//! The matrix `J M⁻¹ Jᵀ` is singular when joints repeat each other's constraints (every
|
||||
//! revolute of a planar linkage built in 3D also holds the linkage in its plane), but its
|
||||
//! right-hand side is then consistent. A Cholesky factorisation with diagonal pivoting stops at
|
||||
//! the numerical rank and sets the multipliers of the dependent rows to zero, which solves such
|
||||
//! a system exactly.
|
||||
|
||||
/// A square row-major matrix.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Sym {
|
||||
n: usize,
|
||||
data: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Sym {
|
||||
pub(crate) fn zeros(n: usize) -> Sym {
|
||||
Sym { n, data: vec![0.0; n.saturating_mul(n)] }
|
||||
}
|
||||
pub(crate) fn get(&self, r: usize, c: usize) -> f64 {
|
||||
self.data.get(r * self.n + c).copied().unwrap_or(0.0)
|
||||
}
|
||||
pub(crate) fn set(&mut self, r: usize, c: usize, v: f64) {
|
||||
if let Some(x) = self.data.get_mut(r * self.n + c) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
fn swap(&mut self, a: usize, b: usize) {
|
||||
if a == b {
|
||||
return;
|
||||
}
|
||||
let n = self.n;
|
||||
for c in 0..n {
|
||||
self.data.swap(a * n + c, b * n + c);
|
||||
}
|
||||
for r in 0..n {
|
||||
self.data.swap(r * n + a, r * n + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve `A x = b` for a symmetric positive semi-definite `A`, dropping the directions whose
|
||||
/// pivot falls below `rel_tol` times the largest diagonal. Returns the solution and the rank;
|
||||
/// `None` when the input is not finite or the sizes disagree.
|
||||
pub(crate) fn solve_psd(mut a: Sym, b: &[f64], rel_tol: f64) -> Option<(Vec<f64>, usize)> {
|
||||
let n = a.n;
|
||||
if b.len() != n || a.data.len() != n.checked_mul(n)? || !b.iter().chain(&a.data).all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut perm: Vec<usize> = (0..n).collect();
|
||||
let mut rhs = b.to_vec();
|
||||
let max_diag = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max);
|
||||
let tol = (max_diag * rel_tol).max(f64::MIN_POSITIVE);
|
||||
let mut rank = 0;
|
||||
for k in 0..n {
|
||||
// The largest remaining diagonal becomes the pivot.
|
||||
let (best, best_val) = (k..n).map(|i| (i, a.get(i, i))).fold((k, f64::NEG_INFINITY), |acc, x| if x.1 > acc.1 { x } else { acc });
|
||||
if !(best_val > tol) {
|
||||
break;
|
||||
}
|
||||
a.swap(k, best);
|
||||
perm.swap(k, best);
|
||||
rhs.swap(k, best);
|
||||
let l = best_val.sqrt();
|
||||
a.set(k, k, l);
|
||||
// Both triangles are kept, so a later pivot swap moves whole rows and columns.
|
||||
for i in k + 1..n {
|
||||
let v = a.get(i, k) / l;
|
||||
a.set(i, k, v);
|
||||
a.set(k, i, v);
|
||||
}
|
||||
for j in k + 1..n {
|
||||
let ljk = a.get(j, k);
|
||||
if ljk == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for i in j..n {
|
||||
let v = a.get(i, j) - a.get(i, k) * ljk;
|
||||
a.set(i, j, v);
|
||||
a.set(j, i, v);
|
||||
}
|
||||
}
|
||||
rank = k + 1;
|
||||
}
|
||||
// L y = rhs (first `rank` rows), then Lᵀ x = y; the dependent unknowns stay 0.
|
||||
let mut y = vec![0.0; n];
|
||||
for i in 0..rank {
|
||||
let s = (0..i).fold(rhs.get(i).copied().unwrap_or(0.0), |s, j| s - a.get(i, j) * y.get(j).copied().unwrap_or(0.0));
|
||||
if let Some(v) = y.get_mut(i) {
|
||||
*v = s / a.get(i, i);
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for i in (0..rank).rev() {
|
||||
let s = (i + 1..rank).fold(y.get(i).copied().unwrap_or(0.0), |s, j| s - a.get(j, i) * x.get(j).copied().unwrap_or(0.0));
|
||||
if let Some(v) = x.get_mut(i) {
|
||||
*v = s / a.get(i, i);
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0; n];
|
||||
for (k, p) in perm.iter().enumerate() {
|
||||
if let (Some(o), Some(v)) = (out.get_mut(*p), x.get(k)) {
|
||||
*o = *v;
|
||||
}
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some((out, rank))
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Rotations for the solver: a 3×3 matrix and a unit quaternion.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
/// A 3×3 matrix, row-major (`m[row][col]`); acts on column vectors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mat3(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Mat3 {
|
||||
pub const IDENTITY: Mat3 = Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
pub const ZERO: Mat3 = Mat3([[0.0; 3]; 3]);
|
||||
|
||||
/// The matrix whose columns are `a`, `b` and `c`.
|
||||
pub fn from_cols(a: Vec3, b: Vec3, c: Vec3) -> Mat3 {
|
||||
Mat3([[a.x, b.x, c.x], [a.y, b.y, c.y], [a.z, b.z, c.z]])
|
||||
}
|
||||
pub fn diagonal(d: Vec3) -> Mat3 {
|
||||
Mat3([[d.x, 0.0, 0.0], [0.0, d.y, 0.0], [0.0, 0.0, d.z]])
|
||||
}
|
||||
pub fn col(&self, i: usize) -> Vec3 {
|
||||
let [r0, r1, r2] = self.0;
|
||||
match i {
|
||||
0 => Vec3::new(r0[0], r1[0], r2[0]),
|
||||
1 => Vec3::new(r0[1], r1[1], r2[1]),
|
||||
_ => Vec3::new(r0[2], r1[2], r2[2]),
|
||||
}
|
||||
}
|
||||
pub fn row(&self, i: usize) -> Vec3 {
|
||||
let [a, b, c] = match i {
|
||||
0 => self.0[0],
|
||||
1 => self.0[1],
|
||||
_ => self.0[2],
|
||||
};
|
||||
Vec3::new(a, b, c)
|
||||
}
|
||||
pub fn mul_vec(&self, v: Vec3) -> Vec3 {
|
||||
Vec3::new(self.row(0).dot(v), self.row(1).dot(v), self.row(2).dot(v))
|
||||
}
|
||||
pub fn mul(&self, o: &Mat3) -> Mat3 {
|
||||
Mat3::from_cols(self.mul_vec(o.col(0)), self.mul_vec(o.col(1)), self.mul_vec(o.col(2)))
|
||||
}
|
||||
pub fn transpose(&self) -> Mat3 {
|
||||
Mat3::from_cols(self.row(0), self.row(1), self.row(2))
|
||||
}
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.0.iter().flatten().all(|x| x.is_finite())
|
||||
}
|
||||
/// The largest difference between `m[i][j]` and `m[j][i]`.
|
||||
pub fn asymmetry(&self) -> f64 {
|
||||
let m = &self.0;
|
||||
(m[0][1] - m[1][0]).abs().max((m[0][2] - m[2][0]).abs()).max((m[1][2] - m[2][1]).abs())
|
||||
}
|
||||
pub fn determinant(&self) -> f64 {
|
||||
self.col(0).dot(self.col(1).cross(self.col(2)))
|
||||
}
|
||||
/// The inverse of a symmetric positive definite matrix (an inertia tensor); `None` when it is
|
||||
/// not one (by its leading principal minors, scaled to its size).
|
||||
pub fn spd_inverse(&self) -> Option<Mat3> {
|
||||
let m = &self.0;
|
||||
let scale = m[0][0].abs().max(m[1][1].abs()).max(m[2][2].abs());
|
||||
if !(scale > 0.0) || !self.is_finite() || self.asymmetry() > 1e-9 * scale {
|
||||
return None;
|
||||
}
|
||||
let rel = 1e-12;
|
||||
let m1 = m[0][0];
|
||||
let m2 = m[0][0] * m[1][1] - m[0][1] * m[1][0];
|
||||
let det = self.determinant();
|
||||
if !(m1 > rel * scale) || !(m2 > rel * scale * scale) || !(det > rel * scale * scale * scale) {
|
||||
return None;
|
||||
}
|
||||
// Adjugate over determinant: the cross products of the columns are the adjugate's rows.
|
||||
let (a, b, c) = (self.col(0), self.col(1), self.col(2));
|
||||
let inv = Mat3::from_cols(b.cross(c), c.cross(a), a.cross(b)).transpose();
|
||||
let inv = Mat3(inv.0.map(|r| r.map(|x| x / det)));
|
||||
inv.is_finite().then_some(inv)
|
||||
}
|
||||
}
|
||||
|
||||
/// `a × (b × c)`-style helper: the vector `a × (a × v)`.
|
||||
pub fn cross2(a: Vec3, v: Vec3) -> Vec3 {
|
||||
a.cross(a.cross(v))
|
||||
}
|
||||
|
||||
/// A rotation as a unit quaternion `w + xi + yj + zk`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Quat {
|
||||
pub w: f64,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
impl Default for Quat {
|
||||
fn default() -> Self {
|
||||
Quat::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Quat {
|
||||
pub const IDENTITY: Quat = Quat { w: 1.0, x: 0.0, y: 0.0, z: 0.0 };
|
||||
|
||||
pub fn vec(self) -> Vec3 {
|
||||
Vec3::new(self.x, self.y, self.z)
|
||||
}
|
||||
/// The turn by `angle` (radians) about `axis` (any length; the identity for a zero axis).
|
||||
pub fn from_axis_angle(axis: Vec3, angle: f64) -> Quat {
|
||||
match axis.normalized() {
|
||||
Some(a) => {
|
||||
let (s, c) = (angle * 0.5).sin_cos();
|
||||
Quat { w: c, x: a.x * s, y: a.y * s, z: a.z * s }
|
||||
}
|
||||
None => Quat::IDENTITY,
|
||||
}
|
||||
}
|
||||
/// The turn by the rotation vector `v` (axis times angle).
|
||||
pub fn from_rotation_vector(v: Vec3) -> Quat {
|
||||
// sin(θ/2)/θ by its series for small turns: a projection's corrections are far below
|
||||
// the length a direction can be normalised from.
|
||||
let angle = v.len();
|
||||
let (s, c) = (angle * 0.5).sin_cos();
|
||||
let k = if angle < 1e-4 { 0.5 - angle * angle / 48.0 } else { s / angle };
|
||||
if !k.is_finite() || !v.is_finite() {
|
||||
return Quat::IDENTITY;
|
||||
}
|
||||
Quat { w: c, x: v.x * k, y: v.y * k, z: v.z * k }
|
||||
}
|
||||
/// The rotation whose matrix has these orthonormal columns (a right-handed frame).
|
||||
pub fn from_mat3(m: &Mat3) -> Option<Quat> {
|
||||
let r = &m.0;
|
||||
let tr = r[0][0] + r[1][1] + r[2][2];
|
||||
let q = if tr > 0.0 {
|
||||
let s = (tr + 1.0).sqrt() * 2.0;
|
||||
Quat { w: 0.25 * s, x: (r[2][1] - r[1][2]) / s, y: (r[0][2] - r[2][0]) / s, z: (r[1][0] - r[0][1]) / s }
|
||||
} else if r[0][0] > r[1][1] && r[0][0] > r[2][2] {
|
||||
let s = (1.0 + r[0][0] - r[1][1] - r[2][2]).sqrt() * 2.0;
|
||||
Quat { w: (r[2][1] - r[1][2]) / s, x: 0.25 * s, y: (r[0][1] + r[1][0]) / s, z: (r[0][2] + r[2][0]) / s }
|
||||
} else if r[1][1] > r[2][2] {
|
||||
let s = (1.0 + r[1][1] - r[0][0] - r[2][2]).sqrt() * 2.0;
|
||||
Quat { w: (r[0][2] - r[2][0]) / s, x: (r[0][1] + r[1][0]) / s, y: 0.25 * s, z: (r[1][2] + r[2][1]) / s }
|
||||
} else {
|
||||
let s = (1.0 + r[2][2] - r[0][0] - r[1][1]).sqrt() * 2.0;
|
||||
Quat { w: (r[1][0] - r[0][1]) / s, x: (r[0][2] + r[2][0]) / s, y: (r[1][2] + r[2][1]) / s, z: 0.25 * s }
|
||||
};
|
||||
q.normalized()
|
||||
}
|
||||
/// The Hamilton product `self ⊗ o` (turn by `o`, then by `self`).
|
||||
pub fn compose(self, o: Quat) -> Quat {
|
||||
Quat {
|
||||
w: self.w * o.w - self.x * o.x - self.y * o.y - self.z * o.z,
|
||||
x: self.w * o.x + self.x * o.w + self.y * o.z - self.z * o.y,
|
||||
y: self.w * o.y - self.x * o.z + self.y * o.w + self.z * o.x,
|
||||
z: self.w * o.z + self.x * o.y - self.y * o.x + self.z * o.w,
|
||||
}
|
||||
}
|
||||
pub fn conjugate(self) -> Quat {
|
||||
Quat { w: self.w, x: -self.x, y: -self.y, z: -self.z }
|
||||
}
|
||||
pub fn norm(self) -> f64 {
|
||||
(self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
|
||||
}
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.w.is_finite() && self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
/// Scaled to length 1; `None` for a zero or non-finite quaternion.
|
||||
pub fn normalized(self) -> Option<Quat> {
|
||||
let n = self.norm();
|
||||
(n > 1e-300 && n.is_finite()).then(|| Quat { w: self.w / n, x: self.x / n, y: self.y / n, z: self.z / n })
|
||||
}
|
||||
pub fn to_mat3(self) -> Mat3 {
|
||||
let Quat { w, x, y, z } = self;
|
||||
Mat3([
|
||||
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)],
|
||||
[2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)],
|
||||
[2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)],
|
||||
])
|
||||
}
|
||||
pub fn rotate(self, v: Vec3) -> Vec3 {
|
||||
// v + 2 q×(q×v + w v)
|
||||
let q = self.vec();
|
||||
v + q.cross(q.cross(v) + v * self.w) * 2.0
|
||||
}
|
||||
/// The rate of change of this orientation when the body turns at `omega` (world frame):
|
||||
/// `q̇ = ½ (0, ω) ⊗ q`.
|
||||
pub fn rate(self, omega: Vec3) -> Quat {
|
||||
let o = Quat { w: 0.0, x: omega.x, y: omega.y, z: omega.z }.compose(self);
|
||||
Quat { w: 0.5 * o.w, x: 0.5 * o.x, y: 0.5 * o.y, z: 0.5 * o.z }
|
||||
}
|
||||
/// `self + k·o`, component-wise (an integrator's stage; normalise afterwards).
|
||||
pub fn add_scaled(self, o: Quat, k: f64) -> Quat {
|
||||
Quat { w: self.w + k * o.w, x: self.x + k * o.x, y: self.y + k * o.y, z: self.z + k * o.z }
|
||||
}
|
||||
/// The rotation vector (axis times angle, angle in [0, π]).
|
||||
pub fn rotation_vector(self) -> Vec3 {
|
||||
let q = if self.w < 0.0 { Quat { w: -self.w, x: -self.x, y: -self.y, z: -self.z } } else { self };
|
||||
let s = q.vec().len();
|
||||
if s < 1e-12 {
|
||||
return q.vec() * 2.0;
|
||||
}
|
||||
q.vec() * (2.0 * s.atan2(q.w) / s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! What a simulation is made of: rigid bodies, joints between frames on them, force elements
|
||||
//! and the solver's settings. Everything is checked by [`Model::validate`] before a run.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::math::{Mat3, Quat};
|
||||
use crate::{DynamicsError, Result};
|
||||
|
||||
/// Bodies one simulation takes at most.
|
||||
pub const MAX_BODIES: usize = 200;
|
||||
/// Joints one simulation takes at most.
|
||||
pub const MAX_JOINTS: usize = 400;
|
||||
/// Constraint equations one simulation takes at most (the dense solve grows with their cube).
|
||||
pub const MAX_CONSTRAINT_ROWS: usize = 1200;
|
||||
/// Force elements one simulation takes at most.
|
||||
pub const MAX_FORCES: usize = 2000;
|
||||
/// Integration steps one run takes at most (accepted and rejected together).
|
||||
pub const MAX_STEPS: usize = 2_000_000;
|
||||
/// Output samples one run keeps at most.
|
||||
pub const MAX_SAMPLES: usize = 100_000;
|
||||
/// Projection iterations per step at most.
|
||||
pub const MAX_PROJECTION_ITERATIONS: usize = 50;
|
||||
/// Coordinates are kept within this size (mm, mm/s, rad/s); beyond it the run stops with
|
||||
/// [`DynamicsError::Diverged`].
|
||||
pub const MAX_MAGNITUDE: f64 = 1e12;
|
||||
|
||||
/// A rigid body. Lengths in mm, mass in kg, time in s, so forces are in kg·mm/s² (mN) and
|
||||
/// torques in mN·mm.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Body {
|
||||
/// Mass (kg), > 0.
|
||||
pub mass: f64,
|
||||
/// Inertia tensor about the centre of mass in the body's own axes (kg·mm²), symmetric
|
||||
/// positive definite.
|
||||
pub inertia: Mat3,
|
||||
/// Centre of mass at the start (world, mm). The body's own frame has its origin here.
|
||||
pub position: Vec3,
|
||||
/// The body's axes at the start (body → world).
|
||||
#[serde(default)]
|
||||
pub orientation: Quat,
|
||||
/// Velocity of the centre of mass at the start (world, mm/s).
|
||||
#[serde(default)]
|
||||
pub velocity: Vec3,
|
||||
/// Angular velocity at the start (world, rad/s).
|
||||
#[serde(default)]
|
||||
pub angular_velocity: Vec3,
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// A body at rest at `position`, axes along the world's, with a diagonal inertia.
|
||||
pub fn at_rest(mass: f64, principal_inertia: Vec3, position: Vec3) -> Body {
|
||||
Body {
|
||||
mass,
|
||||
inertia: Mat3::diagonal(principal_inertia),
|
||||
position,
|
||||
orientation: Quat::IDENTITY,
|
||||
velocity: Vec3::ZERO,
|
||||
angular_velocity: Vec3::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame on a body (in the body's own coordinates: origin at its centre of mass) or, with no
|
||||
/// body, fixed in the world. `z` and `x` need not be unit or square: `x` is laid square to `z`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Marker {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<usize>,
|
||||
pub origin: Vec3,
|
||||
pub z: Vec3,
|
||||
pub x: Vec3,
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn new(body: Option<usize>, origin: Vec3, z: Vec3, x: Vec3) -> Marker {
|
||||
Marker { body, origin, z, x }
|
||||
}
|
||||
/// A frame fixed in the world.
|
||||
pub fn ground(origin: Vec3, z: Vec3, x: Vec3) -> Marker {
|
||||
Marker { body: None, origin, z, x }
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on a body (body coordinates) or, with no body, in the world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Anchor {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<usize>,
|
||||
pub point: Vec3,
|
||||
}
|
||||
|
||||
/// The joints the solver knows; each holds `b`'s frame to `a`'s as the matching assembly joint
|
||||
/// does (z is the joint's axis), with the frames coinciding at zero joint values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JointKind {
|
||||
/// No relative motion (6 equations).
|
||||
Rigid,
|
||||
/// Turns about z (5 equations).
|
||||
Revolute,
|
||||
/// Slides along z (5 equations).
|
||||
Slider,
|
||||
/// Turns about and slides along z (4 equations).
|
||||
Cylindrical,
|
||||
/// Slides in the xy plane and turns about z (3 equations).
|
||||
Planar,
|
||||
/// Turns any way about the common origin (3 equations).
|
||||
Ball,
|
||||
}
|
||||
|
||||
impl JointKind {
|
||||
/// How many constraint equations the joint adds.
|
||||
pub fn equations(self) -> usize {
|
||||
match self {
|
||||
JointKind::Rigid => 6,
|
||||
JointKind::Revolute | JointKind::Slider => 5,
|
||||
JointKind::Cylindrical => 4,
|
||||
JointKind::Planar | JointKind::Ball => 3,
|
||||
}
|
||||
}
|
||||
/// The joint's values, in the order [`crate::JointResult::values`] gives them.
|
||||
pub fn values(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
JointKind::Rigid => &[],
|
||||
JointKind::Revolute => &["angle"],
|
||||
JointKind::Slider => &["distance"],
|
||||
JointKind::Cylindrical => &["angle", "distance"],
|
||||
JointKind::Planar => &["x", "y", "angle"],
|
||||
JointKind::Ball => &["rx", "ry", "rz"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint: `b`'s frame held to `a`'s.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Joint {
|
||||
pub kind: JointKind,
|
||||
pub a: Marker,
|
||||
pub b: Marker,
|
||||
}
|
||||
|
||||
/// A force element.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Force {
|
||||
/// A linear spring and damper between two points: the tension is
|
||||
/// `stiffness·(length − free_length) + damping·(rate of length) + preload`.
|
||||
Spring {
|
||||
a: Anchor,
|
||||
b: Anchor,
|
||||
/// kg/s² (= N/m = mN/mm), ≥ 0.
|
||||
stiffness: f64,
|
||||
/// kg/s (= N·s/m), ≥ 0.
|
||||
damping: f64,
|
||||
/// mm, ≥ 0.
|
||||
free_length: f64,
|
||||
/// mN, tension at free length.
|
||||
#[serde(default)]
|
||||
preload: f64,
|
||||
},
|
||||
/// A constant force (world, mN) at a point of a body, and a constant torque (world, mN·mm).
|
||||
Load {
|
||||
body: usize,
|
||||
#[serde(default)]
|
||||
point: Vec3,
|
||||
#[serde(default)]
|
||||
force: Vec3,
|
||||
#[serde(default)]
|
||||
torque: Vec3,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the equations are stepped in time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Integrator {
|
||||
/// Classical fourth-order Runge–Kutta with a fixed step (s).
|
||||
Rk4 { step: f64 },
|
||||
/// Dormand–Prince 5(4) with error control: steps between `min_step` and `max_step` (s)
|
||||
/// keep the local error under `abs_tol + rel_tol·|y|`.
|
||||
Adaptive { rel_tol: f64, abs_tol: f64, min_step: f64, max_step: f64 },
|
||||
}
|
||||
|
||||
/// A run's settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// Simulated time (s), > 0.
|
||||
pub duration: f64,
|
||||
/// Time between output samples (s), > 0; the integrator lands on every sample.
|
||||
pub output_interval: f64,
|
||||
pub integrator: Integrator,
|
||||
/// Project positions and velocities back onto the joints after every step (on by default).
|
||||
#[serde(default = "yes")]
|
||||
pub projection: bool,
|
||||
/// The constraint error (mm, or radians times mm for directions) projection reaches.
|
||||
#[serde(default = "default_projection_tol")]
|
||||
pub projection_tol: f64,
|
||||
/// Baumgarte stabilisation `(α, β)` (1/s): the constraint accelerations become
|
||||
/// `Φ̈ + 2αΦ̇ + β²Φ = 0`. Off by default (projection keeps the joints).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub baumgarte: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_projection_tol() -> f64 {
|
||||
1e-10
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Fixed-step RK4 settings.
|
||||
pub fn rk4(duration: f64, step: f64, output_interval: f64) -> Settings {
|
||||
Settings {
|
||||
duration,
|
||||
output_interval,
|
||||
integrator: Integrator::Rk4 { step },
|
||||
projection: true,
|
||||
projection_tol: default_projection_tol(),
|
||||
baumgarte: None,
|
||||
}
|
||||
}
|
||||
/// Adaptive settings with the given relative and absolute tolerances.
|
||||
pub fn adaptive(duration: f64, output_interval: f64, rel_tol: f64, abs_tol: f64) -> Settings {
|
||||
Settings {
|
||||
duration,
|
||||
output_interval,
|
||||
integrator: Integrator::Adaptive { rel_tol, abs_tol, min_step: 1e-9, max_step: output_interval },
|
||||
projection: true,
|
||||
projection_tol: default_projection_tol(),
|
||||
baumgarte: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A multibody system.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Model {
|
||||
pub bodies: Vec<Body>,
|
||||
#[serde(default)]
|
||||
pub joints: Vec<Joint>,
|
||||
#[serde(default)]
|
||||
pub forces: Vec<Force>,
|
||||
/// Gravity (mm/s²), e.g. `(0, 0, −9806.65)`.
|
||||
#[serde(default)]
|
||||
pub gravity: Vec3,
|
||||
}
|
||||
|
||||
/// Standard gravity (mm/s²).
|
||||
pub const STANDARD_GRAVITY: f64 = 9806.65;
|
||||
|
||||
fn finite_vec(v: Vec3) -> bool {
|
||||
v.is_finite() && v.x.abs() < MAX_MAGNITUDE && v.y.abs() < MAX_MAGNITUDE && v.z.abs() < MAX_MAGNITUDE
|
||||
}
|
||||
|
||||
fn bad(what: String) -> DynamicsError {
|
||||
DynamicsError::InvalidModel(what)
|
||||
}
|
||||
|
||||
/// A marker's axes in its body's coordinates (columns x, y, z), from its `z` and `x`.
|
||||
pub(crate) fn marker_axes(m: &Marker) -> Option<Mat3> {
|
||||
let z = m.z.normalized()?;
|
||||
let x = (m.x - z * m.x.dot(z)).normalized()?;
|
||||
let y = z.cross(x).normalized()?;
|
||||
Some(Mat3::from_cols(x, y, z))
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Checks every number and reference, and the caps. Called by [`crate::simulate`].
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.bodies.is_empty() {
|
||||
return Err(bad("no bodies".into()));
|
||||
}
|
||||
if self.bodies.len() > MAX_BODIES {
|
||||
return Err(DynamicsError::TooLarge(format!("{} bodies (at most {MAX_BODIES})", self.bodies.len())));
|
||||
}
|
||||
if self.joints.len() > MAX_JOINTS {
|
||||
return Err(DynamicsError::TooLarge(format!("{} joints (at most {MAX_JOINTS})", self.joints.len())));
|
||||
}
|
||||
if self.forces.len() > MAX_FORCES {
|
||||
return Err(DynamicsError::TooLarge(format!("{} force elements (at most {MAX_FORCES})", self.forces.len())));
|
||||
}
|
||||
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum();
|
||||
if rows > MAX_CONSTRAINT_ROWS {
|
||||
return Err(DynamicsError::TooLarge(format!("{rows} constraint equations (at most {MAX_CONSTRAINT_ROWS})")));
|
||||
}
|
||||
if !finite_vec(self.gravity) {
|
||||
return Err(bad("gravity is not a finite vector".into()));
|
||||
}
|
||||
for (i, b) in self.bodies.iter().enumerate() {
|
||||
if !(b.mass > 0.0) || !(b.mass < MAX_MAGNITUDE) {
|
||||
return Err(bad(format!("body {i}: mass must be positive and finite")));
|
||||
}
|
||||
if b.inertia.spd_inverse().is_none() {
|
||||
return Err(bad(format!("body {i}: inertia must be symmetric positive definite")));
|
||||
}
|
||||
if !finite_vec(b.position) || !finite_vec(b.velocity) || !finite_vec(b.angular_velocity) {
|
||||
return Err(bad(format!("body {i}: position and velocities must be finite")));
|
||||
}
|
||||
if b.orientation.normalized().is_none() {
|
||||
return Err(bad(format!("body {i}: orientation is not a rotation")));
|
||||
}
|
||||
}
|
||||
let body_ok = |b: Option<usize>| b.is_none_or(|b| b < self.bodies.len());
|
||||
for (i, j) in self.joints.iter().enumerate() {
|
||||
for m in [&j.a, &j.b] {
|
||||
if !body_ok(m.body) {
|
||||
return Err(bad(format!("joint {i}: no body {}", m.body.unwrap_or(0))));
|
||||
}
|
||||
if !finite_vec(m.origin) || marker_axes(m).is_none() {
|
||||
return Err(bad(format!("joint {i}: a frame's origin or axes are not usable")));
|
||||
}
|
||||
}
|
||||
if j.a.body == j.b.body {
|
||||
return Err(bad(format!("joint {i}: both frames are on the same body")));
|
||||
}
|
||||
}
|
||||
for (i, f) in self.forces.iter().enumerate() {
|
||||
match f {
|
||||
Force::Spring { a, b, stiffness, damping, free_length, preload } => {
|
||||
if !body_ok(a.body) || !body_ok(b.body) {
|
||||
return Err(bad(format!("force {i}: no such body")));
|
||||
}
|
||||
if !finite_vec(a.point) || !finite_vec(b.point) {
|
||||
return Err(bad(format!("force {i}: anchor points must be finite")));
|
||||
}
|
||||
let ok = |x: f64| (0.0..MAX_MAGNITUDE).contains(&x);
|
||||
if !ok(*stiffness) || !ok(*damping) || !ok(*free_length) || !preload.is_finite() || preload.abs() >= MAX_MAGNITUDE {
|
||||
return Err(bad(format!("force {i}: stiffness, damping and free length must be ≥ 0 and finite")));
|
||||
}
|
||||
}
|
||||
Force::Load { body, point, force, torque } => {
|
||||
if *body >= self.bodies.len() {
|
||||
return Err(bad(format!("force {i}: no body {body}")));
|
||||
}
|
||||
if !finite_vec(*point) || !finite_vec(*force) || !finite_vec(*torque) {
|
||||
return Err(bad(format!("force {i}: point, force and torque must be finite")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Checks the times, tolerances and that the run fits the step and sample caps.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let pos = |x: f64| x > 0.0 && x < MAX_MAGNITUDE;
|
||||
if !pos(self.duration) || !pos(self.output_interval) {
|
||||
return Err(DynamicsError::InvalidSettings("duration and output interval must be positive and finite".into()));
|
||||
}
|
||||
let samples = (self.duration / self.output_interval).ceil();
|
||||
if !(samples < MAX_SAMPLES as f64) {
|
||||
return Err(DynamicsError::TooLarge(format!("{samples} output samples (at most {MAX_SAMPLES})")));
|
||||
}
|
||||
if !pos(self.projection_tol) {
|
||||
return Err(DynamicsError::InvalidSettings("projection tolerance must be positive".into()));
|
||||
}
|
||||
if let Some((a, b)) = self.baumgarte
|
||||
&& (!(0.0..MAX_MAGNITUDE).contains(&a) || !(0.0..MAX_MAGNITUDE).contains(&b))
|
||||
{
|
||||
return Err(DynamicsError::InvalidSettings("Baumgarte terms must be ≥ 0 and finite".into()));
|
||||
}
|
||||
match self.integrator {
|
||||
Integrator::Rk4 { step } => {
|
||||
if !pos(step) {
|
||||
return Err(DynamicsError::InvalidSettings("step must be positive".into()));
|
||||
}
|
||||
let steps = (self.duration / step.min(self.output_interval)).ceil();
|
||||
if !(steps < MAX_STEPS as f64) {
|
||||
return Err(DynamicsError::TooLarge(format!("{steps} steps (at most {MAX_STEPS})")));
|
||||
}
|
||||
}
|
||||
Integrator::Adaptive { rel_tol, abs_tol, min_step, max_step } => {
|
||||
if !pos(rel_tol) || !pos(abs_tol) || !pos(min_step) || !pos(max_step) || min_step > max_step {
|
||||
return Err(DynamicsError::InvalidSettings("tolerances and step bounds must be positive, min ≤ max".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
//! The equations of motion: body states, joint constraint equations with their Jacobians,
|
||||
//! applied forces, the constrained accelerations and the projections back onto the joints.
|
||||
//!
|
||||
//! Bodies use maximal coordinates: centre-of-mass position `x`, unit quaternion `q`, velocity
|
||||
//! `v` and angular velocity `ω` (both world). With `u = (v, ω)` per body, mass matrix
|
||||
//! `M = diag(m·I₃, I_w)` (`I_w = R I_body Rᵀ`), joint equations `Φ(x, q) = 0`, their Jacobian
|
||||
//! `J` (`Φ̇ = J u`) and `γ = −J̇ u`, the accelerations solve
|
||||
//!
|
||||
//! ```text
|
||||
//! M u̇ + Jᵀ λ = F − ω × I_w ω, J u̇ = γ
|
||||
//! ```
|
||||
//!
|
||||
//! through the Schur complement `(J M⁻¹ Jᵀ) λ = J M⁻¹ F − γ` (`linalg::solve_psd`, which takes
|
||||
//! repeated joint equations), and `λ` gives the joints' reactions.
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::linalg::{Sym, solve_psd};
|
||||
use crate::math::{Mat3, Quat, cross2};
|
||||
use crate::model::{Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, marker_axes};
|
||||
use crate::{BodyState, DynamicsError, Energy, JointResult, Result};
|
||||
|
||||
/// Numbers per body in the flat state: x (3), q (4), v (3), ω (3).
|
||||
pub(crate) const STRIDE: usize = 13;
|
||||
|
||||
/// Pivots under this fraction of the largest are taken as repeated equations.
|
||||
const RANK_TOL: f64 = 1e-10;
|
||||
|
||||
fn v3(y: &[f64], at: usize) -> Vec3 {
|
||||
let g = |i: usize| y.get(at + i).copied().unwrap_or(0.0);
|
||||
Vec3::new(g(0), g(1), g(2))
|
||||
}
|
||||
fn put3(y: &mut [f64], at: usize, v: Vec3) {
|
||||
for (i, c) in [v.x, v.y, v.z].into_iter().enumerate() {
|
||||
if let Some(s) = y.get_mut(at + i) {
|
||||
*s = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn quat(y: &[f64], at: usize) -> Quat {
|
||||
let g = |i: usize| y.get(at + i).copied().unwrap_or(0.0);
|
||||
Quat { w: g(0), x: g(1), y: g(2), z: g(3) }
|
||||
}
|
||||
fn put_quat(y: &mut [f64], at: usize, q: Quat) {
|
||||
for (i, c) in [q.w, q.x, q.y, q.z].into_iter().enumerate() {
|
||||
if let Some(s) = y.get_mut(at + i) {
|
||||
*s = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One body's precomputed constants.
|
||||
struct BodyConst {
|
||||
mass: f64,
|
||||
inertia: Mat3,
|
||||
inertia_inv: Mat3,
|
||||
}
|
||||
|
||||
/// A joint frame: its body, origin and axes in the body's coordinates.
|
||||
struct MarkerConst {
|
||||
body: Option<usize>,
|
||||
origin: Vec3,
|
||||
axes: Mat3,
|
||||
}
|
||||
|
||||
struct JointConst {
|
||||
kind: JointKind,
|
||||
a: MarkerConst,
|
||||
b: MarkerConst,
|
||||
}
|
||||
|
||||
/// A body's kinematics at one state.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Kin {
|
||||
x: Vec3,
|
||||
rot: Mat3,
|
||||
v: Vec3,
|
||||
w: Vec3,
|
||||
}
|
||||
|
||||
const GROUND: Kin = Kin { x: Vec3::ZERO, rot: Mat3::IDENTITY, v: Vec3::ZERO, w: Vec3::ZERO };
|
||||
|
||||
/// A joint frame at one state (world).
|
||||
struct MarkerWorld {
|
||||
body: Option<usize>,
|
||||
/// Origin minus the body's centre of mass.
|
||||
r: Vec3,
|
||||
/// Origin.
|
||||
p: Vec3,
|
||||
/// Velocity of the origin.
|
||||
pdot: Vec3,
|
||||
axes: [Vec3; 3],
|
||||
w: Vec3,
|
||||
}
|
||||
|
||||
/// A body's part of one Jacobian row: (linear, angular).
|
||||
type Block = (usize, Vec3, Vec3);
|
||||
|
||||
/// One constraint equation at a state.
|
||||
pub(crate) struct Row {
|
||||
a: Option<Block>,
|
||||
b: Option<Block>,
|
||||
pub(crate) phi: f64,
|
||||
/// `J̇ u`: the equation's second derivative at zero accelerations.
|
||||
jdot_u: f64,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn blocks(&self) -> impl Iterator<Item = &Block> {
|
||||
self.a.iter().chain(self.b.iter())
|
||||
}
|
||||
/// `J u`.
|
||||
fn rate(&self, kin: &[Kin]) -> f64 {
|
||||
self.blocks().map(|(i, l, a)| kin.get(*i).map_or(0.0, |k| l.dot(k.v) + a.dot(k.w))).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A model ready to integrate.
|
||||
pub(crate) struct System {
|
||||
bodies: Vec<BodyConst>,
|
||||
joints: Vec<JointConst>,
|
||||
forces: Vec<Force>,
|
||||
gravity: Vec3,
|
||||
baumgarte: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Accelerations and multipliers at one state.
|
||||
pub(crate) struct Accel {
|
||||
/// The flat state's derivative.
|
||||
pub(crate) ydot: Vec<f64>,
|
||||
/// One multiplier per constraint row, in joint order.
|
||||
lambda: Vec<f64>,
|
||||
rows: Vec<Row>,
|
||||
/// The rank of `J M⁻¹ Jᵀ`.
|
||||
pub(crate) rank: usize,
|
||||
}
|
||||
|
||||
impl Accel {
|
||||
/// The number of joint equations.
|
||||
pub(crate) fn row_count(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn marker_const(m: &crate::Marker) -> Result<MarkerConst> {
|
||||
let axes = marker_axes(m).ok_or_else(|| DynamicsError::InvalidModel("a joint frame's axes are not usable".into()))?;
|
||||
Ok(MarkerConst { body: m.body, origin: m.origin, axes })
|
||||
}
|
||||
|
||||
impl System {
|
||||
pub(crate) fn new(model: &Model, baumgarte: Option<(f64, f64)>) -> Result<System> {
|
||||
model.validate()?;
|
||||
let bodies = model
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let inertia_inv = b.inertia.spd_inverse().ok_or_else(|| DynamicsError::InvalidModel("inertia is not positive definite".into()))?;
|
||||
Ok(BodyConst { mass: b.mass, inertia: b.inertia, inertia_inv })
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let joints = model
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| Ok(JointConst { kind: j.kind, a: marker_const(&j.a)?, b: marker_const(&j.b)? }))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(System { bodies, joints, forces: model.forces.clone(), gravity: model.gravity, baumgarte })
|
||||
}
|
||||
|
||||
/// The flat state of the model's starting conditions.
|
||||
pub(crate) fn initial_state(model: &Model) -> Result<Vec<f64>> {
|
||||
let mut y = vec![0.0; model.bodies.len() * STRIDE];
|
||||
for (i, b) in model.bodies.iter().enumerate() {
|
||||
let at = i * STRIDE;
|
||||
let q = b.orientation.normalized().ok_or_else(|| DynamicsError::InvalidModel(format!("body {i}: orientation is not a rotation")))?;
|
||||
put3(&mut y, at, b.position);
|
||||
put_quat(&mut y, at + 3, q);
|
||||
put3(&mut y, at + 7, b.velocity);
|
||||
put3(&mut y, at + 10, b.angular_velocity);
|
||||
}
|
||||
Ok(y)
|
||||
}
|
||||
|
||||
/// Unit quaternions again after a step.
|
||||
pub(crate) fn normalize(&self, y: &mut [f64]) -> Result<()> {
|
||||
for i in 0..self.bodies.len() {
|
||||
let q = quat(y, i * STRIDE + 3).normalized().ok_or(DynamicsError::Diverged { time: f64::NAN })?;
|
||||
put_quat(y, i * STRIDE + 3, q);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kinematics(&self, y: &[f64]) -> Vec<Kin> {
|
||||
(0..self.bodies.len())
|
||||
.map(|i| {
|
||||
let at = i * STRIDE;
|
||||
Kin { x: v3(y, at), rot: quat(y, at + 3).to_mat3(), v: v3(y, at + 7), w: v3(y, at + 10) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kin_of(kin: &[Kin], body: Option<usize>) -> Kin {
|
||||
body.and_then(|b| kin.get(b).copied()).unwrap_or(GROUND)
|
||||
}
|
||||
|
||||
fn marker_world(kin: &[Kin], m: &MarkerConst) -> MarkerWorld {
|
||||
let k = Self::kin_of(kin, m.body);
|
||||
let r = k.rot.mul_vec(m.origin);
|
||||
let axes = [k.rot.mul_vec(m.axes.col(0)), k.rot.mul_vec(m.axes.col(1)), k.rot.mul_vec(m.axes.col(2))];
|
||||
MarkerWorld { body: m.body, r, p: k.x + r, pdot: k.v + k.w.cross(r), axes, w: k.w }
|
||||
}
|
||||
|
||||
/// The constraint equations of every joint at `kin`, in joint order.
|
||||
fn rows(&self, kin: &[Kin]) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
for j in &self.joints {
|
||||
let a = Self::marker_world(kin, &j.a);
|
||||
let b = Self::marker_world(kin, &j.b);
|
||||
joint_rows(j.kind, &a, &b, &mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// `M⁻¹` applied to one body's (force, torque).
|
||||
fn minv(&self, kin: &[Kin], body: usize, f: Vec3, t: Vec3) -> (Vec3, Vec3) {
|
||||
match (self.bodies.get(body), kin.get(body)) {
|
||||
(Some(b), Some(k)) => {
|
||||
let iw_inv = k.rot.mul(&b.inertia_inv).mul(&k.rot.transpose());
|
||||
(f * (1.0 / b.mass), iw_inv.mul_vec(t))
|
||||
}
|
||||
_ => (Vec3::ZERO, Vec3::ZERO),
|
||||
}
|
||||
}
|
||||
|
||||
/// `J M⁻¹ Jᵀ`, and each row's `M⁻¹ Jᵀ` blocks.
|
||||
fn schur(&self, kin: &[Kin], rows: &[Row]) -> (Sym, Vec<Vec<Block>>) {
|
||||
let w: Vec<Vec<Block>> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.blocks()
|
||||
.map(|(i, l, a)| {
|
||||
let (ml, ma) = self.minv(kin, *i, *l, *a);
|
||||
(*i, ml, ma)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let n = rows.len();
|
||||
let mut s = Sym::zeros(n);
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
for (c, wc) in w.iter().enumerate().skip(r) {
|
||||
let mut sum = 0.0;
|
||||
for (i, l, a) in row.blocks() {
|
||||
for (k, ml, ma) in wc {
|
||||
if i == k {
|
||||
sum += l.dot(*ml) + a.dot(*ma);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.set(r, c, sum);
|
||||
s.set(c, r, sum);
|
||||
}
|
||||
}
|
||||
(s, w)
|
||||
}
|
||||
|
||||
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included.
|
||||
fn applied(&self, kin: &[Kin]) -> Vec<(Vec3, Vec3)> {
|
||||
let mut out: Vec<(Vec3, Vec3)> = self
|
||||
.bodies
|
||||
.iter()
|
||||
.zip(kin)
|
||||
.map(|(b, k)| {
|
||||
let iw = k.rot.mul(&b.inertia).mul(&k.rot.transpose());
|
||||
(self.gravity * b.mass, -k.w.cross(iw.mul_vec(k.w)))
|
||||
})
|
||||
.collect();
|
||||
let mut add = |body: Option<usize>, r: Vec3, f: Vec3, t: Vec3| {
|
||||
if let Some(slot) = body.and_then(|b| out.get_mut(b)) {
|
||||
slot.0 += f;
|
||||
slot.1 += r.cross(f) + t;
|
||||
}
|
||||
};
|
||||
for f in &self.forces {
|
||||
match *f {
|
||||
Force::Spring { a, b, stiffness, damping, free_length, preload } => {
|
||||
let (ka, kb) = (Self::kin_of(kin, a.body), Self::kin_of(kin, b.body));
|
||||
let (ra, rb) = (ka.rot.mul_vec(a.point), kb.rot.mul_vec(b.point));
|
||||
let d = (kb.x + rb) - (ka.x + ra);
|
||||
let len = d.len();
|
||||
let Some(n) = d.normalized().filter(|_| len > 1e-9) else { continue };
|
||||
let rate = n.dot((kb.v + kb.w.cross(rb)) - (ka.v + ka.w.cross(ra)));
|
||||
let tension = stiffness * (len - free_length) + damping * rate + preload;
|
||||
add(a.body, ra, n * tension, Vec3::ZERO);
|
||||
add(b.body, rb, n * -tension, Vec3::ZERO);
|
||||
}
|
||||
Force::Load { body, point, force, torque } => {
|
||||
let r = kin.get(body).map_or(Vec3::ZERO, |k| k.rot.mul_vec(point));
|
||||
add(Some(body), r, force, torque);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The state's derivative and the joints' multipliers.
|
||||
pub(crate) fn accelerations(&self, y: &[f64]) -> Result<Accel> {
|
||||
let kin = self.kinematics(y);
|
||||
let applied = self.applied(&kin);
|
||||
let rows = self.rows(&kin);
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
// Unconstrained accelerations M⁻¹F.
|
||||
let free: Vec<(Vec3, Vec3)> = applied.iter().enumerate().map(|(i, (f, t))| self.minv(&kin, i, *f, *t)).collect();
|
||||
let rhs: Vec<f64> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let jmf: f64 = r.blocks().map(|(i, l, a)| free.get(*i).map_or(0.0, |(fl, fa)| l.dot(*fl) + a.dot(*fa))).sum();
|
||||
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * r.rate(&kin) + beta * beta * r.phi);
|
||||
jmf + r.jdot_u + stab
|
||||
})
|
||||
.collect();
|
||||
let (lambda, rank) =
|
||||
if rows.is_empty() { (Vec::new(), 0) } else { solve_psd(s, &rhs, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })? };
|
||||
let mut acc = free;
|
||||
for (wr, l) in w.iter().zip(&lambda) {
|
||||
for (i, ml, ma) in wr {
|
||||
if let Some(a) = acc.get_mut(*i) {
|
||||
a.0 -= *ml * *l;
|
||||
a.1 -= *ma * *l;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ydot = vec![0.0; y.len()];
|
||||
for (i, (k, a)) in kin.iter().zip(&acc).enumerate() {
|
||||
let at = i * STRIDE;
|
||||
put3(&mut ydot, at, k.v);
|
||||
put_quat(&mut ydot, at + 3, quat(y, at + 3).rate(k.w));
|
||||
put3(&mut ydot, at + 7, a.0);
|
||||
put3(&mut ydot, at + 10, a.1);
|
||||
}
|
||||
if !ydot.iter().all(|x| x.is_finite()) {
|
||||
return Err(DynamicsError::Diverged { time: f64::NAN });
|
||||
}
|
||||
Ok(Accel { ydot, lambda, rows, rank })
|
||||
}
|
||||
|
||||
/// The largest joint equation error at `y`.
|
||||
pub(crate) fn constraint_error(&self, y: &[f64]) -> f64 {
|
||||
self.rows(&self.kinematics(y)).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Moves the bodies the least (mass-weighted) onto the joints: Gauss–Newton on `Φ = 0`.
|
||||
/// Returns the error left.
|
||||
pub(crate) fn project_positions(&self, y: &mut [f64], tol: f64) -> Result<f64> {
|
||||
let mut err;
|
||||
let mut iteration = 0;
|
||||
loop {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
err = rows.iter().map(|r| r.phi.abs()).fold(0.0, f64::max);
|
||||
if !(err > tol) || iteration >= MAX_PROJECTION_ITERATIONS {
|
||||
break;
|
||||
}
|
||||
iteration += 1;
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
let phi: Vec<f64> = rows.iter().map(|r| r.phi).collect();
|
||||
let (mu, _) = solve_psd(s, &phi, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
|
||||
let mut delta = vec![(Vec3::ZERO, Vec3::ZERO); self.bodies.len()];
|
||||
for (wr, m) in w.iter().zip(&mu) {
|
||||
for (i, ml, ma) in wr {
|
||||
if let Some(d) = delta.get_mut(*i) {
|
||||
d.0 -= *ml * *m;
|
||||
d.1 -= *ma * *m;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, (dx, dth)) in delta.into_iter().enumerate() {
|
||||
let at = i * STRIDE;
|
||||
put3(y, at, v3(y, at) + dx);
|
||||
let q = Quat::from_rotation_vector(dth).compose(quat(y, at + 3)).normalized().ok_or(DynamicsError::Diverged { time: f64::NAN })?;
|
||||
put_quat(y, at + 3, q);
|
||||
}
|
||||
}
|
||||
if !err.is_finite() || !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
|
||||
return Err(DynamicsError::Diverged { time: f64::NAN });
|
||||
}
|
||||
Ok(err)
|
||||
}
|
||||
|
||||
/// Removes the velocities the joints don't allow (mass-weighted): `J u = 0`.
|
||||
pub(crate) fn project_velocities(&self, y: &mut [f64]) -> Result<()> {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin)).collect();
|
||||
let (mu, _) = solve_psd(s, &ju, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
|
||||
for (wr, m) in w.iter().zip(&mu) {
|
||||
for (i, ml, ma) in wr {
|
||||
let at = i * STRIDE;
|
||||
put3(y, at + 7, v3(y, at + 7) - *ml * *m);
|
||||
put3(y, at + 10, v3(y, at + 10) - *ma * *m);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bodies' states.
|
||||
pub(crate) fn body_states(&self, y: &[f64]) -> Vec<BodyState> {
|
||||
(0..self.bodies.len())
|
||||
.map(|i| {
|
||||
let at = i * STRIDE;
|
||||
BodyState { position: v3(y, at), orientation: quat(y, at + 3), velocity: v3(y, at + 7), angular_velocity: v3(y, at + 10) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kinetic, gravity and spring energy.
|
||||
pub(crate) fn energy(&self, y: &[f64]) -> Energy {
|
||||
let kin = self.kinematics(y);
|
||||
let mut e = Energy::default();
|
||||
for (b, k) in self.bodies.iter().zip(&kin) {
|
||||
let iw = k.rot.mul(&b.inertia).mul(&k.rot.transpose());
|
||||
e.kinetic += 0.5 * b.mass * k.v.len2() + 0.5 * k.w.dot(iw.mul_vec(k.w));
|
||||
e.gravity -= b.mass * self.gravity.dot(k.x);
|
||||
}
|
||||
for f in &self.forces {
|
||||
if let Force::Spring { a, b, stiffness, free_length, preload, .. } = *f {
|
||||
let (ka, kb) = (Self::kin_of(&kin, a.body), Self::kin_of(&kin, b.body));
|
||||
let stretch = ((kb.x + kb.rot.mul_vec(b.point)) - (ka.x + ka.rot.mul_vec(a.point))).len() - free_length;
|
||||
e.spring += 0.5 * stiffness * stretch * stretch + preload * stretch;
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Each joint's values, rates and reaction (on `b`'s side, at its frame's origin, world),
|
||||
/// from the multipliers `acc` found at `y`.
|
||||
pub(crate) fn joint_results(&self, y: &[f64], acc: &Accel) -> Vec<JointResult> {
|
||||
let kin = self.kinematics(y);
|
||||
let mut at = 0;
|
||||
let mut out = Vec::with_capacity(self.joints.len());
|
||||
for j in &self.joints {
|
||||
let a = Self::marker_world(&kin, &j.a);
|
||||
let b = Self::marker_world(&kin, &j.b);
|
||||
let (values, rates) = joint_values(j.kind, &a, &b);
|
||||
let n = j.kind.equations();
|
||||
// The wrench the joint's equations put on each body: −Jᵀλ.
|
||||
let (mut fa, mut ta, mut fb, mut tb) = (Vec3::ZERO, Vec3::ZERO, Vec3::ZERO, Vec3::ZERO);
|
||||
for k in at..at + n {
|
||||
let (Some(row), Some(l)) = (acc.rows.get(k), acc.lambda.get(k)) else { continue };
|
||||
if let Some((_, lin, ang)) = row.a {
|
||||
fa -= lin * *l;
|
||||
ta -= ang * *l;
|
||||
}
|
||||
if let Some((_, lin, ang)) = row.b {
|
||||
fb -= lin * *l;
|
||||
tb -= ang * *l;
|
||||
}
|
||||
}
|
||||
at += n;
|
||||
let (force, torque) = if b.body.is_some() {
|
||||
(fb, tb - b.r.cross(fb))
|
||||
} else {
|
||||
// B is the ground: it takes what A takes, turned round, about B's origin.
|
||||
let xa = Self::kin_of(&kin, a.body).x;
|
||||
(-fa, -(ta + (xa - b.p).cross(fa)))
|
||||
};
|
||||
out.push(JointResult { values, rates, force, torque });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `(Φ, J, J̇u)` for one body point's world component along `e`: `e·(p_B − p_A)`.
|
||||
fn point_row(a: &MarkerWorld, b: &MarkerWorld, e: Vec3) -> Row {
|
||||
let acc = |m: &MarkerWorld| cross2(m.w, m.r);
|
||||
Row { a: a.body.map(|i| (i, -e, -a.r.cross(e))), b: b.body.map(|i| (i, e, b.r.cross(e))), phi: e.dot(b.p - a.p), jdot_u: e.dot(acc(b) - acc(a)) }
|
||||
}
|
||||
|
||||
/// `u·v = 0` for a direction `u` of A and `v` of B.
|
||||
fn dot1_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld, v: Vec3) -> Row {
|
||||
let (ud, vd) = (a.w.cross(u), b.w.cross(v));
|
||||
let (udd, vdd) = (cross2(a.w, u), cross2(b.w, v));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
|
||||
b: b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
|
||||
phi: u.dot(v),
|
||||
jdot_u: udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
|
||||
}
|
||||
}
|
||||
|
||||
/// `d·u = 0` for `d = p_B − p_A` and a direction `u` of A.
|
||||
fn dot2_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld) -> Row {
|
||||
let d = b.p - a.p;
|
||||
let dd = b.pdot - a.pdot;
|
||||
let ddd = cross2(b.w, b.r) - cross2(a.w, a.r);
|
||||
let (ud, udd) = (a.w.cross(u), cross2(a.w, u));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
|
||||
b: b.body.map(|i| (i, u, b.r.cross(u))),
|
||||
phi: d.dot(u),
|
||||
jdot_u: ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
|
||||
}
|
||||
}
|
||||
|
||||
fn joint_rows(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld, rows: &mut Vec<Row>) {
|
||||
let [ax, ay, az] = a.axes;
|
||||
let [bx, by, bz] = b.axes;
|
||||
let point = |rows: &mut Vec<Row>| rows.extend([Vec3::X, Vec3::Y, Vec3::Z].map(|e| point_row(a, b, e)));
|
||||
match kind {
|
||||
JointKind::Rigid => {
|
||||
point(rows);
|
||||
rows.extend([dot1_row(a, ay, b, bz), dot1_row(a, az, b, bx), dot1_row(a, ax, b, by)]);
|
||||
}
|
||||
JointKind::Revolute => {
|
||||
point(rows);
|
||||
rows.extend([dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz)]);
|
||||
}
|
||||
JointKind::Ball => point(rows),
|
||||
JointKind::Cylindrical => {
|
||||
rows.extend([dot2_row(a, ax, b), dot2_row(a, ay, b), dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz)]);
|
||||
}
|
||||
JointKind::Slider => {
|
||||
rows.extend([dot2_row(a, ax, b), dot2_row(a, ay, b), dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz), dot1_row(a, ax, b, by)]);
|
||||
}
|
||||
JointKind::Planar => {
|
||||
rows.extend([dot2_row(a, az, b), dot1_row(a, az, b, bx), dot1_row(a, az, b, by)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint's values and their rates (see [`JointKind::values`]).
|
||||
fn joint_values(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld) -> (Vec<f64>, Vec<f64>) {
|
||||
let [ax, ay, az] = a.axes;
|
||||
let bx = b.axes[0];
|
||||
let d = b.p - a.p;
|
||||
let dd = b.pdot - a.pdot;
|
||||
let angle = || bx.dot(ay).atan2(bx.dot(ax));
|
||||
let spin = || (b.w - a.w).dot(az);
|
||||
let along = |u: Vec3| (d.dot(u), dd.dot(u) + d.dot(a.w.cross(u)));
|
||||
match kind {
|
||||
JointKind::Rigid => (vec![], vec![]),
|
||||
JointKind::Revolute => (vec![angle()], vec![spin()]),
|
||||
JointKind::Slider => {
|
||||
let (s, r) = along(az);
|
||||
(vec![s], vec![r])
|
||||
}
|
||||
JointKind::Cylindrical => {
|
||||
let (s, r) = along(az);
|
||||
(vec![angle(), s], vec![spin(), r])
|
||||
}
|
||||
JointKind::Planar => {
|
||||
let ((x, xr), (y, yr)) = (along(ax), along(ay));
|
||||
(vec![x, y, angle()], vec![xr, yr, spin()])
|
||||
}
|
||||
JointKind::Ball => {
|
||||
// B's turn from A as a rotation vector in A's frame, and the relative angular
|
||||
// velocity in A's frame.
|
||||
let am = Mat3::from_cols(ax, ay, az);
|
||||
let rel = am.transpose().mul(&Mat3::from_cols(b.axes[0], b.axes[1], b.axes[2]));
|
||||
let rv = Quat::from_mat3(&rel).map_or(Vec3::ZERO, Quat::rotation_vector);
|
||||
let w = am.transpose().mul_vec(b.w - a.w);
|
||||
(vec![rv.x, rv.y, rv.z], vec![w.x, w.y, w.z])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testing {
|
||||
//! Finite-difference checks of the Jacobians and `J̇u` (tests only).
|
||||
use super::*;
|
||||
|
||||
/// `(Φ, J u, J̇ u)` of every row at `y`.
|
||||
pub(crate) fn rows_at(sys: &System, y: &[f64]) -> Vec<(f64, f64, f64)> {
|
||||
let kin = sys.kinematics(y);
|
||||
sys.rows(&kin).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
|
||||
}
|
||||
|
||||
/// The state moved along its velocities for `h` (positions and orientations only).
|
||||
pub(crate) fn drift(sys: &System, y: &[f64], h: f64) -> Vec<f64> {
|
||||
let mut out = y.to_vec();
|
||||
let kin = sys.kinematics(y);
|
||||
for (i, k) in kin.iter().enumerate() {
|
||||
let at = i * STRIDE;
|
||||
put3(&mut out, at, k.x + k.v * h);
|
||||
let q = Quat::from_rotation_vector(k.w * h).compose(quat(y, at + 3));
|
||||
put_quat(&mut out, at + 3, q);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
//! Analytic checks: free fall, pendulum periods, spring-mass oscillation, energy and momentum
|
||||
//! conservation, slider-crank kinematics, joint reactions, Jacobians against finite
|
||||
//! differences, and hostile input.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::system::{System, testing};
|
||||
use crate::*;
|
||||
|
||||
const G: f64 = STANDARD_GRAVITY;
|
||||
|
||||
fn down() -> Vec3 {
|
||||
Vec3::new(0.0, 0.0, -G)
|
||||
}
|
||||
|
||||
fn ok<T>(r: Result<T>) -> T {
|
||||
match r {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("simulation failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A body hanging `len` mm under a hinge at the origin turning about Y, at angle `theta` from
|
||||
/// straight down (towards +X) and turning at `omega`.
|
||||
fn pendulum(len: f64, theta: f64, omega: f64, inertia: f64) -> Model {
|
||||
let dir = Vec3::new(theta.sin(), 0.0, -theta.cos());
|
||||
// Turning about +Y takes −Z towards −X; the hinge's own Y axis is the world's.
|
||||
let rot = Quat::from_axis_angle(Vec3::Y, -theta);
|
||||
let mut body = Body::at_rest(1.0, Vec3::new(inertia, inertia, inertia), dir * len);
|
||||
body.orientation = rot;
|
||||
body.angular_velocity = Vec3::Y * -omega;
|
||||
body.velocity = (Vec3::Y * -omega).cross(dir * len);
|
||||
let joint = Joint {
|
||||
kind: JointKind::Revolute,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Y, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::new(0.0, 0.0, len), Vec3::Y, Vec3::X),
|
||||
};
|
||||
Model { bodies: vec![body], joints: vec![joint], forces: vec![], gravity: down() }
|
||||
}
|
||||
|
||||
/// Times where `f` crosses zero upwards, linearly interpolated between samples.
|
||||
fn upward_crossings(traj: &Trajectory, f: impl Fn(&Sample) -> f64) -> Vec<f64> {
|
||||
traj.samples
|
||||
.windows(2)
|
||||
.filter_map(|w| {
|
||||
let (a, b) = (w.first()?, w.get(1)?);
|
||||
let (fa, fb) = (f(a), f(b));
|
||||
(fa < 0.0 && fb >= 0.0).then(|| a.time + (b.time - a.time) * fa / (fa - fb))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean_period(crossings: &[f64]) -> f64 {
|
||||
assert!(crossings.len() >= 3, "too few crossings: {crossings:?}");
|
||||
let (first, last) = (crossings.first().copied().unwrap_or(0.0), crossings.last().copied().unwrap_or(0.0));
|
||||
(last - first) / (crossings.len() - 1) as f64
|
||||
}
|
||||
|
||||
fn angle_of(s: &Sample) -> f64 {
|
||||
s.joints.first().and_then(|j| j.values.first()).copied().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_fall_follows_the_parabola() {
|
||||
let mut body = Body::at_rest(2.5, Vec3::new(10.0, 20.0, 30.0), Vec3::new(1.0, 2.0, 3.0));
|
||||
body.velocity = Vec3::new(10.0, -5.0, 50.0);
|
||||
body.angular_velocity = Vec3::new(0.0, 0.0, 1.0);
|
||||
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], gravity: down() };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 0.01, 0.1)));
|
||||
assert_eq!(traj.samples.len(), 11);
|
||||
for s in &traj.samples {
|
||||
let t = s.time;
|
||||
let expect = body.position + body.velocity * t + down() * (0.5 * t * t);
|
||||
let got = s.bodies.first().map(|b| b.position).unwrap_or(Vec3::ZERO);
|
||||
assert!(got.dist(expect) < 1e-7, "t={t}: {got:?} vs {expect:?}");
|
||||
// A symmetric body spinning about z keeps spinning.
|
||||
let w = s.bodies.first().map(|b| b.angular_velocity).unwrap_or(Vec3::ZERO);
|
||||
assert!(w.dist(Vec3::Z) < 1e-12);
|
||||
// Energy is conserved exactly enough.
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
assert!((s.energy.total() - e0).abs() < 1e-6 * e0.abs().max(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pendulum_small_angle_period() {
|
||||
let (len, inertia) = (100.0, 40.0);
|
||||
let i_pivot = inertia + len * len;
|
||||
let period = TAU * (i_pivot / (G * len)).sqrt();
|
||||
let amplitude = 0.01;
|
||||
let model = pendulum(len, 0.0, amplitude * TAU / period, inertia);
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 1e-3, 1e-3)));
|
||||
let measured = mean_period(&upward_crossings(&traj, angle_of));
|
||||
// The finite amplitude lengthens the period by about θ²/16 (6e-6 here).
|
||||
assert!((measured - period).abs() < 1e-4 * period, "period {measured} vs {period}");
|
||||
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_integration_matches_the_pendulum_period() {
|
||||
let (len, inertia) = (100.0, 40.0);
|
||||
let period = TAU * ((inertia + len * len) / (G * len)).sqrt();
|
||||
let model = pendulum(len, 0.0, 0.01 * TAU / period, inertia);
|
||||
let traj = ok(simulate(&model, &Settings::adaptive(3.0, 1e-3, 1e-9, 1e-9)));
|
||||
let measured = mean_period(&upward_crossings(&traj, angle_of));
|
||||
assert!((measured - period).abs() < 1e-4 * period, "period {measured} vs {period}");
|
||||
// Larger tolerances take fewer steps than samples (the output still lands on each).
|
||||
let coarse = Settings {
|
||||
integrator: Integrator::Adaptive { rel_tol: 1e-6, abs_tol: 1e-6, min_step: 1e-9, max_step: 0.05 },
|
||||
..Settings::rk4(3.0, 1.0, 0.05)
|
||||
};
|
||||
let traj = ok(simulate(&model, &coarse));
|
||||
assert_eq!(traj.samples.len(), 61);
|
||||
let measured = mean_period(&upward_crossings(&traj, angle_of));
|
||||
assert!((measured - period).abs() < 2e-3 * period, "coarse period {measured} vs {period}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanging_body_pin_carries_its_weight() {
|
||||
let model = pendulum(100.0, 0.0, 0.0, 40.0);
|
||||
let traj = ok(simulate(&model, &Settings::rk4(0.1, 1e-3, 0.05)));
|
||||
for s in &traj.samples {
|
||||
let j = s.joints.first().map(|j| (j.force, j.torque)).unwrap_or_default();
|
||||
assert!(j.0.dist(Vec3::new(0.0, 0.0, G)) < 1e-6 * G, "force {:?}", j.0);
|
||||
assert!(j.1.len() < 1e-6 * G, "torque {:?}", j.1);
|
||||
assert!(angle_of(s).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_swing_conserves_energy() {
|
||||
// Released from horizontal: a compound pendulum swinging through ±90°.
|
||||
let model = pendulum(100.0, PI / 2.0, 0.0, 400.0);
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
let scale = G * 100.0;
|
||||
for s in &traj.samples {
|
||||
assert!((s.energy.total() - e0).abs() < 1e-6 * scale, "t={} drift {}", s.time, s.energy.total() - e0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two links hinged about Y, released from horizontal.
|
||||
fn double_pendulum() -> Model {
|
||||
let link = |x: f64| Body::at_rest(1.0, Vec3::new(100.0, 900.0, 900.0), Vec3::new(x, 0.0, 0.0));
|
||||
let hinge = |a: Option<usize>, pa: Vec3, b: usize, pb: Vec3| Joint {
|
||||
kind: JointKind::Revolute,
|
||||
a: Marker::new(a, pa, Vec3::Y, Vec3::X),
|
||||
b: Marker::new(Some(b), pb, Vec3::Y, Vec3::X),
|
||||
};
|
||||
Model {
|
||||
bodies: vec![link(50.0), link(150.0)],
|
||||
joints: vec![
|
||||
hinge(None, Vec3::ZERO, 0, Vec3::new(-50.0, 0.0, 0.0)),
|
||||
hinge(Some(0), Vec3::new(50.0, 0.0, 0.0), 1, Vec3::new(-50.0, 0.0, 0.0)),
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_pendulum_conserves_energy() {
|
||||
let traj = ok(simulate(&double_pendulum(), &Settings::rk4(5.0, 5e-4, 1e-2)));
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
let scale = 2.0 * G * 150.0;
|
||||
let worst = traj.samples.iter().map(|s| (s.energy.total() - e0).abs()).fold(0.0, f64::max);
|
||||
assert!(worst < 1e-5 * scale, "energy drift {worst} of {scale}");
|
||||
// It did swing hard: the lower link went well past vertical.
|
||||
let lowest = traj.samples.iter().filter_map(|s| s.bodies.get(1)).map(|b| b.position.z).fold(0.0, f64::min);
|
||||
assert!(lowest < -100.0, "{lowest}");
|
||||
assert_eq!(traj.stats.redundant_equations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_drift_stays_bounded_over_long_runs() {
|
||||
// Projection holds the joints to its tolerance (1e-10) however long the run.
|
||||
let traj = ok(simulate(&double_pendulum(), &Settings::rk4(20.0, 1e-3, 0.1)));
|
||||
assert!(traj.stats.max_constraint_error <= 1e-10, "{:?}", traj.stats);
|
||||
// Without projection, Baumgarte terms keep the drift bounded too, if less tightly.
|
||||
let settings = Settings { projection: false, baumgarte: Some((20.0, 20.0)), ..Settings::rk4(20.0, 1e-3, 0.1) };
|
||||
let traj = ok(simulate(&double_pendulum(), &settings));
|
||||
assert!(traj.stats.max_constraint_error < 1e-3, "{:?}", traj.stats);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_crank_follows_its_closed_form() {
|
||||
let (r, l) = (50.0, 150.0);
|
||||
let theta0 = PI / 3.0;
|
||||
let pin = Vec3::new(r * theta0.cos(), 0.0, -r * theta0.sin());
|
||||
let s0 = r * theta0.cos() + (l * l - (r * theta0.sin()).powi(2)).sqrt();
|
||||
let slide = Vec3::new(s0, 0.0, 0.0);
|
||||
let u = (slide - pin) * (1.0 / l);
|
||||
let mut crank = Body::at_rest(1.0, Vec3::new(100.0, 2000.0, 2000.0), pin * 0.5);
|
||||
crank.orientation = Quat::from_axis_angle(Vec3::Y, theta0);
|
||||
crank.angular_velocity = Vec3::Y * TAU;
|
||||
crank.velocity = (Vec3::Y * TAU).cross(pin * 0.5);
|
||||
let mut rod = Body::at_rest(0.5, Vec3::new(50.0, 1000.0, 1000.0), (pin + slide) * 0.5);
|
||||
rod.orientation = Quat::from_axis_angle(Vec3::Y, (-u.z).atan2(u.x));
|
||||
let slider = Body::at_rest(0.3, Vec3::new(50.0, 50.0, 50.0), slide);
|
||||
let hinge = |a: Option<usize>, pa: Vec3, b: usize, pb: Vec3| Joint {
|
||||
kind: JointKind::Revolute,
|
||||
a: Marker::new(a, pa, Vec3::Y, Vec3::X),
|
||||
b: Marker::new(Some(b), pb, Vec3::Y, Vec3::X),
|
||||
};
|
||||
let model = Model {
|
||||
bodies: vec![crank, rod, slider],
|
||||
joints: vec![
|
||||
hinge(None, Vec3::ZERO, 0, Vec3::new(-r / 2.0, 0.0, 0.0)),
|
||||
hinge(Some(0), Vec3::new(r / 2.0, 0.0, 0.0), 1, Vec3::new(-l / 2.0, 0.0, 0.0)),
|
||||
hinge(Some(1), Vec3::new(l / 2.0, 0.0, 0.0), 2, Vec3::ZERO),
|
||||
Joint { kind: JointKind::Slider, a: Marker::ground(Vec3::ZERO, Vec3::X, Vec3::Y), b: Marker::new(Some(2), Vec3::ZERO, Vec3::X, Vec3::Y) },
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
// A planar linkage built from 3D joints repeats three equations; they are solved, not refused.
|
||||
assert_eq!(traj.stats.redundant_equations, 3);
|
||||
let mut turned = 0.0_f64;
|
||||
let mut last = theta0;
|
||||
for s in &traj.samples {
|
||||
let theta = angle_of(s);
|
||||
let x = s.joints.get(3).and_then(|j| j.values.first()).copied().unwrap_or(f64::NAN);
|
||||
let expect = r * theta.cos() + (l * l - (r * theta.sin()).powi(2)).sqrt();
|
||||
assert!((x - expect).abs() < 1e-6, "t={}: slider {x} vs {expect}", s.time);
|
||||
let step = (theta - last + PI).rem_euclid(TAU) - PI;
|
||||
turned += step;
|
||||
last = theta;
|
||||
}
|
||||
assert!(turned.abs() > TAU, "the crank turned {turned} rad");
|
||||
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
|
||||
// No forces: kinetic energy is conserved.
|
||||
let e0 = traj.samples.first().map(|s| s.energy.kinetic).unwrap_or(0.0);
|
||||
assert!(traj.samples.iter().all(|s| (s.energy.kinetic - e0).abs() < 1e-6 * e0), "kinetic energy drifted");
|
||||
}
|
||||
|
||||
/// A body on a slider along X, pulled toward the origin by a spring of free length 40 mm,
|
||||
/// starting 10 mm stretched, with gravity across the slide.
|
||||
fn spring_mass(damping: f64) -> Model {
|
||||
Model {
|
||||
bodies: vec![Body::at_rest(2.0, Vec3::new(500.0, 500.0, 500.0), Vec3::new(50.0, 0.0, 0.0))],
|
||||
joints: vec![Joint {
|
||||
kind: JointKind::Slider,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::X, Vec3::Y),
|
||||
b: Marker::new(Some(0), Vec3::ZERO, Vec3::X, Vec3::Y),
|
||||
}],
|
||||
forces: vec![Force::Spring {
|
||||
a: Anchor { body: None, point: Vec3::ZERO },
|
||||
b: Anchor { body: Some(0), point: Vec3::ZERO },
|
||||
stiffness: 800.0,
|
||||
damping,
|
||||
free_length: 40.0,
|
||||
preload: 0.0,
|
||||
}],
|
||||
gravity: down(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_mass_oscillates_at_its_natural_frequency() {
|
||||
// ω = √(k/m) = 20 rad/s.
|
||||
let traj = ok(simulate(&spring_mass(0.0), &Settings::rk4(1.0, 1e-4, 1e-3)));
|
||||
for s in &traj.samples {
|
||||
let x = s.bodies.first().map(|b| b.position.x).unwrap_or(f64::NAN);
|
||||
let expect = 40.0 + 10.0 * (20.0 * s.time).cos();
|
||||
assert!((x - expect).abs() < 1e-6, "t={}: {x} vs {expect}", s.time);
|
||||
// The slide holds the body up against gravity.
|
||||
let f = s.joints.first().map(|j| j.force).unwrap_or_default();
|
||||
assert!((f.z - 2.0 * G).abs() < 1e-6 * G && f.x.abs() < 1e-6 * G, "reaction {f:?}");
|
||||
}
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
assert!(traj.samples.iter().all(|s| (s.energy.total() - e0).abs() < 1e-8 * 800.0 * 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_damper_decays_as_the_closed_form() {
|
||||
// c = 8 kg/s: ζ = c / (2√(km)) = 0.1.
|
||||
let (wn, zeta) = (20.0_f64, 0.1_f64);
|
||||
let wd = wn * (1.0 - zeta * zeta).sqrt();
|
||||
let traj = ok(simulate(&spring_mass(8.0), &Settings::rk4(1.0, 1e-4, 1e-3)));
|
||||
for s in &traj.samples {
|
||||
let t = s.time;
|
||||
let x = s.bodies.first().map(|b| b.position.x).unwrap_or(f64::NAN);
|
||||
let expect = 40.0 + (-zeta * wn * t).exp() * 10.0 * ((wd * t).cos() + zeta * wn / wd * (wd * t).sin());
|
||||
assert!((x - expect).abs() < 1e-6, "t={t}: {x} vs {expect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torque_free_body_keeps_energy_and_momentum() {
|
||||
let mut body = Body::at_rest(1.0, Vec3::new(1000.0, 2000.0, 3000.0), Vec3::ZERO);
|
||||
body.orientation = Quat::from_axis_angle(Vec3::new(1.0, 2.0, 3.0), 0.7);
|
||||
body.angular_velocity = Vec3::new(1.0, 0.3, 5.0);
|
||||
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], gravity: Vec3::ZERO };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-4, 1e-2)));
|
||||
let momentum = |s: &Sample| {
|
||||
let b = s.bodies.first().copied();
|
||||
b.map_or(Vec3::ZERO, |b| {
|
||||
let r = b.orientation.to_mat3();
|
||||
r.mul(&Mat3::diagonal(Vec3::new(1000.0, 2000.0, 3000.0))).mul(&r.transpose()).mul_vec(b.angular_velocity)
|
||||
})
|
||||
};
|
||||
let (l0, e0) = traj.samples.first().map(|s| (momentum(s), s.energy.kinetic)).unwrap_or_default();
|
||||
let mut tumbled = false;
|
||||
for s in &traj.samples {
|
||||
assert!(momentum(s).dist(l0) < 1e-7 * l0.len(), "t={} momentum {:?} vs {l0:?}", s.time, momentum(s));
|
||||
assert!((s.energy.kinetic - e0).abs() < 1e-7 * e0);
|
||||
let w = s.bodies.first().map(|b| b.angular_velocity).unwrap_or_default();
|
||||
tumbled |= w.dist(Vec3::new(1.0, 0.3, 5.0)) > 0.1;
|
||||
}
|
||||
assert!(tumbled, "the angular velocity should precess");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spherical_pendulum_keeps_energy_and_vertical_momentum() {
|
||||
// A body on a ball joint, tilted and pushed sideways: it swings round the vertical.
|
||||
let com = Vec3::new(60.0, 0.0, -80.0);
|
||||
let mut body = Body::at_rest(1.5, Vec3::new(300.0, 500.0, 700.0), com);
|
||||
body.velocity = Vec3::new(0.0, 400.0, 0.0);
|
||||
let model = Model {
|
||||
bodies: vec![body],
|
||||
joints: vec![Joint {
|
||||
kind: JointKind::Ball,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(0), -com, Vec3::Z, Vec3::X),
|
||||
}],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 5e-4, 1e-2)));
|
||||
let lz = |s: &Sample| {
|
||||
s.bodies.first().map_or(0.0, |b| {
|
||||
let r = b.orientation.to_mat3();
|
||||
let iw = r.mul(&Mat3::diagonal(Vec3::new(300.0, 500.0, 700.0))).mul(&r.transpose());
|
||||
(b.position.cross(b.velocity * 1.5) + iw.mul_vec(b.angular_velocity)).z
|
||||
})
|
||||
};
|
||||
let (l0, e0) = traj.samples.first().map(|s| (lz(s), s.energy.total())).unwrap_or_default();
|
||||
assert!(l0.abs() > 1e3);
|
||||
for s in &traj.samples {
|
||||
assert!((lz(s) - l0).abs() < 1e-6 * l0.abs(), "t={}: Lz {} vs {l0}", s.time, lz(s));
|
||||
assert!((s.energy.total() - e0).abs() < 1e-6 * G * 100.0, "t={}: energy drift {}", s.time, s.energy.total() - e0);
|
||||
// The joint holds the body's pivot at the origin.
|
||||
let pivot = s.bodies.first().map(|b| b.position + b.orientation.rotate(-com)).unwrap_or_default();
|
||||
assert!(pivot.len() < 1e-9, "{pivot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every joint kind between two moving bodies and between the ground and a body: the
|
||||
/// Jacobian and `J̇u` agree with finite differences of `Φ` along the motion.
|
||||
#[test]
|
||||
fn jacobians_match_finite_differences() {
|
||||
let kinds = [JointKind::Rigid, JointKind::Revolute, JointKind::Slider, JointKind::Cylindrical, JointKind::Planar, JointKind::Ball];
|
||||
let mut a = Body::at_rest(1.0, Vec3::new(1.0, 2.0, 3.0), Vec3::new(10.0, -20.0, 5.0));
|
||||
a.orientation = Quat::from_axis_angle(Vec3::new(0.3, -1.0, 0.4), 0.9);
|
||||
a.velocity = Vec3::new(3.0, -1.0, 2.0);
|
||||
a.angular_velocity = Vec3::new(0.7, -0.4, 1.1);
|
||||
let mut b = Body::at_rest(2.0, Vec3::new(3.0, 2.0, 1.0), Vec3::new(-5.0, 15.0, 30.0));
|
||||
b.orientation = Quat::from_axis_angle(Vec3::new(1.0, 0.2, -0.5), -1.3);
|
||||
b.velocity = Vec3::new(-2.0, 4.0, 1.0);
|
||||
b.angular_velocity = Vec3::new(-0.5, 1.3, 0.2);
|
||||
for kind in kinds {
|
||||
for a_body in [Some(0), None] {
|
||||
let joint = Joint {
|
||||
kind,
|
||||
a: Marker::new(a_body, Vec3::new(4.0, -3.0, 7.0), Vec3::new(0.2, 0.5, 1.0), Vec3::new(1.0, 0.0, 0.3)),
|
||||
b: Marker::new(Some(1), Vec3::new(-6.0, 2.0, 1.0), Vec3::new(-0.4, 1.0, 0.1), Vec3::new(0.0, 0.3, 1.0)),
|
||||
};
|
||||
let model = Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], gravity: Vec3::ZERO };
|
||||
let sys = ok(System::new(&model, None));
|
||||
let y = ok(System::initial_state(&model));
|
||||
let h = 1e-4;
|
||||
let (now, plus, minus) = (
|
||||
testing::rows_at(&sys, &y),
|
||||
testing::rows_at(&sys, &testing::drift(&sys, &y, h)),
|
||||
testing::rows_at(&sys, &testing::drift(&sys, &y, -h)),
|
||||
);
|
||||
assert_eq!(now.len(), kind.equations());
|
||||
for ((phi, ju, jdu), ((p, _, _), (m, _, _))) in now.iter().zip(plus.iter().zip(&minus)) {
|
||||
let rate = (p - m) / (2.0 * h);
|
||||
let accel = (p - 2.0 * phi + m) / (h * h);
|
||||
assert!((rate - ju).abs() < 1e-5 * (1.0 + ju.abs()), "{kind:?} {a_body:?}: J u {ju} vs {rate}");
|
||||
assert!((accel - jdu).abs() < 1e-3 * (1.0 + jdu.abs()), "{kind:?} {a_body:?}: J̇ u {jdu} vs {accel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_values_read_the_relative_motion() {
|
||||
// A body on each joint kind, displaced along its free directions, reads them back.
|
||||
let place = |kind: JointKind, offset: Vec3, turn: f64| {
|
||||
let mut body = Body::at_rest(1.0, Vec3::new(1.0, 1.0, 1.0), offset);
|
||||
body.orientation = Quat::from_axis_angle(Vec3::Z, turn);
|
||||
let model = Model {
|
||||
bodies: vec![body],
|
||||
joints: vec![Joint { kind, a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X), b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X) }],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(0.01, 0.01, 0.01)));
|
||||
traj.samples.first().and_then(|s| s.joints.first()).map(|j| j.values.clone()).unwrap_or_default()
|
||||
};
|
||||
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
assert!(close(&place(JointKind::Revolute, Vec3::ZERO, 0.4), &[0.4]));
|
||||
assert!(close(&place(JointKind::Slider, Vec3::new(0.0, 0.0, 12.0), 0.0), &[12.0]));
|
||||
assert!(close(&place(JointKind::Cylindrical, Vec3::new(0.0, 0.0, -3.0), -1.0), &[-1.0, -3.0]));
|
||||
assert!(close(&place(JointKind::Planar, Vec3::new(5.0, -7.0, 0.0), 2.0), &[5.0, -7.0, 2.0]));
|
||||
assert!(close(&place(JointKind::Ball, Vec3::ZERO, 0.5), &[0.0, 0.0, 0.5]));
|
||||
assert!(close(&place(JointKind::Rigid, Vec3::ZERO, 0.0), &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_start_is_projected_onto_the_joints() {
|
||||
// The pendulum's body starts 2 mm off its hinge and moving across it.
|
||||
let mut model = pendulum(100.0, 0.3, 0.0, 40.0);
|
||||
if let Some(b) = model.bodies.first_mut() {
|
||||
b.position += Vec3::new(1.0, 2.0, -1.0);
|
||||
b.velocity = Vec3::new(0.0, 50.0, 0.0);
|
||||
}
|
||||
let traj = ok(simulate(&model, &Settings::rk4(0.5, 1e-3, 0.05)));
|
||||
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
|
||||
let y = traj.samples.first().and_then(|s| s.bodies.first()).map(|b| b.velocity.y).unwrap_or(f64::NAN);
|
||||
assert!(y.abs() < 1e-9, "velocity along the hinge is removed: {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let good = pendulum(100.0, 0.2, 0.0, 40.0);
|
||||
let settings = Settings::rk4(0.1, 1e-3, 1e-2);
|
||||
let mut cases: Vec<(Model, Settings)> = Vec::new();
|
||||
let mut with = |f: &dyn Fn(&mut Model)| {
|
||||
let mut m = good.clone();
|
||||
f(&mut m);
|
||||
cases.push((m, settings));
|
||||
};
|
||||
with(&|m| m.bodies.clear());
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = f64::NAN));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = 0.0));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = -1.0));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3::diagonal(Vec3::new(1.0, -1.0, 1.0))));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3([[1.0, 5.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3::ZERO));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.position.x = f64::INFINITY));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.orientation = Quat { w: 0.0, x: 0.0, y: 0.0, z: 0.0 }));
|
||||
with(&|m| m.bodies.iter_mut().for_each(|b| b.velocity.z = 1e300));
|
||||
with(&|m| m.gravity = Vec3::new(f64::NAN, 0.0, 0.0));
|
||||
with(&|m| m.joints.iter_mut().for_each(|j| j.b.body = Some(7)));
|
||||
with(&|m| m.joints.iter_mut().for_each(|j| j.a.body = j.b.body));
|
||||
with(&|m| m.joints.iter_mut().for_each(|j| j.a.z = Vec3::ZERO));
|
||||
with(&|m| m.joints.iter_mut().for_each(|j| j.b.x = j.b.z));
|
||||
with(&|m| m.forces.push(Force::Load { body: 3, point: Vec3::ZERO, force: Vec3::X, torque: Vec3::ZERO }));
|
||||
with(&|m| {
|
||||
m.forces.push(Force::Spring {
|
||||
a: Anchor { body: None, point: Vec3::ZERO },
|
||||
b: Anchor { body: Some(0), point: Vec3::ZERO },
|
||||
stiffness: -1.0,
|
||||
damping: 0.0,
|
||||
free_length: 0.0,
|
||||
preload: 0.0,
|
||||
})
|
||||
});
|
||||
with(&|m| {
|
||||
m.forces.push(Force::Spring {
|
||||
a: Anchor { body: Some(9), point: Vec3::ZERO },
|
||||
b: Anchor { body: Some(0), point: Vec3::ZERO },
|
||||
stiffness: 1.0,
|
||||
damping: 0.0,
|
||||
free_length: 0.0,
|
||||
preload: 0.0,
|
||||
})
|
||||
});
|
||||
with(&|m| m.bodies = vec![Body::at_rest(1.0, Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO); MAX_BODIES + 1]);
|
||||
with(&|m| {
|
||||
m.joints = vec![
|
||||
m.joints.first().copied().unwrap_or(Joint {
|
||||
kind: JointKind::Ball,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X)
|
||||
});
|
||||
MAX_JOINTS + 1
|
||||
]
|
||||
});
|
||||
let bad_settings = [
|
||||
Settings::rk4(0.0, 1e-3, 1e-2),
|
||||
Settings::rk4(-1.0, 1e-3, 1e-2),
|
||||
Settings::rk4(f64::NAN, 1e-3, 1e-2),
|
||||
Settings::rk4(1.0, 0.0, 1e-2),
|
||||
Settings::rk4(1.0, f64::INFINITY, 1e-2),
|
||||
Settings::rk4(1.0, 1e-3, 0.0),
|
||||
Settings::rk4(1e6, 1e-6, 1e-2),
|
||||
Settings::rk4(1e3, 1e-3, 1e-6),
|
||||
Settings { projection_tol: -1.0, ..settings },
|
||||
Settings { baumgarte: Some((f64::NAN, 1.0)), ..settings },
|
||||
Settings { integrator: Integrator::Adaptive { rel_tol: 1e-6, abs_tol: 1e-6, min_step: 1.0, max_step: 0.1 }, ..settings },
|
||||
Settings { integrator: Integrator::Adaptive { rel_tol: 0.0, abs_tol: 1e-6, min_step: 1e-9, max_step: 0.1 }, ..settings },
|
||||
];
|
||||
cases.extend(bad_settings.into_iter().map(|s| (good.clone(), s)));
|
||||
for (i, (m, s)) in cases.iter().enumerate() {
|
||||
assert!(simulate(m, s).is_err(), "case {i} should be refused");
|
||||
}
|
||||
// Inputs that are valid but extreme stop with an error or finish; they never panic.
|
||||
let stiff = Model {
|
||||
forces: vec![Force::Spring {
|
||||
a: Anchor { body: None, point: Vec3::ZERO },
|
||||
b: Anchor { body: Some(0), point: Vec3::ZERO },
|
||||
stiffness: 1e11,
|
||||
damping: 0.0,
|
||||
free_length: 0.0,
|
||||
preload: 0.0,
|
||||
}],
|
||||
..good.clone()
|
||||
};
|
||||
let _ = simulate(&stiff, &Settings::rk4(0.1, 1e-2, 1e-2));
|
||||
let tight = Settings { integrator: Integrator::Adaptive { rel_tol: 1e-14, abs_tol: 1e-14, min_step: 1e-3, max_step: 1e-3 }, ..settings };
|
||||
let _ = simulate(&stiff, &tight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_round_trip_through_json() {
|
||||
let mut model = spring_mass(1.5);
|
||||
model.forces.push(Force::Load { body: 0, point: Vec3::new(1.0, 0.0, 0.0), force: Vec3::new(0.0, 5.0, 0.0), torque: Vec3::ZERO });
|
||||
let text = match serde_json::to_string(&model) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("{e}"),
|
||||
};
|
||||
let back: Model = match serde_json::from_str(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => panic!("{e}"),
|
||||
};
|
||||
assert_eq!(back, model);
|
||||
let settings: Settings = match serde_json::from_str(r#"{"duration":1.0,"output_interval":0.1,"integrator":{"type":"rk4","step":0.001}}"#) {
|
||||
Ok(s) => s,
|
||||
Err(e) => panic!("{e}"),
|
||||
};
|
||||
assert!(settings.projection && settings.validate().is_ok());
|
||||
}
|
||||
@@ -1202,6 +1202,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let previous = s.doc.clone();
|
||||
let st = s.model.state();
|
||||
let placed = s.doc.body_frames_with_source(&st);
|
||||
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
|
||||
if st.body(name).is_some() {
|
||||
return Err(bad(cmd, format!("another body is called `{name}`")));
|
||||
@@ -1319,6 +1320,8 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, e));
|
||||
}
|
||||
d.rename_body_keys(&old, name);
|
||||
// A sketch projecting the body in another placement of its component keeps it.
|
||||
super::sketch_project::follow_placed_renames(d, &st, &placed, Some((&old, name)));
|
||||
s.remember_body_rename(&previous, &old, name);
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
@@ -394,6 +394,8 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", cmd)?;
|
||||
let name =
|
||||
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
let st = s.model.state();
|
||||
let placed = s.doc.body_frames_with_source(&st);
|
||||
let d = s.doc_mut();
|
||||
if id == 0 {
|
||||
d.name = name.to_string();
|
||||
@@ -406,6 +408,8 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.name = format!("{name}:{k}");
|
||||
}
|
||||
}
|
||||
// A sketch projecting a body of one of those placements keeps it.
|
||||
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
|
||||
}
|
||||
Ok(json!({"component": id, "name": name}))
|
||||
}
|
||||
|
||||
@@ -547,6 +547,8 @@ fn relink_repairs_a_moved_source_for_every_instance_in_one_undo_step() {
|
||||
assert!((volume(&mut s) - 12000.0).abs() < 1e-5);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), after);
|
||||
// A move not captured stops a save (#9736).
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path": assembly}));
|
||||
|
||||
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -105,7 +105,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -1335,7 +1335,8 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
|
||||
/// world axis (also as saved: {type: world, axis: X|Y|Z}), a construction axis by name
|
||||
/// (followed when it is edited), {origin, dir}, or an edge
|
||||
/// (followed when it is edited), {origin, dir} (each origin coordinate a number or an expression over
|
||||
/// the parameters, which the axis follows), or an edge
|
||||
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
|
||||
/// changes. A line of the sketch wins over a construction axis of the same name.
|
||||
pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Result<AxisRef> {
|
||||
@@ -1348,9 +1349,12 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
|
||||
str_(o, "axis").filter(|a| matches!(*a, "X" | "Y" | "Z")).ok_or_else(|| bad(cmd, "world axis requires `axis` to be X, Y or Z"))?;
|
||||
return Ok(AxisRef::World { axis: axis.into() });
|
||||
}
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
|
||||
return Ok(AxisRef::Line { origin: Position(origin), dir: Dir(dir) });
|
||||
let origin = point_param(s, o, "origin", cmd)?.unwrap_or_else(|| solidcraft_doc::point_expr(Vec3::ZERO));
|
||||
let dir = match o.get("dir") {
|
||||
None => return Err(bad(cmd, "axis needs `dir`")),
|
||||
Some(v) => vec3(v).ok_or_else(|| bad(cmd, "axis `dir` must be a direction [x, y, z] of numbers"))?,
|
||||
};
|
||||
return Ok(AxisRef::Line { origin, dir: Dir(dir) });
|
||||
}
|
||||
match str_(p, "axis") {
|
||||
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
|
||||
@@ -2827,9 +2831,16 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
|
||||
None => {
|
||||
// Into the face the position lies on: against the outward normal of the face
|
||||
// nearest it (planar or curved), as it faces out of its own body.
|
||||
// nearest it (planar or curved), as it faces out of its own body. A hole that
|
||||
// names the bodies it drills goes by the face of one of them where one passes
|
||||
// through the position, never that of a body touching it there (#12066).
|
||||
let named = match string_list(cmd, p, "participants")? {
|
||||
v if v.is_empty() => string_list(cmd, p, "targets")?,
|
||||
v => v,
|
||||
};
|
||||
let scope: &[String] = if named.iter().any(|b| b == "*") { &[] } else { &named };
|
||||
let st = s.model.state();
|
||||
solidcraft_doc::hole_direction_at(&st, position).ok_or_else(|| bad(cmd, "no face at `position`; give `direction`"))?
|
||||
solidcraft_doc::hole_direction_at(&st, position, scope).ok_or_else(|| bad(cmd, "no face at `position`; give `direction`"))?
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -520,6 +520,16 @@ fn write(s: &mut Session, cmd: &str, path: &str, format: &str, compression: &str
|
||||
// A path outside the agent's folders says so first, whatever its name.
|
||||
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
check_design_name(cmd, path)?;
|
||||
// A move not captured yet isn't in the document a file holds: refuse, rather than write the
|
||||
// file without it and say nothing (#9736). The app asks the Capture Position question first.
|
||||
// An exploded view shown saves as before: its placements aren't the design's.
|
||||
if s.moved_uncaptured() {
|
||||
let moved: Vec<&str> = s.doc.occurrences.iter().filter(|o| s.pending_moves.contains_key(&o.id)).map(|o| o.name.as_str()).collect();
|
||||
return Err(EngineError::Other(format!(
|
||||
"{path}: {} moved and not captured yet; component.capture_position keeps the move and component.revert_position puts it back, then save",
|
||||
moved.join(", ")
|
||||
)));
|
||||
}
|
||||
if s.path.as_deref() == Some(path) {
|
||||
if s.read_only {
|
||||
return Err(EngineError::Other(format!("{path}: the design was opened read-only; save it to another file with file.save_as")));
|
||||
@@ -1432,6 +1442,53 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #9736: a component moved and not captured yet is a change the file doesn't hold. The
|
||||
/// design reads as unsaved, and Save and Save As refuse, naming the component, until the move
|
||||
/// is captured (then the file has it) or reverted (then the design is as saved).
|
||||
#[test]
|
||||
fn a_move_not_captured_is_unsaved_and_saving_waits_for_it() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-pending-move-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("two.sc").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 10, "corner": [0, 0, 0], "body_name": "BaseB"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 30, "corner": [15, 15, 10], "body_name": "ArmB"})).unwrap();
|
||||
s.execute("component.from_bodies", &json!({"bodies": ["BaseB", "ArmB"]})).unwrap();
|
||||
s.execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
let arm = s.doc.occurrences.iter().find(|o| o.name.starts_with("ArmB")).unwrap().name.clone();
|
||||
let arm_x = |s: &Session| {
|
||||
let world = s.world_state().unwrap();
|
||||
let body = world.bodies.iter().find(|b| b.name.starts_with("ArmB")).unwrap();
|
||||
solidcraft_kernel::measure(&body.body).unwrap().centroid.x
|
||||
};
|
||||
for answer in ["component.revert_position", "component.capture_position"] {
|
||||
s.execute("occurrence.move", &json!({"occurrence": arm, "translate": [7, 0, 0], "capture": false})).unwrap();
|
||||
assert!(s.is_dirty(), "{answer}: the move is a change to save");
|
||||
for (cmd, p) in [("file.save", json!({})), ("file.save_as", json!({"path": dir.join("other.sc").to_string_lossy()}))] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(&arm) && e.contains("not captured") && e.contains("component.capture_position"), "{cmd}: {e}");
|
||||
}
|
||||
assert!(!dir.join("other.sc").exists(), "nothing written");
|
||||
s.execute(answer, &json!({})).unwrap();
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!s.is_dirty(), "{answer}");
|
||||
}
|
||||
let opened = Session::new(crate::io::read_design(&std::fs::read(&path).unwrap()).unwrap());
|
||||
assert!((arm_x(&opened) - 27.0).abs() < 1e-6, "the captured move is in the file: {}", arm_x(&opened));
|
||||
// An exploded view shown places the components only while it is shown: no change to
|
||||
// save, and the design saves as it is. A move on top of it is one.
|
||||
s.execute("explode.create", &json!({"name": "Apart", "moves": [{"occurrence": arm, "translate": [0, 0, 40]}]})).unwrap();
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
s.execute("explode.show", &json!({"view": "Apart"})).unwrap();
|
||||
assert!(!s.pending_moves.is_empty() && !s.is_dirty(), "an exploded view shown");
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
s.execute("occurrence.move", &json!({"occurrence": arm, "translate": [5, 0, 0], "capture": false})).unwrap();
|
||||
assert!(s.is_dirty());
|
||||
assert!(s.execute("file.save", &json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual warmed engine save and provisional-open benchmark"]
|
||||
fn benchmark_warm_design_save_with_preview() {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use sketch_construction::{
|
||||
};
|
||||
pub use sketch_create::{tangent_arc_preview, tangent_arc_starts_at};
|
||||
pub use sketch_import::drawing_import_command;
|
||||
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
|
||||
pub use sketch_modify::{TrimHover, extend_arc_preview, extend_end_at, extend_preview, trim_hover, trim_piece};
|
||||
pub use view_dxf::sketch::exportable as view_dxf_exportable;
|
||||
pub(crate) use view_navigation::camera_form;
|
||||
pub use view_navigation::standard_view;
|
||||
|
||||
@@ -14,7 +14,10 @@ use solidcraft_kernel::Body;
|
||||
use super::CommandSpec;
|
||||
use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, place_holding, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::motion::{FindLimitsParams, FindLimitsResult};
|
||||
use crate::schema::motion::{
|
||||
ContactCheck, ContactCollision, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetChange,
|
||||
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, FindLimitsParams, FindLimitsResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -39,26 +42,55 @@ macro_rules! trace_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const CONTACT_ENABLE_CONTRACT: TypedContract<ContactEnableParams> = TypedContract::new();
|
||||
const CONTACT_NONE_CONTRACT: TypedContract<ContactNoParams> = TypedContract::new();
|
||||
const CONTACT_CREATE_CONTRACT: TypedContract<ContactCreateParams> = TypedContract::new();
|
||||
const CONTACT_EDIT_CONTRACT: TypedContract<ContactEditParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("contact_sets").params(
|
||||
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
|
||||
),
|
||||
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", crate::checked!("contact.enable_sets", enable_sets))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("contact_sets")
|
||||
.contract(&CONTACT_ENABLE_CONTRACT)
|
||||
.output(output_schema_for::<ContactSwitch>)
|
||||
.params(
|
||||
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
|
||||
),
|
||||
CommandSpec::new("contact.enable_all", "Enable All Contact", crate::checked!("contact.enable_all", enable_all))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("contact_all")
|
||||
.contract(&CONTACT_NONE_CONTRACT)
|
||||
.output(output_schema_for::<ContactSwitch>)
|
||||
.params("every occurrence takes part in contact (one set of all)"),
|
||||
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("contact_off").params("contact off"),
|
||||
CommandSpec::new("contact.create", "New Contact Set", new_set)
|
||||
CommandSpec::new("contact.disable_all", "Disable Contact", crate::checked!("contact.disable_all", disable_all))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("contact_off")
|
||||
.contract(&CONTACT_NONE_CONTRACT)
|
||||
.output(output_schema_for::<ContactSwitch>)
|
||||
.params("contact off"),
|
||||
CommandSpec::new("contact.create", "New Contact Set", crate::checked!("contact.create", new_set))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("contact_new")
|
||||
.contract(&CONTACT_CREATE_CONTRACT)
|
||||
.output(output_schema_for::<ContactSetCreated>)
|
||||
.params("occurrences: [ids or names] (two or more); name?"),
|
||||
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set)
|
||||
CommandSpec::new("contact.edit", "Edit Contact Set", crate::checked!("contact.edit", edit_set))
|
||||
.icon("edit")
|
||||
.contract(&CONTACT_EDIT_CONTRACT)
|
||||
.output(output_schema_for::<ContactSetEdited>)
|
||||
.params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).icon("list").noundo().doc_only().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.check", "Check Contact", check)
|
||||
CommandSpec::new("contact.list", "List Contact Sets", crate::checked!("contact.list", list_sets))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&CONTACT_NONE_CONTRACT)
|
||||
.output(output_schema_for::<ContactSets>)
|
||||
.params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.check", "Check Contact", crate::checked!("contact.check", check))
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.contract(&CONTACT_NONE_CONTRACT)
|
||||
.output(output_schema_for::<ContactCheck>)
|
||||
.params("→ pairs in contact sets that pass through each other now"),
|
||||
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("play").params(
|
||||
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
|
||||
@@ -525,24 +557,30 @@ pub fn drive_joint_index(s: &mut Session, id: u64, q: Vec<f64>, index: usize) ->
|
||||
Ok(drive_with_contact(s, &[(id, q)], &[(id, index)].into_iter().collect())?.0)
|
||||
}
|
||||
|
||||
/// A contact command's reply as JSON: they build their replies from the types their output
|
||||
/// schemas describe, so the two can't drift apart.
|
||||
fn contact_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = bool_(p, "enabled").unwrap_or(true);
|
||||
s.doc_mut().assembly.contact_enabled = on;
|
||||
Ok(json!({"enabled": on}))
|
||||
let enabled = bool_(p, "enabled").unwrap_or(true);
|
||||
s.doc_mut().assembly.contact_enabled = enabled;
|
||||
contact_reply("contact.enable_sets", ContactSwitch { enabled, all: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn enable_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_enabled = true;
|
||||
a.contact_all = true;
|
||||
Ok(json!({"enabled": true, "all": true}))
|
||||
contact_reply("contact.enable_all", ContactSwitch { enabled: true, all: Some(true), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_enabled = false;
|
||||
a.contact_all = false;
|
||||
Ok(json!({"enabled": false}))
|
||||
contact_reply("contact.disable_all", ContactSwitch { enabled: false, all: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -560,7 +598,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
|
||||
a.contact_enabled = true;
|
||||
Ok(json!({"set": id, "name": name}))
|
||||
contact_reply(cmd, ContactSetCreated { set: id, name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn set_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
@@ -582,7 +620,7 @@ fn edit_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = set_index(s, p, cmd)?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
s.doc_mut().assembly.contact_sets.remove(i);
|
||||
return Ok(json!({"deleted": true}));
|
||||
return contact_reply(cmd, ContactSetEdited { change: ContactSetChange::Deleted { deleted: true }, param_warnings: Vec::new() });
|
||||
}
|
||||
let occ = if p.get("occurrences").is_some() { Some(occurrences(s, p, cmd)?) } else { None };
|
||||
if occ.as_ref().is_some_and(|o| o.len() < 2) {
|
||||
@@ -598,17 +636,19 @@ fn edit_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = bool_(p, "suppressed") {
|
||||
c.suppressed = x;
|
||||
}
|
||||
Ok(json!({"set": c.id}))
|
||||
let set = c.id;
|
||||
contact_reply(cmd, ContactSetEdited { change: ContactSetChange::Changed { set }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn list_sets(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &s.doc.assembly;
|
||||
Ok(json!({"enabled": a.contact_enabled, "all": a.contact_all, "sets": a.contact_sets}))
|
||||
let sets = a.contact_sets.iter().map(ContactSetInfo::from).collect();
|
||||
contact_reply("contact.list", ContactSets { enabled: a.contact_enabled, all: a.contact_all, sets, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let c = collisions(s);
|
||||
Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::<Vec<_>>()}))
|
||||
let collisions = collisions(s).into_iter().map(|(a, b, depth_mm)| ContactCollision { a, b, depth_mm }).collect();
|
||||
contact_reply("contact.check", ContactCheck { collisions, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
// ---- Motion studies ----
|
||||
@@ -1174,6 +1214,12 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"view": v.id, "name": v.name}))
|
||||
}
|
||||
|
||||
/// Whether the pending moves are an exploded view shown (`explode.show`), which places the
|
||||
/// occurrences only while it is shown: not a move to capture or save (#9736).
|
||||
pub(crate) fn shows_exploded_view(s: &Session) -> bool {
|
||||
s.doc.assembly.exploded.iter().any(|v| exploded_at(&explode_moves(s, v), 1.0) == s.pending_moves)
|
||||
}
|
||||
|
||||
/// What exploded view `v` moves: (occurrence, its placement, the translation the view adds). Moves
|
||||
/// of occurrences no longer in the design, or not finite (a hostile design file), are left out;
|
||||
/// a later move of one occurrence replaces an earlier one.
|
||||
|
||||
@@ -257,6 +257,41 @@ fn rules_and_canvases_follow_a_renamed_parameter() {
|
||||
assert_eq!(s.doc.sheet.rules[0].thickness, "gauge");
|
||||
}
|
||||
|
||||
/// #9623: outside a rule's own `thickness`, `Thickness` is the rule's thickness, not a design
|
||||
/// parameter of that name: renaming that parameter leaves it, and it doesn't count as a user.
|
||||
#[test]
|
||||
fn a_rules_own_thickness_is_not_a_parameter_named_thickness() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "Thickness", "expression": "2 mm"}));
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "Thickness", "bend_radius": "Thickness", "active": true}));
|
||||
run(&mut s, "plastic.manage_rules", json!({"name": "Housing", "thickness": "3 mm", "clearance": "Thickness / 10"}));
|
||||
let u = run(&mut s, "parameters.users", json!({"name": "Thickness"}));
|
||||
let fields: Vec<String> = u["users"].as_array().unwrap().iter().map(|x| x.to_string()).collect();
|
||||
assert_eq!(fields.len(), 1, "only the sheet metal rule's thickness: {u}");
|
||||
run(&mut s, "parameters.rename", json!({"name": "Thickness", "new_name": "Gauge"}));
|
||||
let r = &s.doc.sheet.rules[0];
|
||||
assert_eq!((r.thickness.as_str(), r.bend_radius.as_str(), r.gap.as_str()), ("Gauge", "Thickness", "Thickness"));
|
||||
let housing = s.doc.plastic.rules.iter().find(|r| r.name == "Housing").unwrap();
|
||||
assert_eq!(housing.clearance, "Thickness / 10");
|
||||
}
|
||||
|
||||
/// #9623: renaming a parameter to `Thickness` is refused while a rule field other than its own
|
||||
/// thickness uses it (that field would then read the rule's thickness), and nothing changes.
|
||||
#[test]
|
||||
fn renaming_to_thickness_is_refused_where_a_rule_would_read_its_own() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "Factor", "expression": "0.3", "unit": ""}));
|
||||
run(&mut s, "parameters.add", json!({"name": "Gauge", "expression": "2 mm"}));
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "Gauge", "k_factor": "Factor", "active": true}));
|
||||
let before = s.doc.clone();
|
||||
let e = err(&mut s, "parameters.rename", json!({"name": "Factor", "new_name": "Thickness"}));
|
||||
assert!(e.contains("sheet metal rule R (k_factor)"), "{e}");
|
||||
assert!(s.doc == before, "a refused rename changes nothing");
|
||||
// Used only by a rule's own thickness, the rename goes through.
|
||||
run(&mut s, "parameters.rename", json!({"name": "Gauge", "new_name": "Thickness"}));
|
||||
assert_eq!((s.doc.sheet.rules[0].thickness.as_str(), s.doc.sheet.rules[0].k_factor.as_str()), ("Thickness", "Factor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_comments_and_completion() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1218,3 +1218,24 @@ fn rectangular_pattern_of_a_join_on_a_hollow_part_copies_the_whole_boss() {
|
||||
);
|
||||
assert!(rel(volume(&mut s), 79500.0) < 1e-6, "patterned copy lost material");
|
||||
}
|
||||
|
||||
/// A Move that copies can be the source of a feature pattern (#5931): each pattern copy is
|
||||
/// another moved copy, placed round the axis, and the same as patterning the copied body.
|
||||
#[test]
|
||||
fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Block"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Block"], "translate": [10, 0, 0], "copy": true, "name": "Move1"}));
|
||||
let one = volume(&mut s);
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Move1"], "axis": "Z", "count": 4, "angle": "360 deg"}));
|
||||
assert!(rel(volume(&mut s), one * 2.5) < 1e-6 && s.world_state().unwrap().bodies.len() == 5, "the moved copy repeats three more times");
|
||||
// Moving the first copy elsewhere moves the repeats with it: they follow the feature.
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Move1", "command": "solid.move", "params": {"bodies": ["Block"], "translate": [20, 0, 0], "copy": true}}),
|
||||
);
|
||||
assert_eq!(s.world_state().unwrap().bodies.len(), 5);
|
||||
let far = s.world_state().unwrap().bodies.iter().map(|b| b.mesh().bounds().max.x).fold(f64::MIN, f64::max);
|
||||
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
|
||||
}
|
||||
|
||||
@@ -594,6 +594,26 @@ mod tests {
|
||||
assert!(s.execute("plastic.assign_rule", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
|
||||
}
|
||||
|
||||
/// #9623: `Thickness` in a rule's expressions is the rule's thickness, also when the design
|
||||
/// has a parameter of that name; the rule's own `thickness` may still use that parameter.
|
||||
#[test]
|
||||
fn thickness_in_a_plastic_rule_is_the_rules_even_beside_a_parameter_of_that_name() {
|
||||
let values = |param: Option<&str>, thickness: &str| -> Value {
|
||||
let mut s = Session::default();
|
||||
if let Some(e) = param {
|
||||
run(&mut s, "parameters.add", json!({"name": "Thickness", "expression": e}));
|
||||
}
|
||||
run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "thickness": thickness, "clearance": "Thickness / 10"}))["rule"]["values"]
|
||||
.clone()
|
||||
};
|
||||
let want = values(None, "3 mm");
|
||||
assert_eq!((want["thickness"].as_f64(), want["nominal_radius"].as_f64()), (Some(3.0), Some(1.5)), "{want}");
|
||||
assert!(want["clearance"].as_f64().is_some_and(|c| (c - 0.3).abs() < 1e-12), "{want}");
|
||||
assert!(want["thickness_variation"].as_f64().is_some_and(|v| (v - 0.45).abs() < 1e-12), "{want}");
|
||||
assert_eq!(values(Some("10 mm"), "3 mm"), want);
|
||||
assert_eq!(values(Some("3 mm"), "Thickness"), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groove_takes_the_rule_clearance() {
|
||||
let mut g = Session::default();
|
||||
|
||||
@@ -378,6 +378,29 @@ fn construction_axis_revolve_follows_the_axis() {
|
||||
assert!((volume(&s) - 80.0 * PI).abs() < 0.05, "{}", volume(&s));
|
||||
}
|
||||
|
||||
/// A revolve finds its construction axis among the features before it, so moving the axis
|
||||
/// behind the revolve breaks it, and moving it back mends it, at once (#9544).
|
||||
#[test]
|
||||
fn construction_axis_moved_behind_its_revolve_and_back() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[0,0,0]},"b":{"point":[0,30,0]},"name":"Axis1"}));
|
||||
profile(&mut s, false);
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"Profile","axis":"Axis1","angle":"180 deg","operation":"new","profiles":[0],"name":"Turn"}));
|
||||
let error = s.execute("timeline.reorder", &json!({"feature":"Axis1","position":2})).unwrap_err().to_string();
|
||||
assert!(error.contains("moving Axis1 there breaks Turn"), "{error}");
|
||||
assert_eq!(s.doc.features[0].name, "Axis1");
|
||||
let moved = run(&mut s, "timeline.reorder", json!({"feature":"Axis1","position":2,"allow_errors":true}));
|
||||
assert_eq!(moved["broken"][0]["feature"], "Turn", "{moved}");
|
||||
let turn = s.doc.find_feature("Turn").unwrap().id;
|
||||
let error = |s: &Session| s.model.result(turn).and_then(|r| r.error.clone());
|
||||
assert!(error(&s).is_some_and(|e| e.contains("Axis1")), "{:?}", error(&s));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
// Moved back, the revolve finds its axis again.
|
||||
run(&mut s, "timeline.reorder", json!({"feature":"Axis1","position":0}));
|
||||
assert_eq!(error(&s), None);
|
||||
assert!((solidcraft_kernel::measure(&s.model.state().bodies[0].body).unwrap().volume - 60.0 * PI).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_axis_revolve_rejects_invalid_axes_atomically() {
|
||||
for (a, b, message) in [
|
||||
@@ -450,3 +473,74 @@ fn invalid_world_axis_objects_are_rejected_atomically() {
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_axis_origin_takes_expressions_and_follows_parameters() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
profile(&mut s, false);
|
||||
let volume = |s: &Session| solidcraft_kernel::measure(&s.model.state().bodies[0].body).unwrap().volume;
|
||||
let axis = json!({"origin": ["W / 2 - 20", 0, 0], "dir": [0, 1, 0]});
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": axis, "name": "Turn", "operation": "new"}));
|
||||
// W / 2 - 20 is 0 mm: radii 2 to 4 about x = 0.
|
||||
assert!((volume(&s) - 120.0 * PI).abs() < 0.2, "{}", volume(&s));
|
||||
let FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Line { origin, .. }, .. } = &s.doc.find_feature("Turn").unwrap().kind else {
|
||||
panic!("not a line axis");
|
||||
};
|
||||
assert_eq!(origin[0].as_str(), "W / 2 - 20");
|
||||
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.find_feature("Turn").unwrap().kind, s.doc.find_feature("Turn").unwrap().kind);
|
||||
// Moving the axis follows W: the origin is at x = 1, so the radii are 1 to 3.
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": "42 mm"}));
|
||||
assert!((volume(&s) - 80.0 * PI).abs() < 0.2, "{}", volume(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_axis_refuses_an_origin_or_dir_it_cannot_read() {
|
||||
let mut s = Session::default();
|
||||
profile(&mut s, false);
|
||||
let before = s.doc.to_json();
|
||||
for axis in [
|
||||
json!({"origin": ["no_such_parameter", 0, 0], "dir": [0, 1, 0]}),
|
||||
json!({"origin": ["1 +", 0, 0], "dir": [0, 1, 0]}),
|
||||
json!({"origin": [0, 0], "dir": [0, 1, 0]}),
|
||||
json!({"origin": [null, 0, 0], "dir": [0, 1, 0]}),
|
||||
json!({"origin": [0, 0, 0], "dir": ["W", 1, 0]}),
|
||||
] {
|
||||
let error = s.execute("solid.revolve", &json!({"sketch": "Profile", "axis": axis, "operation": "new"})).unwrap_err();
|
||||
assert!(matches!(error, crate::EngineError::BadParams { .. }), "{axis}: {error}");
|
||||
assert!(!error.to_string().contains("sketch plane"), "{axis}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_revolve_axis_origin_follows_a_parameter() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
profile(&mut s, false);
|
||||
let axis = json!({"origin": ["W / 2 - 20", 0, 0], "dir": [0, 1, 0]});
|
||||
run(&mut s, "surface.revolve", json!({"sketch": "Profile", "axis": axis, "name": "Wall"}));
|
||||
let min_x = |s: &Session| s.model.state().bodies[0].body.mesh(&solidcraft_kernel::MeshTolerance::chord(1e-3)).unwrap().bounds().min.x;
|
||||
// W / 2 - 20 is 0 mm: the profile (x from 2 to 4) sweeps out to x = -4.
|
||||
assert!((min_x(&s) + 4.0).abs() < 0.05, "{}", min_x(&s));
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": "42 mm"}));
|
||||
// The axis is at x = 1: the farthest point is 3 from it.
|
||||
assert!((min_x(&s) + 2.0).abs() < 0.05, "{}", min_x(&s));
|
||||
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.find_feature("Wall").unwrap().kind, s.doc.find_feature("Wall").unwrap().kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_driven_axis_origin_names_revolve_faces_as_the_numeric_origin_does() {
|
||||
let names = |origin: Value| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
profile(&mut s, false);
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": origin, "dir": [0, 1, 0]}, "operation": "new"}));
|
||||
solidcraft_doc::naming::face_names(&s.model.state().bodies[0]).to_vec()
|
||||
};
|
||||
let numeric = names(json!([20, 0, 0]));
|
||||
assert!(numeric.iter().all(|n| !n.contains(":face:")), "index names: {numeric:?}");
|
||||
assert_eq!(names(json!(["W / 2", 0, 0])), numeric);
|
||||
}
|
||||
|
||||
@@ -546,6 +546,47 @@ fn a_refused_rule_names_the_value_at_fault() {
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": 2, "bend_radius": 0}));
|
||||
}
|
||||
|
||||
/// #9623: `Thickness` in a rule's expressions is the rule's thickness, also when the design has a
|
||||
/// parameter of that name; the rule's own `thickness` may still use that parameter.
|
||||
#[test]
|
||||
fn thickness_in_a_rule_is_the_rules_even_beside_a_parameter_of_that_name() {
|
||||
fn build(param: Option<&str>, rule: Value) -> (Value, f64) {
|
||||
let mut s = Session::default();
|
||||
if let Some(e) = param {
|
||||
run(&mut s, "parameters.add", json!({"name": "Thickness", "expression": e}));
|
||||
}
|
||||
run(&mut s, "sheet.manage_rules", rule);
|
||||
base(&mut s, 100.0, 50.0);
|
||||
let t = s.model.state().sheets[0].t;
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t], [100, 25, t]], "height": 20}));
|
||||
let failing: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||
assert!(failing.is_empty(), "{failing:?}");
|
||||
let l = run(&mut s, "sheet.rules", json!({}));
|
||||
let values = l["rules"].as_array().and_then(|r| r.iter().find(|r| r["rule"]["name"] == "R")).map(|r| r["values"].clone()).unwrap_or_default();
|
||||
(values, volume(&mut s))
|
||||
}
|
||||
let rule = json!({"name": "R", "thickness": 2, "k_factor": 0.5, "bend_radius": 2, "corner_relief_shape": "round", "active": true});
|
||||
let (want, v) = build(None, rule.clone());
|
||||
for (k, x) in [("thickness", 2.0), ("gap", 2.0), ("relief_width", 2.0), ("relief_depth", 1.0), ("corner_relief", 8.0), ("bend_radius", 2.0)] {
|
||||
assert_eq!(want[k].as_f64(), Some(x), "{k}: {want}");
|
||||
}
|
||||
assert!(rel(v, 14651.157) < 1e-4, "{v}");
|
||||
let (got, w) = build(Some("10 mm"), rule);
|
||||
assert_eq!(got, want);
|
||||
assert!(rel(w, v) < 1e-9, "{w} against {v}");
|
||||
// A bend radius of `Thickness` is the rule's 2 mm, not the parameter's 10.
|
||||
let rule = json!({"name": "R", "thickness": 2, "k_factor": 0.5, "bend_radius": "Thickness", "corner_relief_shape": "round", "active": true});
|
||||
let (got, w) = build(Some("10 mm"), rule);
|
||||
assert_eq!(got, want);
|
||||
assert!(rel(w, v) < 1e-9, "{w} against {v}");
|
||||
// The rule's thickness itself reads the design's `Thickness`, and the rest follow it.
|
||||
let rule =
|
||||
json!({"name": "R", "thickness": "Thickness", "k_factor": 0.5, "bend_radius": "Thickness", "corner_relief_shape": "round", "active": true});
|
||||
let (got, w) = build(Some("2 mm"), rule);
|
||||
assert_eq!(got, want);
|
||||
assert!(rel(w, v) < 1e-9, "{w} against {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_flange_hem_and_tray() {
|
||||
// Oracle 72.
|
||||
|
||||
@@ -9,7 +9,7 @@ use solidcore_record::{Plane, Vec2};
|
||||
use solidcraft_doc::ProfileSel;
|
||||
use solidcraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, Wire, WireSpace, intersections, intersections_within};
|
||||
|
||||
use super::sketch::{add_c, coincident_points, edit, ids_of, line_or_axis_ref, solve_current};
|
||||
use super::sketch::{add_c, coincident_points, dof_now, edit, ids_of, line_or_axis_ref, solve_current};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
@@ -61,7 +61,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("trim")
|
||||
.key("T")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it). Trimming a line source releases its dependent straight offsets' linked extents (offset_released: curve ids), retaining their driving normal-distance dimensions and parallel relations", created_doc!())),
|
||||
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it). Trimming a line source or its single-line offset releases linked extents (offset_released: curve ids), retaining their driving normal-distance dimensions and parallel relations", created_doc!())),
|
||||
CommandSpec::new("sketch.trim.path", "Trim Along Path", trim_path)
|
||||
.icon("trim")
|
||||
.enabled(in_sketch)
|
||||
@@ -99,7 +99,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_mirror")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [curve/point ids], line: straight curve id, x / y for a sketch axis, or {axis: X|Y|Z} for an origin axis in the sketch plane (symmetry constraints are added)", created_doc!())),
|
||||
.params(concat!("entities: [curve/point ids], line: straight curve id, x / y for a sketch axis, or {axis: X|Y|Z} for an origin axis (the mirror line is the axis's projection into the sketch plane; an axis perpendicular to the plane is refused) (symmetry constraints are added)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_circ_pattern")
|
||||
@@ -375,9 +375,18 @@ fn rebind_endpoint_tangencies(sk: &mut Sketch, ci: usize, pieces: &[usize], tang
|
||||
}
|
||||
}
|
||||
|
||||
/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves.
|
||||
fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) };
|
||||
/// The cuts on a curve a trim chooses between, made once for any number of trim points
|
||||
/// ([`TrimCuts::around`]): crossings and touching ends, in order along the curve, the curve's own
|
||||
/// ends left out.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TrimCuts {
|
||||
cuts: Vec<(f64, Vec2, usize)>,
|
||||
/// A whole circle: the cuts wrap round.
|
||||
full: bool,
|
||||
}
|
||||
|
||||
fn trim_cuts(sk: &Sketch, ci: usize) -> TrimCuts {
|
||||
let Some(sh) = sk.shape(ci) else { return TrimCuts::default() };
|
||||
let (t0, t1) = sh.range();
|
||||
let full = sh.is_full();
|
||||
let span = t1 - t0;
|
||||
@@ -390,20 +399,82 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
.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();
|
||||
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if full {
|
||||
if cuts.len() < 2 {
|
||||
return (Vec::new(), true);
|
||||
TrimCuts { cuts, full }
|
||||
}
|
||||
|
||||
impl TrimCuts {
|
||||
/// The cuts bracketing parameter `tq`: (before, after) with their cutter curves, and whether
|
||||
/// the curve is a whole circle.
|
||||
fn around(&self, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
let cuts = &self.cuts;
|
||||
if self.full {
|
||||
if cuts.len() < 2 {
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
// Cuts around tq on the circle (wrapping).
|
||||
let after = cuts.iter().position(|c| c.0 > tq).unwrap_or(0);
|
||||
let before = (after + cuts.len() - 1) % cuts.len();
|
||||
let mut two = vec![cuts[before], cuts[after]];
|
||||
two.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
return (two, true);
|
||||
}
|
||||
// Cuts around tq on the circle (wrapping).
|
||||
let after = cuts.iter().position(|c| c.0 > tq).unwrap_or(0);
|
||||
let before = (after + cuts.len() - 1) % cuts.len();
|
||||
let mut two = vec![cuts[before], cuts[after]];
|
||||
two.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
return (two, true);
|
||||
let before = cuts.iter().rev().find(|c| c.0 < tq).copied();
|
||||
let after = cuts.iter().find(|c| c.0 > tq).copied();
|
||||
(before.into_iter().chain(after).collect(), false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves.
|
||||
fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
trim_cuts(sk, ci).around(tq)
|
||||
}
|
||||
|
||||
/// What the Trim tool's hover needs of one curve, made once per sketch state: the curve and the
|
||||
/// cuts on it. Finding the cuts intersects the curve with every other one (free-form ones by
|
||||
/// their polylines), while the piece under the cursor ([`TrimHover::piece`]) only looks the
|
||||
/// cursor up among them, so the hover keeps this while the sketch is unchanged and asks it on
|
||||
/// each mouse move.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrimHover {
|
||||
sh: Shape,
|
||||
cuts: TrimCuts,
|
||||
}
|
||||
|
||||
/// The [`TrimHover`] of `curve`. `None` where trim works otherwise (a free-form or infinite
|
||||
/// curve) or the curve isn't in the sketch: the tool shows the curve.
|
||||
pub fn trim_hover(sk: &Sketch, curve: &str) -> Option<TrimHover> {
|
||||
let ci = sk.curve_index(curve)?;
|
||||
let c = sk.curves.get(ci)?;
|
||||
if c.infinite || c.kind.is_freeform() {
|
||||
return None;
|
||||
}
|
||||
let sh = sk.shape(ci)?;
|
||||
Some(TrimHover { sh, cuts: trim_cuts(sk, ci) })
|
||||
}
|
||||
|
||||
impl TrimHover {
|
||||
/// The piece a trim at `at` takes away, as [`trim_piece`] answers it.
|
||||
pub fn piece(&self, at: Vec2) -> Option<Vec<Vec2>> {
|
||||
let sh = &self.sh;
|
||||
let tq = sh.param(sh.project(at));
|
||||
let (t0, t1) = sh.range();
|
||||
let (cuts, full) = self.cuts.around(tq);
|
||||
let (a, b) = match (cuts.as_slice(), full) {
|
||||
([], _) => (t0, t1),
|
||||
// A whole circle: the arc between the two crossings round `at`.
|
||||
([x, y], true) => {
|
||||
if tq > x.0 && tq < y.0 {
|
||||
(x.0, y.0)
|
||||
} else {
|
||||
(y.0, x.0 + TAU)
|
||||
}
|
||||
}
|
||||
(_, true) => return None,
|
||||
(cuts, false) => (cuts.iter().rev().find(|c| c.0 < tq).map_or(t0, |c| c.0), cuts.iter().find(|c| c.0 > tq).map_or(t1, |c| c.0)),
|
||||
};
|
||||
let n = if matches!(sh, Shape::Line { .. }) { 1 } else { 64 };
|
||||
Some((0..=n).map(|i| sh.point(a + (b - a) * f64::from(i) / f64::from(n))).collect())
|
||||
}
|
||||
let before = cuts.iter().rev().find(|c| c.0 < tq).copied();
|
||||
let after = cuts.iter().find(|c| c.0 > tq).copied();
|
||||
(before.into_iter().chain(after).collect(), false)
|
||||
}
|
||||
|
||||
/// The piece of `curve` a trim at `at` takes away, as points along it, for the Trim tool to show
|
||||
@@ -411,30 +482,7 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
/// cuts it, or the whole curve where nothing crosses it. `None` where trim works otherwise (a
|
||||
/// free-form or infinite curve) or the curve isn't in the sketch: the tool shows the curve.
|
||||
pub fn trim_piece(sk: &Sketch, curve: &str, at: Vec2) -> Option<Vec<Vec2>> {
|
||||
let ci = sk.curve_index(curve)?;
|
||||
let c = sk.curves.get(ci)?;
|
||||
if c.infinite || c.kind.is_freeform() {
|
||||
return None;
|
||||
}
|
||||
let sh = sk.shape(ci)?;
|
||||
let tq = sh.param(sh.project(at));
|
||||
let (t0, t1) = sh.range();
|
||||
let (cuts, full) = bracket(sk, ci, tq);
|
||||
let (a, b) = match (cuts.as_slice(), full) {
|
||||
([], _) => (t0, t1),
|
||||
// A whole circle: the arc between the two crossings round `at`.
|
||||
([x, y], true) => {
|
||||
if tq > x.0 && tq < y.0 {
|
||||
(x.0, y.0)
|
||||
} else {
|
||||
(y.0, x.0 + TAU)
|
||||
}
|
||||
}
|
||||
(_, true) => return None,
|
||||
(cuts, false) => (cuts.iter().rev().find(|c| c.0 < tq).map_or(t0, |c| c.0), cuts.iter().find(|c| c.0 > tq).map_or(t1, |c| c.0)),
|
||||
};
|
||||
let n = if matches!(sh, Shape::Line { .. }) { 1 } else { 64 };
|
||||
Some((0..=n).map(|i| sh.point(a + (b - a) * f64::from(i) / f64::from(n))).collect())
|
||||
trim_hover(sk, curve)?.piece(at)
|
||||
}
|
||||
|
||||
/// Trim and extend refuse an offset's output: its ends are recomputed from its source curves on
|
||||
@@ -458,7 +506,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let released = release_source_line_offsets(sk, doc, ci, cmd)?;
|
||||
let released = release_line_offsets(sk, doc, ci, cmd)?;
|
||||
let (mut out, _) = remove_piece(sk, ci, at, cmd)?;
|
||||
if !released.is_empty() {
|
||||
out["offset_released"] = json!(released);
|
||||
@@ -469,10 +517,10 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"result": out, "sketch": info}))
|
||||
}
|
||||
|
||||
/// A source line's endpoints may change independently of its parallel offsets. Materialize
|
||||
/// Source lines and their single-line offsets can have independent endpoints. Materialize
|
||||
/// the affected line chains before Trim, retaining their original driving distance parameters
|
||||
/// as point-line dimensions. Directly trimming an associated output remains a separate command policy.
|
||||
fn release_source_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document, ci: usize, cmd: &str) -> Result<Vec<String>> {
|
||||
/// as point-line dimensions. Closed or mixed-curve output chains still need their association.
|
||||
fn release_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document, ci: usize, cmd: &str) -> Result<Vec<String>> {
|
||||
let Some(curve) = sk.curves.get(ci) else { return Ok(Vec::new()) };
|
||||
if curve.infinite || sk.is_linked_curve(ci) || !matches!(curve.kind, CurveKind::Line { .. }) {
|
||||
return Ok(Vec::new());
|
||||
@@ -484,7 +532,7 @@ fn release_source_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document,
|
||||
.enumerate()
|
||||
.filter_map(|(i, offset)| {
|
||||
(offset.topology.is_none()
|
||||
&& offset.sources.iter().any(|(source, _)| source == &id)
|
||||
&& (offset.sources.iter().any(|(source, _)| source == &id) || (offset.sources.len() == 1 && offset.outputs.contains(&id)))
|
||||
&& offset.sources.len() == offset.outputs.len()
|
||||
&& offset.sources.iter().zip(&offset.outputs).all(|((source, _), output)| {
|
||||
[source, output]
|
||||
@@ -499,7 +547,7 @@ fn release_source_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document,
|
||||
}
|
||||
let report = solve_current(sk, doc)?;
|
||||
if !report.keeps_orientation() {
|
||||
return Err(bad(cmd, report.failure(|_| "cannot trim a source with conflicting offset dimensions".into())));
|
||||
return Err(bad(cmd, report.failure(|_| "cannot trim a line with conflicting offset dimensions".into())));
|
||||
}
|
||||
let mut released = Vec::new();
|
||||
for index in affected.into_iter().rev() {
|
||||
@@ -555,6 +603,14 @@ fn release_source_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document,
|
||||
Ok(released)
|
||||
}
|
||||
|
||||
/// Whether the sketch already holds point `m` at the middle of line `l`, though no constraint says
|
||||
/// so: adding that Midpoint takes away no degree of freedom (a center rectangle's centre, held on
|
||||
/// one diagonal, is the other's middle too because the diagonals bisect each other).
|
||||
fn middle_implied(sk: &Sketch, m: usize, l: usize) -> bool {
|
||||
let mut with = sk.clone();
|
||||
add_c(&mut with, ConstraintKind::Midpoint { p: m, l }).is_ok() && dof_now(&with) == dof_now(sk)
|
||||
}
|
||||
|
||||
/// Remove the piece of curve `ci` around `at`: between the nearest intersections on either
|
||||
/// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the
|
||||
/// removed piece as a polyline.
|
||||
@@ -597,6 +653,18 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
.then(|| dimension.id.clone())
|
||||
})
|
||||
.collect();
|
||||
// Points held at the middle of the trimmed line (a center rectangle's centre on its first
|
||||
// diagonal): `release_extent` drops that hold, so remember them for the cuts below.
|
||||
let midpoints: Vec<usize> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.filter_map(|k| match k.kind {
|
||||
ConstraintKind::Midpoint { p, l } if l == ci => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The sketch as it was, for `middle_implied`: only needed when a midpoint can be cut.
|
||||
let before = (!midpoints.is_empty()).then(|| sk.clone());
|
||||
release_extent(sk, ci);
|
||||
let tangencies = endpoint_tangencies(sk, ci);
|
||||
let ts: Vec<f64> = cuts.iter().map(|c| c.0).collect();
|
||||
@@ -608,8 +676,40 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
} else {
|
||||
ts.iter().filter(|t| **t < tq).count()
|
||||
};
|
||||
// A cut on a point already held there ends the piece on that point: no second point.
|
||||
let mut welds: Vec<(usize, usize)> = Vec::new();
|
||||
for (pi, c) in pts.iter().zip(&cuts) {
|
||||
pin(sk, *pi, c.2)?;
|
||||
let cutter = c.2;
|
||||
let Some(at) = sk.point(*pi) else { continue };
|
||||
let near = |sk: &Sketch, p: usize| sk.point(p).is_some_and(|q| q.dist(at) < TOL * 10.0);
|
||||
// The cutter's middle, held there, is taken as its ends are (`pin`).
|
||||
let held = sk.constraints.iter().find_map(|k| match k.kind {
|
||||
ConstraintKind::Midpoint { p, l } if l == cutter && near(sk, p) => Some(p),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = held {
|
||||
welds.push((*pi, m));
|
||||
} else if let Some(m) = midpoints.iter().copied().find(|m| near(sk, *m)) {
|
||||
// The trimmed line's middle: it moves to the cutter's middle when the sketch makes it
|
||||
// that too (a center rectangle's diagonals bisect each other), else it stays on the
|
||||
// cutter. A cutter whose middle is there only by chance gets no hold it never had.
|
||||
welds.push((*pi, m));
|
||||
let middle = match sk.curves.get(cutter).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => sk.point(*a).zip(sk.point(*b)).map(|(a, b)| (a + b) * 0.5),
|
||||
_ => None,
|
||||
};
|
||||
let implied = match &before {
|
||||
Some(was) if middle.is_some_and(|q| q.dist(at) < TOL * 10.0) => middle_implied(was, m, cutter),
|
||||
_ => false,
|
||||
};
|
||||
if implied {
|
||||
add_c(sk, ConstraintKind::Midpoint { p: m, l: cutter })?;
|
||||
} else {
|
||||
pin(sk, m, cutter)?;
|
||||
}
|
||||
} else {
|
||||
pin(sk, *pi, cutter)?;
|
||||
}
|
||||
}
|
||||
let gone = pieces.get(k).copied().ok_or_else(|| bad(cmd, "piece"))?;
|
||||
let keep: Vec<usize> = pieces.iter().copied().filter(|x| *x != gone).collect();
|
||||
@@ -632,6 +732,13 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
};
|
||||
rebind_endpoint_tangencies(sk, ci, &pieces, &tangencies);
|
||||
let keep: Vec<usize> = pieces.iter().copied().filter(|x| *x != gone).collect();
|
||||
for &(from, to) in &welds {
|
||||
for piece in &keep {
|
||||
if let Some(c) = sk.curves.get_mut(*piece) {
|
||||
c.kind.map_points(&|p| if p == from { to } else { p });
|
||||
}
|
||||
}
|
||||
}
|
||||
tie(sk, &keep)?;
|
||||
if let Some((a, _)) = sk.curves.get(ci).and_then(|c| c.kind.ends()) {
|
||||
for dimension in &mut sk.constraints {
|
||||
@@ -740,7 +847,7 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
after = (si, t);
|
||||
// A piece trim refuses (a closed spline crossed once) stays as it was.
|
||||
let before = sk.clone();
|
||||
let cut = release_source_line_offsets(sk, doc, ci, cmd).and_then(|released| {
|
||||
let cut = release_line_offsets(sk, doc, ci, cmd).and_then(|released| {
|
||||
let (mut out, gone) = remove_piece(sk, ci, q, cmd)?;
|
||||
if !released.is_empty() {
|
||||
out["offset_released"] = json!(released);
|
||||
@@ -985,6 +1092,16 @@ pub fn extend_preview(sk: &solidcraft_sketch::Sketch, ci: usize, hover_pos: Vec2
|
||||
line_aim(sk, ci, hover_pos).ok().flatten().map(|aim| aim.hit)
|
||||
}
|
||||
|
||||
/// Which end of line or arc `ci` an Extend at `at` grows, as `sketch.extend` and its previews
|
||||
/// choose it: `Some(true)` for its `b` end. Unless the sketch has an infinite line (which is cut
|
||||
/// short about the cursor), [`extend_preview`] and [`extend_arc_preview`] depend on `at` only
|
||||
/// through this choice, so a hover may keep a preview while the cursor stays nearer that end.
|
||||
pub fn extend_end_at(sk: &Sketch, ci: usize, at: Vec2) -> Option<bool> {
|
||||
let (CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. }) = sk.curves.get(ci)?.kind else { return None };
|
||||
let (qa, qb) = (sk.point(a)?, sk.point(b)?);
|
||||
Some(at.dist(qb) <= at.dist(qa))
|
||||
}
|
||||
|
||||
/// One end of an arc growing along the arc's own circle, as a new arc, and what stops it.
|
||||
///
|
||||
/// Arcs extend both ways: past their `b` end counter-clockwise and past their `a` end clockwise,
|
||||
@@ -3313,7 +3430,17 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.links.iter().find(|link| link.kind == solidcraft_sketch::LinkKind::Project && link.source == source).map(|link| link.id.clone());
|
||||
let link = match link {
|
||||
Some(link) => link,
|
||||
None => sk.add_link(solidcraft_sketch::LinkKind::Project, source, &geom)?,
|
||||
None => {
|
||||
let link = sk.add_link(solidcraft_sketch::LinkKind::Project, source, &geom)?;
|
||||
// A mirror line is reference geometry: it stays out of profiles, as the
|
||||
// sketch's own axes do.
|
||||
for c in sk.link_curves(&link) {
|
||||
if let Some(curve) = sk.curves.get_mut(c) {
|
||||
curve.construction = true;
|
||||
}
|
||||
}
|
||||
link
|
||||
}
|
||||
};
|
||||
sk.link_curves(&link).first().copied().ok_or_else(|| bad(cmd, "the origin axis does not resolve to a straight line"))?
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind};
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkSource};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -577,6 +577,116 @@ fn trim_circle_makes_an_arc_and_a_profile() {
|
||||
assert!(close(a[0], std::f64::consts::PI * 50.0), "{a:?}");
|
||||
}
|
||||
|
||||
/// Trimming half of a center rectangle's diagonal left two points at the centre, one held by
|
||||
/// nothing: dragging a corner pulled them apart. Either diagonal, the half that stays ends on the
|
||||
/// one centre point, which stays where the diagonals cross.
|
||||
#[test]
|
||||
fn trimming_half_a_center_rectangle_diagonal_keeps_one_centre() {
|
||||
for (diagonal, at) in [(0, [7.5, 4.25]), (1, [7.5, -0.25])] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [12, 6.5]}));
|
||||
let sk = sketch(&s);
|
||||
let diagonals: Vec<String> = sk.curves.iter().filter(|c| c.construction).map(|c| c.id.clone()).collect();
|
||||
assert_eq!(diagonals.len(), 2, "{diagonals:?}");
|
||||
let (cut, other) = (diagonals[diagonal].clone(), diagonals[1 - diagonal].clone());
|
||||
run(&mut s, "sketch.trim", json!({"curve": cut, "at": at}));
|
||||
let sk = sketch(&s);
|
||||
let at_centre = sk.points.iter().filter(|p| p.pos.dist(Vec2::new(3.0, 2.0)) < 1e-7).count();
|
||||
assert_eq!(at_centre, 1, "diagonal {cut}: {} points at the centre", at_centre);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 4, "diagonal {cut}: {si}");
|
||||
assert_ne!(si["status"], "conflict", "diagonal {cut}: {si}");
|
||||
assert!(si["status"].as_str().is_none_or(|t| !t.contains("over")), "diagonal {cut}: {si}");
|
||||
// Drag a corner: the trimmed half still ends where the diagonals cross.
|
||||
run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [12, 6.5], "to": [16, 9]}));
|
||||
let sk = sketch(&s);
|
||||
let ends = |id: &str| {
|
||||
let (a, b) = sk.curves[sk.curve_index(id).unwrap()].kind.ends().unwrap();
|
||||
(sk.point(a).unwrap(), sk.point(b).unwrap())
|
||||
};
|
||||
let (o0, o1) = ends(&other);
|
||||
let centre = (o0 + o1) * 0.5;
|
||||
let (c0, c1) = ends(&cut);
|
||||
assert!(c0.dist(centre).min(c1.dist(centre)) < 1e-6, "diagonal {cut}: {c0:?} {c1:?}, centre {centre:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_the_start_half_of_a_center_rectangle_diagonal_keeps_its_parallel_distance_on_the_centre() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [12, 6.5]}));
|
||||
let diagonal = sketch(&s).curves.iter().find(|c| c.construction).map(|c| c.id.clone()).unwrap();
|
||||
// A line parallel to the diagonal (-6,-2.5)-(12,6.5), 10 mm lower, dimensioned from the
|
||||
// diagonal's start.
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-6, -12.5], [12, -3.5]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.parallel", json!({"a": diagonal, "b": line}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [diagonal, line]}));
|
||||
let value = |sk: &solidcraft_sketch::Sketch| {
|
||||
sk.constraints.iter().find_map(|c| match c.kind {
|
||||
ConstraintKind::PointLineDistance { p, value, .. } => Some((sk.point(p).unwrap(), value)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (from, d) = value(&sketch(&s)).expect("the distance between the parallel lines");
|
||||
assert!(from.dist(Vec2::new(-6.0, -2.5)) < 1e-7, "{from:?}");
|
||||
// Trim the half from the diagonal's start: the kept half starts at the centre, so the
|
||||
// distance moves there, onto the one point the cut is welded to.
|
||||
run(&mut s, "sketch.trim", json!({"curve": diagonal, "at": [-1.5, -0.25]}));
|
||||
let sk = sketch(&s);
|
||||
let (from, after) = value(&sk).expect("the distance survives the trim");
|
||||
assert!(from.dist(Vec2::new(3.0, 2.0)) < 1e-7, "{from:?}");
|
||||
assert!((after - d).abs() < 1e-9, "{after} != {d}");
|
||||
}
|
||||
|
||||
/// A center rectangle whose bottom is held square to a side instead of horizontal keeps one
|
||||
/// centre after a trim: what makes its diagonals bisect each other is the solver's to say, not
|
||||
/// which constraints hold the sides.
|
||||
#[test]
|
||||
fn trimming_a_diagonal_of_a_center_rectangle_held_by_other_constraints_keeps_one_centre() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [12, 6.5]}));
|
||||
let sk = sketch(&s);
|
||||
let bottom = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Horizontal { l: 0 })).map(|c| c.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.delete", json!({"entities": [bottom]}));
|
||||
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "l1", "b": "l2"}));
|
||||
let dof = run(&mut s, "sketch.inspect", json!({}))["dof"].clone();
|
||||
let diagonal = sketch(&s).curves.iter().find(|c| c.construction).map(|c| c.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.trim", json!({"curve": diagonal, "at": [7.5, 4.25]}));
|
||||
let sk = sketch(&s);
|
||||
let at_centre = sk.points.iter().filter(|p| p.pos.dist(Vec2::new(3.0, 2.0)) < 1e-7).count();
|
||||
assert_eq!(at_centre, 1, "{at_centre} points at the centre");
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], dof, "the trim frees the centre: {si}");
|
||||
}
|
||||
|
||||
/// A line's held midpoint trimmed at a crossing line that only happens to have its middle there
|
||||
/// ends the kept half on that point and keeps it on the crossing line, without tying the crossing
|
||||
/// line's middle to it: nothing held that middle before.
|
||||
#[test]
|
||||
fn trimming_at_a_held_midpoint_on_an_unrelated_line_adds_no_hold_to_that_line() {
|
||||
let mut s = new_sketch();
|
||||
let trimmed = ids(&run(&mut s, "sketch.line", json!({"points": [[0,0], [10,0]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let crossing = ids(&run(&mut s, "sketch.line", json!({"points": [[5,-3], [5,3]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.point", json!({"point": format!("mid:{trimmed}")}));
|
||||
let sk = sketch(&s);
|
||||
let mid = sk.points.iter().find(|p| p.pos.dist(Vec2::new(5.0, 0.0)) < 1e-7).map(|p| p.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.trim", json!({"curve": trimmed, "at": [8, 0]}));
|
||||
let sk = sketch(&s);
|
||||
let l = sk.curve_index(&crossing).unwrap();
|
||||
let m = sk.resolve_point(&mid).unwrap();
|
||||
assert!(
|
||||
!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Midpoint { l: cl, .. } if cl == l)),
|
||||
"the crossing line gets no midpoint hold: {:?}",
|
||||
sk.constraints
|
||||
);
|
||||
assert!(
|
||||
sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::PointOnCurve { p, c: cc } if p == m && cc == l)),
|
||||
"the old midpoint stays on the crossing line: {:?}",
|
||||
sk.constraints
|
||||
);
|
||||
assert_eq!(sk.points.iter().filter(|p| p.pos.dist(Vec2::new(5.0, 0.0)) < 1e-7).count(), 1, "one point at the cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_extend_ellipse_preserves_support_target_and_history() {
|
||||
for (y, expected_x) in [(3.0, 12.0), (5.0, 20.0)] {
|
||||
@@ -3946,6 +4056,67 @@ fn trim_piece_is_what_a_trim_there_takes_away() {
|
||||
assert!(near(piece[0], [0.0, 0.0]) && near(piece[piece.len() - 1], [8.0, 0.0]), "{piece:?}");
|
||||
}
|
||||
|
||||
/// The hover keeps one [`super::TrimHover`] per curve while the sketch is unchanged and asks it
|
||||
/// for each cursor position: it answers what working the piece out afresh there answers, and the
|
||||
/// trim there takes that piece away. Lines, circles and arcs cut by each other and by a spline.
|
||||
/// Extend's previews depend on the cursor only through the end it grows
|
||||
/// ([`super::extend_end_at`]), which lets the hover keep them while that end stays the nearer.
|
||||
#[test]
|
||||
fn trim_hover_made_once_and_extend_previews_per_end_answer_as_afresh() {
|
||||
let mut s = new_sketch();
|
||||
for y in [0.0, 6.0, 13.0] {
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20, y], [40, y]], "connect": false}));
|
||||
}
|
||||
for x in [-8.0, 9.0, 27.0] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [x, 4], "radius": 5}));
|
||||
}
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start": [-15, -6], "end": [35, -6], "through": [10, 18]}));
|
||||
run(&mut s, "sketch.spline.fit_point", json!({"points": [[-12, -9], [0, 20], [14, -8], [30, 19]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[3, 3], [5, 4]], "connect": false}));
|
||||
let sk = sketch(&s);
|
||||
let (mut trimmed, mut extended) = (0, 0);
|
||||
for (ci, c) in sk.curves.iter().enumerate() {
|
||||
let Some(hover) = super::trim_hover(&sk, &c.id) else {
|
||||
assert!(c.kind.is_freeform(), "{}", c.id);
|
||||
continue;
|
||||
};
|
||||
let poly = sk.polyline(ci);
|
||||
for k in 1..12 {
|
||||
let at = poly[(poly.len() - 1) * k / 12] + Vec2::new(0.01, -0.02);
|
||||
let piece = hover.piece(at);
|
||||
assert_eq!(piece, super::trim_piece(&sk, &c.id, at), "{} at {at:?}", c.id);
|
||||
let Some(piece) = piece else { continue };
|
||||
if k % 4 == 1 {
|
||||
let mut after = sk.clone();
|
||||
super::remove_piece(&mut after, ci, at, "sketch.trim").unwrap();
|
||||
let mid = if piece.len() > 2 { piece[piece.len() / 2] } else { (piece[0] + piece[1]) * 0.5 };
|
||||
assert!(!(0..after.curves.len()).filter_map(|i| after.shape(i)).any(|sh| sh.dist(mid) < 1e-6), "{} at {at:?}", c.id);
|
||||
trimmed += 1;
|
||||
}
|
||||
}
|
||||
if !matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. }) {
|
||||
continue;
|
||||
}
|
||||
let (CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. }) = c.kind else { continue };
|
||||
let (qa, qb) = (sk.point(a).unwrap(), sk.point(b).unwrap());
|
||||
for k in 0..=10 {
|
||||
let at = qa.lerp(qb, f64::from(k) / 10.0) + Vec2::new(0.3, 0.2);
|
||||
let at_b = super::extend_end_at(&sk, ci, at).unwrap();
|
||||
let end = if at_b { qb } else { qa };
|
||||
let preview = |p: Vec2| {
|
||||
if matches!(c.kind, CurveKind::Arc { .. }) {
|
||||
super::extend_arc_preview(&sk, ci, p)
|
||||
} else {
|
||||
super::extend_preview(&sk, ci, p).map(|q| vec![q])
|
||||
}
|
||||
};
|
||||
assert_eq!(preview(at), preview(end), "{} at {at:?}", c.id);
|
||||
extended += usize::from(preview(at).is_some());
|
||||
}
|
||||
}
|
||||
assert!(trimmed >= 15 && extended >= 40, "{trimmed} trims, {extended} extensions");
|
||||
}
|
||||
|
||||
/// An offset's ends follow its source curves, so trimming a piece off it or extending it is
|
||||
/// refused (the trim used to report success and leave the curve whole). Trimming away a whole
|
||||
/// offset curve still works, and once the association is broken the trim cuts it.
|
||||
@@ -4674,6 +4845,51 @@ fn mirror_origin_axis_in_rotated_plane_reuses_projection_and_rejects_invalid_lin
|
||||
}
|
||||
}
|
||||
|
||||
/// #8201: a sketch on a face or on any other plane that an origin axis does not lie in still
|
||||
/// mirrors about that axis, using the axis's projection into the sketch plane.
|
||||
#[test]
|
||||
fn mirror_uses_the_projection_of_an_origin_axis_outside_the_sketch_plane() {
|
||||
// Slanted through the origin: world X is outside the plane, and its projection is the
|
||||
// sketch's y line, so a mirror about it turns the x coordinate around.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0,0],"x_dir":[0,1,-1],"y_dir":[-2,1,1]}}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,3],[15,8]],"ids":["source"]}));
|
||||
let out = run(&mut s, "sketch.mirror", json!({"entities":["source"],"line":{"axis":"X"}}));
|
||||
let sk = sketch(&s);
|
||||
let copy = out["curves"][0].as_str().unwrap();
|
||||
for (source_end, copy_end) in [("start", "end"), ("end", "start")] {
|
||||
let source = sk.point(sk.resolve_point(&format!("source.{source_end}")).unwrap()).unwrap();
|
||||
let copy = sk.point(sk.resolve_point(&format!("{copy}.{copy_end}")).unwrap()).unwrap();
|
||||
assert!(copy.dist(Vec2::new(-source.x, source.y)) < 1e-6, "{source:?}: {copy:?}");
|
||||
}
|
||||
let link = sk.links.iter().find(|l| matches!(&l.source, LinkSource::Axis { name } if name == "X")).expect("the projected X axis is a link");
|
||||
let line = sk.link_curves(&link.id)[0];
|
||||
assert!(sk.curves[line].construction && sk.is_linked_curve(line));
|
||||
match sk.shape(line) {
|
||||
Some(solidcraft_sketch::Shape::Line { a, b }) => {
|
||||
assert!(a.x.abs() < 1e-6 && b.x.abs() < 1e-6, "the projected X axis is the sketch y: {a:?} {b:?}");
|
||||
}
|
||||
other => panic!("the projected X axis is not a line: {other:?}"),
|
||||
}
|
||||
// A second mirror reuses the same projected axis instead of adding another.
|
||||
run(&mut s, "sketch.mirror", json!({"entities":["source"],"line":{"axis":"X"}}));
|
||||
assert_eq!(sketch(&s).links.len(), 1);
|
||||
|
||||
// Offset above the origin, as a sketch on a part's top face: the projection is still the
|
||||
// world X line, which is the sketch's own x here.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,3],[15,8]],"ids":["source"]}));
|
||||
let out = run(&mut s, "sketch.mirror", json!({"entities":["source"],"line":{"axis":"X"}}));
|
||||
let sk = sketch(&s);
|
||||
let copy = out["curves"][0].as_str().unwrap();
|
||||
for (source_end, copy_end) in [("start", "end"), ("end", "start")] {
|
||||
let source = sk.point(sk.resolve_point(&format!("source.{source_end}")).unwrap()).unwrap();
|
||||
let copy = sk.point(sk.resolve_point(&format!("{copy}.{copy_end}")).unwrap()).unwrap();
|
||||
assert!(copy.dist(Vec2::new(source.x, -source.y)) < 1e-6, "{source:?}: {copy:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_tools_refuse_all_spatial_curves_with_an_unsupported_message() {
|
||||
let mut s = Session::default();
|
||||
@@ -5058,8 +5274,8 @@ fn move_copy_drawn_wire_failures_leave_the_design_and_history_unchanged() {
|
||||
/// A construction line and its two offsets each stop at their own crossing. Trimming one
|
||||
/// must never shorten the others, whatever order the three curves are picked in.
|
||||
#[test]
|
||||
fn trim_source_then_two_offsets_independently_in_either_order() {
|
||||
for order in [[0, 1, 2], [0, 2, 1]] {
|
||||
fn trim_source_and_two_offsets_independently_in_any_order() {
|
||||
for order in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0],[40,40]], "construction": true, "connect": false, "infer": false, "ids": ["source"]}));
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves": ["source"], "distance": 2, "symmetric": true, "chain": false}));
|
||||
@@ -5094,6 +5310,33 @@ fn trim_source_then_two_offsets_independently_in_either_order() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_one_offset_line_keeps_its_source_sibling_and_other_offsets() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0],[40,40]], "connect": false, "infer": false, "ids": ["source"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["source"]}));
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves": ["source"], "distance": 2, "symmetric": true, "chain": false}));
|
||||
let outputs = ids(&out["curves"]);
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20,20],[60,20]], "connect": false, "infer": false}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[100,100],[120,100]], "connect": false, "infer": false, "ids": ["other"]}));
|
||||
let unrelated = ids(&run(&mut s, "sketch.offset", json!({"curves": ["other"], "distance": 3, "chain": false}))["curves"])[0].clone();
|
||||
let before = sketch(&s);
|
||||
let document = s.doc.to_json();
|
||||
let solidcraft_sketch::Shape::Line { a, .. } = before.shape(before.curve_index(&outputs[0]).unwrap()).unwrap() else { panic!("line") };
|
||||
let r = run(&mut s, "sketch.trim", json!({"curve": outputs[0], "at": [a.x+1.0, a.y+1.0]}));
|
||||
assert_eq!(r["sketch"]["solved"], true, "{r}");
|
||||
let after = sketch(&s);
|
||||
let solidcraft_sketch::Shape::Line { a: start, .. } = after.shape(after.curve_index(&outputs[0]).unwrap()).unwrap() else { panic!("line") };
|
||||
assert!((start.y - 20.0).abs() < 1e-6, "{start:?}");
|
||||
for id in ["source", &outputs[1], "other", &unrelated] {
|
||||
assert_eq!(after.polyline(after.curve_index(id).unwrap()), before.polyline(before.curve_index(id).unwrap()), "{id} must stay put");
|
||||
}
|
||||
assert_eq!(after.offset_distance(&outputs[1]), Some(-2.0));
|
||||
assert_eq!(after.offset_distance(&unrelated), Some(3.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), document, "one Undo restores the offset association as well as the trimmed end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_path_cuts_source_and_two_offsets_at_their_own_crossings() {
|
||||
let mut s = new_sketch();
|
||||
@@ -5103,11 +5346,11 @@ fn trim_path_cuts_source_and_two_offsets_at_their_own_crossings() {
|
||||
curves.extend(ids(&out["curves"]));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20,20],[60,20]], "connect": false, "infer": false}));
|
||||
let before = s.doc.to_json();
|
||||
let path = json!({"points": [[4,5],[15,5],[-10,5]], "dry_run": true});
|
||||
let path = json!({"points": [[15,5],[-10,5]], "dry_run": true});
|
||||
let preview = run(&mut s, "sketch.trim.path", path);
|
||||
assert_eq!(preview["trimmed"].as_array().unwrap().len(), 3, "{preview}");
|
||||
assert_eq!(s.doc.to_json(), before, "preview releases no stored associations or parameters");
|
||||
let r = run(&mut s, "sketch.trim.path", json!({"points": [[4,5],[15,5],[-10,5]]}));
|
||||
let r = run(&mut s, "sketch.trim.path", json!({"points": [[15,5],[-10,5]]}));
|
||||
assert_eq!(r["trimmed"], preview["trimmed"]);
|
||||
assert!(r["skipped"].as_array().unwrap().is_empty(), "{r}");
|
||||
let sk = sketch(&s);
|
||||
@@ -5163,14 +5406,20 @@ fn trim_keeps_offset_distances_driving_after_trims_drag_and_reopen() {
|
||||
assert_eq!(sk.constraints.iter().filter(|c| c.param.as_deref() == Some(parameter)).count(), 2, "both sides retain their dimensions");
|
||||
assert!(state.sketch(1).unwrap().report.ok());
|
||||
};
|
||||
for reverse_outputs in [true, false] {
|
||||
for order in [
|
||||
["source", "l1", "l2"],
|
||||
["source", "l2", "l1"],
|
||||
["l1", "source", "l2"],
|
||||
["l1", "l2", "source"],
|
||||
["l2", "source", "l1"],
|
||||
["l2", "l1", "source"],
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[40,40]],"construction":true,"connect":false,"infer":false,"ids":["source"]}));
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves":["source"],"distance":2,"symmetric":true,"chain":false}));
|
||||
let parameter = out["param"].as_str().unwrap().to_string();
|
||||
let cutter =
|
||||
run(&mut s, "sketch.line", json!({"points":[[-20,20],[60,20]],"connect":false,"infer":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
let order = if reverse_outputs { ["source", "l2", "l1"] } else { ["source", "l1", "l2"] };
|
||||
for id in order {
|
||||
let sk = sketch(&s);
|
||||
let solidcraft_sketch::Shape::Line { a, .. } = sk.shape(sk.curve_index(id).unwrap()).unwrap() else { panic!("line") };
|
||||
|
||||
@@ -145,21 +145,84 @@ fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Res
|
||||
}
|
||||
|
||||
/// The model before sketch `id` in the frame its links and its plane are resolved in: the
|
||||
/// attached face's frame for a sketch on a face, else its component's.
|
||||
/// attached face's frame for a sketch on a face, else its component's. The further placements
|
||||
/// `named` names (`Block (Part:2)`) are in it too, where they are (#9957).
|
||||
fn state_for_sketch<'a>(
|
||||
s: &Session,
|
||||
doc: &Document,
|
||||
id: u64,
|
||||
st: &'a solidcraft_doc::ModelState,
|
||||
named: &[&str],
|
||||
) -> std::result::Result<std::borrow::Cow<'a, solidcraft_doc::ModelState>, solidcraft_doc::DocError> {
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp),
|
||||
None => solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp)),
|
||||
let (framed, frame) = match attachment {
|
||||
Some(anchor) => (solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp)?, anchor.author_frame(doc, comp)?),
|
||||
None => {
|
||||
let frame = owner_frame(s, doc, comp);
|
||||
(solidcraft_doc::state_in_frame_at(doc, st, comp, frame)?, frame)
|
||||
}
|
||||
};
|
||||
Ok(solidcraft_doc::with_placed_copies(doc, st, framed, &frame, named))
|
||||
}
|
||||
|
||||
/// The placed bodies of a design (`Document::body_frames_with_source`): world name, definition
|
||||
/// name, component, occurrence path and frame.
|
||||
pub(super) type PlacedBodies = Vec<(String, String, u64, Vec<u64>, solidcraft_doc::Mat)>;
|
||||
|
||||
/// Sketch links keep a further placement's body by the name the world gives it (`Block (Part:2)`,
|
||||
/// #9957), which a rename of the body or of its component changes: each link naming one of
|
||||
/// `before` (the placed bodies before the rename) now names what that body is called in `doc`.
|
||||
/// `renamed` is the body renamed (old name, new name), if one was.
|
||||
pub(super) fn follow_placed_renames(doc: &mut Document, st: &solidcraft_doc::ModelState, before: &PlacedBodies, renamed: Option<(&str, &str)>) {
|
||||
let renames: std::collections::HashMap<String, String> = {
|
||||
let names = solidcraft_doc::WorldNames::new(doc, st);
|
||||
before
|
||||
.iter()
|
||||
.filter(|(world, own, ..)| world != own)
|
||||
.filter_map(|(world, own, component, path, _)| {
|
||||
let source = match renamed {
|
||||
Some((old, new)) if own == old => new,
|
||||
_ => own.as_str(),
|
||||
};
|
||||
let now = names.name(source, *component, path);
|
||||
(now != *world).then(|| (world.clone(), now))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if renames.is_empty() {
|
||||
return;
|
||||
}
|
||||
for f in &mut doc.features {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut f.kind {
|
||||
for link in &mut sketch.links {
|
||||
rename_link_body(&mut link.source, &renames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_link_body(source: &mut LinkSource, renames: &std::collections::HashMap<String, String>) {
|
||||
match source {
|
||||
LinkSource::Edge { body, .. }
|
||||
| LinkSource::Face { body, .. }
|
||||
| LinkSource::Body { body }
|
||||
| LinkSource::Vertex { body, .. }
|
||||
| LinkSource::OnSurface { body, .. }
|
||||
| LinkSource::Iso { body, .. }
|
||||
| LinkSource::Spun { body, .. } => {
|
||||
if let Some(new) = renames.get(body.as_str()) {
|
||||
body.clone_from(new);
|
||||
}
|
||||
}
|
||||
LinkSource::Intersection { a, b } => {
|
||||
rename_link_body(a, renames);
|
||||
rename_link_body(b, renames);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +268,8 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
|
||||
let named: Vec<&str> = sk.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let Ok(st) = state_for_sketch(s, &doc, id, &st, &named) else {
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
|
||||
@@ -251,12 +315,13 @@ pub(super) mod refresh_count {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an origin axis as a mirror line, keeping its projection linked and fixed.
|
||||
/// Resolve an origin axis as a mirror line: the axis's projection into the sketch plane, kept
|
||||
/// linked and fixed. An axis perpendicular to the plane has no line in it and is refused.
|
||||
pub(super) fn origin_axis_line(s: &Session, p: &Value, line: &Value, cmd: &str) -> Result<(LinkSource, Vec<solidcraft_sketch::LinkGeom>)> {
|
||||
let id = sketch_store::target(s, p, cmd)?;
|
||||
let doc = placed_candidate(s, &s.doc);
|
||||
let st = s.model.state_before(id);
|
||||
let st = state_for_sketch(s, &doc, id, &st)?;
|
||||
let st = state_for_sketch(s, &doc, id, &st, &[])?;
|
||||
let plane = sketch_plane(&doc, id, &st)?;
|
||||
let src = source(s, line, cmd)?;
|
||||
let LinkSource::Axis { name } = &src else { return Err(bad(cmd, "the mirror line must be a straight sketch curve or an origin axis")) };
|
||||
@@ -266,8 +331,10 @@ pub(super) fn origin_axis_line(s: &Session, p: &Value, line: &Value, cmd: &str)
|
||||
"Z" => Vec3::Z,
|
||||
_ => return Err(bad(cmd, "the origin axis must be X, Y or Z")),
|
||||
};
|
||||
if plane.normal().dot(dir).abs() > 1e-9 || plane.normal().dot(plane.origin).abs() > 1e-6 {
|
||||
return Err(bad(cmd, "the origin axis must lie in the sketch plane"));
|
||||
// The resolve below projects the axis into the plane: its direction there is the plane's
|
||||
// share of `dir`, and empty means a point, not a mirror line.
|
||||
if Vec2::new(dir.dot(plane.x), dir.dot(plane.y)).len() <= 1e-9 {
|
||||
return Err(bad(cmd, "the origin axis is perpendicular to the sketch plane, so it has no line in it"));
|
||||
}
|
||||
let (vals, _) = doc.param_values();
|
||||
let r = resolve(&doc, &vals, &st, &plane, LinkKind::Project, &src)?;
|
||||
@@ -294,7 +361,7 @@ fn add_link_unless_present(
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let st = state_for_sketch(s, &doc, id, &st)?;
|
||||
let st = state_for_sketch(s, &doc, id, &st, &src.bodies())?;
|
||||
let plane = sketch_plane(&doc, id, &st)?;
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
if skip_present && r.wires.is_empty() && sk.link_geometry_present(&r.geom) {
|
||||
@@ -716,7 +783,7 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
} else if let Some(at) = edge {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
|
||||
let before = s.model.state_before(id);
|
||||
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
|
||||
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before, &[])?)?;
|
||||
let q = plane.to_local(at);
|
||||
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
|
||||
let pi = sk.add_point(q, None)?;
|
||||
|
||||
@@ -251,13 +251,17 @@ fn a_construction_axis_projects_and_stays_linked() {
|
||||
let link = &state.sketch(sketch).unwrap().sketch.links[0];
|
||||
assert_eq!(link.source, solidcraft_sketch::LinkSource::Axis { name: "Hinge axis".into() });
|
||||
assert!(!link.lost, "renamed, the axis is still found");
|
||||
// A design with the axis after the sketch opens with the link lost, saying why.
|
||||
// Moved after the sketch, the axis can't be found from it: the link is lost, saying why, at
|
||||
// once and when the design is opened again (#9544). Moved back, it is found again.
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 1}));
|
||||
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(opened.model.state().sketch(sketch).unwrap().sketch.links[0].lost);
|
||||
let warnings = opened.model.result(sketch).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warnings.contains("it must come earlier in the timeline"), "{warnings}");
|
||||
for session in [&s, &opened] {
|
||||
assert!(session.model.state().sketch(sketch).unwrap().sketch.links[0].lost);
|
||||
let warnings = session.model.result(sketch).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warnings.contains("it must come earlier in the timeline"), "{warnings}");
|
||||
}
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "Hinge axis", "position": 0}));
|
||||
assert!(!s.model.state().sketch(sketch).unwrap().sketch.links[0].lost);
|
||||
// An axis that is no origin axis and no construction axis is refused, by its name.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": sketch}));
|
||||
let error = s.execute("sketch.project", &json!({"refs": [{"axis": "Axis9"}]})).unwrap_err().to_string();
|
||||
@@ -1395,6 +1399,47 @@ fn whole_body_intersect_of_a_filleted_plate_keeps_exact_lines_and_arcs() {
|
||||
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
|
||||
}
|
||||
#[test]
|
||||
fn whole_body_intersect_keeps_a_small_round_between_short_walls() {
|
||||
use solidcraft_sketch::CurveKind;
|
||||
// A tapered groove on each side stops 2 mm short of the R2 corner rounds: walls of 2 mm, a
|
||||
// round, walls of 2 mm, which a loose fit takes for one larger arc that misses the walls.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 150, "width": 55, "height": 150, "corner": [-75, 0, -75], "body_name": "Part"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[75, 27.5, 75], [-75, 27.5, 75], [75, 27.5, -75], [-75, 27.5, -75]], "radius": 2}));
|
||||
for (plane, offset, direction, rect) in [
|
||||
("YZ", 75, "negative", [[7, -71], [48, 71]]),
|
||||
("YZ", -75, "positive", [[7, -71], [48, 71]]),
|
||||
("XY", 75, "negative", [[-71, 7], [71, 48]]),
|
||||
("XY", -75, "positive", [[-71, 7], [71, 48]]),
|
||||
] {
|
||||
run(&mut s, "sketch.create", json!({"plane": plane, "offset": offset}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": rect[0], "p1": rect[1]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 6, "direction": direction, "taper": "-56.31 deg", "operation": "cut", "targets": ["Part"]}));
|
||||
}
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane": "XZ", "offset": 20, "project_edges": false}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Part"}]}));
|
||||
let inspect = run(&mut s, "sketch.inspect", json!({}));
|
||||
let state = s.model.state();
|
||||
let sk = &state.sketch(sketch).unwrap().sketch;
|
||||
let pos = |i: usize| sk.points[i].pos;
|
||||
let arcs: Vec<f64> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|c| match c.kind {
|
||||
CurveKind::Arc { c, a, .. } => Some(pos(c).dist(pos(a))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(arcs.len(), 4, "{arcs:?}");
|
||||
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
|
||||
// Square less the corners' (4 - pi) r^2 and four trapezoid grooves (142 + 124) / 2 * 6.
|
||||
assert_eq!(inspect["profiles"].as_array().map(Vec::len), Some(1), "one closed outline");
|
||||
let area = inspect["profiles"][0]["area_mm2"].as_f64().unwrap();
|
||||
let expected = 150. * 150. - (4. - std::f64::consts::PI) * 4. - 4. * 798.;
|
||||
assert!((area - expected).abs() < 0.5, "{area} vs {expected}");
|
||||
}
|
||||
#[test]
|
||||
fn a_rebuilt_projection_keeps_user_constraints_without_a_warning() {
|
||||
for dimensioned in [false, true] {
|
||||
let mut s = Session::default();
|
||||
@@ -1533,3 +1578,56 @@ fn the_silhouette_of_conic_rounds_closes() {
|
||||
let area = profiles[0]["area_mm2"].as_f64().unwrap();
|
||||
assert!((area - exact).abs() < exact * 2e-4, "area {area}, exact {exact}");
|
||||
}
|
||||
|
||||
/// A second placement's body is projected where that placement puts it (#9957): a sketch sees
|
||||
/// "Block (Part:2)" as the browser shows it, not only the first placement's "Block", on a plane
|
||||
/// and on a face alike.
|
||||
#[test]
|
||||
fn a_second_placements_face_and_edge_project_where_it_is_placed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":100,"width":40,"height":5,"body_name":"Plate"}));
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part"}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.copy", json!({"component":part["component"],"translate":[50,0,5]}));
|
||||
let mut sketches = Vec::new();
|
||||
let picks = [
|
||||
(json!({"plane":"XY","offset":15}), json!({"body":"Block (Part:2)","face":[60,10,15]})),
|
||||
(json!({"plane":"XY","offset":15}), json!({"body":"Block (Part:2)","edge":[60,20,15]})),
|
||||
(json!({"plane":{"face":[90,35,5]},"project_edges":false}), json!({"body":"Block (Part:2)","face":[60,10,15]})),
|
||||
];
|
||||
for (plane, pick) in picks {
|
||||
let id = run(&mut s, "sketch.create", plane)["sketch"].as_u64().unwrap();
|
||||
let projected = run(&mut s, "sketch.project", json!({"refs":[pick]}));
|
||||
assert_eq!(projected["links"].as_array().map(Vec::len), Some(1), "{projected}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
sketches.push(id);
|
||||
}
|
||||
// Every projected point is on the copy (x 50..70, y from `y`), none on the original.
|
||||
let check = |s: &Session, y: f64| {
|
||||
let state = s.model.state();
|
||||
for id in &sketches {
|
||||
let ss = state.sketch(*id).unwrap();
|
||||
let sk = &ss.sketch;
|
||||
assert!(sk.links.iter().all(|l| !l.lost), "{:?}", sk.links);
|
||||
let points: Vec<_> =
|
||||
sk.links.iter().flat_map(|l| sk.link_points(&l.id)).filter_map(|p| sk.points.get(p)).map(|p| ss.plane.to_world(p.pos)).collect();
|
||||
assert!(!points.is_empty());
|
||||
assert!(points.iter().all(|p| p.x > 49.9 && p.x < 70.1 && p.y > y - 0.1 && p.y < y + 20.1), "sketch {id}, y {y}: {points:?}");
|
||||
}
|
||||
};
|
||||
check(&s, 0.0);
|
||||
// A design opened again, so rebuilt from its features, finds the copy as well.
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 0.0);
|
||||
// Moving the copy moves what was projected from it.
|
||||
let first = s.doc.occurrences[0].id;
|
||||
let copy = s.doc.occurrences.iter().map(|o| o.id).find(|id| *id != first).unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":copy,"translate":[0,15,0],"capture":true}));
|
||||
check(&s, 15.0);
|
||||
// Renaming the part or the body renames the copy ("Base (Housing:2)"): the links follow it.
|
||||
run(&mut s, "component.rename", json!({"component":part["component"],"name":"Housing"}));
|
||||
run(&mut s, "body.rename", json!({"body":"Block","name":"Base"}));
|
||||
assert!(s.model.state().sketch(sketches[0]).unwrap().sketch.links.iter().all(|l| l.source.bodies() == ["Base (Housing:2)"]));
|
||||
check(&s, 15.0);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 15.0);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.revolve", "Surface Revolve", surface_revolve)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
|
||||
@@ -485,12 +485,12 @@ fn hook(info: &std::panic::PanicHookInfo<'_>) -> bool {
|
||||
q.push(p);
|
||||
}
|
||||
});
|
||||
eprintln!("SolidCraft: internal error caught in a command; a crash report will be written");
|
||||
solidcraft_geom::note!("SolidCraft: internal error caught in a command; a crash report will be written");
|
||||
} else {
|
||||
// Outside any command: what the journal knows has to do.
|
||||
let kind = if recovered { "kernel_panic_recovered" } else { "panic" };
|
||||
if let Some(id) = write_from_journal(state, kind, Some(&p)) {
|
||||
eprintln!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display());
|
||||
solidcraft_geom::note!("SolidCraft: internal error; crash report {}", state.dir.join(&id).display());
|
||||
if !recovered && let Ok(mut j) = state.journal.try_lock() {
|
||||
j.record(&json!({ "reported": id }));
|
||||
}
|
||||
|
||||
+51
-15
@@ -291,7 +291,10 @@ impl Mapper<'_> {
|
||||
m.insert(k.into(), y);
|
||||
}
|
||||
}
|
||||
// A sketch's view of the model holds a further placement by the name the world
|
||||
// gives it (`with_placed_copies`), so that name stays (#9957).
|
||||
if let Some(bn) = &b
|
||||
&& !self.here
|
||||
&& let Some(own) = self.own_name(bn)
|
||||
{
|
||||
m.insert("body".into(), Value::String(own));
|
||||
@@ -434,7 +437,11 @@ impl Drop for Nested {
|
||||
pub fn to_local(s: &Session, id: &str, params: &Value) -> crate::Result<Option<Value>> {
|
||||
// A command with no world picks has nothing to map, and may not need the bodies placed.
|
||||
let picks = !table(id).is_empty() || matches!(id, "library.apply" | "timeline.redefine" | "sketch.redefine");
|
||||
if !picks || !placed(s) {
|
||||
// Edit Feature of a revolve restores its saved axis (and the origin's expressions) even
|
||||
// where nothing is placed (`map_local`).
|
||||
let revolve_edit =
|
||||
id == "timeline.redefine" && matches!(params.get("command").and_then(Value::as_str), Some("solid.revolve" | "surface.revolve"));
|
||||
if !picks || (!placed(s) && !revolve_edit) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A move of bodies in differently turned frames keeps its world vectors: `solid.move` splits
|
||||
@@ -442,7 +449,8 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> crate::Result<Option<V
|
||||
if id == "solid.move" && split_move(s, params)?.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let world = s.world_state()?;
|
||||
// Nothing placed: the model is its own world, and only the saved axis is restored.
|
||||
let world = if placed(s) { s.world_state()? } else { s.model.state() };
|
||||
Ok(map_local(s, &world, id, params))
|
||||
}
|
||||
|
||||
@@ -527,16 +535,41 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
|
||||
if let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &feature.kind {
|
||||
context.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
|
||||
}
|
||||
let mut inner = map_local(&context, world, cmd, params.get("params")?)?;
|
||||
// Nothing to map (a surface revolve has no picks; a design with no placed component needs
|
||||
// none): an untouched explicit axis is still restored below.
|
||||
let mapped = if placed(s) { map_local(&context, world, cmd, params.get("params")?) } else { None };
|
||||
let mut inner = match mapped {
|
||||
Some(inner) => inner,
|
||||
None if matches!(cmd, "solid.revolve" | "surface.revolve") => params.get("params")?.clone(),
|
||||
None => return None,
|
||||
};
|
||||
// An untouched explicit axis is displayed in world coordinates by Edit
|
||||
// Feature. Keep its exact saved values instead of an affine round trip.
|
||||
if cmd == "solid.revolve"
|
||||
&& let solidcraft_doc::FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Line { origin, dir }, .. } = &feature.kind
|
||||
if let (
|
||||
"solid.revolve" | "surface.revolve",
|
||||
solidcraft_doc::FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Line { origin, dir }, .. }
|
||||
| solidcraft_doc::FeatureKind::SurfaceRevolve { axis: solidcraft_doc::AxisRef::Line { origin, dir }, .. },
|
||||
) = (cmd, &feature.kind)
|
||||
&& let Ok(at) = s.doc.eval_point(origin)
|
||||
{
|
||||
let frame = context.active_component_frame();
|
||||
let displayed = serde_json::json!({"origin":pt(apply_point(&frame,origin.0)),"dir":pt(apply_vector(&frame,dir.0))});
|
||||
if params.get("params").and_then(|p| p.get("axis")) == Some(&displayed) {
|
||||
inner["axis"] = serde_json::json!({"origin":pt(origin.0),"dir":pt(dir.0)});
|
||||
// Each half is kept when the dialog gives back what it showed (to float dust), so
|
||||
// editing only the direction leaves the origin's expressions alone.
|
||||
let given = params.get("params").and_then(|p| p.get("axis"));
|
||||
let shown = |key: &str, shown: Vec3| {
|
||||
let given = given.and_then(|a| a.get(key)).and_then(Value::as_array)?;
|
||||
let [x, y, z] = given.as_slice() else { return None };
|
||||
let near = |g: &Value, w: f64| g.as_f64().is_some_and(|g| (g - w).abs() <= 1e-9 * (1.0 + w.abs()));
|
||||
(near(x, shown.x) && near(y, shown.y) && near(z, shown.z)).then_some(())
|
||||
};
|
||||
if let Some(axis) = inner.get_mut("axis").and_then(Value::as_object_mut) {
|
||||
if shown("origin", apply_point(&frame, at)).is_some() {
|
||||
// The origin as saved: its expressions stay expressions.
|
||||
axis.insert("origin".into(), placement_point(origin));
|
||||
}
|
||||
if shown("dir", apply_vector(&frame, dir.0)).is_some() {
|
||||
axis.insert("dir".into(), pt(dir.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
let points = match (&feature.kind, cmd) {
|
||||
@@ -677,16 +710,19 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
|
||||
m.changed.then_some(Value::Object(out))
|
||||
}
|
||||
|
||||
/// A placement point as saved: a plain number stays a number, an expression is its text.
|
||||
fn placement_point(p: &solidcraft_doc::PointExpr) -> Value {
|
||||
Value::Array(
|
||||
p.iter()
|
||||
.map(|e| e.as_str().trim().parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| Value::from(e.as_str()), Value::from))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A primitive's saved placement inputs, in its component's frame, for redefining it with `cmd`.
|
||||
fn primitive_placement(kind: &solidcraft_doc::FeatureKind, cmd: &str) -> Vec<(&'static str, Value)> {
|
||||
use solidcraft_doc::FeatureKind as Feature;
|
||||
let point = |p: &solidcraft_doc::PointExpr| {
|
||||
Value::Array(
|
||||
p.iter()
|
||||
.map(|e| e.as_str().trim().parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| Value::from(e.as_str()), Value::from))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let point = placement_point;
|
||||
match (kind, cmd) {
|
||||
(Feature::Box { corner, .. }, "solid.box") => vec![("corner", point(corner))],
|
||||
(Feature::Sphere { center, .. }, "solid.sphere") | (Feature::Torus { center, .. }, "solid.torus") => vec![("center", point(center))],
|
||||
|
||||
@@ -358,6 +358,48 @@ fn editing_a_primitive_in_a_turned_component_keeps_its_placement() {
|
||||
assert!((size.x - before.x).abs() < 1e-6 && (size.y - 12.0).abs() < 0.05 && (size.z - 12.0).abs() < 0.05, "{size:?}");
|
||||
}
|
||||
|
||||
/// Edit Feature shows an explicit revolve axis with its origin evaluated; sending that back
|
||||
/// untouched keeps the saved expressions, for a solid and a surface revolve alike.
|
||||
#[test]
|
||||
fn an_untouched_revolve_axis_keeps_its_origin_expressions_through_redefine() {
|
||||
for (command, name) in [("solid.revolve", "Turn"), ("surface.revolve", "Shell")] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [10, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let axis = json!({"origin": ["W / 2", 0, 0], "dir": [0, 1, 0]});
|
||||
let mut made = json!({"sketch": "Profile", "axis": axis, "angle": "90 deg", "name": name});
|
||||
if command == "solid.revolve" {
|
||||
made["operation"] = json!("new");
|
||||
}
|
||||
run(&mut s, command, made);
|
||||
// The origin's coordinates as text (a saved 0 may read back as 0 or 0.0).
|
||||
let saved = |s: &mut Session| -> Vec<String> {
|
||||
let axis = run(s, "timeline.inspect", json!({"feature": name}))["definition"]["axis"].clone();
|
||||
axis["origin"].as_array().unwrap().iter().map(|c| c.as_f64().map_or_else(|| c.as_str().unwrap().to_string(), |x| x.to_string())).collect()
|
||||
};
|
||||
let original = saved(&mut s);
|
||||
assert_eq!(original, ["W / 2", "0", "0"]);
|
||||
// What the dialog sends back untouched: the origin as numbers.
|
||||
let mut params = json!({"sketch": "Profile", "axis": {"origin": [20.0, 0.0, 0.0], "dir": [0.0, 1.0, 0.0]}, "angle": "45 deg"});
|
||||
if command == "solid.revolve" {
|
||||
params["operation"] = json!("new");
|
||||
}
|
||||
run(&mut s, "timeline.redefine", json!({"feature": name, "command": command, "params": params}));
|
||||
assert_eq!(saved(&mut s), original, "{command}");
|
||||
// Only the direction changed: the origin keeps its expressions.
|
||||
let mut turned = params.clone();
|
||||
turned["axis"]["dir"] = json!([0.0, -1.0, 0.0]);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": name, "command": command, "params": turned}));
|
||||
assert_eq!(saved(&mut s), original, "{command}");
|
||||
// A changed origin is taken as given.
|
||||
params["axis"]["origin"] = json!([21.0, 0.0, 0.0]);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": name, "command": command, "params": params}));
|
||||
assert_eq!(saved(&mut s), ["21", "0", "0"], "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A new placement given to `timeline.redefine` is still a world point.
|
||||
#[test]
|
||||
fn a_new_primitive_placement_is_still_mapped_from_the_world() {
|
||||
|
||||
@@ -493,3 +493,86 @@ fn a_hole_wider_than_the_part_says_it_removes_all_of_it() {
|
||||
change(&mut s, "hd", "5 mm");
|
||||
assert_eq!(said(&s, "H1"), "");
|
||||
}
|
||||
|
||||
/// The volume of one body.
|
||||
fn body_volume(s: &mut Session, name: &str) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap_or_else(|| panic!("no body {name}: {m}"));
|
||||
b["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
/// A Block (x 0…20) made first, then a 3 mm Plate (x −3…0) whose front face touches the Block's
|
||||
/// back face round (0, 30, 20).
|
||||
fn plate_behind_block(s: &mut Session) {
|
||||
run(s, "solid.box", json!({"length": 20, "width": 40, "height": 30, "corner": [0, 10, 5], "body_name": "Block", "operation": "new"}));
|
||||
run(s, "solid.box", json!({"length": 3, "width": 60, "height": 40, "corner": [-3, 0, 0], "body_name": "Plate", "operation": "new"}));
|
||||
}
|
||||
|
||||
/// The Plate with a 3.4 mm hole through it at (y 30, z 20), and the Block untouched.
|
||||
fn plate_drilled_through(s: &mut Session) {
|
||||
near(body_volume(s, "Plate"), 7200.0 - pi() * 1.7 * 1.7 * 3.0);
|
||||
near(body_volume(s, "Block"), 24_000.0);
|
||||
assert!(!solid_at(s, -2.5, 30.0, 20.0), "the hole comes out of the Plate's back");
|
||||
assert!(solid_at(s, 0.5, 30.0, 20.0), "the Block is not drilled");
|
||||
}
|
||||
|
||||
/// #12066: a hole on a face that another part touches there drills the part it names. Both faces
|
||||
/// pass through the point, and the Block, made first, used to win: the hole drilled away from
|
||||
/// the Plate and cut only a shallow pocket into it, without a word.
|
||||
#[test]
|
||||
fn a_hole_on_a_face_another_part_touches_drills_the_part_it_names() {
|
||||
let mut s = Session::default();
|
||||
plate_behind_block(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [0, 30, 20], "targets": ["Plate"], "diameter": 3.4, "name": "H"}));
|
||||
assert!(hole_direction(&s, "H").x < -0.999, "drills along -X: {:?}", hole_direction(&s, "H"));
|
||||
assert_eq!(said(&s, "H"), "");
|
||||
plate_drilled_through(&mut s);
|
||||
}
|
||||
|
||||
/// #12066: a design saved with that hole's direction pointing into the Block (as older builds
|
||||
/// saved it) drills the Plate when it is opened: the direction is mirrored into the Plate's face,
|
||||
/// as format 18 does for one taken from a thin plate's far side.
|
||||
#[test]
|
||||
fn a_saved_hole_turned_towards_a_touching_part_drills_its_own_part_when_opened() {
|
||||
let mut s = Session::default();
|
||||
plate_behind_block(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [0, 30, 20], "targets": ["Plate"], "diameter": 3.4, "name": "H"}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let hole = saved["features"].as_array_mut().unwrap().iter_mut().find(|f| f["name"] == "H").unwrap();
|
||||
hole["direction"] = json!([1.0, 0.0, 0.0]);
|
||||
let mut opened = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
|
||||
assert!((hole_direction(&opened, "H").x - 1.0).abs() < 1e-9, "the design keeps the direction it was saved with");
|
||||
assert_eq!(said(&opened, "H"), "");
|
||||
plate_drilled_through(&mut opened);
|
||||
}
|
||||
|
||||
fn hole_direction(s: &Session, name: &str) -> Vec3 {
|
||||
match &s.doc.find_feature(name).unwrap().kind {
|
||||
solidcraft_doc::FeatureKind::Hole { direction, .. } => direction.0,
|
||||
other => panic!("{name} is not a hole: {}", other.type_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A hole placed on a Cover's top to drill the Base under it (`targets` leaves the Cover out)
|
||||
/// still goes by the Cover's face, the only face there: it drills down through the Base, made
|
||||
/// now or opened from a design that saved the direction up (as format 18 repairs).
|
||||
#[test]
|
||||
fn a_hole_placed_on_another_parts_face_drills_its_own_part_behind_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [10, 10, 10], "length": 20, "width": 20, "height": 4, "body_name": "Cover", "operation": "new"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 20, 14], "targets": ["Base"], "diameter": 5, "name": "H"}));
|
||||
let drilled_base_only = |s: &mut Session| {
|
||||
assert_eq!(said(s, "H"), "");
|
||||
near(body_volume(s, "Base"), 16_000.0 - pi() * 6.25 * 10.0);
|
||||
near(body_volume(s, "Cover"), 1600.0);
|
||||
};
|
||||
assert!(hole_direction(&s, "H").z < -0.999, "{:?}", hole_direction(&s, "H"));
|
||||
drilled_base_only(&mut s);
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let hole = saved["features"].as_array_mut().unwrap().iter_mut().find(|f| f["name"] == "H").unwrap();
|
||||
hole["direction"] = json!([0.0, 0.0, 1.0]);
|
||||
let mut opened = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
|
||||
assert!(hole_direction(&opened, "H").z > 0.999);
|
||||
drilled_base_only(&mut opened);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ pub use cmd::{
|
||||
pub use solidcraft_doc as doc;
|
||||
pub use solidcraft_doc::Visibility;
|
||||
pub use solidcraft_geom as geom;
|
||||
pub use solidcraft_geom::note;
|
||||
pub use solidcraft_io as io;
|
||||
pub use solidcraft_kernel as kernel;
|
||||
pub use solidcraft_profile as profile;
|
||||
@@ -959,9 +960,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
/// The design has changes not in its file: in the document, in what is hidden, or a
|
||||
/// component moved and not captured yet (#9736).
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.document_differs() || self.visibility != self.saved_visibility
|
||||
self.document_differs() || self.visibility != self.saved_visibility || self.moved_uncaptured()
|
||||
}
|
||||
|
||||
/// A component was moved and its position not captured yet (#9736). The placements of an
|
||||
/// exploded view shown are not such a move: they last only while it is shown.
|
||||
pub fn moved_uncaptured(&self) -> bool {
|
||||
!self.pending_moves.is_empty() && !cmd::motion::shows_exploded_view(self)
|
||||
}
|
||||
|
||||
/// The document is not the one its file holds: compared once per pair of documents and
|
||||
@@ -1133,8 +1141,9 @@ impl Session {
|
||||
self.model.forget_fingerprints();
|
||||
}
|
||||
|
||||
/// Prepared placements only: optional save caches must not compute a cold world.
|
||||
pub(crate) fn cached_world_state(&self) -> Option<Arc<solidcraft_doc::ModelState>> {
|
||||
/// Prepared placements only: optional save caches must not compute a cold world. `None`
|
||||
/// when the design has components and nothing has placed them since it last changed.
|
||||
pub fn cached_world_state(&self) -> Option<Arc<solidcraft_doc::ModelState>> {
|
||||
let state = self.model.state();
|
||||
if self.doc.occurrences.is_empty() {
|
||||
return Some(state);
|
||||
|
||||
@@ -787,3 +787,61 @@ fn a_chamfer_running_past_the_side_edges_it_takes_up_is_named_by_its_edge() {
|
||||
let at = solidcraft_doc::naming::face_names_at(&s.model.state(), &[on(40.0), on(150.0)]);
|
||||
assert_eq!(at, vec![blends[0].clone(), blends[0].clone()]);
|
||||
}
|
||||
|
||||
/// A line extruded into a surface and thickened into a slab. Every face of the slab is named
|
||||
/// from that history, and a sketch on the offset face attaches and follows the thickness (#7641).
|
||||
#[test]
|
||||
fn a_sketch_on_a_thickened_surface_follows_the_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "thick", "expression": "2 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Line"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "Line", "curves": ["l1"], "distance": 30, "body_name": "Sheet"}));
|
||||
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": "thick", "body_name": "Slab"}));
|
||||
// The offset face is the copy at y = -2. Its frame is X and Z, origin on that plane.
|
||||
let made = run(&mut s, "sketch.create", json!({"plane": {"face": [30.0, -2.0, 10.0]}, "name": "OnFace"}));
|
||||
assert_eq!(made["attachment"], "exact_face", "{made}");
|
||||
let named = names(&s, "Slab");
|
||||
assert!(named.iter().all(|(_, exact)| *exact), "a thickened face must keep exact history: {named:?}");
|
||||
// Kept face keeps the sheet's name; the offset and the edge walls are the thicken feature's.
|
||||
assert!(named.iter().any(|(n, _)| n == "F2:side:l1"), "{named:?}");
|
||||
assert!(named.iter().any(|(n, _)| n == "F3:inner:F2:side:l1"), "{named:?}");
|
||||
assert_eq!(named.iter().filter(|(n, _)| n.starts_with("F3:blend:")).count(), 4, "{named:?}");
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "OnFace", "distance": 4, "operation": "join", "name": "Boss"}));
|
||||
// An end wall (the face at x = 0) takes a sketch too.
|
||||
let wall = run(&mut s, "sketch.create", json!({"plane": {"face": [0.0, -1.0, 15.0]}, "name": "OnWall"}));
|
||||
assert_eq!(wall["attachment"], "exact_face", "{wall}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let slab = |s: &Session| {
|
||||
let body = s.model.state().body("Slab").unwrap().body.clone();
|
||||
let measured = solidcraft_kernel::measure(&body).unwrap();
|
||||
(measured.volume, measured.bbox.min.y)
|
||||
};
|
||||
let (volume, min_y) = slab(&s);
|
||||
let boss = std::f64::consts::PI * 9.0 * 4.0;
|
||||
// The join of the cylinder is a hair under the analytic sum (about 0.02 mm³ here).
|
||||
assert!((volume - (2400.0 + boss)).abs() < 0.05, "slab 40×30×2 plus the boss {}: got {volume}", 2400.0 + boss);
|
||||
assert!((min_y - (-6.0)).abs() < 1e-6, "the boss stands 4 mm off y = -2: {min_y}");
|
||||
assert_eq!(error(&s, "OnFace"), None);
|
||||
assert_eq!(error(&s, "Boss"), None);
|
||||
assert_eq!(warning(&s, "Boss"), None);
|
||||
|
||||
change(&mut s, "thick", "4 mm");
|
||||
assert_eq!(error(&s, "OnFace"), None);
|
||||
assert_eq!(error(&s, "Boss"), None);
|
||||
assert_eq!(warning(&s, "OnFace"), None);
|
||||
assert_eq!(warning(&s, "Boss"), None);
|
||||
let (volume, min_y) = slab(&s);
|
||||
assert!((volume - (4800.0 + boss)).abs() < 0.05, "the slab grew with the thickness and the boss stayed whole {}: got {volume}", 4800.0 + boss);
|
||||
assert!((min_y - (-8.0)).abs() < 1e-6, "the boss followed the face to y = -4: {min_y}");
|
||||
|
||||
let mut cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
cold.refresh();
|
||||
let (volume, min_y) = slab(&cold);
|
||||
assert!((volume - (4800.0 + boss)).abs() < 0.05 && (min_y - (-8.0)).abs() < 1e-6, "reopened: {volume} {min_y}");
|
||||
assert_eq!(error(&cold, "Boss"), None);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
|
||||
/// Find where each value of a joint can go before the parts it moves touch another body. The
|
||||
/// design is not changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -161,6 +163,244 @@ pub struct FindLimitsResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Contact (#11949) ----
|
||||
//
|
||||
// The contact handlers read their parameters from the raw JSON, so each key here reads as its
|
||||
// handler reads it and fails with the handler's message. Whether the occurrences and the set
|
||||
// exist stays the handler's to check.
|
||||
|
||||
/// The most occurrences a contact set's list may name, as `cmd/motion.rs`'s `MAX_ITEMS`.
|
||||
const MAX_OCCURRENCES: usize = 10_000;
|
||||
|
||||
/// Turn contact sets on or off: while on, driving a joint or playing a motion study stops where
|
||||
/// bodies in one contact set would pass through each other. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ContactEnableParams {
|
||||
/// Whether contact sets are on. Default: true.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// No parameters: `contact.enable_all` (every occurrence takes part in contact, as one set),
|
||||
/// `contact.disable_all` (contact off), `contact.list` and `contact.check` (read only).
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ContactNoParams {}
|
||||
|
||||
/// Make a contact set: occurrences whose bodies may touch but not pass through each other while
|
||||
/// joints move. Contact sets are turned on. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = occurrences_required)]
|
||||
pub struct ContactCreateParams {
|
||||
// Optional in the type and required in the schema (`occurrences_required`): left out, the
|
||||
// handler refuses it with its own message, not the check's "is required".
|
||||
#[serde(default, deserialize_with = "some_occurrences", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceKeys")]
|
||||
pub occurrences: Option<OccurrenceKeys>,
|
||||
/// The set's name. Default: "Contact Set" and the first number no set's name has.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Change a contact set: its occurrences, its name, or whether it's suppressed; or delete it. One
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = set_required)]
|
||||
pub struct ContactEditParams {
|
||||
// Required in the schema (`set_required`), optional in the type, as `occurrences` above.
|
||||
#[serde(default, deserialize_with = "contact_set", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "contact_set_schema")]
|
||||
pub set: Option<Value>,
|
||||
/// The set's new occurrences, replacing them all.
|
||||
#[serde(default, deserialize_with = "some_occurrences", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "OccurrenceKeys")]
|
||||
pub occurrences: Option<OccurrenceKeys>,
|
||||
/// The set's new name; blank text keeps the name.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Suppress the set (it stops nothing) or not.
|
||||
#[serde(default)]
|
||||
pub suppressed: Option<bool>,
|
||||
/// Delete the set instead; the other keys are then not used. Default: false.
|
||||
#[serde(default)]
|
||||
pub delete: Option<bool>,
|
||||
}
|
||||
|
||||
/// A contact set's occurrences, as `cmd/motion.rs`'s `occurrences` reads them: a list of ids or
|
||||
/// names, with its messages. An occurrence named twice counts once.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OccurrenceKeys(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for OccurrenceKeys {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(list) if list.len() > MAX_OCCURRENCES => Err(D::Error::custom(whole("too many occurrences"))),
|
||||
Value::Array(list) if list.iter().all(|v| v.is_number() || v.is_string()) => Ok(OccurrenceKeys(list)),
|
||||
Value::Array(_) => Err(D::Error::custom(whole("an occurrence must be an id or a name"))),
|
||||
_ => Err(D::Error::custom(whole("`occurrences` must list occurrences"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for OccurrenceKeys {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"OccurrenceKeys".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Two or more occurrences, each by id (a number) or name; not the root.",
|
||||
"type": "array",
|
||||
"items": {"type": ["number", "string"]},
|
||||
"minItems": 2,
|
||||
"maxItems": MAX_OCCURRENCES
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `occurrences`: given, `null` included, it must be [`OccurrenceKeys`], as the handlers read it
|
||||
/// whenever the key is there.
|
||||
fn some_occurrences<'de, D: Deserializer<'de>>(d: D) -> Result<Option<OccurrenceKeys>, D::Error> {
|
||||
OccurrenceKeys::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// `set`: an id or a name, with the handler's message for anything else, `null` included.
|
||||
fn contact_set<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
v @ (Value::Number(_) | Value::String(_)) => Ok(Some(v)),
|
||||
_ => Err(D::Error::custom(whole("`set` must be an id or a name"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn contact_set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "The contact set, by id (a number) or name.", "type": ["number", "string"]})
|
||||
}
|
||||
|
||||
/// Adds `key` to the schema's `required` list: for a key the type has optional so that the
|
||||
/// handler, not the check, refuses it when it's left out.
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
match schema.get_mut("required") {
|
||||
Some(Value::Array(required)) => {
|
||||
if !required.iter().any(|k| k == key) {
|
||||
required.push(key.into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
schema.insert("required".into(), Value::Array(vec![key.into()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrences_required(schema: &mut Schema) {
|
||||
require(schema, "occurrences");
|
||||
}
|
||||
|
||||
fn set_required(schema: &mut Schema) {
|
||||
require(schema, "set");
|
||||
}
|
||||
|
||||
/// What `contact.enable_sets`, `contact.enable_all` and `contact.disable_all` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactSwitch {
|
||||
/// Whether contact is on.
|
||||
pub enabled: bool,
|
||||
/// From `contact.enable_all`: always true, every occurrence takes part.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub all: Option<bool>,
|
||||
/// 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 `contact.create` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactSetCreated {
|
||||
/// The new set's id.
|
||||
pub set: u64,
|
||||
pub name: 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>,
|
||||
}
|
||||
|
||||
/// What `contact.edit` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactSetEdited {
|
||||
#[serde(flatten)]
|
||||
pub change: ContactSetChange,
|
||||
/// 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 an edit did to the set.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum ContactSetChange {
|
||||
/// The set was deleted.
|
||||
Deleted {
|
||||
/// Always true.
|
||||
deleted: bool,
|
||||
},
|
||||
/// The set was changed.
|
||||
Changed {
|
||||
/// The set's id.
|
||||
set: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `contact.list` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactSets {
|
||||
/// Whether contact is on.
|
||||
pub enabled: bool,
|
||||
/// Whether every occurrence takes part, as one set, instead of `sets`.
|
||||
pub all: bool,
|
||||
pub sets: Vec<ContactSetInfo>,
|
||||
/// 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 contact set, as the design saves it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactSetInfo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Its occurrences' ids.
|
||||
pub occurrences: Vec<u64>,
|
||||
/// Present, and true, when the set is suppressed.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
impl From<&solidcraft_doc::motion::ContactSet> for ContactSetInfo {
|
||||
fn from(set: &solidcraft_doc::motion::ContactSet) -> Self {
|
||||
ContactSetInfo { id: set.id, name: set.name.clone(), occurrences: set.occurrences.clone(), suppressed: set.suppressed }
|
||||
}
|
||||
}
|
||||
|
||||
/// What `contact.check` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactCheck {
|
||||
/// The pairs of occurrences in one contact set whose bodies pass through each other now;
|
||||
/// none while contact is off.
|
||||
pub collisions: Vec<ContactCollision>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Two occurrences whose bodies pass through each other.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ContactCollision {
|
||||
/// The occurrences' names.
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
/// How deep (mm).
|
||||
pub depth_mm: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "motion_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{FindLimitsParams, FindLimitsResult};
|
||||
use super::{
|
||||
ContactCheck, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetCreated, ContactSetEdited, ContactSets,
|
||||
ContactSwitch, FindLimitsParams, FindLimitsResult,
|
||||
};
|
||||
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};
|
||||
@@ -8,17 +11,105 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<FindLimitsParams>(), &["against", "contact_sets", "index", "joint", "occurrences", "refine", "step"]);
|
||||
assert_keys(input_schema_for::<ContactEnableParams>(), &["enabled"]);
|
||||
assert_keys(input_schema_for::<ContactNoParams>(), &[]);
|
||||
assert_keys(input_schema_for::<ContactCreateParams>(), &["name", "occurrences"]);
|
||||
assert_keys(input_schema_for::<ContactEditParams>(), &["delete", "name", "occurrences", "set", "suppressed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let many = vec!["A"; 10_001];
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("joint.find_limits", json!({"joint": true})),
|
||||
("joint.find_limits", json!({"joint": null})),
|
||||
("joint.find_limits", json!({"joint": "Hinge"})),
|
||||
("contact.create", json!({})),
|
||||
("contact.create", json!({"occurrences": null})),
|
||||
("contact.create", json!({"occurrences": "Door"})),
|
||||
("contact.create", json!({"occurrences": many})),
|
||||
("contact.create", json!({"occurrences": [true, "Door"]})),
|
||||
("contact.create", json!({"occurrences": ["Nope", "Door"]})),
|
||||
("contact.create", json!({"occurrences": ["root", "Door"]})),
|
||||
("contact.create", json!({"occurrences": [7]})),
|
||||
("contact.edit", json!({})),
|
||||
("contact.edit", json!({"set": true})),
|
||||
("contact.edit", json!({"set": null})),
|
||||
("contact.edit", json!({"set": "Nope", "delete": true})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The contact contracts say what their handlers need: `occurrences` and `set` are required in
|
||||
/// the schemas, though the types leave them to the handlers' messages, and a list names two or
|
||||
/// more occurrences.
|
||||
#[test]
|
||||
fn the_contact_schemas_take_what_the_handlers_read() {
|
||||
let create = input_schema_for::<ContactCreateParams>();
|
||||
let create = create.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"occurrences": ["Door", 7], "name": "Swing"}), true),
|
||||
(json!({"occurrences": ["Door"]}), false),
|
||||
(json!({"occurrences": [true, "Door"]}), false),
|
||||
(json!({"name": "Swing"}), false),
|
||||
] {
|
||||
assert_eq!(conforms(create, create, &p), takes, "{p}");
|
||||
}
|
||||
let edit = input_schema_for::<ContactEditParams>();
|
||||
let edit = edit.as_value();
|
||||
for (p, takes) in [(json!({"set": 3, "suppressed": true}), true), (json!({"set": null}), false), (json!({"delete": true}), false)] {
|
||||
assert_eq!(conforms(edit, edit, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #11949: every contact command replies as its output schema says, in Warn without warnings:
|
||||
/// sets made, listed, changed, suppressed and deleted, contact switched on and off, and a check
|
||||
/// that finds two parts passing through each other. A misspelt key gets `param_warnings` in Warn,
|
||||
/// since the handlers ignore unknown keys, and strict refuses it.
|
||||
#[test]
|
||||
fn contact_commands_reply_with_their_schemas() {
|
||||
let mut s = door();
|
||||
// A part through the door, so a check finds them.
|
||||
for (id, p) in [
|
||||
("component.activate", json!({"component": "root"})),
|
||||
("component.create", json!({"name": "Clash", "grounded": false})),
|
||||
("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, -3, 0]})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let mut run = |id: &str, p: Value, schema: schemars::Schema| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
run("contact.list", json!({}), output_schema_for::<ContactSets>());
|
||||
let r = run("contact.create", json!({"occurrences": ["Door", "Clash"], "name": "Swing"}), output_schema_for::<ContactSetCreated>());
|
||||
assert_eq!(r["name"], "Swing", "{r}");
|
||||
let other = run("contact.create", json!({"occurrences": ["Door", "Slide", "Door"]}), output_schema_for::<ContactSetCreated>());
|
||||
let r = run("contact.check", json!({}), output_schema_for::<ContactCheck>());
|
||||
assert!(r["collisions"].as_array().is_some_and(|c| !c.is_empty()), "{r}");
|
||||
run(
|
||||
"contact.edit",
|
||||
json!({"set": "Swing", "suppressed": true, "occurrences": ["Door", "Clash", "Block"], "name": "Swing 2"}),
|
||||
output_schema_for::<ContactSetEdited>(),
|
||||
);
|
||||
let r = run("contact.list", json!({}), output_schema_for::<ContactSets>());
|
||||
assert!(r["sets"][0]["suppressed"] == true && r["sets"][1].get("suppressed").is_none(), "{r}");
|
||||
let r = run("contact.edit", json!({"set": other["set"], "delete": true}), output_schema_for::<ContactSetEdited>());
|
||||
assert_eq!(r["deleted"], true, "{r}");
|
||||
for (id, p) in [("contact.enable_sets", json!({"enabled": false})), ("contact.enable_all", json!({})), ("contact.disable_all", json!({}))] {
|
||||
run(id, p, output_schema_for::<ContactSwitch>());
|
||||
}
|
||||
let p = json!({"enable": false});
|
||||
let r = s.execute("contact.enable_sets", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains("enable"), "{r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("contact.enable_sets", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`enable`"), "{error}");
|
||||
}
|
||||
|
||||
/// A 100 × 4 × 50 door hinged about Z at the origin, a block in its way and a slider beside it.
|
||||
fn door() -> Session {
|
||||
let mut s = Session::default();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user