Compare commits

..

19 Commits

Author SHA1 Message Date
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
50 changed files with 119 additions and 1366 deletions
-14
View File
@@ -1,14 +0,0 @@
# 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",
]
-9
View File
@@ -15,12 +15,3 @@ 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.
+3 -1
View File
@@ -27,7 +27,9 @@ concurrency:
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
# 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
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
+3 -1
View File
@@ -33,7 +33,9 @@ concurrency:
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
# 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
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
-3
View File
@@ -7,6 +7,3 @@
/.claude/worktrees/
/target-snapshot
__pycache__/
# cargo-mutants output (docs/mutation-testing.md)
mutants.out*/
+3 -38
View File
@@ -148,8 +148,6 @@ 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.
@@ -264,24 +262,7 @@ 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).
## 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
## Sending a change
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
@@ -312,21 +293,6 @@ 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,
@@ -388,9 +354,8 @@ 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 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.
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.
## Licence
Generated
+17 -17
View File
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.1"
version = "0.16.0"
[[package]]
name = "solidcraft-cli"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4124,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"log",
"serde",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"log",
"serde",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"log",
"naga",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4267,11 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.1"
version = "0.16.0"
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"arboard",
"egui",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.1"
version = "0.16.0"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -7
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.16.1"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -142,9 +142,3 @@ 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
View File
@@ -3,46 +3,6 @@
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 |
-66
View File
@@ -1,66 +0,0 @@
//! 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"));
}
+1 -2
View File
@@ -113,6 +113,7 @@
"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",
@@ -1030,7 +1031,6 @@
"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,7 +1347,6 @@
"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",
+1 -9
View File
@@ -43,6 +43,7 @@
"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)",
@@ -447,12 +448,7 @@
"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",
@@ -773,7 +769,6 @@
"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",
@@ -824,7 +819,6 @@
"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",
@@ -934,7 +928,6 @@
"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",
@@ -1128,7 +1121,6 @@
"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",
+1 -9
View File
@@ -122,6 +122,7 @@
"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",
@@ -655,12 +656,7 @@
"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",
@@ -1053,7 +1049,6 @@
"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",
@@ -1108,7 +1103,6 @@
"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",
@@ -1224,7 +1218,6 @@
"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",
@@ -1434,7 +1427,6 @@
"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",
+1 -2
View File
@@ -120,6 +120,7 @@
"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",
@@ -1061,7 +1062,6 @@
"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,7 +1383,6 @@
"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",
+1 -9
View File
@@ -120,6 +120,7 @@
"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",
@@ -651,12 +652,7 @@
"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",
@@ -1012,7 +1008,6 @@
"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",
@@ -1067,7 +1062,6 @@
"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",
@@ -1183,7 +1177,6 @@
"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",
@@ -1390,7 +1383,6 @@
"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",
+1 -2
View File
@@ -113,6 +113,7 @@
"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",
@@ -1030,7 +1031,6 @@
"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,7 +1347,6 @@
"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",
+1 -2
View File
@@ -119,6 +119,7 @@
"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",
@@ -1054,7 +1055,6 @@
"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,7 +1374,6 @@
"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",
+1 -9
View File
@@ -122,6 +122,7 @@
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Font": "フォント",
"Full Length": "全長",
"Gap": "ギャップ",
"Grid": "グリッド",
"Height": "高さ",
"Hidden edges": "隠線",
@@ -655,12 +656,7 @@
"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": "↖ 選択",
@@ -1053,7 +1049,6 @@
"ui.base_point": "基点",
"ui.blocked": "到達不可",
"ui.bodies": "ボディ",
"ui.bodies_all_if_none": "ボディ(未選択ならすべて)",
"ui.box": "直方体",
"ui.break": "分割",
"ui.calibrate": "校正",
@@ -1108,7 +1103,6 @@
"ui.continuity_g0": "G0 折れ",
"ui.continuity_g1": "G1 接線",
"ui.continuity_g2": "G2 曲率",
"ui.continuity_gap": "隙間",
"ui.copy": "コピー",
"ui.corner": "コーナー",
"ui.corners": "コーナー",
@@ -1224,7 +1218,6 @@
"ui.heading_home": "ホーム",
"ui.heading_insert": "挿入",
"ui.heading_inspect": "検査",
"ui.heading_interference": "干渉",
"ui.heading_loft": "ロフト",
"ui.heading_make": "製造",
"ui.heading_measure": "計測",
@@ -1434,7 +1427,6 @@
"ui.send": "送信",
"ui.send_to_slicer": "スライサーに送る",
"ui.sent_to_slicer": "{slicer} に送信しました",
"ui.sheet_gap": "ギャップ",
"ui.shell": "シェル",
"ui.show": "表示",
"ui.showAll": "すべて表示",
+1 -9
View File
@@ -43,6 +43,7 @@
"Faces": "면",
"Find in Browser": "브라우저에서 찾기",
"Flat": "플랫",
"Gap": "틈",
"Hidden edges": "숨겨진 모서리",
"Holes": "구멍",
"Holes (edges on them)": "구멍 (구멍의 모서리)",
@@ -447,12 +448,7 @@
"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": "↖ 선택",
@@ -773,7 +769,6 @@
"ui.base_point": "기준점",
"ui.blocked": "막힘",
"ui.bodies": "바디",
"ui.bodies_all_if_none": "바디 (없으면 전체)",
"ui.box": "박스",
"ui.break": "끊기",
"ui.calibrate": "캘리브레이션",
@@ -824,7 +819,6 @@
"ui.continuity_g0": "G0 꺾임",
"ui.continuity_g1": "G1 접선",
"ui.continuity_g2": "G2 곡률",
"ui.continuity_gap": "틈",
"ui.copy": "복사",
"ui.corner": "코너",
"ui.corners": "코너",
@@ -934,7 +928,6 @@
"ui.heading_home": "홈",
"ui.heading_insert": "삽입",
"ui.heading_inspect": "검사",
"ui.heading_interference": "간섭",
"ui.heading_loft": "로프트",
"ui.heading_measure": "측정",
"ui.heading_mesh": "메시",
@@ -1125,7 +1118,6 @@
"ui.send": "보내기",
"ui.send_to_slicer": "슬라이서로 보내기",
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
"ui.sheet_gap": "틈",
"ui.shell": "쉘",
"ui.show": "표시",
"ui.showAll": "모두 표시",
+1 -2
View File
@@ -122,6 +122,7 @@
"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",
@@ -1070,7 +1071,6 @@
"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,7 +1395,6 @@
"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",
+1 -2
View File
@@ -113,6 +113,7 @@
"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",
@@ -1010,7 +1011,6 @@
"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,7 +1327,6 @@
"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",
+1 -2
View File
@@ -102,6 +102,7 @@
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Font": "Шрифт",
"Full Length": "Полная длина",
"Gap": "Зазор",
"Grid": "Сетка",
"Height": "Высота",
"Home view": "Главный вид",
@@ -817,7 +818,6 @@
"ui.construct": "Построение",
"ui.construction": "Вспомогательная",
"ui.context_menu": "Контекстное меню",
"ui.continuity_gap": "Зазор",
"ui.copy": "Копировать",
"ui.corner": "Угол",
"ui.corners": "Углы",
@@ -1126,7 +1126,6 @@
"ui.send": "Отправить",
"ui.send_to_slicer": "Отправить в слайсер",
"ui.sent_to_slicer": "Отправлено в {slicer}",
"ui.sheet_gap": "Зазор",
"ui.shell": "Оболочка",
"ui.show": "Показать",
"ui.showAll": "Показать всё",
+1 -2
View File
@@ -107,6 +107,7 @@
"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",
@@ -959,7 +960,6 @@
"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,7 +1274,6 @@
"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",
+1 -2
View File
@@ -118,6 +118,7 @@
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
"Font": "Шрифт",
"Full Length": "Повна довжина",
"Gap": "Проміжок",
"Grid": "Сітка",
"Height": "Висота",
"Hidden edges": "Приховані ребра",
@@ -1033,7 +1034,6 @@
"ui.construct": "Побудувати",
"ui.construction": "Конструкційна",
"ui.context_menu": "Контекстне меню",
"ui.continuity_gap": "Проміжок",
"ui.copy": "Копіювати",
"ui.corner": "Кут",
"ui.corners": "Кути",
@@ -1350,7 +1350,6 @@
"ui.send": "Надіслати",
"ui.send_to_slicer": "Надіслати в слайсер",
"ui.sent_to_slicer": "Надіслано в {slicer}",
"ui.sheet_gap": "Проміжок",
"ui.shell": "Оболонка",
"ui.show": "Показати",
"ui.showAll": "Показати все",
+1 -9
View File
@@ -122,6 +122,7 @@
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
"Font": "字体",
"Full Length": "全长",
"Gap": "间隙",
"Grid": "网格",
"Height": "高度",
"Hidden edges": "隐藏线",
@@ -655,12 +656,7 @@
"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": "↖ 选择",
@@ -1053,7 +1049,6 @@
"ui.base_point": "基点",
"ui.blocked": "被遮挡",
"ui.bodies": "实体",
"ui.bodies_all_if_none": "实体(未选择则全部)",
"ui.box": "长方体",
"ui.break": "打断",
"ui.calibrate": "校准",
@@ -1108,7 +1103,6 @@
"ui.continuity_g0": "G0 折痕",
"ui.continuity_g1": "G1 相切",
"ui.continuity_g2": "G2 曲率",
"ui.continuity_gap": "缝隙",
"ui.copy": "复制",
"ui.corner": "角",
"ui.corners": "角点",
@@ -1224,7 +1218,6 @@
"ui.heading_home": "主页",
"ui.heading_insert": "插入",
"ui.heading_inspect": "检查",
"ui.heading_interference": "干涉",
"ui.heading_loft": "放样",
"ui.heading_make": "制作",
"ui.heading_measure": "测量",
@@ -1434,7 +1427,6 @@
"ui.send": "发送",
"ui.send_to_slicer": "发送到切片软件",
"ui.sent_to_slicer": "已发送到 {slicer}",
"ui.sheet_gap": "间隙",
"ui.shell": "抽壳",
"ui.show": "显示",
"ui.showAll": "显示全部",
@@ -1,4 +0,0 @@
//! 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).
@@ -1,5 +0,0 @@
//! 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).
+1 -45
View File
@@ -62,18 +62,6 @@ 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,
@@ -100,8 +88,7 @@ pub enum XRef {
}
impl Snap {
/// 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).
/// The persistent face names the snap refers to (its face, its end's, its x reference's).
pub fn face_names(&self) -> Vec<&String> {
let mut out = Vec::new();
if let Snap::FaceCenter { face, x, .. } | Snap::CircleCenter { face, x, .. } = self {
@@ -113,9 +100,6 @@ 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
}
@@ -136,33 +120,11 @@ 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 {
@@ -174,7 +136,6 @@ 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(),
}
}
}
@@ -558,7 +519,6 @@ 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)
@@ -1588,10 +1548,6 @@ 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;
-193
View File
@@ -1,193 +0,0 @@
//! 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,27 +233,3 @@ 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:?}"
);
}
-108
View File
@@ -1,108 +0,0 @@
//! 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}");
}
@@ -1,96 +0,0 @@
//! 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);
}
+5 -18
View File
@@ -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) | 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",
"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",
),
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|edge|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|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", "edge", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
let pick = ["face", "circle", "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, edge, point or frame [x, y, z], or a face's name")),
_ => return Err(bad(cmd, "a joint origin needs face, circle, 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,10 +304,6 @@ 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();
@@ -377,11 +373,8 @@ 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(", "))));
}
}
let m = joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
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)
}
@@ -1323,16 +1316,10 @@ 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)]
-6
View File
@@ -461,12 +461,6 @@ 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]
+46 -275
View File
@@ -6,12 +6,10 @@
//! 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. 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.
//! (components flattened with their transforms), converted to millimetres. Packages are
//! untrusted: sizes, entry counts, mesh sizes and component nesting are capped.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::{Cursor, Read, Write};
@@ -352,21 +350,13 @@ 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 = 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 {
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 {
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()));
@@ -376,7 +366,7 @@ fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str)
if out.len() as u64 > MAX_PART {
return bad(format!("{name} expands to more than 1 GB"));
}
Ok(out)
Ok(Some(out))
}
fn attrs(e: &BytesStart) -> HashMap<String, String> {
@@ -411,14 +401,6 @@ 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])
}
@@ -435,37 +417,22 @@ 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<Ref>,
components: Vec<(u32, Xf)>,
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<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>,
build: Vec<(u32, Xf)>,
warnings: Vec<String>,
}
fn color(s: &str) -> Option<[f32; 3]> {
@@ -573,7 +540,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) * m.unit);
o.positions.push(Vec3::new(x, y, z));
}
b"triangle" => {
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
@@ -585,12 +552,22 @@ 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(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
o.components.push((target, parse_xf(a.get("transform"))?));
}
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(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
m.build.push((target, parse_xf(a.get("transform"))?));
}
_ => {}
}
@@ -602,58 +579,15 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
Ok(m)
}
/// 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)) {
/// 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) {
return bad("components nested too deeply or recursive");
}
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));
let o = m.objects.get(&id).ok_or_else(|| IoError::Invalid(format!("3MF: missing object {id}")))?;
*total = total.saturating_add(o.triangles.len());
if *total > MAX_ELEMENTS {
return bad("too many triangles or placements after placing components");
return bad("too many triangles after placing components");
}
let n = o.positions.len();
// One object can be placed by thousands of build items and components, and each placement
@@ -674,7 +608,7 @@ fn flatten<'a>(
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));
out.positions.push(apply(xf, *p) * m.unit);
}
// Vertices keep their relative order, so a mesh whose triangles use all of them is
// numbered exactly as it was in the file.
@@ -693,9 +627,9 @@ fn flatten<'a>(
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((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.push(id);
for (c, cx) in &o.components {
flatten(m, *c, &compose(cx, xf), out, stack, total)?;
}
stack.pop();
Ok(())
@@ -733,44 +667,15 @@ 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 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 m = parse_model(&xml)?;
let mut out = Vec::new();
let mut warnings = Vec::new();
let mut warnings = m.warnings.clone();
let mut total = 0usize;
for (k, item) in model.build.iter().enumerate() {
let part = item.part.as_deref().unwrap_or(&pkg.root);
for (k, (id, xf)) in m.build.iter().enumerate() {
let mut mo = MeshObject::default();
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
match flatten(&m, *id, xf, &mut mo, &mut Vec::new(), &mut total) {
Ok(()) if !mo.triangles.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());
let name = m.objects.get(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);
}
@@ -785,7 +690,7 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
}
#[cfg(test)]
pub(crate) mod tests {
mod tests {
use super::*;
fn tetra(name: &str) -> MeshObject {
@@ -813,135 +718,18 @@ pub(crate) mod tests {
assert_eq!(model.matches("p1=\"1\"").count(), 1, "{model}");
}
/// A package of these parts (name, contents).
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (name, data) in parts {
zip.start_file(*name, o).unwrap();
zip.write_all(data.as_bytes()).unwrap();
if let Some(r) = rels {
zip.start_file("_rels/.rels", o).unwrap();
zip.write_all(r.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\"");
@@ -1049,23 +837,6 @@ pub(crate) 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() {
+3 -69
View File
@@ -498,24 +498,14 @@ 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();
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)
egui::Panel::left("sc_browser")
.exact_size(250.0)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6)))
.show(ui, |ui| {
@@ -558,52 +548,6 @@ 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);
}
@@ -667,19 +611,9 @@ 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, shrink_height])
.auto_shrink([false, false])
.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;
+4 -4
View File
@@ -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(crate::i18n::tr(if stale { "dialog.compute_again" } else { "dialog.compute" })).clicked() {
if ui.button(if stale { "Compute again" } else { "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": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
_ => return (json!({ "error": "the clearance must be a length of 0 or more" }), 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(crate::i18n::format("dialog.bodies_count", &[("count", &r["bodies"].as_u64().unwrap_or(0).to_string())]));
ui.label(format!("{} bodies", r["bodies"].as_u64().unwrap_or(0)));
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(crate::i18n::tr("dialog.could_not_be_checked")).color(t.warning)).on_hover_text(u["reason"].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.end_row();
}
}
+1 -1
View File
@@ -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, "ui.sheet_gap");
row_label(ui, "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));
+11 -60
View File
@@ -111,31 +111,11 @@ 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(mut fonts) => {
if needs_cjk_font(language) {
promote_font_to_front(&mut fonts, CJK_FALLBACK);
}
Some(fonts) => {
ctx.set_fonts(fonts);
true
}
@@ -480,16 +460,9 @@ fn find_system_cjk_font() -> Option<Vec<u8>> {
#[cfg(target_os = "macos")]
{
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) {
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) {
return Some(bytes);
}
}
@@ -523,10 +496,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] {
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],
if language == "zh" {
[CHINESE_FALLBACK, CJK_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]
} else {
[CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK]
}
}
@@ -546,8 +519,7 @@ 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);
}
let order = fallback_order(language);
for name in order {
for name in fallback_order(language) {
let bytes = match name {
CJK_FALLBACK => cjk.clone(),
CHINESE_FALLBACK => chinese.clone(),
@@ -562,15 +534,6 @@ 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);
}
}
@@ -661,8 +624,7 @@ 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]);
assert_eq!(fallback_order("ko"), [HANGUL_FALLBACK, CJK_FALLBACK, CHINESE_FALLBACK, THAI_FALLBACK]);
for lang in ["en", "ja", "de"] {
for lang in ["en", "ja", "ko", "de"] {
assert_eq!(
fallback_order(lang),
[CJK_FALLBACK, CHINESE_FALLBACK, HANGUL_FALLBACK, THAI_FALLBACK],
@@ -703,16 +665,6 @@ 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();
@@ -803,11 +755,10 @@ mod tests {
let ctx = egui::Context::default();
let run = |text: &'static str| {
let mut seen = false;
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label(text);
seen = painted_cjk(ui.ctx());
});
out.textures_delta.clear();
seen
};
assert!(!run("Untitled"));
-31
View File
@@ -290,34 +290,3 @@ 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}");
}
}
+1 -8
View File
@@ -1387,9 +1387,7 @@ impl SolveApp {
timeline::timeline(self, ui);
}
palette::palette_bar(self, ui);
// 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 self.ui.show_browser {
if left_timeline {
browser::browser_below_timeline(self, ui);
} else {
@@ -1403,11 +1401,6 @@ 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);
-19
View File
@@ -251,10 +251,6 @@ 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 {
@@ -322,12 +318,6 @@ 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).
@@ -450,8 +440,6 @@ 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,
@@ -707,8 +695,6 @@ 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;
@@ -1233,11 +1219,6 @@ 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 {
+2 -3
View File
@@ -106,9 +106,8 @@ fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
let tk = crate::theme::Tokens::get();
let [smooth, jump, sharp, gap] = tk.continuity;
// 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 tr = crate::i18n::tr_label;
let rows = [(tr("G2 curvature"), smooth), (tr("G1 tangent"), jump), (tr("G0 crease"), sharp), (tr("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);
-32
View File
@@ -957,38 +957,6 @@ 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();
-10
View File
@@ -28,16 +28,6 @@ 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:
-4
View File
@@ -34,10 +34,6 @@ 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
-69
View File
@@ -1,69 +0,0 @@
# 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.
+1 -1
View File
@@ -20,7 +20,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
}
location ~ "-[0-9a-f]{8,16}(_bg)?\.(js|wasm|css)$" {
location ~ "-[0-9a-f]{16}(_bg)?\.(js|wasm|css)$" {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
}
+1 -1
View File
@@ -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]\{8,16\}_bg\.wasm' | head -n1)"
WASM="$(curl -fs "$BASE/" | grep -o 'solidcraft-web-[0-9a-f]*_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"
+1 -12
View File
@@ -47,18 +47,7 @@ for ((attempt=0; attempt<300; attempt++)); do
sleep .1
done
test "$ready" = 1
# 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
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
printf '%s\n' "$request" >&3
IFS= read -r -t 30 reply <&3
printf '%s\n' "$reply"