mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 19:15:57 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52eb25311a | |||
| dd23be12d5 | |||
| ab22da1343 | |||
| 011ff24107 | |||
| 4a4eb59788 | |||
| 207c67d1da | |||
| ff5d96aaad | |||
| bc68e14c3e | |||
| 6d410c342c | |||
| dded27277f | |||
| 46462957e4 |
@@ -0,0 +1,14 @@
|
||||
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
|
||||
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
|
||||
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
|
||||
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
|
||||
# backend-neutral suites would miss when the kernel is swapped.
|
||||
profile = "mutants"
|
||||
additional_cargo_test_args = [
|
||||
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
|
||||
"oracle_suite",
|
||||
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
|
||||
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
|
||||
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
|
||||
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
|
||||
]
|
||||
@@ -15,3 +15,12 @@ Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavi
|
||||
## Integration status
|
||||
|
||||
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The area is open on the [integration map](https://github.com/tangency-labs/solidcraft/blob/main/ROADMAP.md#integration-map), or the approach was agreed in the linked issue.
|
||||
- [ ] Linked issue (`Fixes #…`), ideally one in a current milestone.
|
||||
- [ ] One topic, under about 1,000 changed lines (over about 2,000 only with an agreed issue).
|
||||
- [ ] Tests added or updated for the change.
|
||||
- [ ] No GPL/LGPL/AGPL code, dependencies or required programs; new dependencies are named above with their licence.
|
||||
- [ ] If AI tools helped, I have read and understood every line and can explain it.
|
||||
|
||||
@@ -27,9 +27,7 @@ concurrency:
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
|
||||
@@ -33,9 +33,7 @@ concurrency:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
+38
-3
@@ -148,6 +148,8 @@ the most complete description of the house style.
|
||||
A geometry change that changes the robustness goldens re-records them in the same pull request:
|
||||
`ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::`
|
||||
(see [docs/robustness.md](docs/robustness.md)); CI runs every seed on pull requests that touch geometry.
|
||||
[docs/mutation-testing.md](docs/mutation-testing.md) shows how to find kernel behaviour these
|
||||
black-box suites miss.
|
||||
|
||||
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
|
||||
press keys, then assert on the result.
|
||||
@@ -262,7 +264,24 @@ and a fresh CI run:
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
|
||||
## Sending a change
|
||||
## Before you start
|
||||
|
||||
- **Check the [integration map](ROADMAP.md#integration-map).** It says which areas are open
|
||||
for pull requests now, which need a conversation first and which are deferred. The CAD core
|
||||
(sketch, solid modelling, assemblies, files) comes first. For a "talk first" area, open an
|
||||
issue (or comment on its RFC) and agree the approach before you write code.
|
||||
- **Keep pull requests reviewable.** Aim for under about 1,000 changed lines on one topic.
|
||||
Anything over about 2,000 lines needs an issue where a maintainer has agreed the plan first.
|
||||
Stacked pull requests are fine, as long as each one makes sense and can be reviewed on its own.
|
||||
Generated files, test data and goldens don't count towards the size, but say what they are.
|
||||
- **AI-assisted code is welcome.** You must understand every line you send and be able to
|
||||
explain it in review. Large amounts of generated code nobody has reviewed will be closed.
|
||||
- **Licences: no GPL, LGPL or AGPL.** That covers code, dependencies and programs SolidCraft
|
||||
needs at run time (for example FreeCAD or OpenCASCADE), as well as code ported or translated
|
||||
from them. The only exception is test tooling you run by hand as a separate program, like the
|
||||
[FreeCAD reference-value scripts](tools/freecad-oracle/README.md). See rule 2 above.
|
||||
|
||||
## Sending a pull request
|
||||
|
||||
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
|
||||
@@ -293,6 +312,21 @@ without pushes. These automatic cleanup deadlines are separate from the daily pr
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
### Review, CI and milestones
|
||||
|
||||
- **Quick checks on every push.** Formatting, the repository checks and a quick compile run on
|
||||
every pull request.
|
||||
- **Full CI after review.** Once a maintainer has looked at the pull request they add the
|
||||
`approved-for-ci` label, which runs the full suite (build, tests, goldens, Clippy). This is
|
||||
coming with [#2689](https://github.com/tangency-labs/solidcraft/pull/2689); until it lands,
|
||||
ready pull requests run the full suite straight away.
|
||||
- **Merges go through the merge queue**, which runs the full suite once more on top of the
|
||||
latest `main` before the change lands.
|
||||
- **Milestones set the order.** The [milestones](https://github.com/tangency-labs/solidcraft/milestones)
|
||||
are the current sprint goals, and pull requests for them are reviewed first. When you can,
|
||||
link your pull request to an issue in one of them. Other pull requests are still welcome;
|
||||
they may just wait longer.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
@@ -354,8 +388,9 @@ In order of how much they'd help users:
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
3. **Drawings, CAM and mesh tools.** These wait until the CAD core is solid. The
|
||||
[integration map](ROADMAP.md#integration-map) says which are "talk first" and which are
|
||||
deferred; please start with an issue rather than code.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
Generated
+17
-17
@@ -4078,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,11 +4109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4124,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4213,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4267,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -142,3 +142,9 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
+40
@@ -3,6 +3,46 @@
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## Integration map
|
||||
|
||||
Where pull requests are welcome right now. The CAD core comes first: sketching, solid
|
||||
modelling, assemblies and files have to be dependable before we build big new workspaces on
|
||||
top of them.
|
||||
|
||||
- **Open**: send pull requests. Bigger changes still start from an issue (see
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#before-you-start)).
|
||||
- **Talk first**: open an issue or comment on the RFC and agree the approach before writing
|
||||
code. Pull requests without that agreement will usually be closed.
|
||||
- **Deferred**: not now. Ideas and requirements are welcome on the linked issues, but code
|
||||
pull requests won't be reviewed until the area opens.
|
||||
|
||||
| Area | Status | Why | Milestone |
|
||||
|---|---|---|---|
|
||||
| Solid modelling robustness (booleans, fillets, chamfers, shell, draft, patterns) | Open | Everything else is built on it. Each failing case is a clear, testable bug. | CAD core robustness |
|
||||
| Sketch and constraint solver | Open | Part of the CAD core. | CAD core robustness |
|
||||
| Parameters, configurations, timeline | Open | Part of the CAD core. | CAD core robustness |
|
||||
| Assemblies and joints | Open | In scope (M9) and in progress. | Assemblies |
|
||||
| Import, export and performance (STEP, IGES, 3MF, STL, OBJ, DXF; opening and rebuild speed) | Open | People need to get their work in and out. | Import, export & performance |
|
||||
| UI refresh (ribbon, toolbar, dialogs) | Open: coordinate on [#2016](https://github.com/tangency-labs/solidcraft/issues/2016) | The redesign is planned in one tracker so changes don't collide. | UI refresh |
|
||||
| Bug and crash fixes | Open | Always welcome, with a regression test. | Stability & bug fixes |
|
||||
| Translations | Open | See [docs/localization.md](docs/localization.md). | — |
|
||||
| Documentation | Open | | Contributor experience |
|
||||
| CI, tooling and contributor docs | Open | | Contributor experience |
|
||||
| Sheet metal and plastic | Open for fixes; talk first for new tools | The planned set (M10) is in; new tools need an agreed issue. | Stability & bug fixes |
|
||||
| Kernel internals | Talk first | The kernel is being replaced by [solidcore](https://github.com/bherbruck/solidcore). Don't rewrite the current kernel code for speed; report slow cases with the `kernel-perf` label. | solidcore kernel ([#1597](https://github.com/tangency-labs/solidcraft/issues/1597)) |
|
||||
| Surface modelling beyond what solids need | Talk first | Scope and order of work are in [#772](https://github.com/tangency-labs/solidcraft/issues/772). | — |
|
||||
| Render | Talk first | Work is planned in [#620](https://github.com/tangency-labs/solidcraft/issues/620); agree a piece there before starting. | — |
|
||||
| Drawings | Talk first | Needs a stable model underneath. Requirements and phases are in RFC [#860](https://github.com/tangency-labs/solidcraft/issues/860); foundations land in small packages ([docs/drawing-foundations.md](docs/drawing-foundations.md)). | — |
|
||||
| Manufacture / CAM | Deferred | The CAD core comes first: toolpaths are only as good as the model they are cut from, and CAM is a large area to review and maintain. Discussion: RFC [#1153](https://github.com/tangency-labs/solidcraft/issues/1153), plugin research [#1621](https://github.com/tangency-labs/solidcraft/issues/1621). | — |
|
||||
| Mesh workspace | Deferred | The CAD core comes first. Mesh import and Convert Mesh bug reports are still welcome. | — |
|
||||
| Form (T-splines) | Deferred | The CAD core comes first ([#446](https://github.com/tangency-labs/solidcraft/issues/446)). | — |
|
||||
| PCB / electronics | Deferred | The CAD core comes first. | — |
|
||||
| Simulation, animation | Deferred | The CAD core comes first. | — |
|
||||
|
||||
Milestones are the current sprint goals:
|
||||
[milestones on GitHub](https://github.com/tangency-labs/solidcraft/milestones). Maintainers
|
||||
review pull requests linked to issues in these milestones first.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The reference tests (tests/oracle, docs/oracle.md) as a `cargo test`, so tools that drive
|
||||
//! `cargo test` (cargo-mutants) can use them: every case with expected values and no
|
||||
//! `known_failure` mark must pass, as `cargo xtask oracle` requires.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by cargo-mutants (.cargo/mutants.toml); CI replays the oracle through cargo xtask oracle"]
|
||||
fn oracle_suite() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let dir = root.join("tests/oracle");
|
||||
let mut cases: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.join("recipe.json").is_file())
|
||||
.map(|p| format!("tests/oracle/{}", p.file_name().unwrap().to_string_lossy()))
|
||||
.collect();
|
||||
cases.sort();
|
||||
assert!(cases.len() >= 50, "oracle cases in {}", dir.display());
|
||||
// One process per core, cases dealt round-robin so the slow ones spread out.
|
||||
let n = std::thread::available_parallelism().map_or(4, |n| n.get()).min(cases.len());
|
||||
let children: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let chunk: Vec<&String> = cases.iter().skip(i).step_by(n).collect();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.current_dir(&root)
|
||||
.arg("oracle")
|
||||
.arg("--json")
|
||||
.args(chunk)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for child in children {
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let part: Vec<Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
results.extend(part);
|
||||
}
|
||||
assert_eq!(results.len(), cases.len());
|
||||
let mut failed = Vec::new();
|
||||
for r in &results {
|
||||
let case = r["case"].as_str().unwrap_or("");
|
||||
let want: Value =
|
||||
std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
if want.is_null() || want["known_failure"].is_string() || r["pass"].as_bool().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let checks: Vec<String> = r["checks"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
|
||||
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
|
||||
.collect();
|
||||
failed.push(format!("{case}: {} {}", r["error"].as_str().unwrap_or(""), checks.join("; ")));
|
||||
}
|
||||
assert!(failed.is_empty(), "oracle cases failed:\n{}", failed.join("\n"));
|
||||
}
|
||||
@@ -113,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
|
||||
"Font": "Písmo",
|
||||
"Full Length": "Celá délka",
|
||||
"Gap": "Mezera",
|
||||
"Grid": "Mřížka",
|
||||
"Height": "Výška",
|
||||
"Hidden edges": "Skryté hrany",
|
||||
@@ -1031,6 +1030,7 @@
|
||||
"ui.construct": "Konstrukce",
|
||||
"ui.construction": "Konstrukční",
|
||||
"ui.context_menu": "Místní nabídka",
|
||||
"ui.continuity_gap": "Mezera",
|
||||
"ui.copy": "Kopírovat",
|
||||
"ui.corner": "Roh",
|
||||
"ui.corners": "Rohy",
|
||||
@@ -1347,6 +1347,7 @@
|
||||
"ui.send": "Odeslat",
|
||||
"ui.send_to_slicer": "Odeslat do sliceru",
|
||||
"ui.sent_to_slicer": "Odesláno do {slicer}",
|
||||
"ui.sheet_gap": "Mezera",
|
||||
"ui.shell": "Skořepina",
|
||||
"ui.show": "Zobrazit",
|
||||
"ui.showAll": "Zobrazit vše",
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"Faces": "Flächen",
|
||||
"Find in Browser": "Im Browser suchen",
|
||||
"Flat": "Flach",
|
||||
"Gap": "Spalt",
|
||||
"Hidden edges": "Verdeckte Kanten",
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
@@ -448,7 +447,12 @@
|
||||
"coupling.rack": "Zahnstange",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} Körper",
|
||||
"dialog.clearance_invalid": "der Abstand muss eine Länge von 0 oder mehr sein",
|
||||
"dialog.compute": "Berechnen",
|
||||
"dialog.compute_again": "Erneut berechnen",
|
||||
"dialog.computing": "Wird berechnet… {seconds} s",
|
||||
"dialog.could_not_be_checked": "nicht prüfbar",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
@@ -769,6 +773,7 @@
|
||||
"ui.base_point": "Basispunkt",
|
||||
"ui.blocked": "Verdeckt",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.bodies_all_if_none": "Körper (alle, wenn keine)",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.calibrate": "Kalibrieren",
|
||||
@@ -819,6 +824,7 @@
|
||||
"ui.continuity_g0": "G0 Knick",
|
||||
"ui.continuity_g1": "G1 Tangente",
|
||||
"ui.continuity_g2": "G2 Krümmung",
|
||||
"ui.continuity_gap": "Lücke",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.corner": "Ecke",
|
||||
"ui.corners": "Ecken",
|
||||
@@ -928,6 +934,7 @@
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_interference": "EINGRIFF",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
@@ -1121,6 +1128,7 @@
|
||||
"ui.send": "Senden",
|
||||
"ui.send_to_slicer": "An Slicer senden",
|
||||
"ui.sent_to_slicer": "An {slicer} gesendet",
|
||||
"ui.sheet_gap": "Spalt",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.showAll": "Alle einblenden",
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
|
||||
"Font": "Font",
|
||||
"Full Length": "Full Length",
|
||||
"Gap": "Gap",
|
||||
"Grid": "Grid",
|
||||
"Height": "Height",
|
||||
"Hidden edges": "Hidden edges",
|
||||
@@ -656,7 +655,12 @@
|
||||
"coupling.rack": "Rack",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} bodies",
|
||||
"dialog.clearance_invalid": "the clearance must be a length of 0 or more",
|
||||
"dialog.compute": "Compute",
|
||||
"dialog.compute_again": "Compute again",
|
||||
"dialog.computing": "Computing… {seconds} s",
|
||||
"dialog.could_not_be_checked": "could not be checked",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.select": "↖ Select",
|
||||
@@ -1049,6 +1053,7 @@
|
||||
"ui.base_point": "Base point",
|
||||
"ui.blocked": "Blocked",
|
||||
"ui.bodies": "Bodies",
|
||||
"ui.bodies_all_if_none": "Bodies (all if none)",
|
||||
"ui.box": "Box",
|
||||
"ui.break": "Break",
|
||||
"ui.calibrate": "Calibrate",
|
||||
@@ -1103,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 crease",
|
||||
"ui.continuity_g1": "G1 tangent",
|
||||
"ui.continuity_g2": "G2 curvature",
|
||||
"ui.continuity_gap": "Gap",
|
||||
"ui.copy": "Copy",
|
||||
"ui.corner": "Corner",
|
||||
"ui.corners": "Corners",
|
||||
@@ -1218,6 +1224,7 @@
|
||||
"ui.heading_home": "HOME",
|
||||
"ui.heading_insert": "INSERT",
|
||||
"ui.heading_inspect": "INSPECT",
|
||||
"ui.heading_interference": "INTERFERENCE",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_make": "MAKE",
|
||||
"ui.heading_measure": "MEASURE",
|
||||
@@ -1427,6 +1434,7 @@
|
||||
"ui.send": "Send",
|
||||
"ui.send_to_slicer": "Send to slicer",
|
||||
"ui.sent_to_slicer": "Sent to {slicer}",
|
||||
"ui.sheet_gap": "Gap",
|
||||
"ui.shell": "Shell",
|
||||
"ui.show": "Show",
|
||||
"ui.showAll": "Show All",
|
||||
|
||||
@@ -120,7 +120,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)",
|
||||
"Font": "Fuente",
|
||||
"Full Length": "Longitud total",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Cuadrícula",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Aristas ocultas",
|
||||
@@ -1062,6 +1061,7 @@
|
||||
"ui.continuity_g0": "G0 arista",
|
||||
"ui.continuity_g1": "G1 tangencia",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1383,6 +1383,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar al laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Vaciado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
|
||||
@@ -120,7 +120,6 @@
|
||||
"Follow the camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Font": "Police",
|
||||
"Full Length": "Longueur totale",
|
||||
"Gap": "Jeu",
|
||||
"Grid": "Grille",
|
||||
"Height": "Hauteur",
|
||||
"Hidden edges": "Arêtes cachées",
|
||||
@@ -652,7 +651,12 @@
|
||||
"coupling.rack": "Crémaillère",
|
||||
"coupling.radius_1": "Rayon 1",
|
||||
"coupling.radius_2": "Rayon 2",
|
||||
"dialog.bodies_count": "{count} corps",
|
||||
"dialog.clearance_invalid": "le jeu doit être une longueur positive ou nulle",
|
||||
"dialog.compute": "Calculer",
|
||||
"dialog.compute_again": "Recalculer",
|
||||
"dialog.computing": "Calcul… {seconds} s",
|
||||
"dialog.could_not_be_checked": "non vérifiable",
|
||||
"dialog.custom_axis": "↖ Axe personnalisé",
|
||||
"dialog.edit_title": "MODIFIER {title}",
|
||||
"dialog.select": "↖ Sélectionner",
|
||||
@@ -1008,6 +1012,7 @@
|
||||
"ui.base_point": "Point de base",
|
||||
"ui.blocked": "Inaccessible",
|
||||
"ui.bodies": "Corps",
|
||||
"ui.bodies_all_if_none": "Corps (tous si aucun)",
|
||||
"ui.box": "Boîte",
|
||||
"ui.break": "Fractionner",
|
||||
"ui.calibrate": "Étalonner",
|
||||
@@ -1062,6 +1067,7 @@
|
||||
"ui.continuity_g0": "G0 angle vif",
|
||||
"ui.continuity_g1": "G1 tangence",
|
||||
"ui.continuity_g2": "G2 courbure",
|
||||
"ui.continuity_gap": "Écart",
|
||||
"ui.copy": "Copier",
|
||||
"ui.corner": "Angle",
|
||||
"ui.corners": "Angles",
|
||||
@@ -1177,6 +1183,7 @@
|
||||
"ui.heading_home": "ACCUEIL",
|
||||
"ui.heading_insert": "INSÉRER",
|
||||
"ui.heading_inspect": "INSPECTER",
|
||||
"ui.heading_interference": "INTERFÉRENCE",
|
||||
"ui.heading_loft": "LISSAGE",
|
||||
"ui.heading_make": "FABRIQUER",
|
||||
"ui.heading_measure": "MESURER",
|
||||
@@ -1383,6 +1390,7 @@
|
||||
"ui.send": "Envoyer",
|
||||
"ui.send_to_slicer": "Envoyer au slicer",
|
||||
"ui.sent_to_slicer": "Envoyé à {slicer}",
|
||||
"ui.sheet_gap": "Jeu",
|
||||
"ui.shell": "Coque",
|
||||
"ui.show": "Afficher",
|
||||
"ui.showAll": "Tout afficher",
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
|
||||
"Font": "Tipo de letra",
|
||||
"Full Length": "Lonxitude completa",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Grella",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -1031,6 +1030,7 @@
|
||||
"ui.construct": "Construír",
|
||||
"ui.construction": "Construción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1347,6 +1347,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Baleirado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"Follow the camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
|
||||
"Font": "Carattere",
|
||||
"Full Length": "Lunghezza totale",
|
||||
"Gap": "Gioco",
|
||||
"Grid": "Griglia",
|
||||
"Height": "Altezza",
|
||||
"Hidden edges": "Spigoli nascosti",
|
||||
@@ -1055,6 +1054,7 @@
|
||||
"ui.construct": "Costruisci",
|
||||
"ui.construction": "Costruzione",
|
||||
"ui.context_menu": "Menu contestuale",
|
||||
"ui.continuity_gap": "Gioco",
|
||||
"ui.copy": "Copia",
|
||||
"ui.corner": "Angolo",
|
||||
"ui.corners": "Angoli",
|
||||
@@ -1374,6 +1374,7 @@
|
||||
"ui.send": "Invia",
|
||||
"ui.send_to_slicer": "Invia allo slicer",
|
||||
"ui.sent_to_slicer": "Inviato a {slicer}",
|
||||
"ui.sheet_gap": "Gioco",
|
||||
"ui.shell": "Svuota",
|
||||
"ui.show": "Mostra",
|
||||
"ui.showAll": "Mostra tutto",
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Font": "フォント",
|
||||
"Full Length": "全長",
|
||||
"Gap": "ギャップ",
|
||||
"Grid": "グリッド",
|
||||
"Height": "高さ",
|
||||
"Hidden edges": "隠線",
|
||||
@@ -656,7 +655,12 @@
|
||||
"coupling.rack": "ラック",
|
||||
"coupling.radius_1": "半径 1",
|
||||
"coupling.radius_2": "半径 2",
|
||||
"dialog.bodies_count": "{count} ボディ",
|
||||
"dialog.clearance_invalid": "クリアランスは 0 以上の長さにしてください",
|
||||
"dialog.compute": "計算",
|
||||
"dialog.compute_again": "再計算",
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "チェックできません",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
@@ -1049,6 +1053,7 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "到達不可",
|
||||
"ui.bodies": "ボディ",
|
||||
"ui.bodies_all_if_none": "ボディ(未選択ならすべて)",
|
||||
"ui.box": "直方体",
|
||||
"ui.break": "分割",
|
||||
"ui.calibrate": "校正",
|
||||
@@ -1103,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 折れ",
|
||||
"ui.continuity_g1": "G1 接線",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "隙間",
|
||||
"ui.copy": "コピー",
|
||||
"ui.corner": "コーナー",
|
||||
"ui.corners": "コーナー",
|
||||
@@ -1218,6 +1224,7 @@
|
||||
"ui.heading_home": "ホーム",
|
||||
"ui.heading_insert": "挿入",
|
||||
"ui.heading_inspect": "検査",
|
||||
"ui.heading_interference": "干渉",
|
||||
"ui.heading_loft": "ロフト",
|
||||
"ui.heading_make": "製造",
|
||||
"ui.heading_measure": "計測",
|
||||
@@ -1427,6 +1434,7 @@
|
||||
"ui.send": "送信",
|
||||
"ui.send_to_slicer": "スライサーに送る",
|
||||
"ui.sent_to_slicer": "{slicer} に送信しました",
|
||||
"ui.sheet_gap": "ギャップ",
|
||||
"ui.shell": "シェル",
|
||||
"ui.show": "表示",
|
||||
"ui.showAll": "すべて表示",
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"Faces": "면",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Flat": "플랫",
|
||||
"Gap": "틈",
|
||||
"Hidden edges": "숨겨진 모서리",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
@@ -448,7 +447,12 @@
|
||||
"coupling.rack": "랙",
|
||||
"coupling.radius_1": "반지름 1",
|
||||
"coupling.radius_2": "반지름 2",
|
||||
"dialog.bodies_count": "바디 {count}개",
|
||||
"dialog.clearance_invalid": "간격은 0 이상의 길이여야 합니다",
|
||||
"dialog.compute": "계산",
|
||||
"dialog.compute_again": "다시 계산",
|
||||
"dialog.computing": "계산 중… {seconds}초",
|
||||
"dialog.could_not_be_checked": "확인할 수 없음",
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -769,6 +773,7 @@
|
||||
"ui.base_point": "기준점",
|
||||
"ui.blocked": "막힘",
|
||||
"ui.bodies": "바디",
|
||||
"ui.bodies_all_if_none": "바디 (없으면 전체)",
|
||||
"ui.box": "박스",
|
||||
"ui.break": "끊기",
|
||||
"ui.calibrate": "캘리브레이션",
|
||||
@@ -819,6 +824,7 @@
|
||||
"ui.continuity_g0": "G0 꺾임",
|
||||
"ui.continuity_g1": "G1 접선",
|
||||
"ui.continuity_g2": "G2 곡률",
|
||||
"ui.continuity_gap": "틈",
|
||||
"ui.copy": "복사",
|
||||
"ui.corner": "코너",
|
||||
"ui.corners": "코너",
|
||||
@@ -928,6 +934,7 @@
|
||||
"ui.heading_home": "홈",
|
||||
"ui.heading_insert": "삽입",
|
||||
"ui.heading_inspect": "검사",
|
||||
"ui.heading_interference": "간섭",
|
||||
"ui.heading_loft": "로프트",
|
||||
"ui.heading_measure": "측정",
|
||||
"ui.heading_mesh": "메시",
|
||||
@@ -1118,6 +1125,7 @@
|
||||
"ui.send": "보내기",
|
||||
"ui.send_to_slicer": "슬라이서로 보내기",
|
||||
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
|
||||
"ui.sheet_gap": "틈",
|
||||
"ui.shell": "쉘",
|
||||
"ui.show": "표시",
|
||||
"ui.showAll": "모두 표시",
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "Podążaj za kamerą (Patrz na szkice)",
|
||||
"Font": "Czcionka",
|
||||
"Full Length": "Pełna długość",
|
||||
"Gap": "Szczelina",
|
||||
"Grid": "Siatka",
|
||||
"Height": "Wysokość",
|
||||
"Hidden edges": "Krawędzie niewidoczne",
|
||||
@@ -1071,6 +1070,7 @@
|
||||
"ui.continuity_g0": "G0 załamanie",
|
||||
"ui.continuity_g1": "G1 styczność",
|
||||
"ui.continuity_g2": "G2 krzywizna",
|
||||
"ui.continuity_gap": "Szczelina",
|
||||
"ui.copy": "Kopiuj",
|
||||
"ui.corner": "Naroże",
|
||||
"ui.corners": "Naroża",
|
||||
@@ -1395,6 +1395,7 @@
|
||||
"ui.send": "Wyślij",
|
||||
"ui.send_to_slicer": "Wyślij do slicera",
|
||||
"ui.sent_to_slicer": "Wysłano do {slicer}",
|
||||
"ui.sheet_gap": "Szczelina",
|
||||
"ui.shell": "Skorupa",
|
||||
"ui.show": "Pokaż",
|
||||
"ui.showAll": "Pokaż wszystko",
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
|
||||
"Font": "Fonte",
|
||||
"Full Length": "Comprimento total",
|
||||
"Gap": "Folga",
|
||||
"Grid": "Grade",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -1011,6 +1010,7 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construção",
|
||||
"ui.context_menu": "Menu de contexto",
|
||||
"ui.continuity_gap": "Folga",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Canto",
|
||||
"ui.corners": "Cantos",
|
||||
@@ -1327,6 +1327,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao fatiador",
|
||||
"ui.sent_to_slicer": "Enviado para {slicer}",
|
||||
"ui.sheet_gap": "Folga",
|
||||
"ui.shell": "Casca",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar tudo",
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Полная длина",
|
||||
"Gap": "Зазор",
|
||||
"Grid": "Сетка",
|
||||
"Height": "Высота",
|
||||
"Home view": "Главный вид",
|
||||
@@ -818,6 +817,7 @@
|
||||
"ui.construct": "Построение",
|
||||
"ui.construction": "Вспомогательная",
|
||||
"ui.context_menu": "Контекстное меню",
|
||||
"ui.continuity_gap": "Зазор",
|
||||
"ui.copy": "Копировать",
|
||||
"ui.corner": "Угол",
|
||||
"ui.corners": "Углы",
|
||||
@@ -1126,6 +1126,7 @@
|
||||
"ui.send": "Отправить",
|
||||
"ui.send_to_slicer": "Отправить в слайсер",
|
||||
"ui.sent_to_slicer": "Отправлено в {slicer}",
|
||||
"ui.sheet_gap": "Зазор",
|
||||
"ui.shell": "Оболочка",
|
||||
"ui.show": "Показать",
|
||||
"ui.showAll": "Показать всё",
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"Follow the camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
|
||||
"Font": "Yazı tipi",
|
||||
"Full Length": "Tam uzunluk",
|
||||
"Gap": "Boşluk",
|
||||
"Grid": "Izgara",
|
||||
"Height": "Yükseklik",
|
||||
"Holes": "Delikler",
|
||||
@@ -960,6 +959,7 @@
|
||||
"ui.construct": "Yapı",
|
||||
"ui.construction": "Yapı çizgisi",
|
||||
"ui.context_menu": "Bağlam menüsü",
|
||||
"ui.continuity_gap": "Boşluk",
|
||||
"ui.copy": "Kopyala",
|
||||
"ui.corner": "Köşe",
|
||||
"ui.corners": "Köşeler",
|
||||
@@ -1274,6 +1274,7 @@
|
||||
"ui.send": "Gönder",
|
||||
"ui.send_to_slicer": "Dilimleyiciye gönder",
|
||||
"ui.sent_to_slicer": "{slicer} uygulamasına gönderildi",
|
||||
"ui.sheet_gap": "Boşluk",
|
||||
"ui.shell": "Kabuk",
|
||||
"ui.show": "Göster",
|
||||
"ui.showAll": "Tümünü göster",
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Повна довжина",
|
||||
"Gap": "Проміжок",
|
||||
"Grid": "Сітка",
|
||||
"Height": "Висота",
|
||||
"Hidden edges": "Приховані ребра",
|
||||
@@ -1034,6 +1033,7 @@
|
||||
"ui.construct": "Побудувати",
|
||||
"ui.construction": "Конструкційна",
|
||||
"ui.context_menu": "Контекстне меню",
|
||||
"ui.continuity_gap": "Проміжок",
|
||||
"ui.copy": "Копіювати",
|
||||
"ui.corner": "Кут",
|
||||
"ui.corners": "Кути",
|
||||
@@ -1350,6 +1350,7 @@
|
||||
"ui.send": "Надіслати",
|
||||
"ui.send_to_slicer": "Надіслати в слайсер",
|
||||
"ui.sent_to_slicer": "Надіслано в {slicer}",
|
||||
"ui.sheet_gap": "Проміжок",
|
||||
"ui.shell": "Оболонка",
|
||||
"ui.show": "Показати",
|
||||
"ui.showAll": "Показати все",
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Font": "字体",
|
||||
"Full Length": "全长",
|
||||
"Gap": "间隙",
|
||||
"Grid": "网格",
|
||||
"Height": "高度",
|
||||
"Hidden edges": "隐藏线",
|
||||
@@ -656,7 +655,12 @@
|
||||
"coupling.rack": "齿条",
|
||||
"coupling.radius_1": "半径 1",
|
||||
"coupling.radius_2": "半径 2",
|
||||
"dialog.bodies_count": "{count} 个实体",
|
||||
"dialog.clearance_invalid": "间隙必须是 0 或更大的长度",
|
||||
"dialog.compute": "计算",
|
||||
"dialog.compute_again": "重新计算",
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "无法检查",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.select": "↖ 选择",
|
||||
@@ -1049,6 +1053,7 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "被遮挡",
|
||||
"ui.bodies": "实体",
|
||||
"ui.bodies_all_if_none": "实体(未选择则全部)",
|
||||
"ui.box": "长方体",
|
||||
"ui.break": "打断",
|
||||
"ui.calibrate": "校准",
|
||||
@@ -1103,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 折痕",
|
||||
"ui.continuity_g1": "G1 相切",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "缝隙",
|
||||
"ui.copy": "复制",
|
||||
"ui.corner": "角",
|
||||
"ui.corners": "角点",
|
||||
@@ -1218,6 +1224,7 @@
|
||||
"ui.heading_home": "主页",
|
||||
"ui.heading_insert": "插入",
|
||||
"ui.heading_inspect": "检查",
|
||||
"ui.heading_interference": "干涉",
|
||||
"ui.heading_loft": "放样",
|
||||
"ui.heading_make": "制作",
|
||||
"ui.heading_measure": "测量",
|
||||
@@ -1427,6 +1434,7 @@
|
||||
"ui.send": "发送",
|
||||
"ui.send_to_slicer": "发送到切片软件",
|
||||
"ui.sent_to_slicer": "已发送到 {slicer}",
|
||||
"ui.sheet_gap": "间隙",
|
||||
"ui.shell": "抽壳",
|
||||
"ui.show": "显示",
|
||||
"ui.showAll": "显示全部",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A joint origin can sit on an edge of a planar face (`Snap::Edge`, saved as `"type": "edge"`):
|
||||
//! at the edge's middle or at a corner where it meets another edge, all three found again by name.
|
||||
//! Older designs need no transformation: they have no such origins. The last release refuses a
|
||||
//! design saved with this (the newer format number).
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A face or circle joint origin given no `x` reference keeps the x its frame had when picked
|
||||
//! (`x` as a direction, component coordinates) and lays it onto the face as it is now, so a part
|
||||
//! on a face that tilts keeps its turn. Older designs need no transformation: an origin without
|
||||
//! `x` works its frame out from the normal alone, as before. The last release refuses a design
|
||||
//! saved with this (the newer format number).
|
||||
@@ -62,6 +62,18 @@ pub enum Snap {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<XRef>,
|
||||
},
|
||||
/// A point on an edge of a planar face: the edge's middle, or the corner where it meets the
|
||||
/// edge `corner` names (`joints_edge.rs`). `edge` and `face` are the persistent names of the
|
||||
/// edge and the face (found first); `pick` is a point on the edge as it was picked.
|
||||
Edge {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edge: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z).
|
||||
Point {
|
||||
point: Vec3,
|
||||
@@ -88,7 +100,8 @@ pub enum XRef {
|
||||
}
|
||||
|
||||
impl Snap {
|
||||
/// The persistent face names the snap refers to (its face, its end's, its x reference's).
|
||||
/// The persistent face and edge names the snap refers to (its face, its end's, its x reference's;
|
||||
/// an edge origin's edge, face and corner edge).
|
||||
pub fn face_names(&self) -> Vec<&String> {
|
||||
let mut out = Vec::new();
|
||||
if let Snap::FaceCenter { face, x, .. } | Snap::CircleCenter { face, x, .. } = self {
|
||||
@@ -100,6 +113,9 @@ impl Snap {
|
||||
if let Snap::CircleCenter { end: Some(end), .. } = self {
|
||||
out.push(end);
|
||||
}
|
||||
if let Snap::Edge { edge, face, corner, .. } = self {
|
||||
out.extend(edge.iter().chain(face.iter()).chain(corner.iter()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -120,11 +136,33 @@ impl Snap {
|
||||
out.push(face);
|
||||
}
|
||||
}
|
||||
Snap::Edge { edge, face, corner, .. } => out.extend(edge.iter_mut().chain(face.iter_mut()).chain(corner.iter_mut())),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The direction a face or circle snap keeps as its frame's x (`XRef::Dir`), in the
|
||||
/// component's coordinates; `None` for a face reference or none.
|
||||
pub fn frame_x(&self) -> Option<Vec3> {
|
||||
match self {
|
||||
Snap::FaceCenter { x: Some(XRef::Dir(d)), .. } | Snap::CircleCenter { x: Some(XRef::Dir(d)), .. } => Some(*d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face or circle snap given no x reference keeps `x` (the frame's x as it is now): each
|
||||
/// solve lays it onto the face, or square to the hole's axis, as the geometry is then, so a
|
||||
/// part on a face that tilts keeps its turn about z. Without it (designs made before) x
|
||||
/// follows from z alone, and turns through a right angle where z crosses 0.9 along the
|
||||
/// component's X. A snap with a reference keeps it.
|
||||
pub fn with_frame_x(mut self, x: Vec3) -> Snap {
|
||||
if let Snap::FaceCenter { x: slot @ None, .. } | Snap::CircleCenter { x: slot @ None, .. } = &mut self {
|
||||
*slot = Some(XRef::Dir(x));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether every point and direction in the snap is finite.
|
||||
pub fn is_finite(&self) -> bool {
|
||||
let x_ok = |x: &Option<XRef>| match x {
|
||||
@@ -136,6 +174,7 @@ impl Snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
Snap::Edge { pick, .. } => pick.is_finite(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +558,7 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
};
|
||||
frame(c, n, face::x_axis(doc, st, x.as_ref(), c)?).ok_or_else(bad)
|
||||
}
|
||||
Snap::Edge { .. } => edge::edge_frame(doc, st, component_of(doc, o.occurrence)?, &o.snap),
|
||||
Snap::CircleCenter { x, .. } => {
|
||||
let r = round_of(doc, st, o)?;
|
||||
frame(r.centre, r.z, face::x_axis(doc, st, x.as_ref(), r.centre)?).ok_or_else(bad)
|
||||
@@ -1548,6 +1588,10 @@ impl Document {
|
||||
#[path = "joints_cam.rs"]
|
||||
pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub use edge::edge_snap;
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Joint origins on an edge of a planar face (`Snap::Edge`): the edge's middle, or a corner where
|
||||
//! it meets another edge of the face. The edge, the face and the corner's other edge are found
|
||||
//! again by name on every solve, so the origin follows the face as it grows, shrinks or moves:
|
||||
//! hardware set 40 mm in from a panel's corner stays 40 mm in from that corner at any size.
|
||||
//!
|
||||
//! The frame never depends on which way an edge's polyline runs: z is the face's outward normal;
|
||||
//! at the middle y points into the face, square to the edge, and x = y × z runs along it; at a
|
||||
//! corner x and y run along the two edges, away from the corner into the face, in the order that
|
||||
//! makes the frame right-handed.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
|
||||
/// Points that close are one corner (as a share of the body's size, at least 1 µm).
|
||||
fn tol(b: &ModelBody) -> f64 {
|
||||
(b.body.size() * 2e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// The edge of `b` nearest `p`, and how far it is.
|
||||
fn nearest_edge(b: &ModelBody, p: Vec3) -> Option<(usize, f64)> {
|
||||
b.mesh()
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (i, e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)))
|
||||
.filter(|(_, d)| d.is_finite())
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// Edge `i` of `b`: by its name (`None` when the body has no such edge), else the edge at `pick`.
|
||||
fn edge_index(b: &ModelBody, name: Option<&str>, pick: Vec3) -> Option<usize> {
|
||||
match name {
|
||||
Some(n) => crate::name_lookup::locate_edge(b, n, pick).ok().map(|h| h.edge),
|
||||
None => nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar face beside edge `i` the origin sits on: the named one, else the larger planar face
|
||||
/// the edge bounds. Its index, outward normal and centroid.
|
||||
fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(usize, Vec3, Vec3)> {
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let infos = b.body.faces(tol(b))?;
|
||||
let planar = |f: usize| infos.iter().find(|x| x.index == f).and_then(|x| x.plane_normal.map(|n| (x.index, n, x.centroid, x.area)));
|
||||
let chosen = match name {
|
||||
Some(n) => {
|
||||
let (f, _, _) = crate::naming::face_on_body(b, n, pick)
|
||||
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)")))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid(format!("joint origin: the face `{n}` no longer meets its edge (edit the joint to pick again)")));
|
||||
}
|
||||
planar(f)
|
||||
}
|
||||
None => beside.iter().filter_map(|f| planar(*f)).max_by(|a, b| a.3.total_cmp(&b.3)),
|
||||
};
|
||||
chosen.map(|(f, n, c, _)| (f, n, c)).ok_or_else(|| DocError::Invalid("joint origin: no planar face beside that edge".into()))
|
||||
}
|
||||
|
||||
/// Edge `i`'s middle (along its polyline) and the direction it runs there.
|
||||
fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if l > 0.0 && acc + l >= total / 2.0 {
|
||||
let t = (total / 2.0 - acc) / l;
|
||||
return Some((w[0] + (w[1] - w[0]) * t, (w[1] - w[0]) / l));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An edge's end at `p` (within `tol`) and the direction it leaves the end in.
|
||||
fn leaving(e: &[Vec3], p: Vec3, tol: f64) -> Option<Vec3> {
|
||||
let (first, last) = (*e.first()?, *e.last()?);
|
||||
let away = |q: Option<&Vec3>, from: Vec3| q.and_then(|q| (*q - from).normalized());
|
||||
if first.dist(p) <= tol {
|
||||
away(e.iter().find(|q| q.dist(first) > tol * 0.5), first)
|
||||
} else if last.dist(p) <= tol {
|
||||
away(e.iter().rev().find(|q| q.dist(last) > tol * 0.5), last)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner where edges `e` and `f` meet: the closest pair of their ends, when within `tol`.
|
||||
fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
|
||||
let ends = |x: &[Vec3]| [x.first().copied(), x.last().copied()];
|
||||
ends(e)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|a| ends(f).into_iter().flatten().map(move |b| (a, a.dist(b))))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(a, _)| a)
|
||||
}
|
||||
|
||||
/// The frame of an edge snap in its component's coordinates.
|
||||
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| edge_index(b, edge.as_deref(), *pick).map(|i| (b, i)));
|
||||
let (b, i) = found.ok_or_else(|| match edge {
|
||||
Some(n) => gone(&format!("the edge `{n}`")),
|
||||
None => DocError::Invalid("joint origin: no edge there".into()),
|
||||
})?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(i).ok_or_else(bad)?;
|
||||
let (_, z, centroid) = face_of(b, i, face.as_deref(), *pick)?;
|
||||
let flat = |v: Vec3| (v - z * v.dot(z)).normalized();
|
||||
match corner {
|
||||
None => {
|
||||
let (mid, along) = middle(e).ok_or_else(bad)?;
|
||||
// Into the face: toward its centroid, square to the edge; a face whose centroid lies on
|
||||
// the edge's line (only a sliver) falls back to the side z × along gives.
|
||||
let to_c = centroid - mid;
|
||||
let y = flat(to_c - along * to_c.dot(along)).unwrap_or_else(|| z.cross(along));
|
||||
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
|
||||
}
|
||||
Some(other) => {
|
||||
let j = crate::name_lookup::locate_edge(b, other, *pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
let f = m.edges.get(j).ok_or_else(bad)?;
|
||||
let t = tol(b);
|
||||
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
|
||||
let (de, df) = (leaving(e, at, t).and_then(flat).ok_or_else(bad)?, leaving(f, at, t).and_then(flat).ok_or_else(bad)?);
|
||||
let x = if z.dot(de.cross(df)) >= 0.0 { de } else { df };
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge snap for an edge picked at `pick` on component `comp` (its own coordinates): on the
|
||||
/// planar face holding `on` (default: the larger planar face beside the edge), at the edge's
|
||||
/// middle or, with `corner`, at the end nearest it. The edge, the face and the corner's other
|
||||
/// edge are kept by name.
|
||||
pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Option<Vec3>, corner: Option<Vec3>) -> Result<Snap> {
|
||||
let (b, i) = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| (b, i)))
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no edge there".into()))?;
|
||||
let names = crate::naming::edge_names(b);
|
||||
let face_names = crate::naming::face_names(b);
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let face_index = match on {
|
||||
Some(p) => {
|
||||
let f = crate::naming::face_name_at_body(b, p)
|
||||
.and_then(|n| crate::naming::face_on_body(b, &n, p))
|
||||
.map(|(f, _, _)| f)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: `on` is not on a face of that body".into()))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid("joint origin: `on` picks a face the edge doesn't bound".into()));
|
||||
}
|
||||
f
|
||||
}
|
||||
None => face_of(b, i, None, pick)?.0,
|
||||
};
|
||||
let face = face_names.get(face_index).cloned().filter(|n| !n.is_empty());
|
||||
let corner = match corner {
|
||||
None => None,
|
||||
Some(c) => {
|
||||
let e = m.edges.get(i).cloned().unwrap_or_default();
|
||||
let t = tol(b);
|
||||
let end = [e.first().copied(), e.last().copied()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min_by(|a, b| a.dist(c).total_cmp(&b.dist(c)))
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: that edge has no ends".into()))?;
|
||||
// The other edge of the face that ends there.
|
||||
let other = m
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i && m.edge_faces.get(*j).is_some_and(|fs| fs.contains(&(face_index as u32))))
|
||||
.find(|(_, f)| meeting(&[end], f, t).is_some())
|
||||
.map(|(j, _)| j)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no other edge of the face meets that end (a round edge has no corner)".into()))?;
|
||||
Some(
|
||||
names
|
||||
.get(other)
|
||||
.cloned()
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: the corner's other edge has no name".into()))?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
|
||||
}
|
||||
@@ -233,3 +233,27 @@ fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens()
|
||||
assert_eq!(reopened.assembly, s.doc.assembly);
|
||||
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
|
||||
}
|
||||
|
||||
/// An edge origin's edge, face and corner edge are the copy's too.
|
||||
#[test]
|
||||
fn an_edge_origin_is_copied_onto_the_copys_edges() {
|
||||
let (source, mut destination) = documents();
|
||||
let edge = |e: &str| Some(e.to_string());
|
||||
let mut source = source;
|
||||
source.assembly.origins = vec![NamedOrigin {
|
||||
name: "Corner".into(),
|
||||
origin: JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::Edge { pick: Vec3::new(1.0, 2.0, 3.0), edge: edge("F1:top|F1:side"), face: edge("F1:top"), corner: edge("F1:top|F10:end") },
|
||||
},
|
||||
}];
|
||||
destination.assembly.origins.clear();
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
|
||||
.unwrap();
|
||||
let copied = destination.assembly.origins.last().map(|o| &o.origin.snap);
|
||||
assert!(
|
||||
matches!(copied, Some(Snap::Edge { edge: Some(e), face: Some(f), corner: Some(c), .. })
|
||||
if e == "F11:top|F11:side" && f == "F11:top" && c == "F11:top|F110:end"),
|
||||
"{copied:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Joint origins on an edge of a face (its middle, or a corner) follow the face as it changes size.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{place, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 2 mm panel `W` x 300 (W = 400) in component Panel, and a 20 x 10 x 6 bracket in component
|
||||
/// Bracket, off to the side.
|
||||
fn panel() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "400 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": "W", "width": 300, "height": 2, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 6, "corner": [0, 500, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The middle of the bracket's bottom face, where it is now.
|
||||
fn bracket(s: &Session) -> Vec3 {
|
||||
place(s, "Bracket", Vec3::new(10.0, 505.0, 0.0))
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_set_in_from_a_corner_stays_there_as_the_panel_grows() {
|
||||
let mut s = panel();
|
||||
// The front right corner of the top face; the bracket 40 mm in along both edges.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [200, 0, 2], "corner": [400, 0, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [40, 40, 0], "name": "Corner"}),
|
||||
);
|
||||
for w in [400.0, 600.0, 250.0, 1000.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 40.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
assert_eq!(run(&mut s, "joint.solve", json!({}))["errors"], json!([]));
|
||||
}
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(
|
||||
matches!(&snap, Some(solidcraft_doc::joints::Snap::Edge { edge: Some(_), face: Some(_), corner: Some(_), .. })),
|
||||
"the edge, the face and the corner's other edge are kept by name: {snap:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_at_an_edges_middle_follows_it_and_the_face_can_be_chosen() {
|
||||
let mut s = panel();
|
||||
// The right edge of the top face: its middle is (W, 150, 2), y points into the face (−X).
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [400, 100, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [0, 40, 0], "name": "Mid"}),
|
||||
);
|
||||
for w in [400.0, 700.0, 320.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 150.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
}
|
||||
// The same edge on the end face: z points out of the end (+X), y into it (down).
|
||||
run(&mut s, "joint.delete", json!({"joint": "Mid"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [320, 100, 2], "on": [320, 150, 1]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "End"}),
|
||||
);
|
||||
assert!(near(bracket(&s), Vec3::new(320.0, 150.0, 2.0)), "{:?}", bracket(&s));
|
||||
// The bracket's bottom faces the end face: its top (6 mm up its own z) is out along +X.
|
||||
assert!(
|
||||
near(place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0)), Vec3::new(326.0, 150.0, 2.0)),
|
||||
"{:?}",
|
||||
place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_origins_refuse_bad_picks_and_report_a_lost_edge() {
|
||||
let mut s = panel();
|
||||
let bad = |s: &mut Session, a: serde_json::Value| {
|
||||
s.execute("joint.create", &json!({"type": "rigid", "a": a, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}})).unwrap_err().to_string()
|
||||
};
|
||||
// Not on an edge; a face the edge doesn't bound.
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 150, 2]})).contains("no edge there"));
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 0, 2], "on": [200, 150, 0]})).contains("doesn't bound"));
|
||||
// A design whose edge name no longer finds anything: the joint is left out with the reason.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [200, 0, 2]}, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "J"}),
|
||||
);
|
||||
let mut doc = (*s.doc).clone();
|
||||
if let Some(j) = doc.assembly.joints.first_mut()
|
||||
&& let solidcraft_doc::joints::Snap::Edge { edge, .. } = &mut j.a.snap
|
||||
{
|
||||
*edge = Some("F999:nothing|F998:nowhere".into());
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert!(r["errors"][0]["why"].as_str().is_some_and(|w| w.contains("no longer exists")), "{r}");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! A joint origin on a face or a hole keeps its turn about z as the geometry tilts.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 40 x 40 x 2 plate in component Panel, turned about Y by the `tilt` parameter (a Move feature),
|
||||
/// and a 10 x 4 x 4 bar in component Bar, long along its X.
|
||||
fn tilted(tilt: f64) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [-20, -20, 0]}));
|
||||
let plate = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "solid.move", json!({"bodies": [plate], "axis": [0, 1, 0], "angle": "tilt"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bar"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 4, "height": 4, "corner": [100, 0, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A point on the plate's top face (the largest face facing up).
|
||||
fn top(s: &mut Session) -> Value {
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
let faces = run(s, "model.faces", json!({"body": body}));
|
||||
let best = faces["faces"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.3))
|
||||
.max_by(|a, b| a["area"].as_f64().unwrap_or(0.0).total_cmp(&b["area"].as_f64().unwrap_or(0.0)))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
best["point"].clone()
|
||||
}
|
||||
|
||||
/// How far the bar's long axis is turned from Y, the plate's axis of tilt (degrees, 0…90).
|
||||
fn bar_from_tilt_axis(s: &Session) -> f64 {
|
||||
let m = solidcraft_doc::joints::occurrence_world(&s.doc, occ(s, "Bar"));
|
||||
let x = solidcraft_doc::apply_vector(&m, Vec3::X);
|
||||
x.dot(Vec3::Y).abs().min(1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_on_a_tilting_face_keeps_its_turn_where_the_face_normal_crosses_0_9_along_x() {
|
||||
// The plate starts at 60°: its normal is 0.87 along X. Past 64.2° it is over 0.9, where a
|
||||
// frame worked out from the normal alone picks its x another way and the bar turned a
|
||||
// right angle on the face.
|
||||
let mut s = tilted(60.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let start = bar_from_tilt_axis(&s);
|
||||
for tilt in [62.0, 64.0, 66.0, 70.0, 80.0, 50.0, 30.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
let now = bar_from_tilt_axis(&s);
|
||||
assert!((now - start).abs() < 1e-6, "at {tilt}°: the bar is {now:.3}° from the tilt axis, {start:.3}° at first");
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
}
|
||||
// The origin keeps that x in the design.
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(snap.as_ref().and_then(solidcraft_doc::joints::Snap::frame_x).is_some(), "{snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_saved_without_a_kept_x_is_placed_as_before() {
|
||||
let mut s = tilted(30.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let kept = bar_from_tilt_axis(&s);
|
||||
// The same joint as an older design has it: no x kept on either side. It is placed as
|
||||
// a frame from the normal alone places it, which here is where the kept x placed it too.
|
||||
let mut doc = (*s.doc).clone();
|
||||
for j in &mut doc.assembly.joints {
|
||||
for o in [&mut j.a, &mut j.b] {
|
||||
if let solidcraft_doc::joints::Snap::FaceCenter { x, .. } | solidcraft_doc::joints::Snap::CircleCenter { x, .. } = &mut o.snap {
|
||||
*x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = serde_json::to_string(&doc.assembly).unwrap_or_default();
|
||||
assert!(!text.contains("\"x\""), "{text}");
|
||||
*s.doc_mut() = doc;
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
assert!((bar_from_tilt_axis(&s) - kept).abs() < 1e-6);
|
||||
}
|
||||
@@ -17,10 +17,10 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \
|
||||
offset?; angle?; name?; quiet?: bool (leave out dof and fixed)",
|
||||
@@ -279,11 +279,11 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
if let Some((what, name)) = ["face", "circle"].iter().find_map(|k| v.get(*k).and_then(Value::as_str).map(|n| (*k, n))) {
|
||||
return origin_by_name(s, v, what, name, cmd);
|
||||
}
|
||||
let pick = ["face", "circle", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let pick = ["face", "circle", "edge", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let (what, world) = match (pick, v.get("frame")) {
|
||||
(Some(x), _) => x,
|
||||
(None, Some(f)) => ("frame", f.get("origin").and_then(vec3).ok_or_else(|| bad(cmd, "`frame` needs origin and z"))?),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, point or frame [x, y, z], or a face's name")),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name")),
|
||||
};
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
@@ -304,6 +304,10 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
let x = x_param(s, v, component, &inv, cmd)?;
|
||||
if what == "face" { Snap::FaceCenter { pick, face, x } } else { Snap::CircleCenter { pick, face, end: None, x } }
|
||||
}
|
||||
"edge" => {
|
||||
let (on, corner) = (v.get("on").and_then(vec3).map(local), v.get("corner").and_then(vec3).map(local));
|
||||
joints::edge_snap(&s.doc, &st, component, local(world), on, corner).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
|
||||
_ => {
|
||||
let f = v.get("frame").cloned().unwrap_or_default();
|
||||
@@ -373,8 +377,11 @@ fn settle(s: &Session, mut o: JointOrigin, v: &Value, cmd: &str) -> Result<Joint
|
||||
return Err(bad(cmd, format!("the round face doesn't end on face `{want}` (its ends lie on: {})", names.join(", "))));
|
||||
}
|
||||
}
|
||||
joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let m = joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
joints::pin_end(&s.doc, &st, &mut o.snap);
|
||||
// A face or circle given no `x` keeps the x it has now, so its frame doesn't turn as the face
|
||||
// tilts.
|
||||
o.snap = o.snap.with_frame_x(Vec3::new(m[0][0], m[0][1], m[0][2]));
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -1316,10 +1323,16 @@ pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, u
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "joint_edge_tests.rs"]
|
||||
mod edge_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_face_tests.rs"]
|
||||
mod face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joint_frames_tests.rs"]
|
||||
mod frames_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_reach_tests.rs"]
|
||||
mod reach_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -461,6 +461,12 @@ outer
|
||||
survives("3MF", &mutations(&good, false, 0x3f), usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
// Objects in another model part (production extension), as slicers save projects.
|
||||
let production = crate::threemf::tests::production_package();
|
||||
assert!(crate::read_3mf(&production).is_ok());
|
||||
survives("3MF (production extension)", &mutations(&production, false, 0x2089), usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+275
-46
@@ -6,10 +6,12 @@
|
||||
//! triangle, the B-rep face it lies on (`ft:face`, SolidCraft's face-tag namespace; spec:
|
||||
//! docs/formats/3mf-face-tags.md) and are written at full `f64`
|
||||
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
|
||||
//! (components flattened with their transforms), converted to millimetres. Packages are
|
||||
//! untrusted: sizes, entry counts, mesh sizes and component nesting are capped.
|
||||
//! (components flattened with their transforms), converted to millimetres. Components and build
|
||||
//! items may name an object in another model part of the package (`p:path`, the 3MF Production
|
||||
//! extension, which slicers use for multi-part projects); those parts are read too. Packages are
|
||||
//! untrusted: sizes, entry counts, model parts, mesh sizes and component nesting are capped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
@@ -350,13 +352,21 @@ fn bad<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
|
||||
}
|
||||
|
||||
/// The name a part is looked up by: no leading `/`, in lower case (part names compare
|
||||
/// case-insensitively).
|
||||
fn part_key(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
|
||||
let want = name.trim_start_matches('/').to_ascii_lowercase();
|
||||
let Some(idx) = (0..zip.len()).find(|i| {
|
||||
zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want)
|
||||
}) else {
|
||||
let want = part_key(name);
|
||||
let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| part_key(&n) == want)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
read_entry(zip, idx, name).map(Some)
|
||||
}
|
||||
|
||||
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str) -> Result<Vec<u8>> {
|
||||
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
if f.size() > MAX_PART {
|
||||
return bad(format!("{name} expands to {} bytes", f.size()));
|
||||
@@ -366,7 +376,7 @@ fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Opt
|
||||
if out.len() as u64 > MAX_PART {
|
||||
return bad(format!("{name} expands to more than 1 GB"));
|
||||
}
|
||||
Ok(Some(out))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn attrs(e: &BytesStart) -> HashMap<String, String> {
|
||||
@@ -401,6 +411,14 @@ fn parse_xf(s: Option<&String>) -> Result<Xf> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// `xf` with its translation, given in a model's `unit`, in millimetres.
|
||||
fn in_mm(mut xf: Xf, unit: f64) -> Xf {
|
||||
for t in xf.iter_mut().skip(9) {
|
||||
*t *= unit;
|
||||
}
|
||||
xf
|
||||
}
|
||||
|
||||
fn apply(m: &Xf, p: Vec3) -> Vec3 {
|
||||
Vec3::new(p.x * m[0] + p.y * m[3] + p.z * m[6] + m[9], p.x * m[1] + p.y * m[4] + p.z * m[7] + m[10], p.x * m[2] + p.y * m[5] + p.z * m[8] + m[11])
|
||||
}
|
||||
@@ -417,22 +435,37 @@ fn det(m: &Xf) -> f64 {
|
||||
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
|
||||
}
|
||||
|
||||
/// A component or build item: object `id` placed by `xf`, in model part `part` (its key, from
|
||||
/// `p:path`) or, without one, in the part that names it.
|
||||
struct Ref {
|
||||
part: Option<String>,
|
||||
id: u32,
|
||||
xf: Xf,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Object {
|
||||
name: String,
|
||||
positions: Vec<Vec3>,
|
||||
triangles: Vec<[u32; 3]>,
|
||||
components: Vec<(u32, Xf)>,
|
||||
components: Vec<Ref>,
|
||||
material: Option<(u32, usize)>,
|
||||
}
|
||||
|
||||
/// One model part. Positions and translations are in millimetres (converted with the part's
|
||||
/// unit as it is read), so objects of parts in different units fit together.
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
unit: f64,
|
||||
objects: HashMap<u32, Object>,
|
||||
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
|
||||
build: Vec<(u32, Xf)>,
|
||||
warnings: Vec<String>,
|
||||
build: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// The model parts read from a package, by key, and the key of the root model.
|
||||
struct Package {
|
||||
root: String,
|
||||
parts: HashMap<String, Model>,
|
||||
}
|
||||
|
||||
fn color(s: &str) -> Option<[f32; 3]> {
|
||||
@@ -540,7 +573,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
return bad("too many vertices");
|
||||
}
|
||||
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
|
||||
o.positions.push(Vec3::new(x, y, z));
|
||||
o.positions.push(Vec3::new(x, y, z) * m.unit);
|
||||
}
|
||||
b"triangle" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
|
||||
@@ -552,22 +585,12 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
}
|
||||
b"component" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
|
||||
if a.keys().any(|k| k == "path") {
|
||||
m.warnings.push("components in other model parts (production extension) are not read".into());
|
||||
buf.clear();
|
||||
continue;
|
||||
}
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
|
||||
o.components.push((target, parse_xf(a.get("transform"))?));
|
||||
o.components.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
}
|
||||
b"item" => {
|
||||
if a.keys().any(|k| k == "path") {
|
||||
m.warnings.push("build items in other model parts (production extension) are not read".into());
|
||||
buf.clear();
|
||||
continue;
|
||||
}
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
|
||||
m.build.push((target, parse_xf(a.get("transform"))?));
|
||||
m.build.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -579,15 +602,58 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Append object `id` (and its components) placed by `xf` to `out`.
|
||||
fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec<u32>, total: &mut usize) -> Result<()> {
|
||||
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&id) {
|
||||
/// Queue the model parts `m` names that were not seen yet.
|
||||
fn queue_parts(m: &Model, seen: &mut HashSet<String>, pending: &mut Vec<String>) -> Result<()> {
|
||||
for key in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
|
||||
if !seen.contains(key) {
|
||||
// A package holds no more parts than it has entries.
|
||||
if seen.len() >= MAX_ENTRIES {
|
||||
return bad(format!("objects name more than {MAX_ENTRIES} model parts"));
|
||||
}
|
||||
seen.insert(key.clone());
|
||||
pending.push(key.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An error reading model part `key`, saying which part it was.
|
||||
fn in_part(key: &str, e: IoError) -> IoError {
|
||||
match e {
|
||||
IoError::Invalid(msg) => {
|
||||
let msg = msg.strip_prefix("3MF: ").or_else(|| msg.strip_prefix("3MF ")).unwrap_or(&msg);
|
||||
IoError::Invalid(format!("3MF model part `{key}`: {msg}"))
|
||||
}
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append object `id` of model part `part` (and its components) placed by `xf` to `out`.
|
||||
fn flatten<'a>(
|
||||
pkg: &'a Package,
|
||||
part: &'a str,
|
||||
id: u32,
|
||||
xf: &Xf,
|
||||
out: &mut MeshObject,
|
||||
stack: &mut Vec<(&'a str, u32)>,
|
||||
total: &mut usize,
|
||||
) -> Result<()> {
|
||||
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&(part, id)) {
|
||||
return bad("components nested too deeply or recursive");
|
||||
}
|
||||
let o = m.objects.get(&id).ok_or_else(|| IoError::Invalid(format!("3MF: missing object {id}")))?;
|
||||
*total = total.saturating_add(o.triangles.len());
|
||||
let m = pkg.parts.get(part).ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{part}`")))?;
|
||||
let o = m.objects.get(&id).ok_or_else(|| {
|
||||
IoError::Invalid(if part == pkg.root {
|
||||
format!("3MF: missing object {id}")
|
||||
} else {
|
||||
format!("3MF: missing object {id} in model part `{part}`")
|
||||
})
|
||||
})?;
|
||||
// Every placement costs at least one unit of the budget, so objects that only place other
|
||||
// objects cannot fan out (hundreds of components per level, nested) into endless visits.
|
||||
*total = total.saturating_add(o.triangles.len().max(1));
|
||||
if *total > MAX_ELEMENTS {
|
||||
return bad("too many triangles after placing components");
|
||||
return bad("too many triangles or placements after placing components");
|
||||
}
|
||||
let n = o.positions.len();
|
||||
// One object can be placed by thousands of build items and components, and each placement
|
||||
@@ -608,7 +674,7 @@ fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec<u3
|
||||
let Some(p) = o.positions.get(*v as usize) else {
|
||||
return bad(format!("object {id}: triangle refers to a missing vertex"));
|
||||
};
|
||||
out.positions.push(apply(xf, *p) * m.unit);
|
||||
out.positions.push(apply(xf, *p));
|
||||
}
|
||||
// Vertices keep their relative order, so a mesh whose triangles use all of them is
|
||||
// numbered exactly as it was in the file.
|
||||
@@ -627,9 +693,9 @@ fn flatten(m: &Model, id: u32, xf: &Xf, out: &mut MeshObject, stack: &mut Vec<u3
|
||||
if out.color.is_none() {
|
||||
out.color = o.material.and_then(|(g, i)| m.materials.get(&g).and_then(|g| g.get(i).copied().flatten()));
|
||||
}
|
||||
stack.push(id);
|
||||
for (c, cx) in &o.components {
|
||||
flatten(m, *c, &compose(cx, xf), out, stack, total)?;
|
||||
stack.push((part, id));
|
||||
for c in &o.components {
|
||||
flatten(pkg, c.part.as_deref().unwrap_or(part), c.id, &compose(&c.xf, xf), out, stack, total)?;
|
||||
}
|
||||
stack.pop();
|
||||
Ok(())
|
||||
@@ -667,15 +733,44 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
|
||||
}
|
||||
}
|
||||
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
|
||||
let m = parse_model(&xml)?;
|
||||
let root = part_key(&root);
|
||||
let model = parse_model(&xml)?;
|
||||
let mut model_bytes = xml.len();
|
||||
// The model parts objects name (production extension), each read once. The entries are
|
||||
// indexed first, so a package naming thousands of parts costs no scan per name; a name
|
||||
// with no entry is left out, and the placements that need it say so.
|
||||
let mut entries = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(Ok(n)) = zip.name_for_index(i) {
|
||||
entries.entry(part_key(&n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::from([root.clone()]);
|
||||
let mut pending = Vec::new();
|
||||
queue_parts(&model, &mut seen, &mut pending)?;
|
||||
let mut parts = HashMap::from([(root.clone(), model)]);
|
||||
while let Some(key) = pending.pop() {
|
||||
let Some(&idx) = entries.get(&key) else { continue };
|
||||
let xml = read_entry(&mut zip, idx, &key)?;
|
||||
model_bytes = model_bytes.saturating_add(xml.len());
|
||||
if model_bytes as u64 > MAX_PART {
|
||||
return bad("model parts expand to more than 1 GB");
|
||||
}
|
||||
let m = parse_model(&xml).map_err(|e| in_part(&key, e))?;
|
||||
queue_parts(&m, &mut seen, &mut pending)?;
|
||||
parts.insert(key, m);
|
||||
}
|
||||
let pkg = Package { root, parts };
|
||||
let Some(model) = pkg.parts.get(&pkg.root) else { return bad("no root model") };
|
||||
let mut out = Vec::new();
|
||||
let mut warnings = m.warnings.clone();
|
||||
let mut warnings = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for (k, (id, xf)) in m.build.iter().enumerate() {
|
||||
for (k, item) in model.build.iter().enumerate() {
|
||||
let part = item.part.as_deref().unwrap_or(&pkg.root);
|
||||
let mut mo = MeshObject::default();
|
||||
match flatten(&m, *id, xf, &mut mo, &mut Vec::new(), &mut total) {
|
||||
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
|
||||
Ok(()) if !mo.triangles.is_empty() => {
|
||||
let name = m.objects.get(id).map(|o| o.name.trim().to_string()).filter(|n| !n.is_empty());
|
||||
let name = pkg.parts.get(part).and_then(|m| m.objects.get(&item.id)).map(|o| o.name.trim().to_string()).filter(|n| !n.is_empty());
|
||||
mo.name = name.unwrap_or_else(|| format!("Mesh{}", k + 1));
|
||||
out.push(mo);
|
||||
}
|
||||
@@ -690,7 +785,7 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tetra(name: &str) -> MeshObject {
|
||||
@@ -718,18 +813,135 @@ mod tests {
|
||||
assert_eq!(model.matches("p1=\"1\"").count(), 1, "{model}");
|
||||
}
|
||||
|
||||
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
|
||||
/// A package of these parts (name, contents).
|
||||
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
if let Some(r) = rels {
|
||||
zip.start_file("_rels/.rels", o).unwrap();
|
||||
zip.write_all(r.as_bytes()).unwrap();
|
||||
for (name, data) in parts {
|
||||
zip.start_file(*name, o).unwrap();
|
||||
zip.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
zip.start_file("3D/3dmodel.model", o).unwrap();
|
||||
zip.write_all(model.as_bytes()).unwrap();
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
|
||||
let mut parts: Vec<(&str, &str)> = rels.map(|r| ("_rels/.rels", r)).into_iter().collect();
|
||||
parts.push(("3D/3dmodel.model", model));
|
||||
package_parts(&parts)
|
||||
}
|
||||
|
||||
const PRODUCTION: &str = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#;
|
||||
const OBJECTS_PART: &str = "3D/Objects/object_1.model";
|
||||
|
||||
/// Object `id`: a tetrahedron with legs of `s` along the axes (volume s³/6).
|
||||
fn tet_object(id: u32, name: &str, s: f64) -> String {
|
||||
format!(
|
||||
r#"<object id="{id}" type="model" name="{name}"><mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="{s}" y="0" z="0"/><vertex x="0" y="{s}" z="0"/><vertex x="0" y="0" z="{s}"/></vertices><triangles><triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="1" v3="3"/><triangle v1="1" v2="2" v3="3"/><triangle v1="0" v2="3" v3="2"/></triangles></mesh></object>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A model part holding `objects`, in `unit`, with no build items of its own.
|
||||
fn model_part(unit: &str, objects: &str) -> String {
|
||||
format!(r#"<?xml version="1.0"?><model unit="{unit}" {PRODUCTION}><resources>{objects}</resources><build/></model>"#)
|
||||
}
|
||||
|
||||
fn volume(o: &MeshObject) -> f64 {
|
||||
let p = |i: u32| o.positions[i as usize];
|
||||
o.triangles.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2])))).sum::<f64>() / 6.0
|
||||
}
|
||||
|
||||
/// A multi-part project as slicers save it: the root model's object is two components in
|
||||
/// another model part, which holds the meshes.
|
||||
pub(crate) fn production_package() -> Vec<u8> {
|
||||
let root = format!(
|
||||
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model" name="Pair"><components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1"/>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="2" transform="1 0 0 0 1 0 0 0 1 5 0 0"/>
|
||||
</components></object></resources><build><item objectid="10" transform="1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#
|
||||
);
|
||||
let objects = model_part("millimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
|
||||
package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])
|
||||
}
|
||||
|
||||
/// Objects kept in a model part of their own (3MF Production extension) come in with their
|
||||
/// components' and the build item's transforms (#2089).
|
||||
#[test]
|
||||
fn components_in_other_model_parts() {
|
||||
let (objs, warnings) = read_3mf(&production_package()).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(objs.len(), 1);
|
||||
let o = &objs[0];
|
||||
assert_eq!(o.name, "Pair");
|
||||
assert_eq!(o.triangles.len(), 8);
|
||||
assert!((volume(o) - (1.0 + 8.0) / 6.0).abs() < 1e-9, "{}", volume(o));
|
||||
// B moved 5 along x by its component, both 2 up by the build item.
|
||||
let max_x = o.positions.iter().map(|p| p.x).fold(f64::MIN, f64::max);
|
||||
assert!((max_x - 7.0).abs() < 1e-9, "{max_x}");
|
||||
assert!(o.positions.iter().all(|p| p.z >= 2.0 - 1e-9 && p.z <= 4.0 + 1e-9));
|
||||
}
|
||||
|
||||
/// Build items can name another part too, part names compare without regard to case, and
|
||||
/// each part has its own unit: positions come out in millimetres, translations in the unit
|
||||
/// of the part that names them.
|
||||
#[test]
|
||||
fn build_items_in_other_model_parts_and_their_units() {
|
||||
let root = format!(
|
||||
r#"<model unit="millimeter" {PRODUCTION}><resources/><build>
|
||||
<item p:path="/3D/Objects/object_1.model" objectid="1" transform="1 0 0 0 1 0 0 0 1 5 0 0"/>
|
||||
<item p:path="/3d/objects/OBJECT_1.model" objectid="2"/></build></model>"#
|
||||
);
|
||||
let objects = model_part("centimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect();
|
||||
assert_eq!(names, ["A", "B"]);
|
||||
// A: legs of 1 cm, moved 5 mm; B: legs of 2 cm.
|
||||
assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6 && (volume(&objs[1]) - 8000.0 / 6.0).abs() < 1e-6);
|
||||
let xs = |o: &MeshObject| o.positions.iter().map(|p| p.x).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(x), b.max(x)));
|
||||
assert_eq!(xs(&objs[0]), (5.0, 15.0));
|
||||
assert_eq!(xs(&objs[1]), (0.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_model_parts_are_errors() {
|
||||
let root = |components: &str| {
|
||||
format!(
|
||||
r#"<model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model"><components>{components}</components></object></resources><build><item objectid="10"/></build></model>"#
|
||||
)
|
||||
};
|
||||
let to_objects = r#"<component p:path="/3D/Objects/object_1.model" objectid="1"/>"#;
|
||||
let tet = model_part("millimeter", &tet_object(1, "A", 1.0));
|
||||
let looped = model_part(
|
||||
"millimeter",
|
||||
r#"<object id="1" type="model"><components><component p:path="/3D/3dmodel.model" objectid="10"/></components></object>"#,
|
||||
);
|
||||
let many: String = (0..=MAX_ENTRIES).map(|i| format!(r#"<component p:path="/p{i}.model" objectid="1"/>"#)).collect();
|
||||
let cases = [
|
||||
// A part that isn't in the package, one outside it and an empty name.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/missing.model" objectid="1"/>"#))]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
// An object the part doesn't have.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
// A part that is no model.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
|
||||
// Parts that place each other in a loop.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]),
|
||||
// More part names than a package can hold.
|
||||
package_parts(&[("3D/3dmodel.model", &root(&many))]),
|
||||
];
|
||||
for (i, c) in cases.iter().enumerate() {
|
||||
assert!(read_3mf(c).is_err(), "case {i}");
|
||||
}
|
||||
// A placement whose part is missing is reported; the others still come in.
|
||||
let two = format!(
|
||||
r#"<model unit="millimeter" {PRODUCTION}><resources/><build><item p:path="/3D/Objects/object_1.model" objectid="1"/><item p:path="/gone.model" objectid="1"/></build></model>"#
|
||||
);
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &two), (OBJECTS_PART, &tet)])).unwrap();
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!(warnings.iter().any(|w| w.contains("build item 2") && w.contains("gone.model")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_is_well_formed() {
|
||||
let mut t = tetra("A <&> \"b\"");
|
||||
@@ -837,6 +1049,23 @@ mod tests {
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Objects that only place other objects count toward the budget too: six levels of 300
|
||||
/// components over an empty object (300^6 placements, and no triangles to count) stop at the
|
||||
/// budget instead of running for days.
|
||||
#[test]
|
||||
fn nested_fan_out_of_empty_objects_is_bounded() {
|
||||
let t = std::time::Instant::now();
|
||||
let components = |child: u32| (0..300).map(|_| format!(r#"<component objectid="{child}"/>"#)).collect::<String>();
|
||||
let mut objects = r#"<object id="1" type="model"/>"#.to_string();
|
||||
for id in 2..=7 {
|
||||
objects += &format!(r#"<object id="{id}" type="model"><components>{}</components></object>"#, components(id - 1));
|
||||
}
|
||||
let model = format!(r#"<model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build><item objectid="7"/></build></model>"#);
|
||||
let err = read_3mf(&package(&model, None)).unwrap_err().to_string();
|
||||
assert!(err.contains("placements"), "{err}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
|
||||
#[test]
|
||||
fn placements_share_the_triangle_budget() {
|
||||
|
||||
@@ -498,14 +498,24 @@ pub fn browser_below_timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
browser_panel(app, ui, true);
|
||||
}
|
||||
|
||||
/// How narrow the browser sidebar may be dragged, and how wide (the viewport keeps the rest).
|
||||
const MIN_BROWSER_WIDTH: f32 = 170.0;
|
||||
const MAX_BROWSER_WIDTH: f32 = 620.0;
|
||||
/// How wide a grip the sidebar's right edge offers, counted inwards from the edge.
|
||||
const BROWSER_RAIL: f32 = 6.0;
|
||||
/// How opaque the floating browser's card is; the workspace shows through the rest.
|
||||
const BROWSER_FLOAT_ALPHA: f32 = 0.4;
|
||||
|
||||
fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) {
|
||||
let t = Tokens::get();
|
||||
app.tree.order.clear();
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
OCCURRENCES.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
egui::Panel::left("sc_browser")
|
||||
.exact_size(250.0)
|
||||
let remembered = app.preferences.browser_width;
|
||||
let width = if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH };
|
||||
let panel = egui::Panel::left("sc_browser")
|
||||
.exact_size(width)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6)))
|
||||
.show(ui, |ui| {
|
||||
@@ -548,6 +558,52 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) {
|
||||
browser_contents(app, ui, &mut acts);
|
||||
}
|
||||
});
|
||||
// The sidebar's right edge is a drag handle; the width it is dragged to is remembered.
|
||||
let rect = panel.response.rect;
|
||||
let rail = egui::Rect::from_x_y_ranges(rect.right() - BROWSER_RAIL..=rect.right(), rect.y_range());
|
||||
let response = ui.interact(rail, egui::Id::new("sc_browser_rail"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|input| input.pointer.delta().x);
|
||||
if delta.is_finite() {
|
||||
app.preferences.browser_width = (width + delta).clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH);
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_handle("left-browser-rail", egui::pos2(rect.right() - BROWSER_RAIL / 2.0, rect.center().y));
|
||||
finish_browser(app, ui, acts);
|
||||
}
|
||||
|
||||
/// The tree on a translucent card over the workspace, instead of a column that takes width from
|
||||
/// it (Preferences: "Float it over the workspace"). Drawn before the central view, in a layer
|
||||
/// above it, so the viewport keeps the whole window and the card sits on top of it.
|
||||
pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, viewport: egui::Rect) {
|
||||
let t = Tokens::get();
|
||||
app.tree.order.clear();
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
OCCURRENCES.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
let remembered = app.preferences.browser_width;
|
||||
let width = if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH };
|
||||
// Tall enough for the tree, short enough to leave the view room; the rows scroll inside.
|
||||
let height = (viewport.height() * 0.7).clamp(160.0, 560.0);
|
||||
let card = crate::frame::area(egui::Id::new("sc_browser_float")).fixed_pos(viewport.min + vec2(8.0, 8.0)).order(egui::Order::Middle).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(t.panel.gamma_multiply(BROWSER_FLOAT_ALPHA))
|
||||
.stroke(egui::Stroke::new(1.0, t.border))
|
||||
.corner_radius(6)
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(width - 16.0);
|
||||
ui.set_max_height(height);
|
||||
ui.label(RichText::new(crate::i18n::tr_label("BROWSER")).size(11.0).strong().color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
// The card hugs the tree, so it hides as little of the view as it can.
|
||||
browser_rows_hugging(app, ui, &mut acts);
|
||||
});
|
||||
},
|
||||
);
|
||||
crate::scenario::publish_rect("browser:float", card.response.rect);
|
||||
finish_browser(app, ui, acts);
|
||||
}
|
||||
|
||||
@@ -611,9 +667,19 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<
|
||||
}
|
||||
|
||||
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
rows_scrolling(app, ui, acts, false);
|
||||
}
|
||||
|
||||
/// As [browser_rows], but the area hugs the rows it drew (the floating card), up to the height
|
||||
/// the caller allowed.
|
||||
fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
rows_scrolling(app, ui, acts, true);
|
||||
}
|
||||
|
||||
fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shrink_height: bool) {
|
||||
prepare_model(app);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.auto_shrink([false, shrink_height])
|
||||
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
|
||||
@@ -701,7 +701,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
let stale = result.is_some() && *of != picked;
|
||||
if ui.button(if stale { "Compute again" } else { "Compute" }).clicked() {
|
||||
if ui.button(crate::i18n::tr(if stale { "dialog.compute_again" } else { "dialog.compute" })).clicked() {
|
||||
let (r, o) = interference(app, &picked, clearance);
|
||||
*result = Some(r);
|
||||
*overlaps = o;
|
||||
@@ -729,7 +729,7 @@ fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value,
|
||||
let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let clearance = match crate::prefs::parse_length_mm(clearance, scale) {
|
||||
Some(v) if v >= 0.0 => v,
|
||||
_ => return (json!({ "error": "the clearance must be a length of 0 or more" }), Vec::new()),
|
||||
_ => return (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
|
||||
};
|
||||
let mut p = json!({ "clearance": clearance, "nearest": 1 });
|
||||
if !names.is_empty() {
|
||||
@@ -762,7 +762,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
|
||||
}
|
||||
let hits = r["overlaps"].as_array().cloned().unwrap_or_default();
|
||||
row_label(ui, "Checked");
|
||||
ui.label(format!("{} bodies", r["bodies"].as_u64().unwrap_or(0)));
|
||||
ui.label(crate::i18n::format("dialog.bodies_count", &[("count", &r["bodies"].as_u64().unwrap_or(0).to_string())]));
|
||||
ui.end_row();
|
||||
let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
let near = r["near"].as_array().cloned().unwrap_or_default();
|
||||
@@ -812,7 +812,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
|
||||
// Pairs the check could not work out: not clear, not known to interfere.
|
||||
for u in unchecked {
|
||||
row_label(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or("")));
|
||||
ui.label(RichText::new("could not be checked").color(t.warning)).on_hover_text(u["reason"].as_str().unwrap_or(""));
|
||||
ui.label(RichText::new(crate::i18n::tr("dialog.could_not_be_checked")).color(t.warning)).on_hover_text(u["reason"].as_str().unwrap_or(""));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
row_label(ui, "Length");
|
||||
enter |= field(ui, length);
|
||||
ui.end_row();
|
||||
row_label(ui, "Gap");
|
||||
row_label(ui, "ui.sheet_gap");
|
||||
let r = ui.add(egui::TextEdit::singleline(gap).hint_text(crate::i18n::tr_label("from the rule")));
|
||||
crate::params_dialog::complete(ui, &r, gap);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
|
||||
+60
-11
@@ -111,11 +111,31 @@ fn with_fallback(mut fonts: egui::FontDefinitions, name: &str, bytes: Vec<u8>) -
|
||||
Some(fonts)
|
||||
}
|
||||
|
||||
/// Move `name` to the head of [`egui::FontFamily::Proportional`] so that ASCII characters
|
||||
/// (digits, units, labels) and CJK glyphs use the same face, keeping them on one baseline.
|
||||
fn promote_font_to_front(fonts: &mut egui::FontDefinitions, name: &str) {
|
||||
if let Some(list) = fonts.families.get_mut(&egui::FontFamily::Proportional)
|
||||
&& let Some(pos) = list.iter().position(|e| e == name)
|
||||
{
|
||||
let item = list.remove(pos);
|
||||
list.insert(0, item);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install font bytes as fallback for Proportional and Monospace font families.
|
||||
/// For CJK languages, promotes the font to the front of Proportional so digits and labels share one baseline.
|
||||
/// Returns true if the font was parsed and installed successfully.
|
||||
pub fn install_cjk_font(ctx: &egui::Context, bytes: Vec<u8>) -> bool {
|
||||
install_cjk_font_for(ctx, bytes, "ja")
|
||||
}
|
||||
|
||||
/// Install font bytes for `language`.
|
||||
pub fn install_cjk_font_for(ctx: &egui::Context, bytes: Vec<u8>, language: &str) -> bool {
|
||||
match with_cjk_fallback(egui::FontDefinitions::default(), bytes) {
|
||||
Some(fonts) => {
|
||||
Some(mut fonts) => {
|
||||
if needs_cjk_font(language) {
|
||||
promote_font_to_front(&mut fonts, CJK_FALLBACK);
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
true
|
||||
}
|
||||
@@ -460,9 +480,16 @@ fn find_system_cjk_font() -> Option<Vec<u8>> {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let dirs = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"];
|
||||
for d in dirs {
|
||||
if let Some(bytes) = scan_dir_for_font(std::path::Path::new(d), 2, is_cjk_font_name) {
|
||||
let mut dirs = vec![
|
||||
std::path::PathBuf::from("/System/Library/Fonts"),
|
||||
std::path::PathBuf::from("/System/Library/Fonts/Supplemental"),
|
||||
std::path::PathBuf::from("/Library/Fonts"),
|
||||
];
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
dirs.push(std::path::PathBuf::from(home).join("Library/Fonts"));
|
||||
}
|
||||
for d in &dirs {
|
||||
if let Some(bytes) = scan_dir_for_font(d, 2, is_cjk_font_name) {
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
@@ -496,10 +523,10 @@ fn find_system_cjk_font() -> Option<Vec<u8>> {
|
||||
/// inside one word. One face for the whole word keeps them on one baseline.
|
||||
/// Thai comes last whatever the language: no other face draws it.
|
||||
pub fn fallback_order(language: &str) -> [&'static str; 4] {
|
||||
if language == "zh" {
|
||||
[CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]
|
||||
} else {
|
||||
[CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]
|
||||
match language {
|
||||
"zh" => [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
|
||||
"ko" => [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK],
|
||||
_ => [CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +546,8 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) {
|
||||
if let Some(font) = self::ui_font(ui_font) {
|
||||
fonts = with_ui_font(fonts, font);
|
||||
}
|
||||
for name in fallback_order(language) {
|
||||
let order = fallback_order(language);
|
||||
for name in order {
|
||||
let bytes = match name {
|
||||
CJK_FALLBACK => cjk.clone(),
|
||||
CHINESE_FALLBACK => chinese.clone(),
|
||||
@@ -534,6 +562,15 @@ pub fn setup_fonts_for(ctx: &egui::Context, language: &str, ui_font: &str) {
|
||||
}
|
||||
}
|
||||
if found {
|
||||
if needs_cjk_font(language) && ui_font.trim().is_empty() {
|
||||
// For a CJK UI (Japanese, Chinese, Korean) without a custom interface font,
|
||||
// promote the primary CJK font to the front of FontFamily::Proportional.
|
||||
// This ensures that ASCII characters (digits, units, English words) within mixed
|
||||
// labels (e.g. "4 個選択中", "2 mm") are drawn with the same font face as the
|
||||
// CJK glyphs, sharing identical line height, ascender, and baseline (#1070 follow-up).
|
||||
let primary = order[0];
|
||||
promote_font_to_front(&mut fonts, primary);
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
}
|
||||
@@ -624,7 +661,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_chinese_ui_puts_the_chinese_face_before_the_japanese_one() {
|
||||
assert_eq!(fallback_order("zh"), [CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]);
|
||||
for lang in ["en", "ja", "ko", "de"] {
|
||||
assert_eq!(fallback_order("ko"), [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK]);
|
||||
for lang in ["en", "ja", "de"] {
|
||||
assert_eq!(
|
||||
fallback_order(lang),
|
||||
[CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
|
||||
@@ -665,6 +703,16 @@ mod tests {
|
||||
assert!(thai(with_fallback(egui::FontDefinitions::default(), THAI_FALLBACK, bytes).unwrap()), "Thai draws with the fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_ui_promotes_primary_cjk_font_to_front_for_consistent_baseline() {
|
||||
let defaults = egui::FontDefinitions::default();
|
||||
let mut fonts = with_cjk_fallback(defaults.clone(), latin_font_bytes()).unwrap();
|
||||
promote_font_to_front(&mut fonts, CJK_FALLBACK);
|
||||
let prop = &fonts.families[&egui::FontFamily::Proportional];
|
||||
assert_eq!(prop.first().map(String::as_str), Some(CJK_FALLBACK), "CJK font is promoted to first so digits and CJK share one baseline");
|
||||
assert!(prop.contains(&defaults.families[&egui::FontFamily::Proportional][0]), "Latin font remains in the fallback chain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hangul_font_falls_back_after_the_cjk_font() {
|
||||
let defaults = egui::FontDefinitions::default();
|
||||
@@ -755,10 +803,11 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
let run = |text: &'static str| {
|
||||
let mut seen = false;
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.label(text);
|
||||
seen = painted_cjk(ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
seen
|
||||
};
|
||||
assert!(!run("Untitled"));
|
||||
|
||||
@@ -290,3 +290,34 @@ fn long_panel_label_wraps_centred_and_its_button_is_centred() {
|
||||
assert!(rows.iter().all(|row| lr.contains_rect(*row)), "the highlight holds both lines: {lr:?} {rows:?}");
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The Interference dialog's title, input and Compute button come from the catalogs: they were
|
||||
/// English in every language.
|
||||
#[test]
|
||||
fn interference_dialog_renders_in_the_active_language() {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.language = "fr".into();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.start", json!({"command":"inspect.interference"}));
|
||||
h.frames(3);
|
||||
for word in ["INTERFÉRENCE", "Corps (tous si aucun)", "Calculer"] {
|
||||
assert!(h.widget(word).is_some(), "{word:?} must be shown");
|
||||
}
|
||||
for english in ["INTERFERENCE", "Bodies (all if none)", "Compute"] {
|
||||
assert!(h.widget(english).is_none(), "{english:?} is still shown in English");
|
||||
}
|
||||
let fr = crate::i18n::Locale::for_language("fr");
|
||||
assert_eq!(fr.format("dialog.bodies_count", &[("count", "3")]), "3 corps");
|
||||
assert_eq!(fr.text("dialog.compute_again"), "Recalculer");
|
||||
}
|
||||
|
||||
/// The continuity legend's Gap (an opening between faces) and the sheet-metal hem's gap are
|
||||
/// different words in some languages, so each has its own message.
|
||||
#[test]
|
||||
fn continuity_gap_and_sheet_metal_gap_are_separate_messages() {
|
||||
for (lang, legend, sheet) in [("fr", "Écart", "Jeu"), ("de", "Lücke", "Spalt")] {
|
||||
let locale = crate::i18n::Locale::for_language(lang);
|
||||
assert_eq!(locale.text("ui.continuity_gap"), legend, "{lang}");
|
||||
assert_eq!(locale.text("ui.sheet_gap"), sheet, "{lang}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1387,7 +1387,9 @@ impl SolveApp {
|
||||
timeline::timeline(self, ui);
|
||||
}
|
||||
palette::palette_bar(self, ui);
|
||||
if self.ui.show_browser {
|
||||
// A floating browser keeps the workspace the whole width: it is drawn over the view below.
|
||||
let browser_float = self.ui.show_browser && self.preferences.browser_float;
|
||||
if self.ui.show_browser && !browser_float {
|
||||
if left_timeline {
|
||||
browser::browser_below_timeline(self, ui);
|
||||
} else {
|
||||
@@ -1401,6 +1403,11 @@ impl SolveApp {
|
||||
}
|
||||
agent_chat::show(self, ui);
|
||||
preview::update(self, ui.ctx());
|
||||
if browser_float {
|
||||
// The room the central view is about to take, before it takes it.
|
||||
let viewport = ui.available_rect_before_wrap();
|
||||
browser::floating(self, ui, viewport);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
if render_workspace::active(self) {
|
||||
render_workspace::show(self, ui);
|
||||
|
||||
@@ -251,6 +251,10 @@ pub enum OrbitAround {
|
||||
Cursor,
|
||||
}
|
||||
|
||||
/// The browser sidebar's first width, and the width [`Prefs::default`] restores; the panel
|
||||
/// itself clamps what the user drags to its own range.
|
||||
pub const DEFAULT_BROWSER_WIDTH: f32 = 250.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Prefs {
|
||||
@@ -318,6 +322,12 @@ pub struct Prefs {
|
||||
pub timeline_left_height: f32,
|
||||
/// Whether the browser below the left feature history is collapsed.
|
||||
pub timeline_left_browser_collapsed: bool,
|
||||
/// Width of the left sidebar holding the browser (and the feature history above it), which
|
||||
/// the user drags wider or narrower at its right edge.
|
||||
pub browser_width: f32,
|
||||
/// Draw the parts browser on a translucent card over the 3D view instead of a column of its
|
||||
/// own, so the workspace keeps the whole window width.
|
||||
pub browser_float: bool,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// Spread discrete wheel input across frames (off: apply each event immediately).
|
||||
@@ -440,6 +450,8 @@ impl Default for Prefs {
|
||||
timeline_left: false,
|
||||
timeline_left_height: 360.0,
|
||||
timeline_left_browser_collapsed: false,
|
||||
browser_width: DEFAULT_BROWSER_WIDTH,
|
||||
browser_float: false,
|
||||
zoom_to_cursor: true,
|
||||
smooth_wheel_zoom: true,
|
||||
trackpad_pan: true,
|
||||
@@ -695,6 +707,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.timeline_left = d.timeline_left;
|
||||
p.timeline_left_height = d.timeline_left_height;
|
||||
p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed;
|
||||
p.browser_width = d.browser_width;
|
||||
p.browser_float = d.browser_float;
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
p.angle_decimals = d.angle_decimals;
|
||||
@@ -1219,6 +1233,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, app.tr("ui.timeline_left_note"));
|
||||
ui.label(app.tr_label("Parts browser"));
|
||||
let float_label = app.tr_label("Float it over the workspace").to_string();
|
||||
ui.checkbox(&mut app.preferences.browser_float, float_label);
|
||||
ui.end_row();
|
||||
note(ui, app.tr_label("The tree draws on a translucent card over the 3D view instead of a column of its own."));
|
||||
ui.label(app.tr("ui.units_of_new_designs"));
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
|
||||
@@ -106,8 +106,9 @@ fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let [smooth, jump, sharp, gap] = tk.continuity;
|
||||
let tr = crate::i18n::tr_label;
|
||||
let rows = [(tr("G2 curvature"), smooth), (tr("G1 tangent"), jump), (tr("G0 crease"), sharp), (tr("Gap"), gap)];
|
||||
// Stable ids: this Gap is a hole between faces, not the sheet-metal gap (`ui.sheet_gap`).
|
||||
let tr = crate::i18n::tr;
|
||||
let rows = [(tr("ui.continuity_g2"), smooth), (tr("ui.continuity_g1"), jump), (tr("ui.continuity_g0"), sharp), (tr("ui.continuity_gap"), gap)];
|
||||
let font = egui::FontId::proportional(12.0);
|
||||
let (row, pad, swatch) = (18.0, 8.0, 22.0);
|
||||
let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max);
|
||||
|
||||
@@ -957,6 +957,38 @@ mod tests {
|
||||
assert!(handle_at("row:Untitled").is_some(), "expanding the browser should restore its rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_sidebar_drags_wider_and_narrower() {
|
||||
let mut harness = Harness::new();
|
||||
harness.frames(4);
|
||||
let before = harness.app.preferences.browser_width;
|
||||
let rail = handle_at("left-browser-rail").expect("the browser sidebar's right edge is drawn");
|
||||
harness.step(&json!({"drag": [[rail.x, rail.y], [rail.x + 90.0, rail.y]]})).unwrap();
|
||||
let wider = harness.app.preferences.browser_width;
|
||||
assert!(wider > before + 40.0, "dragging the right edge right should widen the browser: {before} -> {wider}");
|
||||
|
||||
let rail = handle_at("left-browser-rail").expect("the browser sidebar's right edge is drawn");
|
||||
harness.step(&json!({"drag": [[rail.x, rail.y], [rail.x - 40.0, rail.y]]})).unwrap();
|
||||
let narrower = harness.app.preferences.browser_width;
|
||||
assert!(narrower < wider - 20.0, "dragging it left should narrow it again: {wider} -> {narrower}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_browser_sits_over_the_workspace_and_docks_again() {
|
||||
let mut harness = Harness::new();
|
||||
harness.app.preferences.browser_float = true;
|
||||
harness.frames(4);
|
||||
let card = crate::scenario::rect_of("browser:float").expect("the floating browser is drawn");
|
||||
assert!(card.min.x < 40.0, "the card sits at the view's left edge: {card:?}");
|
||||
assert!(handle_at("left-browser-rail").is_none(), "a floating browser must not take a column of the window");
|
||||
assert!(handle_at("row:Untitled").is_some(), "the tree is still drawn and usable");
|
||||
|
||||
harness.app.preferences.browser_float = false;
|
||||
harness.frames(4);
|
||||
assert!(crate::scenario::rect_of("browser:float").is_none(), "docking again removes the card");
|
||||
assert!(handle_at("left-browser-rail").is_some(), "the docked sidebar is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_bar_above_last_feature_can_be_dragged_to_the_end() {
|
||||
let mut harness = Harness::new();
|
||||
|
||||
@@ -28,6 +28,16 @@ made. Contact sets stop motion where bodies would pass through each other,
|
||||
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping
|
||||
volumes in red, lists the pairs closer than its **Clearance** and each body's nearest neighbour.
|
||||
|
||||
A joint origin on a face or a hole keeps the turn it had when it was picked: when the face tilts
|
||||
(a bend angle or a rotation changing with a parameter) the part on it tilts with the face but
|
||||
doesn't spin about it.
|
||||
|
||||
A joint origin can also sit on an edge of a flat face (`"edge": [x, y, z]`): at the edge's middle,
|
||||
pointing out of the face with its y into it, or with `"corner": [x, y, z]` at the corner near that
|
||||
point, its x and y running along the two edges into the face. The edge, the face and the corner
|
||||
are found again by name, so a bracket set 40 mm in from a panel's corner (a planar joint with
|
||||
values `[40, 40, 0]`) stays 40 mm in from that corner when the panel changes size.
|
||||
|
||||
The check behind it, `inspect.clearance`, does all of that in one call for any number of bodies,
|
||||
which suits scripts and agents checking a whole assembly:
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ the model looks right in Render; textures, skins and animations are not imported
|
||||
mirrored nodes are baked into their meshes. Compressed meshes (Draco, meshopt) are refused with a
|
||||
message: save the file uncompressed first.
|
||||
|
||||
A 3MF project saved by a slicer comes in too, also when it keeps its objects in model parts of
|
||||
their own inside the file (the 3MF Production extension): each object on the build plate becomes
|
||||
a mesh body, with its parts where the slicer placed them.
|
||||
|
||||
**File → Save as mesh…** (also on the Mesh tab under Modify, and in the right-click menu of a
|
||||
body) saves the selected bodies as STL, 3MF or 3MF with face tags (below). With **Send to slicer** ticked, it writes the file to
|
||||
a temporary folder and opens it in the chosen slicer instead of asking for a file name. The slicer
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Mutation testing
|
||||
|
||||
SolidCraft will move from truck to solidcore (#1597). The tests that have to catch regressions
|
||||
across that switch are the ones that don't depend on the kernel: the reference tests
|
||||
([docs/oracle.md](oracle.md)), the robustness goldens ([docs/robustness.md](robustness.md)) and
|
||||
the engine's command tests. Most of the kernel's own unit tests call truck-specific code and go
|
||||
away with it.
|
||||
|
||||
Mutation testing checks those suites. [cargo-mutants](https://mutants.rs) makes small changes to
|
||||
the source, one at a time (`*` becomes `/`, a condition is negated, a function returns a default
|
||||
value), rebuilds, and runs the tests. A mutant the tests don't catch is a change in behaviour
|
||||
nobody would notice, and solidcore could get the same thing wrong without anyone noticing.
|
||||
|
||||
## What judges a mutant
|
||||
|
||||
`.cargo/mutants.toml` judges mutants by the black-box suites only:
|
||||
|
||||
- `oracle_suite` (`apps/solidcraft-cli/tests/oracle_cases.rs`): every case in `tests/oracle` with
|
||||
expected values and no `known_failure` must pass, as `cargo xtask oracle` requires. It is
|
||||
`#[ignore]`d so CI doesn't replay the oracle twice.
|
||||
- The pull-request sample of the robustness goldens: every 8th seed, as `ci.yml` runs it.
|
||||
|
||||
The kernel's unit tests are left out on purpose. A mutant that only they catch marks a behaviour
|
||||
the black-box suites would miss once the kernel is swapped.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
cargo install cargo-mutants --locked
|
||||
CARGO_TARGET_DIR=$PWD/target cargo mutants --in-place \
|
||||
-f crates/kernel/src/measure.rs \
|
||||
--test-package solidcraft-cli --test-package solidcraft-engine \
|
||||
--baseline skip --timeout 900 --build-timeout 900
|
||||
```
|
||||
|
||||
- Use a worktree you don't otherwise touch: `--in-place` writes each mutant into the source tree
|
||||
and restores it afterwards.
|
||||
- `--shard 0/200 --sharding round-robin` with `-d crates/kernel` takes an even sample (every 200th
|
||||
mutant) of the whole crate.
|
||||
- The `mutants` profile in `Cargo.toml` is the `ci` profile with incremental builds. It cuts the
|
||||
rebuild after a kernel change from about 3 minutes to about 15 seconds.
|
||||
- A mutant costs 1.5–5 minutes on a 12-core Mac, mostly the tests. The kernel has about 40,000
|
||||
mutants, so run a sample or one file at a time, never the whole crate. This is a tool for
|
||||
finding gaps, not a CI gate.
|
||||
|
||||
Two problems with cargo-mutants 27.1 are behind the flags above:
|
||||
|
||||
- The baseline (the unmutated run) ignores `--test-package` and tests the mutated crate, which
|
||||
fails. Skip it with `--baseline skip`, set `--timeout` yourself, and check the unmutated tree
|
||||
passes first.
|
||||
- `--re` and `--exclude-re` don't filter "delete field" mutants. Saved diffs in
|
||||
`mutants.out/diff/` name the mutation, not the file, in their `+++` line: apply one with
|
||||
`patch <file> < <diff>`.
|
||||
|
||||
## Pilot results (October 2026, `cb857f1f`)
|
||||
|
||||
| Sample | Mutants that build | Caught by the black-box suites |
|
||||
|---|---:|---:|
|
||||
| `crates/kernel/src/measure.rs`, every mutant | 85 | 85% |
|
||||
| Whole kernel, every 200th mutant | 173 | 15% (95% interval 10–21%) |
|
||||
|
||||
Out of 49 of the kernel sample's survivors, the kernel's own unit tests caught 21 (43%) and
|
||||
nothing caught 28 (57%). About 11% of survivors swap `>` and `>=` on floating-point values that
|
||||
are never exactly equal, so they don't change behaviour.
|
||||
|
||||
The black-box suites are weakest on tessellation and the display mesh (0 of 19 mutants caught),
|
||||
offset, shell, emboss and lip (0 of 14), blends (15%) and STEP/IGES exchange (17%). For a
|
||||
survivor that a kernel unit test catches, that test's design is the starting point for a new
|
||||
oracle case or engine test.
|
||||
@@ -20,7 +20,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
location ~ "-[0-9a-f]{16}(_bg)?\.(js|wasm|css)$" {
|
||||
location ~ "-[0-9a-f]{8,16}(_bg)?\.(js|wasm|css)$" {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ curl -fs "$BASE/" | grep -q 'solidcraft_canvas' || fail "index.html is not the a
|
||||
curl -fs "$BASE/sw.js" | grep -q 'const VERSION = "dev"' && fail "sw.js is the unbuilt template (not a release build)"
|
||||
[ "$(header "$BASE/build.json" Cache-Control)" = no-cache ] || fail "build.json must not be cached"
|
||||
|
||||
WASM="$(curl -fs "$BASE/" | grep -o 'solidcraft-web-[0-9a-f]*_bg\.wasm' | head -n1)"
|
||||
WASM="$(curl -fs "$BASE/" | grep -o 'solidcraft-web-[0-9a-f]\{8,16\}_bg\.wasm' | head -n1)"
|
||||
[ -n "$WASM" ] || fail "index.html names no wasm"
|
||||
[ "$(header "$BASE/$WASM" Content-Type)" = application/wasm ] || fail "$WASM is not served as application/wasm"
|
||||
header "$BASE/$WASM" Cache-Control | grep -q immutable || fail "$WASM should be cached as immutable"
|
||||
|
||||
@@ -47,7 +47,18 @@ for ((attempt=0; attempt<300; attempt++)); do
|
||||
sleep .1
|
||||
done
|
||||
test "$ready" = 1
|
||||
for request in "{\"id\":1,\"method\":\"ui.inspect\",\"params\":{},\"token\":\"$SOLIDCRAFT_CONTROL_TOKEN\"}" '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do
|
||||
# The control port opens before the first frame, so ui.inspect has no viewport yet; poll until it does.
|
||||
inspect="{\"id\":1,\"method\":\"ui.inspect\",\"params\":{},\"token\":\"$SOLIDCRAFT_CONTROL_TOKEN\"}"
|
||||
deadline=$((SECONDS + 60))
|
||||
until printf '%s\n' "$inspect" >&3 && IFS= read -r -t 30 reply <&3 && [[ "$reply" =~ \"viewport\":\ *\[ ]]; do
|
||||
if ! kill -0 "$app" 2>/dev/null || ((SECONDS >= deadline)); then
|
||||
echo "viewport never appeared: ${reply:-no reply}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep .25
|
||||
done
|
||||
printf '%s\n' "$reply"
|
||||
for request in '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do
|
||||
printf '%s\n' "$request" >&3
|
||||
IFS= read -r -t 30 reply <&3
|
||||
printf '%s\n' "$reply"
|
||||
|
||||
Reference in New Issue
Block a user