mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 03:25:58 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43f6ee6a98 | |||
| 0690ee5875 | |||
| 1dd921fc52 | |||
| 1d387d3f21 | |||
| 51d237829a | |||
| df4f9d3529 | |||
| ef2f485e15 | |||
| a43cd99e55 | |||
| 8f26457487 | |||
| 07ef94e102 | |||
| 02f276e296 | |||
| 9602587b0d |
@@ -1,6 +1,6 @@
|
||||
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
|
||||
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
|
||||
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
|
||||
# answer (CONTRIBUTING.md, "Kernel work goes to solidcore"). The logic is in scripts/kernel-paused/ (tested
|
||||
# in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
|
||||
|
||||
## Start here
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
|
||||
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see.
|
||||
|
||||
## Hard rules (what enforces each)
|
||||
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
|
||||
@@ -11,7 +11,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
|
||||
- New kernel cases go to solidcore: `cargo xtask lists`.
|
||||
- Kernel ops and math go to solidcore; features call its API: `cargo xtask lists`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
@@ -31,7 +31,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
## Working on X: read the skill first
|
||||
| Working on | Read |
|
||||
|---|---|
|
||||
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| A feature, PR, claim, review, CI, evidence | `skills/solidcraft-contributing/SKILL.md` |
|
||||
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
|
||||
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
|
||||
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
|
||||
|
||||
@@ -317,7 +317,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
|
||||
|
||||
+27
-12
@@ -17,15 +17,27 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work is paused
|
||||
## Kernel work goes to solidcore
|
||||
|
||||
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
|
||||
change `crates/kernel/`, `crates/geom/` or `vendor/truck*`, or issues and discussions about kernel
|
||||
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
|
||||
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
|
||||
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
|
||||
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
|
||||
including the dialogs and tools for these features.
|
||||
Since 0.40, SolidCraft's geometry kernel is [solidcore](https://github.com/tangency-labs/solidcore),
|
||||
and the kernel operations still in this repository are migrating there. The split:
|
||||
|
||||
- **SolidCraft** is the application: commands, the timeline, sketches, the UI, file handling. A new
|
||||
feature calls the solidcore API and builds on the operations it offers.
|
||||
- **solidcore** holds the geometry: evaluators, intersections, booleans, blends, shells, sweeps,
|
||||
tessellation and the rest of the math. If a feature needs kernel functionality solidcore does not
|
||||
have yet, add it to solidcore first (its own issue and PR), then use it here.
|
||||
|
||||
So pull requests that add geometry math to SolidCraft (`crates/kernel/`, `crates/geom/`, or a new
|
||||
evaluator or solver anywhere else) are not taken; they get the `kernel-paused` label
|
||||
(`.github/workflows/kernel-paused.yml`) and a maintainer points them to solidcore. Crashes and data
|
||||
loss are still taken as issues here, and the dialogs and tools for kernel features carry on as
|
||||
normal.
|
||||
|
||||
Work started before 0.40 was written against the earlier kernel. It is still welcome: port it onto
|
||||
today's code, calling solidcore for the geometry, before it can land. Expect a substantial rework
|
||||
for anything that touched the kernel. Designs keep opening and rebuilding on the new kernel; where a
|
||||
result changes, the PR says so and why.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -277,7 +289,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
|
||||
change's file, or any `v<N>/` folder: those are fixed once released.
|
||||
|
||||
That's all. The build counts the files: while any are pending it writes the next format number,
|
||||
which no release reads, so a release warns about a design that has your change and keeps what it can't read of it.
|
||||
which no release reads, so a release refuses a design that has your change instead of losing it.
|
||||
A file also lists the pending changes it has (`format_pending`), so a later development build
|
||||
runs only the steps that file is missing. Two pull requests that each add a change merge without
|
||||
a conflict. `crates/doc/src/format/pending/README.md` has an example.
|
||||
@@ -329,8 +341,11 @@ and a fresh CI run:
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
bigger than a bug fix that has no issue yet, discuss it on [Discord](https://discord.gg/FtU4NQNVDH)
|
||||
first, then open an issue. Before you start, search **all** issues and pull requests, open and
|
||||
closed, for similar work (`gh issue list -R tangency-labs/solidcraft --state all --search "<words>"`,
|
||||
and the same with `gh pr list`). **Claim the issue with a draft PR** before you start, so two
|
||||
people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
@@ -443,7 +458,7 @@ correct them freely and it won't put them back.
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; the kernel is [solidcore](https://github.com/tangency-labs/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
|
||||
|
||||
@@ -318,19 +318,6 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
|
||||
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
|
||||
/// the session keeps.
|
||||
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
|
||||
let s = read_session(path, check, validation)?;
|
||||
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
|
||||
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
|
||||
eprintln!("{path}: warning: {warning}");
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn read_session(
|
||||
path: &str,
|
||||
check: solidcraft_engine::schema::ParamCheck,
|
||||
validation: solidcraft_engine::kernel::Validation,
|
||||
) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
s.param_check = check;
|
||||
s.set_validation(validation);
|
||||
@@ -592,7 +579,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut out = json!({
|
||||
let out = json!({
|
||||
"body_count": m["body_count"],
|
||||
"bodies": m["bodies"],
|
||||
"total": m["total"],
|
||||
@@ -600,10 +587,6 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
"params": d["params"],
|
||||
"sketches": d["sketches"],
|
||||
});
|
||||
if let Some(file) = &s.doc.newer_file {
|
||||
out["newer_file"] = file.report();
|
||||
out["warnings"] = json!(file.warnings());
|
||||
}
|
||||
print_json(&out, json_out)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
|
||||
}
|
||||
|
||||
/// #1369: a pending concept that did not land in the numbered release must not be silently
|
||||
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
|
||||
/// dropped on open/resave, in either encoding or before replacing an existing destination.
|
||||
#[test]
|
||||
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
|
||||
use solidcraft_engine::{Session, doc, io};
|
||||
@@ -173,29 +173,34 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
|
||||
let source = dir.0.join(format!("experimental-{version}.{extension}"));
|
||||
std::fs::write(&source, &bytes).unwrap();
|
||||
let mut session = Session::default();
|
||||
// It opens, and the reply says what it is.
|
||||
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
|
||||
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
|
||||
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
|
||||
assert_eq!(reply["warnings"][0], notice.as_str());
|
||||
assert_eq!(session.doc.name, "Experimental");
|
||||
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
|
||||
let original = session.doc.clone();
|
||||
let original_path = session.path.clone();
|
||||
let undo = session.undo.len();
|
||||
let dirty = session.is_dirty();
|
||||
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
|
||||
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
|
||||
assert_eq!(session.doc, original);
|
||||
assert_eq!(session.path, original_path);
|
||||
assert_eq!(session.undo.len(), undo);
|
||||
assert_eq!(session.is_dirty(), dirty);
|
||||
|
||||
for existing in [false, true] {
|
||||
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
|
||||
let backup = io::backup_path(&destination);
|
||||
if existing {
|
||||
std::fs::write(&destination, b"existing design").unwrap();
|
||||
std::fs::write(&backup, b"existing backup").unwrap();
|
||||
}
|
||||
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(0), "{error}");
|
||||
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
|
||||
// Saved, it still says it has the change, and opens the same way.
|
||||
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
|
||||
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
|
||||
assert!(saved.newer_file.is_some());
|
||||
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
|
||||
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
|
||||
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
|
||||
} else {
|
||||
assert!(!destination.exists() && !backup.exists());
|
||||
}
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
@@ -203,43 +208,6 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
|
||||
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
|
||||
#[test]
|
||||
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
|
||||
use solidcraft_engine::{Session, doc, io};
|
||||
|
||||
let dir = Scratch::new();
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
|
||||
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
|
||||
let newer = doc::format::format_string(doc::format::FORMAT + 1);
|
||||
file["format"] = json!(newer);
|
||||
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
|
||||
file["features"].as_array_mut().unwrap().push(future.clone());
|
||||
let source = dir.0.join("newer.json");
|
||||
std::fs::write(&source, file.to_string()).unwrap();
|
||||
|
||||
let output = cli().args(["eval"]).arg(&source).output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(0), "{error}");
|
||||
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["body_count"], 1);
|
||||
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
|
||||
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
|
||||
|
||||
for extension in ["json", "sc"] {
|
||||
let destination = dir.0.join(format!("saved.{extension}"));
|
||||
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
|
||||
assert_eq!(saved.format, newer, "never a lower number");
|
||||
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
|
||||
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
|
||||
use solidcraft_engine::{doc, io};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
//! `?webgpu`), `?sample` opens the sample part, `?embed=editor` runs it as an editing embed in a
|
||||
//! host page (no application bar or start page; nothing kept in the browser; #8517).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -42,6 +43,8 @@ fn query_flag(query: &str, name: &str) -> bool {
|
||||
struct Launch {
|
||||
open: Option<String>,
|
||||
embed: bool,
|
||||
/// `?embed=editor` (#8517): an editing embed in a host page that keeps the files itself.
|
||||
editor: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -52,16 +55,19 @@ impl Launch {
|
||||
for (key, value) in url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"open" if launch.open.is_none() => launch.open = Some(value.into_owned()),
|
||||
"embed" if value == "editor" => launch.editor = true,
|
||||
"embed" => embed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
launch.embed = embed && launch.open.is_some();
|
||||
launch.embed = embed && launch.open.is_some() && !launch.editor;
|
||||
launch
|
||||
}
|
||||
|
||||
/// The browser's own storage (designs, autosave, recovery, preferences) is used: not when
|
||||
/// embedded, where the host keeps what is kept.
|
||||
fn persists(&self) -> bool {
|
||||
!self.embed
|
||||
!self.embed && !self.editor
|
||||
}
|
||||
|
||||
fn full_app_url(&self, mut url: url::Url) -> String {
|
||||
@@ -928,7 +934,7 @@ mod web {
|
||||
}
|
||||
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
|
||||
let (restorable, restorer) = (files.clone(), files.clone());
|
||||
let services = if launch.embed { Services::default() } else { Services {
|
||||
let services = if !launch.persists() { Services::default() } else { Services {
|
||||
// Open shows the browser's open dialog for a design where it has one
|
||||
// (the file keeps its handle, so Save writes back to it), else the
|
||||
// browser's files window. A command that reads another kind of file
|
||||
@@ -972,6 +978,7 @@ mod web {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
|
||||
app.embedded = launch.embed;
|
||||
app.embed_editor = launch.editor;
|
||||
// Listening before reading: settings another tab writes from now on are
|
||||
// newer than those read here.
|
||||
let other_tab_prefs = PrefsInbox::default();
|
||||
@@ -1082,6 +1089,17 @@ mod tests {
|
||||
assert!(full.fragment().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_editor_needs_no_design_link_and_keeps_nothing_in_the_browser() {
|
||||
let editor = Launch::parse("?embed=editor");
|
||||
assert!(editor.editor && !editor.embed && !editor.persists());
|
||||
let linked = Launch::parse("?embed=editor&open=https://example.org/a.sc");
|
||||
assert!(linked.editor && !linked.embed, "an editing embed may open a linked design, still editable");
|
||||
let viewer = Launch::parse("?embed=viewer&open=https://example.org/a.sc");
|
||||
assert!(viewer.embed && !viewer.editor, "any other value is the viewer embed, as before");
|
||||
assert!(!Launch::parse("?other=editor").editor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
|
||||
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
|
||||
|
||||
@@ -60,7 +60,6 @@ circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
colored_components false
|
||||
colored_components_off false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disable component colors</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "تحليل العرض",
|
||||
"cmd.inspect_center_of_mass": "مركز الكتلة",
|
||||
"cmd.inspect_clearance": "الخلوص",
|
||||
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components": "ألوان المكوّنات",
|
||||
"cmd.inspect_compare": "مقارنة الأجسام",
|
||||
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
|
||||
"cmd.inspect_continuity": "تحليل الاستمرارية",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا",
|
||||
"nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع السحب العمودي بالأوسط يكبّره أو يصغّره (السحب لأسفل للتكبير)؛ وتدوير العجلة يكبّره أو يصغّره",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "يستمر السحب بالزر الأوسط أو الأيمن في التدوير أو التحريك أو التكبير كما بدأ حتى تحرير الزر، مهما ضُغطت المفاتيح أو حُرّرت في أثناء ذلك. عند الإيقاف: تقرر المفاتيح المضغوطة في كل لحظة من السحب.",
|
||||
"nav.drag_keeps_mode.label": "يحتفظ السحب بطريقة التنقل التي بدأ بها",
|
||||
"nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.",
|
||||
"nav.free_orbit.label": "تدوير حر",
|
||||
"nav.free_orbit.snap_help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. اضغط مطولًا على Shift أثناء سحب التدوير لالتقاط اتجاه العرض واللف.",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
|
||||
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
|
||||
"ui.report_a_bug": "الإبلاغ عن خطأ",
|
||||
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
|
||||
"ui.report_characters_left_other": "الأحرف المتبقية: {count}",
|
||||
"ui.reset": "إعادة تعيين",
|
||||
"ui.reset_all": "إعادة تعيين الكل",
|
||||
"ui.reset_the_settings_on_this_page": "إعادة تعيين إعدادات هذه الصفحة",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Centar mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Uporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Uporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analýza přístupnosti",
|
||||
"cmd.inspect_center_of_mass": "Těžiště",
|
||||
"cmd.inspect_clearance": "Vůle",
|
||||
"cmd.inspect_colored_components": "Zapnout barvy komponent",
|
||||
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
|
||||
"cmd.inspect_compare": "Porovnat tělesa",
|
||||
"cmd.inspect_compare_picture": "Porovnat obrázek",
|
||||
"cmd.inspect_continuity": "Analýza spojitosti",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massecentrum",
|
||||
"cmd.inspect_clearance": "Frigangskontrol",
|
||||
"cmd.inspect_colored_components": "Aktivér komponentfarver",
|
||||
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
|
||||
"cmd.inspect_compare": "Sammenlign emner",
|
||||
"cmd.inspect_compare_picture": "Sammenlign billede",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -960,8 +960,6 @@
|
||||
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
|
||||
"cmd.inspect_clearance": "Freiraum",
|
||||
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
|
||||
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
|
||||
"cmd.inspect_compare": "Körper vergleichen",
|
||||
"cmd.inspect_continuity": "Kontinuitätsprüfung",
|
||||
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
|
||||
@@ -2021,6 +2019,8 @@
|
||||
"nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert",
|
||||
"nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "Ein Ziehen mit der mittleren oder rechten Taste dreht, verschiebt oder zoomt weiter wie begonnen, bis die Taste losgelassen wird, egal welche Tasten dabei gedrückt oder losgelassen werden. Aus: Die gehaltenen Tasten entscheiden in jedem Moment des Ziehens.",
|
||||
"nav.drag_keeps_mode.label": "Ziehen behält die Navigation bei, mit der es begann",
|
||||
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
|
||||
"nav.free_orbit.label": "Freie Drehung",
|
||||
"nav.free_orbit.snap_help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollausrichtung bei. Halten Sie beim Ziehen zum Drehen die Umschalttaste gedrückt, um Blickrichtung und Rollausrichtung einrasten zu lassen.",
|
||||
@@ -5305,6 +5305,8 @@
|
||||
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.report_characters_left_one": "{count} Zeichen übrig",
|
||||
"ui.report_characters_left_other": "{count} Zeichen übrig",
|
||||
"ui.reset": "Zurücksetzen",
|
||||
"ui.reset_all": "Alle zurücksetzen",
|
||||
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"cmd.component_cycle_color": "Cycle Component Colour",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.inspect_colored_components": "Enable Component Colours",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colours",
|
||||
"cmd.inspect_colored_components": "Component Colours",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.render_aging_describe": "Layered Surface Aging",
|
||||
"cmd.render_effects_describe": "Lens and Creative Effects",
|
||||
|
||||
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "View Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_clearance": "Clearance",
|
||||
"cmd.inspect_colored_components": "Enable Component Colors",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colors",
|
||||
"cmd.inspect_colored_components": "Component Colors",
|
||||
"cmd.inspect_compare": "Compare Bodies",
|
||||
"cmd.inspect_compare_picture": "Compare Picture",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
|
||||
"nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "A middle or right drag goes on orbiting, panning or zooming as it began until the button is released, whatever keys are pressed or let go meanwhile. Off: the keys held decide at every moment of the drag.",
|
||||
"nav.drag_keeps_mode.label": "A drag keeps the navigation it started with",
|
||||
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
|
||||
"nav.free_orbit.label": "Free orbit",
|
||||
"nav.free_orbit.snap_help": "Mouse and 3D mouse rotate through both poles and preserve roll. Hold Shift during an orbit drag to snap the view direction and roll.",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.report_characters_left_one": "{count} character left",
|
||||
"ui.report_characters_left_other": "{count} characters left",
|
||||
"ui.reset": "Reset",
|
||||
"ui.reset_all": "Reset All",
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
|
||||
@@ -963,8 +963,6 @@
|
||||
"cmd.inspect_analysis": "Análisis de vista",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Holgura",
|
||||
"cmd.inspect_colored_components": "Activar colores de componente",
|
||||
"cmd.inspect_colored_components_disable": "Desactivar colores de componente",
|
||||
"cmd.inspect_compare": "Comparar cuerpos",
|
||||
"cmd.inspect_compare_picture": "Comparar imagen",
|
||||
"cmd.inspect_continuity": "Análisis de continuidad",
|
||||
|
||||
@@ -964,8 +964,6 @@
|
||||
"cmd.inspect_analysis": "Näytä analyysi",
|
||||
"cmd.inspect_center_of_mass": "Massakeskipiste",
|
||||
"cmd.inspect_clearance": "Välys",
|
||||
"cmd.inspect_colored_components": "Ota komponenttivärit käyttöön",
|
||||
"cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä",
|
||||
"cmd.inspect_compare": "Vertaa kappaleita",
|
||||
"cmd.inspect_compare_picture": "Vertaa kuvaa",
|
||||
"cmd.inspect_continuity": "Jatkuvuusanalyysi",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_colored_components": "Activer les couleurs des composants",
|
||||
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_compare_picture": "Comparer les images",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Análise de accesibilidade",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Folga",
|
||||
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
|
||||
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
|
||||
"cmd.inspect_compare": "Comparar corpos",
|
||||
"cmd.inspect_compare_picture": "Comparar a imaxe",
|
||||
"cmd.inspect_continuity": "Análise de continuidade",
|
||||
|
||||
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "ניתוח תצוגה",
|
||||
"cmd.inspect_center_of_mass": "מרכז מסה",
|
||||
"cmd.inspect_clearance": "מרווח",
|
||||
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
|
||||
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
|
||||
"cmd.inspect_colored_components": "צבעי רכיבים",
|
||||
"cmd.inspect_compare": "השוואת גופים",
|
||||
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
|
||||
"cmd.inspect_continuity": "ניתוח רציפות",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "גרירה אמצעית מסובבת, Shift וגרירה אמצעית מזיזים, Ctrl וגרירה אמצעית מבצעים זום; גרירה ימנית לא מזיזה את המצלמה",
|
||||
"nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "גרירה בכפתור האמצעי או הימני ממשיכה לסובב, להזיז או לשנות זום כפי שהתחילה עד שמשחררים את הכפתור, בלי קשר למקשים שנלחצים או משתחררים בינתיים. כבוי: המקשים הלחוצים קובעים בכל רגע של הגרירה.",
|
||||
"nav.drag_keeps_mode.label": "גרירה שומרת על הניווט שבו התחילה",
|
||||
"nav.free_orbit.help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. הצמדת הצירים עם Shift מושבתת.",
|
||||
"nav.free_orbit.label": "סיבוב חופשי",
|
||||
"nav.free_orbit.snap_help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. יש להחזיק את Shift בזמן גרירת סיבוב כדי להצמיד את כיוון התצוגה ואת הגלגול.",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
|
||||
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
|
||||
"ui.report_a_bug": "דיווח על באג",
|
||||
"ui.report_characters_left_one": "תווים שנותרו: {count}",
|
||||
"ui.report_characters_left_other": "תווים שנותרו: {count}",
|
||||
"ui.reset": "איפוס",
|
||||
"ui.reset_all": "איפוס הכול",
|
||||
"ui.reset_the_settings_on_this_page": "איפוס ההגדרות בדף זה",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Središte mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Usporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Usporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
|
||||
"cmd.inspect_center_of_mass": "Tömegközéppont",
|
||||
"cmd.inspect_clearance": "Hézag",
|
||||
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
|
||||
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
|
||||
"cmd.inspect_compare": "Testek összehasonlítása",
|
||||
"cmd.inspect_compare_picture": "Kép összehasonlítása",
|
||||
"cmd.inspect_continuity": "Folytonosságelemzés",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
|
||||
"cmd.inspect_center_of_mass": "Pusat Massa",
|
||||
"cmd.inspect_clearance": "Kelonggaran",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Bodi",
|
||||
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
|
||||
"cmd.inspect_continuity": "Analisis Kontinuitas",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisi di accessibilità",
|
||||
"cmd.inspect_center_of_mass": "Centro di massa",
|
||||
"cmd.inspect_clearance": "Distanza libera",
|
||||
"cmd.inspect_colored_components": "Attiva colori dei componenti",
|
||||
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
|
||||
"cmd.inspect_compare": "Confronta corpi",
|
||||
"cmd.inspect_compare_picture": "Confronta immagine",
|
||||
"cmd.inspect_continuity": "Analisi di continuità",
|
||||
|
||||
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "表示解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_clearance": "クリアランス",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
|
||||
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分け",
|
||||
"cmd.inspect_compare": "ボディを比較",
|
||||
"cmd.inspect_compare_picture": "参考画像と比較",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。",
|
||||
"nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "中ボタンまたは右ボタンのドラッグは、途中でキーを押したり離したりしても、ボタンを離すまで開始時の回転・パン・ズームを続けます。オフにすると、ドラッグ中の各時点で押しているキーによって決まります。",
|
||||
"nav.drag_keeps_mode.label": "ドラッグは開始時のナビゲーションを維持",
|
||||
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
|
||||
"nav.free_orbit.label": "自由回転",
|
||||
"nav.free_orbit.snap_help": "マウスと3Dマウスで両極を越えて回転でき、ロールも保持されます。オービットのドラッグ中にShiftを押すと、視線方向とロールがスナップします。",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.report_characters_left_one": "残り {count} 文字",
|
||||
"ui.report_characters_left_other": "残り {count} 文字",
|
||||
"ui.reset": "リセット",
|
||||
"ui.reset_all": "すべてリセット",
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "ანალიზის ნახვა",
|
||||
"cmd.inspect_center_of_mass": "მასის ცენტრი",
|
||||
"cmd.inspect_clearance": "ღრეჩო",
|
||||
"cmd.inspect_colored_components": "კომპონენტების ფერების ჩართვა",
|
||||
"cmd.inspect_colored_components_disable": "კომპონენტების ფერების გამორთვა",
|
||||
"cmd.inspect_colored_components": "კომპონენტების ფერები",
|
||||
"cmd.inspect_compare": "სხეულების შედარება",
|
||||
"cmd.inspect_compare_picture": "სურათთან შედარება",
|
||||
"cmd.inspect_continuity": "უწყვეტობის ანალიზი",
|
||||
|
||||
@@ -1465,6 +1465,8 @@
|
||||
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
|
||||
"nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "가운데 또는 오른쪽 버튼 드래그는 도중에 키를 누르거나 떼어도 버튼을 놓을 때까지 시작할 때의 회전, 이동 또는 확대/축소를 계속합니다. 끄면 드래그하는 동안 누르고 있는 키가 매 순간 결정합니다.",
|
||||
"nav.drag_keeps_mode.label": "드래그는 시작할 때의 탐색 방식을 유지",
|
||||
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
|
||||
"nav.free_orbit.label": "자유 회전",
|
||||
"nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
|
||||
"cmd.inspect_center_of_mass": "Pusat Jisim",
|
||||
"cmd.inspect_clearance": "Kelegaan",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Jasad",
|
||||
"cmd.inspect_compare_picture": "Banding dengan Imej",
|
||||
"cmd.inspect_continuity": "Analisis Keselanjaran",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massesenter",
|
||||
"cmd.inspect_clearance": "Klaring",
|
||||
"cmd.inspect_colored_components": "Aktiver komponentfarger",
|
||||
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
|
||||
"cmd.inspect_compare": "Sammenlign kropper",
|
||||
"cmd.inspect_compare_picture": "Sammenlign bilde",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Zwaartepunt",
|
||||
"cmd.inspect_clearance": "Speling",
|
||||
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
|
||||
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
|
||||
"cmd.inspect_compare": "Lichamen vergelijken",
|
||||
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
|
||||
"cmd.inspect_continuity": "Continuïteitsanalyse",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.",
|
||||
"document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.",
|
||||
"document.newer_file_not_read": "Not read: {what}. Saving keeps it."
|
||||
}
|
||||
}
|
||||
@@ -934,8 +934,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostępności",
|
||||
"cmd.inspect_center_of_mass": "Środek masy",
|
||||
"cmd.inspect_clearance": "Prześwit",
|
||||
"cmd.inspect_colored_components": "Włącz kolory komponentów",
|
||||
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
|
||||
"cmd.inspect_compare": "Porównaj bryły",
|
||||
"cmd.inspect_continuity": "Analiza ciągłości",
|
||||
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza accesibilității",
|
||||
"cmd.inspect_center_of_mass": "Centru de masă",
|
||||
"cmd.inspect_clearance": "Joc",
|
||||
"cmd.inspect_colored_components": "Activează culorile componentelor",
|
||||
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
|
||||
"cmd.inspect_compare": "Compară corpurile",
|
||||
"cmd.inspect_compare_picture": "Compară imaginea",
|
||||
"cmd.inspect_continuity": "Analiza continuității",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Анализ доступности",
|
||||
"cmd.inspect_center_of_mass": "Центр масс",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_colored_components": "Включить цвета компонентов",
|
||||
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
|
||||
"cmd.inspect_compare": "Сравнить тела",
|
||||
"cmd.inspect_compare_picture": "Сравнить с изображением",
|
||||
"cmd.inspect_continuity": "Анализ непрерывности",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Анализа доступности",
|
||||
"cmd.inspect_center_of_mass": "Центар масе",
|
||||
"cmd.inspect_clearance": "Провера зазора",
|
||||
"cmd.inspect_colored_components": "Омогући боје компоненти",
|
||||
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
|
||||
"cmd.inspect_compare": "Упореди тела",
|
||||
"cmd.inspect_compare_picture": "Упореди слику",
|
||||
"cmd.inspect_continuity": "Анализа континуитета",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
|
||||
"cmd.inspect_center_of_mass": "Masscentrum",
|
||||
"cmd.inspect_clearance": "Frigång",
|
||||
"cmd.inspect_colored_components": "Aktivera komponentfärger",
|
||||
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
|
||||
"cmd.inspect_compare": "Jämför kroppar",
|
||||
"cmd.inspect_compare_picture": "Jämför bild",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalys",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
|
||||
"cmd.inspect_center_of_mass": "Kütle merkezi",
|
||||
"cmd.inspect_clearance": "Açıklık",
|
||||
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
|
||||
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
|
||||
"cmd.inspect_compare": "Gövdeleri karşılaştır",
|
||||
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
|
||||
"cmd.inspect_continuity": "Süreklilik kontrolü",
|
||||
|
||||
@@ -963,8 +963,6 @@
|
||||
"cmd.inspect_analysis": "Перегляд аналізу",
|
||||
"cmd.inspect_center_of_mass": "Центр мас",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_colored_components": "Увімкнути кольори компонентів",
|
||||
"cmd.inspect_colored_components_disable": "Вимкнути кольори компонентів",
|
||||
"cmd.inspect_compare": "Порівняти тіла",
|
||||
"cmd.inspect_compare_picture": "Порівняти зображення",
|
||||
"cmd.inspect_continuity": "Аналіз неперервності",
|
||||
|
||||
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "Phân tích khung nhìn",
|
||||
"cmd.inspect_center_of_mass": "Trọng tâm",
|
||||
"cmd.inspect_clearance": "Khoảng cách an toàn",
|
||||
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components": "Màu cụm chi tiết",
|
||||
"cmd.inspect_compare": "So sánh các thân khối",
|
||||
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
|
||||
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để trượt, Ctrl+chuột giữa để thu phóng; kéo chuột phải giữ nguyên camera",
|
||||
"nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "Kéo bằng nút giữa hoặc nút phải sẽ tiếp tục xoay, di chuyển hoặc thu phóng như lúc bắt đầu cho đến khi nhả nút, dù trong lúc đó có nhấn hay nhả phím nào. Tắt: các phím đang giữ quyết định ở mọi thời điểm của thao tác kéo.",
|
||||
"nav.drag_keeps_mode.label": "Thao tác kéo giữ nguyên kiểu điều hướng lúc bắt đầu",
|
||||
"nav.free_orbit.help": "Chuột và chuột 3D xoay tự do qua cả hai cực và giữ nguyên độ lăn nghiêng (Roll). Bắt dính trục bằng phím Shift sẽ bị tắt.",
|
||||
"nav.free_orbit.label": "Xoay góc nhìn tự do",
|
||||
"nav.free_orbit.snap_help": "Chuột và chuột 3D xoay qua cả hai cực và giữ nguyên góc nghiêng cuộn. Giữ Shift trong khi kéo xoay quỹ đạo để bắt dính hướng nhìn và góc nghiêng cuộn.",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
|
||||
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
|
||||
"ui.report_a_bug": "Báo cáo lỗi",
|
||||
"ui.report_characters_left_one": "Còn {count} ký tự",
|
||||
"ui.report_characters_left_other": "Còn {count} ký tự",
|
||||
"ui.reset": "Đặt lại",
|
||||
"ui.reset_all": "Đặt lại tất cả",
|
||||
"ui.reset_the_settings_on_this_page": "Đặt lại các cài đặt trên trang này",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "可達性分析",
|
||||
"cmd.inspect_center_of_mass": "質心",
|
||||
"cmd.inspect_clearance": "間隙",
|
||||
"cmd.inspect_colored_components": "啟用元件顏色",
|
||||
"cmd.inspect_colored_components_disable": "停用元件顏色",
|
||||
"cmd.inspect_compare": "比較實體",
|
||||
"cmd.inspect_compare_picture": "與圖片比較",
|
||||
"cmd.inspect_continuity": "連續性分析",
|
||||
|
||||
@@ -964,8 +964,7 @@
|
||||
"cmd.inspect_analysis": "视图分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_clearance": "间隙",
|
||||
"cmd.inspect_colored_components": "启用组件颜色",
|
||||
"cmd.inspect_colored_components_disable": "禁用组件颜色",
|
||||
"cmd.inspect_colored_components": "组件颜色",
|
||||
"cmd.inspect_compare": "比较实体",
|
||||
"cmd.inspect_compare_picture": "比较参考图",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
@@ -2040,6 +2039,8 @@
|
||||
"nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机",
|
||||
"nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放",
|
||||
"nav.creo.label": "Creo",
|
||||
"nav.drag_keeps_mode.help": "中键或右键拖动会保持开始时的旋转、平移或缩放,直到松开按键,期间按下或松开任何键都不会改变。关闭后:拖动的每一刻都由当时按住的键决定。",
|
||||
"nav.drag_keeps_mode.label": "拖动保持开始时的导航方式",
|
||||
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
|
||||
"nav.free_orbit.label": "自由旋转",
|
||||
"nav.free_orbit.snap_help": "鼠标和3D鼠标可越过两极旋转,并保留画面旋转角度。在拖动环绕时按住Shift,可吸附视图方向和画面旋转角度。",
|
||||
@@ -5364,6 +5365,8 @@
|
||||
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.report_characters_left_one": "剩余 {count} 个字符",
|
||||
"ui.report_characters_left_other": "剩余 {count} 个字符",
|
||||
"ui.reset": "重置",
|
||||
"ui.reset_all": "全部重置",
|
||||
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
|
||||
|
||||
@@ -366,6 +366,13 @@ fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
|
||||
return opaque_depth(i, front);
|
||||
}
|
||||
|
||||
// The virtual ground preserves the viewport background; only contact occlusion is blended.
|
||||
@fragment
|
||||
fn fs_ground_ao(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 1.0 - occlusion(i));
|
||||
}
|
||||
|
||||
/// The AO prepass's depth of an opaque surface's fragment.
|
||||
fn opaque_depth(i: TOut, front: bool) -> AoDepth {
|
||||
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
|
||||
|
||||
@@ -2,6 +2,46 @@
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A profile moved into the component receiving its own body is read through the recorded
|
||||
/// transfer frame. Reading its live placement would move the source geometry when that
|
||||
/// component moves, then move the transferred body a second time. All outputs of a constructor
|
||||
/// must reach the sketch's component through the same frame; independent cross-component
|
||||
/// references retain their ordinary live placement.
|
||||
pub(crate) fn profile_frame(doc: &Document, feature: &crate::Feature, sketch: u64) -> Option<Mat> {
|
||||
if !matches!(
|
||||
feature.kind,
|
||||
crate::FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
|
||||
| crate::FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
|
||||
) || feature.body_names.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let owner = doc.feature(sketch)?.component;
|
||||
if owner == feature.component {
|
||||
return None;
|
||||
}
|
||||
let mut frames: std::collections::BTreeMap<&str, (u64, Mat)> =
|
||||
feature.body_names.iter().map(|name| (name.as_str(), (feature.component, crate::IDENTITY))).collect();
|
||||
let start = doc.feature_index(feature.id)?.saturating_add(1);
|
||||
for step in doc.features.iter().take(doc.marker.unwrap_or(usize::MAX)).skip(start).filter(|step| !step.suppressed) {
|
||||
let crate::FeatureKind::BodyTransfer { bodies, from, to, transform } = &step.kind else { continue };
|
||||
for name in bodies {
|
||||
if let Some((component, frame)) = frames.get_mut(name.as_ref()) {
|
||||
if *component != *from || !valid_transfer_matrix(transform) {
|
||||
return None;
|
||||
}
|
||||
*component = *to;
|
||||
*frame = crate::mat_mul(transform, frame);
|
||||
}
|
||||
}
|
||||
if frames.values().all(|(component, _)| *component == owner) {
|
||||
let (_, first) = frames.values().next()?;
|
||||
return frames.values().all(|(_, frame)| frame == first).then(|| crate::mat_inverse(first)).flatten();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn valid_transfer_matrix(m: &Mat) -> bool {
|
||||
crate::is_placement(m)
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
}
|
||||
assert_eq!(legacy, current, "the migration preserves every prior field");
|
||||
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -659,12 +659,6 @@ impl AlignFrame {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FeatureKind {
|
||||
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
|
||||
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
|
||||
/// file as it came.
|
||||
NewerFileFeature {
|
||||
raw: crate::newer_file::RawFeature,
|
||||
},
|
||||
/// A recorded timeline ownership boundary. Geometry is converted from its
|
||||
/// current local frame into the destination component frame exactly once.
|
||||
BodyTransfer {
|
||||
@@ -2606,7 +2600,6 @@ impl FeatureKind {
|
||||
/// Short type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "ExtrudeFeature",
|
||||
@@ -2692,7 +2685,6 @@ impl FeatureKind {
|
||||
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
|
||||
pub fn kind_key(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
|
||||
FeatureKind::BodyTransfer { .. } => "body_transfer",
|
||||
FeatureKind::Sketch { .. } => "sketch",
|
||||
FeatureKind::Extrude { .. } => "extrude",
|
||||
@@ -2855,7 +2847,6 @@ impl FeatureKind {
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
pub fn base_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::NewerFileFeature { .. } => "Unknown",
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "Extrude",
|
||||
@@ -3128,10 +3119,6 @@ pub fn is_counted_body_name(name: &str) -> bool {
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
|
||||
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
|
||||
#[serde(skip)]
|
||||
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
|
||||
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
|
||||
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
|
||||
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
|
||||
@@ -3493,7 +3480,6 @@ impl Document {
|
||||
|
||||
pub fn new(name: &str) -> Self {
|
||||
Document {
|
||||
newer_file: None,
|
||||
render: Default::default(),
|
||||
format: default_format(),
|
||||
format_pending: crate::format::pending_names(),
|
||||
@@ -3915,12 +3901,7 @@ impl Document {
|
||||
|
||||
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
|
||||
pub fn from_json(s: &str) -> Result<Document> {
|
||||
// A newer build's resources may not read here; the section stays out then (`newer_file`).
|
||||
if let Err(why) = crate::render_resources::check_json_budget(s)
|
||||
&& !crate::format::says_newer(s)
|
||||
{
|
||||
return Err(why);
|
||||
}
|
||||
crate::render_resources::check_json_budget(s)?;
|
||||
let mut d = crate::format::read(s)?;
|
||||
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
@@ -3932,11 +3913,6 @@ impl Document {
|
||||
Ok(d)
|
||||
}
|
||||
pub fn to_json(&self) -> String {
|
||||
if self.newer_file.is_some() {
|
||||
let mut value = serde_json::to_value(self).unwrap_or_default();
|
||||
crate::newer_file::restore(self, &mut value);
|
||||
return serde_json::to_string_pretty(&value).unwrap_or_default();
|
||||
}
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
+3
-125
@@ -84,65 +84,6 @@ pub struct Drawing {
|
||||
pub language: String,
|
||||
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
|
||||
pub sheets: Vec<Arc<Sheet>>,
|
||||
/// Set when a newer SolidCraft saved the file this came from.
|
||||
#[serde(skip)]
|
||||
pub newer_file: Option<Arc<NewerDrawing>>,
|
||||
}
|
||||
|
||||
/// The fields a drawing's record has: anything else in a file is a newer build's.
|
||||
const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"];
|
||||
|
||||
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
|
||||
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
|
||||
let Some(Value::Array(list)) = top.get_mut(key) else { return };
|
||||
let mut readable = Vec::with_capacity(list.len());
|
||||
for element in std::mem::take(list) {
|
||||
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
|
||||
Ok(_) => readable.push(element),
|
||||
Err(why) => {
|
||||
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
|
||||
file.not_understood.push(format!("{one} `{name}` ({why})"));
|
||||
file.elements.push((key.to_string(), element));
|
||||
}
|
||||
}
|
||||
}
|
||||
*list = readable;
|
||||
}
|
||||
|
||||
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NewerDrawing {
|
||||
/// The number the file says.
|
||||
pub format: u32,
|
||||
/// The number this build reads up to.
|
||||
pub reads_up_to: u32,
|
||||
/// The changes the file names that this build doesn't have.
|
||||
pub unknown_changes: Vec<String>,
|
||||
/// What this build couldn't read, by name.
|
||||
pub not_understood: Vec<String>,
|
||||
fields: Vec<(String, Value)>,
|
||||
elements: Vec<(String, Value)>,
|
||||
}
|
||||
|
||||
impl NewerDrawing {
|
||||
/// What to tell the user.
|
||||
pub fn notice(&self) -> String {
|
||||
format!(
|
||||
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
|
||||
self.format, self.reads_up_to
|
||||
)
|
||||
}
|
||||
|
||||
fn restore(&self, value: &mut Value) {
|
||||
for (key, field) in &self.fields {
|
||||
value[key] = field.clone();
|
||||
}
|
||||
for (key, element) in &self.elements {
|
||||
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
|
||||
list.push(element.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A design the drawing draws.
|
||||
@@ -234,7 +175,6 @@ impl Drawing {
|
||||
units: Units::default(),
|
||||
language: language.into(),
|
||||
sheets: vec![Arc::new(first)],
|
||||
newer_file: None,
|
||||
};
|
||||
drawing.validate()?;
|
||||
Ok(drawing)
|
||||
@@ -248,65 +188,11 @@ impl Drawing {
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
let (version, applied) = DRAWING.take_version(&mut v)?;
|
||||
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
|
||||
return Self::from_newer(v, version, applied, newer);
|
||||
}
|
||||
let d = Self::decode(v, version, applied.as_deref())?;
|
||||
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
|
||||
}
|
||||
|
||||
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
|
||||
DRAWING.check_numbers(&v, "properties")?;
|
||||
DRAWING.upgrade(&mut v, version, applied)?;
|
||||
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
d.validate().map_err(damaged)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
|
||||
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
|
||||
/// a source or a sheet that doesn't read, which stays out and is named in
|
||||
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
|
||||
/// format. It takes at least one sheet this build can read to open.
|
||||
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
|
||||
let list = applied.as_deref();
|
||||
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
|
||||
let mut file = NewerDrawing {
|
||||
format: newer.format,
|
||||
reads_up_to: newer.reads_up_to,
|
||||
unknown_changes: newer.unknown_changes.clone(),
|
||||
..NewerDrawing::default()
|
||||
};
|
||||
let mut read = can_read(v.clone());
|
||||
if read.is_err() {
|
||||
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
|
||||
let keys: Vec<String> = top.keys().cloned().collect();
|
||||
for key in keys {
|
||||
if !RECORD_FIELDS.contains(&key.as_str())
|
||||
&& let Some(value) = top.remove(&key)
|
||||
{
|
||||
file.not_understood.push(format!("field `{key}`"));
|
||||
file.fields.push((key, value));
|
||||
}
|
||||
}
|
||||
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
|
||||
take_unread::<Source>(top, "sources", "source", &mut file);
|
||||
read = can_read(v);
|
||||
}
|
||||
let d = read.map_err(|why| {
|
||||
DocError::Invalid(format!(
|
||||
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
|
||||
newer.format, newer.reads_up_to
|
||||
))
|
||||
})?;
|
||||
let (format, format_pending) = if newer.format > newer.reads_up_to {
|
||||
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
|
||||
} else {
|
||||
let mut pending = DRAWING.pending_names();
|
||||
pending.extend(newer.unknown_changes.iter().cloned());
|
||||
(DRAWING.format_string(DRAWING.current()), pending)
|
||||
};
|
||||
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
|
||||
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
|
||||
}
|
||||
|
||||
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
|
||||
@@ -314,15 +200,7 @@ impl Drawing {
|
||||
/// properties are kept as they came, and indenting them can make a drawing that read in
|
||||
/// under the limit save over it.
|
||||
pub fn to_json(&self) -> Result<String> {
|
||||
let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
|
||||
let text = match &self.newer_file {
|
||||
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
|
||||
Some(file) => {
|
||||
let mut value = serde_json::to_value(self).map_err(unsaved)?;
|
||||
file.restore(&mut value);
|
||||
serde_json::to_string_pretty(&value).map_err(unsaved)?
|
||||
}
|
||||
};
|
||||
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
|
||||
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang
|
||||
|
||||
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
|
||||
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
|
||||
the last release does with a drawing that has it (it warns about the newer format number, opens what it can read and keeps the rest).
|
||||
the last release does with a drawing that has it (it refuses the newer format number).
|
||||
- If older drawings must be rewritten as they open, add the step to the same file:
|
||||
|
||||
```rust
|
||||
|
||||
@@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() {
|
||||
let why = refused(&d, edit);
|
||||
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
|
||||
}
|
||||
// A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with
|
||||
// a number out of range, stays out. With no sheet left to show, it says why.
|
||||
// A newer build's file is refused as one before anything in it is read: a field this build
|
||||
// doesn't know, or a number out of its range.
|
||||
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
|
||||
let why = refused(&d, |v| {
|
||||
v["format"] = json!(DRAWING.format_string(newer));
|
||||
v["views"] = json!([]);
|
||||
v["sheets"][0]["dpi"] = json!(1e10);
|
||||
});
|
||||
assert_eq!(why, "read");
|
||||
let why = refused(&d, |v| {
|
||||
v["format"] = json!(DRAWING.format_string(newer));
|
||||
v["sheets"] = json!([{"id": "future", "a_future_field": true}]);
|
||||
});
|
||||
assert!(
|
||||
why.starts_with(&format!(
|
||||
"this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: "
|
||||
)),
|
||||
"{why}"
|
||||
assert_eq!(
|
||||
why,
|
||||
format!(
|
||||
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
|
||||
)
|
||||
);
|
||||
// A list of pending changes means nothing beside a format made of none; beside the pending
|
||||
// number, a change this build doesn't know is a newer build's.
|
||||
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
|
||||
assert_eq!(why, "read");
|
||||
if DRAWING.pending_names().is_empty() {
|
||||
assert_eq!(why, "read");
|
||||
} else {
|
||||
assert!(why.contains("newer SolidCraft"), "{why}");
|
||||
}
|
||||
// A design is never read as a drawing, nor a drawing as a design.
|
||||
let design = crate::Document::new("Plate").to_json();
|
||||
let design_format = crate::format::format_string(crate::format::FORMAT);
|
||||
@@ -551,32 +551,3 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest.
|
||||
#[test]
|
||||
fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() {
|
||||
let d = plate();
|
||||
let mut file: Value = file(&d);
|
||||
assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS");
|
||||
let newer = DRAWING.format_string(DRAWING.current() + 1);
|
||||
file["format"] = json!(newer);
|
||||
// With nothing it can't read: the same drawing.
|
||||
let opened = Drawing::from_json(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.sheets, d.sheets);
|
||||
assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft"));
|
||||
assert_eq!(serde_json::from_str::<Value>(&opened.to_json().unwrap()).unwrap()["format"], json!(newer));
|
||||
// A field and a second sheet it can't read stay out, named, and go back.
|
||||
file["views"] = json!([{"id": "a"}]);
|
||||
let mut future_sheet = file["sheets"][0].clone();
|
||||
future_sheet["name"] = json!("Future");
|
||||
future_sheet["future_layers"] = json!([1]);
|
||||
file["sheets"].as_array_mut().unwrap().push(future_sheet);
|
||||
let opened = Drawing::from_json(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.sheets, d.sheets);
|
||||
let said = &opened.newer_file.as_ref().unwrap().not_understood;
|
||||
assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}");
|
||||
let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap();
|
||||
assert_eq!(again["views"], file["views"]);
|
||||
assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1);
|
||||
assert_eq!(again["format"], json!(newer));
|
||||
}
|
||||
|
||||
+25
-8
@@ -1650,6 +1650,13 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
|
||||
for (sketch, _) in f.kind.profile_sels() {
|
||||
if doc.feature(sketch).is_some_and(|s| s.component != f.component) {
|
||||
reads.sketch(doc, sketch);
|
||||
// A later transfer can preserve this profile's historical frame. Changes to that
|
||||
// boundary (including suppression and rollback) must invalidate the constructor.
|
||||
if let Some(relative) = crate::body_transfer::profile_frame(doc, f, sketch) {
|
||||
"transferred profile".hash(&mut h);
|
||||
sketch.hash(&mut h);
|
||||
hash_placement(&relative, &mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = match &f.kind {
|
||||
@@ -5051,10 +5058,10 @@ fn feature_tools_with_constructor(
|
||||
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
let ss = constructor_profile_in_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
f,
|
||||
)?;
|
||||
let regions = match thin {
|
||||
Some(thin) => thin_regions(vals, &ss, thin)?,
|
||||
@@ -5278,10 +5285,10 @@ fn feature_tools_with_constructor(
|
||||
Ok(tools)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
let ss = constructor_profile_in_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
f,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) =
|
||||
@@ -5686,16 +5693,27 @@ fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64)
|
||||
profile_in_frame(doc, sketch, consumer)
|
||||
}
|
||||
|
||||
fn constructor_profile_in_frame(doc: &Document, sketch: &SolvedSketch, feature: &Feature) -> Result<SolvedSketch> {
|
||||
match crate::body_transfer::profile_frame(doc, feature, sketch.feature) {
|
||||
Some(relative) => profile_with_transform(sketch, &relative),
|
||||
None => sketch_in_feature_frame(doc, sketch, feature.component),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
|
||||
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let inverse =
|
||||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
|
||||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||||
profile_with_transform(sketch, &relative)
|
||||
}
|
||||
|
||||
fn profile_with_transform(sketch: &SolvedSketch, relative: &Mat) -> Result<SolvedSketch> {
|
||||
let mut mapped = sketch.clone();
|
||||
mapped.plane = Plane::new(
|
||||
crate::apply_point(&relative, sketch.plane.origin),
|
||||
crate::apply_vector(&relative, sketch.plane.x),
|
||||
crate::apply_vector(&relative, sketch.plane.y),
|
||||
crate::apply_point(relative, sketch.plane.origin),
|
||||
crate::apply_vector(relative, sketch.plane.x),
|
||||
crate::apply_vector(relative, sketch.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
|
||||
Ok(mapped)
|
||||
@@ -7064,7 +7082,6 @@ fn eval_feature(
|
||||
// Its tools meet bodies in other placements where those are shown (#3383).
|
||||
let _frames = ToolFrames::install(doc, f, st);
|
||||
match &f.kind {
|
||||
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
|
||||
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
|
||||
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
let hole =
|
||||
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
|
||||
vec![
|
||||
("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})),
|
||||
(
|
||||
"body_transfer",
|
||||
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
newer_file_feature
|
||||
body_transfer
|
||||
sketch
|
||||
inputs:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
newer_file_feature
|
||||
body_transfer
|
||||
sketch
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
newer_file_feature
|
||||
body_transfer
|
||||
remap_bodies: bodies[0]
|
||||
sketch
|
||||
|
||||
+117
-139
@@ -2,9 +2,8 @@
|
||||
//!
|
||||
//! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and
|
||||
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
|
||||
//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning:
|
||||
//! what this build reads is read, what it doesn't is reported by name and kept for the next save
|
||||
//! (`crate::newer_file`).
|
||||
//! file's shape, then document-level steps. A file of a newer format than this build knows is
|
||||
//! refused with a message saying so, never guessed at.
|
||||
//!
|
||||
//! History:
|
||||
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
|
||||
@@ -265,7 +264,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p
|
||||
|
||||
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
|
||||
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
|
||||
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
|
||||
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
|
||||
/// for every family.
|
||||
pub struct Family<'a> {
|
||||
/// What a file of the family is called in messages.
|
||||
@@ -318,8 +317,8 @@ impl Family<'_> {
|
||||
}
|
||||
|
||||
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
|
||||
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
|
||||
/// not: [`Family::newer`] says so.
|
||||
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
|
||||
/// here, so nothing a newer build saved is judged by this build's rules.
|
||||
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
|
||||
let noun = self.noun;
|
||||
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
|
||||
@@ -330,19 +329,10 @@ impl Family<'_> {
|
||||
Some(_) => return Err(not_ours("format is not a string".into())),
|
||||
};
|
||||
let applied = take_names(v, "format_pending", noun)?;
|
||||
if version == 0 {
|
||||
return Err(not_ours("format 0".into()));
|
||||
}
|
||||
self.formats.check(noun, version, applied.as_deref())?;
|
||||
Ok((version, applied))
|
||||
}
|
||||
|
||||
/// What says a file of format `version` with the changes `applied` came from a newer build:
|
||||
/// a number past this build's, or a change this build doesn't have anywhere in its history.
|
||||
/// `None` for a file this build fully knows.
|
||||
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
|
||||
self.formats.newer(version, applied)
|
||||
}
|
||||
|
||||
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
|
||||
/// (both from [`Family::take_version`]).
|
||||
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
|
||||
@@ -360,17 +350,6 @@ impl Family<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a file shows a newer build saved it ([`Family::newer`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Newer {
|
||||
/// The number the file says.
|
||||
pub format: u32,
|
||||
/// The number this build reads up to.
|
||||
pub reads_up_to: u32,
|
||||
/// The changes the file names that this build doesn't have.
|
||||
pub unknown_changes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The names of the pending changes this build writes into a design (`format_pending`).
|
||||
pub fn pending_names() -> Vec<String> {
|
||||
DESIGN.pending_names()
|
||||
@@ -415,25 +394,35 @@ impl Formats<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
|
||||
/// change may have landed at a later number. A list beside a format that isn't made of changes
|
||||
/// means nothing (a tool or a test set an older number on a newer file).
|
||||
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
|
||||
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
|
||||
/// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in
|
||||
/// its history, which only a newer build's file can. Changes are recognized across the entire
|
||||
/// history: a branch's pending change may have landed at a later number. A list beside a format
|
||||
/// that isn't made of changes means nothing (a tool or a test set an older number on a newer
|
||||
/// file).
|
||||
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
|
||||
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
|
||||
let current = self.current();
|
||||
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
|
||||
let unknown_changes: Vec<String> =
|
||||
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
|
||||
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
|
||||
if version == 0 {
|
||||
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
|
||||
}
|
||||
if version > current {
|
||||
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
|
||||
}
|
||||
let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
|
||||
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
|
||||
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
|
||||
/// `format_pending`: the changes of its format it already has, when it was written while they
|
||||
/// were pending; the others' steps run first, and already-applied changes never run again at
|
||||
/// their later number. A file past this build's number has no steps to run.
|
||||
/// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused,
|
||||
/// whoever calls, before anything is changed; already-applied changes never run again at
|
||||
/// their later number.
|
||||
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
|
||||
if version == 0 {
|
||||
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
|
||||
}
|
||||
self.check(noun, version, applied)?;
|
||||
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
|
||||
// drops.
|
||||
let applied = applied.filter(|_| self.parts(version).is_some());
|
||||
@@ -688,53 +677,18 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
|
||||
pub fn read(s: &str) -> Result<Document> {
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
|
||||
let (version, applied) = DESIGN.take_version(&mut v)?;
|
||||
match DESIGN.newer(version, applied.as_deref()) {
|
||||
Some(newer) => crate::newer_file::read(v, version, applied, newer),
|
||||
None => decode(v, version, applied.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a design's text says a newer build saved it, read before anything else in it is.
|
||||
pub fn says_newer(text: &str) -> bool {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Says {
|
||||
format: Option<String>,
|
||||
format_pending: Option<Vec<String>>,
|
||||
}
|
||||
serde_json::from_str::<Says>(text)
|
||||
.ok()
|
||||
.and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending)))
|
||||
.is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some())
|
||||
}
|
||||
|
||||
/// The checks of a design's JSON that need nothing outside the part of it they are given.
|
||||
pub(crate) fn check_raw(v: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
|
||||
check_design_numbers(v)?;
|
||||
crate::config::validate_rules_json(v)?;
|
||||
crate::config::validate_styles_json(v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(v)?;
|
||||
validate_versioned_geometry(v)?;
|
||||
validate_emboss_targets(v)?;
|
||||
validate_freeform_offsets(v)?;
|
||||
validate_sketch_sizes(v)?;
|
||||
crate::format_offsets::validate_raw(v)?;
|
||||
validate_wires(v, version)
|
||||
}
|
||||
|
||||
/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by
|
||||
/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to.
|
||||
pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> {
|
||||
check_raw(&v, version)?;
|
||||
DESIGN.upgrade(&mut v, version, applied)?;
|
||||
let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
check_typed_numbers(&d)
|
||||
}
|
||||
|
||||
/// A design's JSON, its format taken out, as the document it holds.
|
||||
pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Document> {
|
||||
let legacy_wires = check_raw(&v, version)?;
|
||||
check_design_numbers(&v)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(&v)?;
|
||||
validate_versioned_geometry(&v)?;
|
||||
validate_emboss_targets(&v)?;
|
||||
validate_freeform_offsets(&v)?;
|
||||
validate_sketch_sizes(&v)?;
|
||||
crate::format_offsets::validate_raw(&v)?;
|
||||
let legacy_wires = validate_wires(&v, version)?;
|
||||
validate_body_transfers(&v)?;
|
||||
DESIGN.upgrade(&mut v, version, applied)?;
|
||||
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
check_typed_numbers(&d)?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
@@ -1373,9 +1327,9 @@ mod tests {
|
||||
let mut v = serde_json::json!({});
|
||||
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
|
||||
assert_eq!(trace_of(&v), Vec::<&str>::new());
|
||||
// A change this build doesn't know is a newer build's: said, and kept.
|
||||
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
|
||||
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
|
||||
// A change this build doesn't know is a newer build's: refused, not guessed at.
|
||||
let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
|
||||
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
|
||||
// A list on a format that isn't made of changes means nothing.
|
||||
let mut v = serde_json::json!({});
|
||||
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
|
||||
@@ -1418,12 +1372,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_card_alias_without_support_is_a_newer_builds() {
|
||||
fn personal_card_alias_without_support_refuses_before_mutation() {
|
||||
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
|
||||
let first = [traced("a-first")];
|
||||
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
|
||||
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
|
||||
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
|
||||
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
assert!(error.to_string().contains("physical-material-cards"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1434,8 +1391,11 @@ mod tests {
|
||||
let cards = [traced("material-engineering-cards")];
|
||||
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
|
||||
for version in [28, 30] {
|
||||
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
|
||||
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
|
||||
let mut file = serde_json::json!({"trace": []});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
assert!(error.to_string().contains("physical-material-cards"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,7 +1408,11 @@ mod tests {
|
||||
for names in
|
||||
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
|
||||
{
|
||||
assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]);
|
||||
let mut file = serde_json::json!({"trace": ["b"]});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1482,14 +1446,18 @@ mod tests {
|
||||
}
|
||||
|
||||
/// An experimental producer can use a number that a release later folds without its
|
||||
/// change. Its unknown concept is still a newer build's.
|
||||
/// change. Its unknown concept must still be refused before any migration runs.
|
||||
#[test]
|
||||
fn unknown_pending_concepts_are_said_after_folding() {
|
||||
fn unknown_pending_concepts_are_refused_after_folding() {
|
||||
let parts = [traced("a-first")];
|
||||
let later = [traced("b-second")];
|
||||
for pending in [&[][..], &later[..]] {
|
||||
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
|
||||
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
|
||||
let mut file = serde_json::json!({"trace": []});
|
||||
let original = file.clone();
|
||||
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
|
||||
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
|
||||
assert_eq!(file, original, "refuse before applying even a known migration");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1511,20 +1479,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A design naming a pending change this build doesn't have is a newer build's: what it can't
|
||||
/// read of it doesn't read as damage.
|
||||
/// A design naming a pending change this build doesn't have is a newer build's, refused as
|
||||
/// such before anything else in it is checked (as a newer number is), so a newer build's
|
||||
/// fields never read as damage.
|
||||
#[test]
|
||||
fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() {
|
||||
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
|
||||
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
|
||||
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
|
||||
let opened = read(&file.to_string()).unwrap();
|
||||
let error = read(&file.to_string()).unwrap_err().to_string();
|
||||
// With changes pending, FORMAT is made of them; once they are folded it is the folded
|
||||
// format, which is made of changes too. Either way an unknown change is a newer build's.
|
||||
let said = opened.newer_file.as_ref().unwrap();
|
||||
assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]);
|
||||
assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice());
|
||||
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
|
||||
assert_eq!(
|
||||
error,
|
||||
format!(
|
||||
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
|
||||
@@ -1604,17 +1575,17 @@ mod tests {
|
||||
fn tagged(family: &Family<'_>, n: u32) -> Value {
|
||||
serde_json::json!({"format": family.format_string(n)})
|
||||
}
|
||||
// A newer number isn't refused: it is said.
|
||||
let says = |family: &Family<'_>, mut file: Value| {
|
||||
let (version, applied) = family.take_version(&mut file).unwrap();
|
||||
family.newer(version, applied.as_deref())
|
||||
};
|
||||
assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] }));
|
||||
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
|
||||
fn newer(noun: &str, n: u32, current: u32) -> String {
|
||||
format!(
|
||||
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
|
||||
)
|
||||
}
|
||||
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
|
||||
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
|
||||
let current = DRAWING.current();
|
||||
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
|
||||
for n in [current + 1, u32::MAX] {
|
||||
assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] }));
|
||||
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
|
||||
}
|
||||
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
|
||||
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
|
||||
@@ -1636,7 +1607,12 @@ mod tests {
|
||||
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
|
||||
let next = design.current();
|
||||
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
|
||||
assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }));
|
||||
assert_eq!(
|
||||
refusal(&design, file),
|
||||
format!(
|
||||
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
|
||||
)
|
||||
);
|
||||
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
|
||||
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
|
||||
}
|
||||
@@ -1663,13 +1639,19 @@ mod tests {
|
||||
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
|
||||
// A list beside format 1 means nothing: the step still runs.
|
||||
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
|
||||
// Of a newer build: said, and what can run runs.
|
||||
let list = ["a-first".to_string(), "c-later".to_string()];
|
||||
assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]);
|
||||
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::<String>::new());
|
||||
assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] });
|
||||
assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::<String>::new());
|
||||
// An upgrade called without `take_version` still says format 0 isn't one.
|
||||
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
|
||||
assert_eq!(
|
||||
error,
|
||||
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
|
||||
);
|
||||
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
|
||||
assert_eq!(
|
||||
error,
|
||||
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
|
||||
);
|
||||
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
|
||||
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
|
||||
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
|
||||
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
|
||||
}
|
||||
|
||||
@@ -1684,8 +1666,7 @@ mod tests {
|
||||
assert!(read(&file.to_string()).is_err());
|
||||
}
|
||||
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
|
||||
let newer = read(&file.to_string()).unwrap();
|
||||
assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding");
|
||||
assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding");
|
||||
file["format_pending"] = Value::from(pending_names());
|
||||
let opened = read(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.format_pending, pending_names());
|
||||
@@ -2157,9 +2138,8 @@ mod tests {
|
||||
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("free-form offset metadata"), "{error}");
|
||||
// A newer build's file isn't held to this: the feature waits for that build.
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2183,7 +2163,8 @@ mod tests {
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("emboss face"), "{error}");
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolidCraft"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2226,7 +2207,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() {
|
||||
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
|
||||
use crate::config::{Column, ConfigRow};
|
||||
let mut d = Document::new("configured styles");
|
||||
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
@@ -2246,11 +2227,9 @@ mod tests {
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
|
||||
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
|
||||
let opened = read(&value.to_string()).unwrap();
|
||||
let said = opened.newer_file.as_ref().unwrap();
|
||||
assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood);
|
||||
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
|
||||
assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them");
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
|
||||
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2305,10 +2284,8 @@ mod tests {
|
||||
assert_eq!(read(&saved.to_string()).unwrap(), d);
|
||||
saved["format"] = Value::from(format_string(FORMAT + 1));
|
||||
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
|
||||
let opened = read(&saved.to_string()).unwrap();
|
||||
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
|
||||
let again: Value = serde_json::from_str(&opened.to_json()).unwrap();
|
||||
assert_eq!(again["features"][0], saved["features"][0]);
|
||||
let error = read(&saved.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2415,14 +2392,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_upgraded_newer_opened_others_rejected() {
|
||||
fn older_upgraded_newer_refused_others_rejected() {
|
||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
v["format"] = Value::from("solidcraft/1");
|
||||
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
|
||||
v.as_object_mut().unwrap().remove("format");
|
||||
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
|
||||
v["format"] = Value::from(format_string(FORMAT + 1));
|
||||
assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
|
||||
let e = read(&v.to_string()).unwrap_err().to_string();
|
||||
assert!(e.contains("newer SolidCraft"), "{e}");
|
||||
for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] {
|
||||
v["format"] = Value::from(bad);
|
||||
assert!(read(&v.to_string()).is_err(), "{bad}");
|
||||
|
||||
@@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan
|
||||
|
||||
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
|
||||
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
|
||||
the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest).
|
||||
the last release does with a design that has it (it refuses the newer format number).
|
||||
- If older designs must be rewritten as they open, add the step to the same file:
|
||||
|
||||
```rust
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A new-body extrusion or revolve whose outputs transfer into its source sketch's component
|
||||
//! reads the profile through the recorded transfer frame. Capturing that component's position
|
||||
//! therefore moves the body once, as the pending preview does. Existing designs use their
|
||||
//! saved transfer transforms without rewriting fields; independent cross-component references
|
||||
//! keep their live placement. The last release refuses the newer design-format number rather
|
||||
//! than reopening the design with the former double-placement behavior.
|
||||
@@ -399,7 +399,7 @@ mod tests {
|
||||
damaged["features"][1]["profiles"]["associations"] = associations;
|
||||
assert!(validate_raw(&damaged).is_err());
|
||||
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some());
|
||||
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
|
||||
}
|
||||
// Absent metadata preserves the historical one-to-one chain representation.
|
||||
let mut legacy = fixture();
|
||||
|
||||
@@ -11,7 +11,6 @@ mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
mod browser_tree;
|
||||
pub mod newer_file;
|
||||
pub mod pbr_appearance;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing.
|
||||
//!
|
||||
//! A newer format number, or a change the file names that this build doesn't have, says a newer
|
||||
//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of
|
||||
//! it can't be read, that part stays out of the way and the rest opens:
|
||||
//! - a feature this build can't read keeps its place on the timeline as
|
||||
//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated;
|
||||
//! - a parameter it can't read is left out;
|
||||
//! - any other top-level section it can't read is left out whole;
|
||||
//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file
|
||||
//! with what was read.
|
||||
//!
|
||||
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
|
||||
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
|
||||
//! the file still holds what the newer build wrote, and a lower number would make that build
|
||||
//! upgrade its own data as if it were old. What can't be put back because the design changed around
|
||||
//! it ([`not_kept`]) is said in the reply to the save.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::format::{self, DESIGN, Newer};
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
|
||||
const FOREIGN_TAG: &str = "newer_file_feature";
|
||||
/// Sections a design can't open without: filled in when the file's can't be read.
|
||||
const CORE: [&str; 3] = ["name", "params", "features"];
|
||||
/// The most sections left out one at a time to find the one a design can't be read with.
|
||||
const MAX_SECTION_TRIES: usize = 64;
|
||||
/// Fields a design container adds beside what a design holds; not unknown to this build.
|
||||
const CONTAINER_ONLY: [&str; 5] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob"];
|
||||
|
||||
/// A feature as a newer build wrote it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct RawFeature(pub Box<Value>);
|
||||
|
||||
impl RawFeature {
|
||||
/// The kind of feature it says it is.
|
||||
pub fn kind(&self) -> &str {
|
||||
self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed")
|
||||
}
|
||||
|
||||
/// Why it doesn't build here.
|
||||
pub fn why(&self) -> String {
|
||||
format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the file says and holds that this build doesn't read.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NewerFile {
|
||||
/// The number the file says.
|
||||
pub format: u32,
|
||||
/// The number this build reads up to.
|
||||
pub reads_up_to: u32,
|
||||
/// The changes the file names that this build doesn't have.
|
||||
pub unknown_changes: Vec<String>,
|
||||
/// What this build couldn't read, by name.
|
||||
pub not_understood: Vec<String>,
|
||||
/// Fields known records lost in typed reading.
|
||||
kept: Option<Kept>,
|
||||
/// Top-level sections left out whole.
|
||||
sections: Vec<Section>,
|
||||
/// Elements left out of a top-level list: its key and the element.
|
||||
elements: Vec<(String, Value)>,
|
||||
/// The parts of a design container this build has no use for: name and bytes. A blob only one
|
||||
/// of the kept pieces refers to is one of them.
|
||||
pub parts: Vec<(String, Arc<[u8]>)>,
|
||||
/// The file's `container.json`, when this build doesn't know what it says.
|
||||
pub container: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Section {
|
||||
key: String,
|
||||
value: Value,
|
||||
/// This build's record of the section, without the file's: while it is unchanged, the file's
|
||||
/// goes back.
|
||||
empty: Option<Value>,
|
||||
}
|
||||
|
||||
/// The fields of a record that typed reading dropped.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Kept {
|
||||
Field(Value),
|
||||
Fields(BTreeMap<String, Kept>),
|
||||
Items(Vec<(Anchor, Kept)>),
|
||||
}
|
||||
|
||||
/// Which element of a list some kept fields belong to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Anchor {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
/// Its place, good only while the list keeps its length.
|
||||
Index {
|
||||
at: usize,
|
||||
of: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl NewerFile {
|
||||
/// What to tell the user, whatever else is said.
|
||||
pub fn notice(&self) -> String {
|
||||
let reads = self.reads_up_to;
|
||||
let format = self.format;
|
||||
match self.unknown_changes.first() {
|
||||
Some(change) if format <= reads => {
|
||||
format!(
|
||||
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
|
||||
)
|
||||
}
|
||||
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every line to report: the notice, then what couldn't be read, by name.
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
|
||||
std::iter::once(self.notice()).chain(unread).collect()
|
||||
}
|
||||
|
||||
/// What a reply says about it.
|
||||
pub fn report(&self) -> Value {
|
||||
json!({
|
||||
"format": self.format,
|
||||
"reads_up_to": self.reads_up_to,
|
||||
"unknown_changes": self.unknown_changes,
|
||||
"not_understood": self.not_understood,
|
||||
"notice": self.notice(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
|
||||
/// for parts beside the design.
|
||||
pub fn needs_container(&self) -> bool {
|
||||
!self.parts.is_empty()
|
||||
}
|
||||
|
||||
/// Whether anything kept back names this text (a blob's name in a container).
|
||||
pub fn mentions(&self, text: &str) -> bool {
|
||||
fn value(v: &Value, text: &str) -> bool {
|
||||
match v {
|
||||
Value::String(s) => s == text,
|
||||
Value::Array(a) => a.iter().any(|x| value(x, text)),
|
||||
Value::Object(o) => o.values().any(|x| value(x, text)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn kept(k: &Kept, text: &str) -> bool {
|
||||
match k {
|
||||
Kept::Field(v) => value(v, text),
|
||||
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
|
||||
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
|
||||
}
|
||||
}
|
||||
self.kept.as_ref().is_some_and(|k| kept(k, text))
|
||||
|| self.sections.iter().any(|s| value(&s.value, text))
|
||||
|| self.elements.iter().any(|(_, v)| value(v, text))
|
||||
}
|
||||
|
||||
/// Say that a section the container reader couldn't finish reading is left out.
|
||||
/// `empty` is what the design writes for the section while it holds nothing, if anything.
|
||||
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
|
||||
self.not_understood.push(format!("section `{key}` ({why})"));
|
||||
self.sections.push(Section { key: key.into(), value, empty });
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a design a newer build saved, or say why it truly can't be read.
|
||||
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
|
||||
let applied_names = applied.as_deref();
|
||||
let mut file =
|
||||
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
|
||||
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
|
||||
Ok(doc) => (doc, raw),
|
||||
Err(_) => {
|
||||
let mut foreign = BTreeMap::new();
|
||||
let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| {
|
||||
DocError::Invalid(format!(
|
||||
"this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
|
||||
newer.format, newer.reads_up_to
|
||||
))
|
||||
})?;
|
||||
fill_foreign(&mut doc, foreign);
|
||||
(doc, raw)
|
||||
}
|
||||
};
|
||||
let mut upgraded = read_as;
|
||||
// Compared with what was read, the file's own shape after the same upgrade.
|
||||
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
|
||||
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
let mut paths = Vec::new();
|
||||
file.kept = vanished(&upgraded, &known, "", &mut paths);
|
||||
file.not_understood.extend(paths);
|
||||
for section in &mut file.sections {
|
||||
section.empty = known.get(§ion.key).cloned();
|
||||
}
|
||||
if newer.format > newer.reads_up_to {
|
||||
// The file's own number and list: it still holds what that build wrote.
|
||||
doc.format = DESIGN.format_string(newer.format);
|
||||
doc.format_pending = applied.unwrap_or_default();
|
||||
} else {
|
||||
// Upgraded to this build's format; the changes it doesn't know are still in it.
|
||||
doc.format_pending.extend(newer.unknown_changes.iter().cloned());
|
||||
}
|
||||
doc.newer_file = Some(Arc::new(file));
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read.
|
||||
fn salvage(
|
||||
raw: &mut Value,
|
||||
version: u32,
|
||||
applied: Option<&[String]>,
|
||||
file: &mut NewerFile,
|
||||
foreign: &mut BTreeMap<u64, Value>,
|
||||
) -> std::result::Result<Document, String> {
|
||||
let piece = |key: &str, value: &Value| {
|
||||
let mut stub = json!({"name": "", "params": [], "features": []});
|
||||
stub[key] = value.clone();
|
||||
format::check_piece(stub, version, applied)
|
||||
};
|
||||
let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) };
|
||||
// A feature it can't read keeps its place; a parameter it can't read stays out.
|
||||
if let Some(Value::Array(features)) = top.get_mut("features") {
|
||||
let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1);
|
||||
for feature in features.iter_mut() {
|
||||
if let Err(why) = piece("features", &json!([feature])) {
|
||||
file.not_understood.push(feature_note(feature, &why.to_string()));
|
||||
*feature = wrapped(std::mem::take(feature), &mut free, foreign);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(params)) = top.get_mut("params") {
|
||||
params.retain(|param| match piece("params", &json!([param])) {
|
||||
Ok(()) => true,
|
||||
Err(why) => {
|
||||
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
|
||||
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
|
||||
file.elements.push(("params".into(), param.clone()));
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
// Any other section, by itself.
|
||||
let keys: Vec<String> = top.keys().cloned().collect();
|
||||
for key in &keys {
|
||||
let value = top.get(key).cloned().unwrap_or_default();
|
||||
if let Err(why) = piece(key, &value) {
|
||||
move_out(top, key, &why.to_string(), file);
|
||||
}
|
||||
}
|
||||
if !top.get("name").is_some_and(Value::is_string) {
|
||||
top.insert("name".into(), json!("Untitled"));
|
||||
}
|
||||
for key in ["params", "features"] {
|
||||
top.entry(key).or_insert_with(|| json!([]));
|
||||
}
|
||||
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
|
||||
let first = match attempt(top) {
|
||||
Ok(doc) => return Ok(doc),
|
||||
Err(why) => why,
|
||||
};
|
||||
// Sections that are fine by themselves but not together with the rest.
|
||||
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
|
||||
// Each try reads the whole design: a file of many sections goes straight to the last resort.
|
||||
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
|
||||
let mut without = top.clone();
|
||||
without.remove(key);
|
||||
if let Ok(doc) = attempt(&without) {
|
||||
move_out(top, key, &first.to_string(), file);
|
||||
return Ok(doc);
|
||||
}
|
||||
}
|
||||
let mut bare = top.clone();
|
||||
bare.retain(|key, _| CORE.contains(&key.as_str()));
|
||||
match attempt(&bare) {
|
||||
Ok(doc) => {
|
||||
for key in &keys {
|
||||
move_out(top, key, &first.to_string(), file);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
Err(_) => Err(first.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave a section out and keep it. A section the design needs is left empty.
|
||||
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
|
||||
let Some(value) = top.remove(key) else { return };
|
||||
file.not_understood.push(format!("section `{key}` ({})", short(why)));
|
||||
file.sections.push(Section { key: key.into(), value, empty: None });
|
||||
}
|
||||
|
||||
/// The first line of a reader's error, without the list of every kind it knows.
|
||||
fn short(why: &str) -> &str {
|
||||
let why = why.split(", expected").next().unwrap_or(why);
|
||||
why.strip_prefix("design file: ").unwrap_or(why)
|
||||
}
|
||||
|
||||
fn feature_note(feature: &Value, why: &str) -> String {
|
||||
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
|
||||
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
|
||||
let why = short(why);
|
||||
if why.starts_with("unknown variant") {
|
||||
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
|
||||
} else {
|
||||
format!("feature `{name}`, a `{kind}` feature ({why})")
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature this build can't read as this build reads it: the fields every feature has, which stay
|
||||
/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this
|
||||
/// build can't read is judged by this build's rules. `free` gives an id to one the file left
|
||||
/// without.
|
||||
fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap<u64, Value>) -> Value {
|
||||
let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| {
|
||||
let id = *free;
|
||||
*free = free.saturating_add(1);
|
||||
id
|
||||
});
|
||||
let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string();
|
||||
let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false);
|
||||
let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0);
|
||||
foreign.insert(id, raw);
|
||||
json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null})
|
||||
}
|
||||
|
||||
/// Give the features [`wrapped`] left without one what the file said.
|
||||
fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap<u64, Value>) {
|
||||
for feature in &mut doc.features {
|
||||
if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind
|
||||
&& let Some(value) = foreign.remove(&feature.id)
|
||||
{
|
||||
raw.0 = Box::new(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature back as the file had it, with the changes this build can make to any feature.
|
||||
fn unwrapped(feature: &mut Value) {
|
||||
let Some(wrapper) = feature.as_object_mut() else { return };
|
||||
let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return };
|
||||
for key in ["id", "name", "suppressed", "component"] {
|
||||
match wrapper.get(key) {
|
||||
// Left out as a writer leaves it out, unless the file said it.
|
||||
Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {}
|
||||
Some(v) => {
|
||||
raw.insert(key.into(), v.clone());
|
||||
}
|
||||
None => {
|
||||
raw.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
*feature = Value::Object(raw);
|
||||
}
|
||||
|
||||
/// A value a writer leaves out without losing anything: not worth telling anyone about.
|
||||
fn looks_default(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Null => true,
|
||||
Value::Bool(b) => !b,
|
||||
Value::Number(n) => n.as_f64() == Some(0.0),
|
||||
Value::String(s) => s.is_empty(),
|
||||
Value::Array(a) => a.is_empty(),
|
||||
Value::Object(o) => o.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
|
||||
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
|
||||
match (raw, known) {
|
||||
(Value::Object(raw), Value::Object(known)) => {
|
||||
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
|
||||
return None;
|
||||
}
|
||||
let mut kept = BTreeMap::new();
|
||||
for (key, value) in raw {
|
||||
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
|
||||
continue;
|
||||
}
|
||||
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
|
||||
match known.get(key) {
|
||||
None => {
|
||||
if !looks_default(value) {
|
||||
paths.push(format!("field `{inner}`"));
|
||||
}
|
||||
kept.insert(key.clone(), Kept::Field(value.clone()));
|
||||
}
|
||||
Some(known) => {
|
||||
if let Some(k) = vanished(value, known, &inner, paths) {
|
||||
kept.insert(key.clone(), k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!kept.is_empty()).then_some(Kept::Fields(kept))
|
||||
}
|
||||
(Value::Array(raw), Value::Array(known)) => {
|
||||
let mut items = Vec::new();
|
||||
for (at, element) in raw.iter().enumerate() {
|
||||
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
|
||||
let Some(partner) = find(known, &anchor) else { continue };
|
||||
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
|
||||
(Some(name), _) => format!("{path}[{name}]"),
|
||||
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
|
||||
_ => format!("{path}[{at}]"),
|
||||
};
|
||||
if let Some(k) = vanished(element, partner, &label, paths) {
|
||||
items.push((anchor, k));
|
||||
}
|
||||
}
|
||||
(!items.is_empty()).then_some(Kept::Items(items))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_of(element: &Value) -> Option<Anchor> {
|
||||
if let Some(id) = element.get("id").and_then(Value::as_u64) {
|
||||
return Some(Anchor::Id(id));
|
||||
}
|
||||
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
|
||||
}
|
||||
|
||||
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
|
||||
match anchor {
|
||||
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
|
||||
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
|
||||
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
|
||||
match anchor {
|
||||
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
|
||||
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
|
||||
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
|
||||
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
|
||||
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
|
||||
match kept {
|
||||
Kept::Field(_) => {}
|
||||
Kept::Fields(fields) => {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
lost.push(format!("field `{path}`"));
|
||||
return;
|
||||
};
|
||||
for (key, sub) in fields {
|
||||
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
|
||||
match (sub, object.get_mut(key)) {
|
||||
(Kept::Field(v), None) => {
|
||||
object.insert(key.clone(), v.clone());
|
||||
}
|
||||
(Kept::Field(_), Some(_)) => {}
|
||||
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
|
||||
// The list it was in is empty and not written: its elements were deleted.
|
||||
(Kept::Items(_), None) => {}
|
||||
(_, None) => lost.push(format!("field `{inner}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Kept::Items(items) => {
|
||||
let Some(list) = value.as_array_mut() else {
|
||||
lost.push(format!("field `{path}`"));
|
||||
return;
|
||||
};
|
||||
for (anchor, sub) in items {
|
||||
match (find_mut(list, anchor), anchor) {
|
||||
(Some(element), _) => apply(sub, element, path, lost),
|
||||
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
|
||||
/// JSON. Returns what couldn't go back ([`not_kept`]).
|
||||
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
|
||||
let mut lost = Vec::new();
|
||||
let Some(file) = &doc.newer_file else { return lost };
|
||||
if let Some(Value::Array(features)) = value.get_mut("features") {
|
||||
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
|
||||
unwrapped(feature);
|
||||
}
|
||||
}
|
||||
if let Some(kept) = &file.kept {
|
||||
apply(kept, value, "", &mut lost);
|
||||
}
|
||||
if let Some(top) = value.as_object_mut() {
|
||||
for section in &file.sections {
|
||||
if top.get(§ion.key) == section.empty.as_ref() {
|
||||
top.insert(section.key.clone(), section.value.clone());
|
||||
} else {
|
||||
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
|
||||
}
|
||||
}
|
||||
for (key, element) in &file.elements {
|
||||
let name = element.get("name");
|
||||
match top.entry(key.clone()).or_insert_with(|| json!([])) {
|
||||
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
|
||||
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
lost
|
||||
}
|
||||
|
||||
/// What of the file a newer build saved the next save can't keep, because the design was changed
|
||||
/// around it. Empty almost always.
|
||||
pub fn not_kept(doc: &Document) -> Vec<String> {
|
||||
let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() };
|
||||
restore(doc, &mut value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "newer_file_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,236 +0,0 @@
|
||||
//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::format::{DESIGN, FORMAT, format_string};
|
||||
use crate::{Document, FeatureKind, Model, PlaneRef};
|
||||
|
||||
/// A real design: a sketched disc and its extrusion, and a parameter.
|
||||
fn design() -> Document {
|
||||
use solidcraft_sketch::Sketch;
|
||||
let mut d = Document::new("Plate");
|
||||
d.set_param("depth", "5 mm", Some("mm"), None).unwrap();
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap();
|
||||
let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap();
|
||||
d.add_feature(extrude(base), Some("Pad")).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn extrude(sketch: u64) -> FeatureKind {
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: crate::SketchId(sketch),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn saved(d: &Document) -> Value {
|
||||
serde_json::from_str(&d.to_json()).unwrap()
|
||||
}
|
||||
|
||||
/// `(body name, volume)` of everything the design builds, and the error of each feature.
|
||||
fn built(d: &Document) -> (Vec<(String, f64)>, Vec<Option<String>>) {
|
||||
let mut model = Model::default();
|
||||
model.evaluate(d);
|
||||
let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect();
|
||||
(bodies, model.results.iter().map(|r| r.error.clone()).collect())
|
||||
}
|
||||
|
||||
fn newer() -> String {
|
||||
format_string(FORMAT + 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() {
|
||||
let original = design();
|
||||
let mut file = saved(&original);
|
||||
file["format"] = json!(newer());
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
let (bodies, errors) = built(&opened);
|
||||
assert_eq!((bodies.clone(), errors), built(&original));
|
||||
assert_eq!(bodies.len(), 1);
|
||||
assert_eq!(opened.features, original.features);
|
||||
let notice = opened.newer_file.as_ref().unwrap().notice();
|
||||
assert_eq!(
|
||||
notice,
|
||||
format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1)
|
||||
);
|
||||
assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty());
|
||||
// Saved, it still says what it is; read again, the same.
|
||||
let again = saved(&opened);
|
||||
assert_eq!(again["format"], json!(newer()));
|
||||
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() {
|
||||
let original = design();
|
||||
let mut file = saved(&original);
|
||||
file["format_pending"] = json!(["a-later-change"]);
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
let report = opened.newer_file.as_ref().unwrap();
|
||||
assert_eq!(report.unknown_changes, ["a-later-change"]);
|
||||
assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice());
|
||||
assert_eq!(built(&opened), built(&original));
|
||||
let again = saved(&opened);
|
||||
assert_eq!(again["format"], json!(format_string(FORMAT)));
|
||||
assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change")));
|
||||
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
|
||||
}
|
||||
|
||||
/// The file, with a feature of a kind this build doesn't have between the sketch and the pad.
|
||||
fn with_unknown_feature() -> Value {
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["format_pending"] = json!(["a-later-change"]);
|
||||
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false});
|
||||
file["features"].as_array_mut().unwrap().insert(1, future);
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() {
|
||||
let file = with_unknown_feature();
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
let report = opened.newer_file.as_ref().unwrap();
|
||||
assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood);
|
||||
assert_eq!(opened.features.len(), 3);
|
||||
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice"));
|
||||
let (bodies, errors) = built(&opened);
|
||||
assert_eq!(bodies.len(), 1, "the pad after it still builds");
|
||||
assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}");
|
||||
let why = errors[1].as_deref().unwrap();
|
||||
assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() {
|
||||
let file = with_unknown_feature();
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
let again = saved(&opened);
|
||||
assert_eq!(again["format"], file["format"], "never a lower number");
|
||||
assert_eq!(again["format_pending"], file["format_pending"]);
|
||||
assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it");
|
||||
assert_eq!(
|
||||
again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::<Vec<_>>(),
|
||||
[json!("sketch"), json!("gyroid_lattice"), json!("extrude")]
|
||||
);
|
||||
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same");
|
||||
// A rename here goes with it; the rest stays.
|
||||
let mut renamed = opened.clone();
|
||||
renamed.features[1].name = "Infill".into();
|
||||
let again = saved(&renamed);
|
||||
assert_eq!(again["features"][1]["name"], "Infill");
|
||||
assert_eq!(again["features"][1]["nested"], json!({"big": 1e300}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_keeps_fields_and_sections_this_version_doesnt_know() {
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["future_section"] = json!({"anything": [1, 2, 3]});
|
||||
file["features"][1]["draft_v2"] = json!({"angle": 3.0});
|
||||
file["params"][0]["future_note"] = json!("keep me");
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
assert_eq!(built(&opened).0.len(), 1);
|
||||
let report = opened.newer_file.as_ref().unwrap();
|
||||
for field in ["future_section", "draft_v2", "future_note"] {
|
||||
assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood);
|
||||
}
|
||||
let again = saved(&opened);
|
||||
assert_eq!(again["future_section"], file["future_section"]);
|
||||
assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]);
|
||||
assert_eq!(again["params"][0]["future_note"], "keep me");
|
||||
// They follow their records when the design changes around them.
|
||||
let mut changed = opened.clone();
|
||||
let id = changed.features[1].id;
|
||||
changed.features.insert(0, changed.features[0].clone());
|
||||
changed.features[0].id = 777;
|
||||
assert_eq!(changed.feature(id).unwrap().name, "Pad");
|
||||
let again = saved(&changed);
|
||||
assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]);
|
||||
assert!(crate::newer_file::not_kept(&changed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_that_doesnt_read_is_left_out_named_and_kept() {
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}});
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
assert_eq!(built(&opened).0.len(), 1);
|
||||
let report = opened.newer_file.as_ref().unwrap();
|
||||
assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood);
|
||||
assert_eq!(saved(&opened)["configs"], file["configs"]);
|
||||
assert!(crate::newer_file::not_kept(&opened).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() {
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}}));
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
assert_eq!(built(&opened).0.len(), 1);
|
||||
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`")));
|
||||
let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect();
|
||||
assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cant_go_back_after_an_edit_is_said() {
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]});
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
assert!(crate::newer_file::not_kept(&opened).is_empty());
|
||||
let mut edited = opened.clone();
|
||||
edited.favorites.insert("depth".into());
|
||||
assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing");
|
||||
let baseline: Value = saved(&edited);
|
||||
assert_eq!(baseline["configs"], file["configs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feature_this_version_reads_wrongly_is_kept_whole() {
|
||||
// A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at.
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
file["features"][1]["operation"] = json!("weave");
|
||||
let opened = Document::from_json(&file.to_string()).unwrap();
|
||||
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude"));
|
||||
assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft"));
|
||||
assert_eq!(saved(&opened)["features"][1], file["features"][1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_truly_cant_be_read_says_why() {
|
||||
for (text, says) in [
|
||||
("not json", "HDR resource admission"),
|
||||
("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"),
|
||||
("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"),
|
||||
] {
|
||||
let why = Document::from_json(text).unwrap_err().to_string();
|
||||
assert!(why.contains(says), "{text}: {why}");
|
||||
}
|
||||
// Two features of one id are damage whatever build saved them.
|
||||
let mut file = saved(&design());
|
||||
file["format"] = json!(newer());
|
||||
let same = file["features"][0].clone();
|
||||
file["features"].as_array_mut().unwrap().push(same);
|
||||
let why = Document::from_json(&file.to_string()).unwrap_err().to_string();
|
||||
assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() {
|
||||
assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none());
|
||||
assert_eq!(DESIGN.newer(FORMAT, None), None);
|
||||
let mut file = saved(&design());
|
||||
file["features"][1]["type"] = json!("gyroid_lattice");
|
||||
assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage");
|
||||
}
|
||||
@@ -123,7 +123,6 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
|
||||
/// Assembly records do not feed any of the feature kinds accepted by `local_geometry`.
|
||||
fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
let Document {
|
||||
newer_file: _,
|
||||
render,
|
||||
format,
|
||||
format_pending,
|
||||
|
||||
@@ -42,7 +42,6 @@ impl Walk for i8 {}
|
||||
impl Walk for f64 {}
|
||||
impl Walk for usize {}
|
||||
impl Walk for u32 {}
|
||||
impl Walk for crate::newer_file::RawFeature {}
|
||||
impl Walk for ProfileSel {}
|
||||
impl Walk for std::collections::BTreeMap<String, String> {}
|
||||
impl Walk for crate::frame::Anchor {}
|
||||
|
||||
@@ -60,7 +60,7 @@ mod occurrence_tests;
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
|
||||
.icon("colored_components").doc_only()
|
||||
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
|
||||
.params("component: id or name; saves the next identification colour and turns Component Colors on; shared by every instance, separate from appearances"),
|
||||
CommandSpec::new("component.hide_history", "Hide Component History", hide_history)
|
||||
.icon("eye").doc_only()
|
||||
.params("component: id or name; keeps the component's features in the design but leaves them out of the History bar (#10393)"),
|
||||
|
||||
@@ -4,6 +4,136 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn transferred_profile(revolve: bool) -> (Session, u64, u64, solidcraft_kernel::Body) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":if revolve { "XZ" } else { "XY" },"name":"Profile"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[30,5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
if revolve {
|
||||
run(&mut s, "solid.revolve", json!({"axis":"y","angle":"360 deg","body_name":"Part"}));
|
||||
} else {
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Part"}));
|
||||
}
|
||||
let original = s.world_state().unwrap().body("Part").unwrap().body.clone();
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Part"]}));
|
||||
let component = created["components"][0]["component"].as_u64().unwrap();
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.component == component).unwrap().id;
|
||||
run(&mut s, "component.move_sketches", json!({"sketches":["Profile"],"component":component}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":false}));
|
||||
(s, component, occurrence, original)
|
||||
}
|
||||
|
||||
fn check_captured_profile_transfer(revolve: bool) {
|
||||
let (mut s, _, occurrence, original) = transferred_profile(revolve);
|
||||
let before = s.doc.to_json();
|
||||
let matrix = solidcraft_doc::rigid(Vec3::new(40.0, 0.0, 0.0), Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_4);
|
||||
let expected = solidcraft_kernel::transform_matrix(&original, matrix).unwrap();
|
||||
let expected_bounds = expected.exact_bounds().unwrap();
|
||||
let assert_pose = |s: &Session| {
|
||||
let state = s.world_state().unwrap();
|
||||
let body = &state.body("Part").unwrap().body;
|
||||
let bounds = body.exact_bounds().unwrap();
|
||||
assert!(bounds.min.dist(expected_bounds.min) < 0.002, "minimum: {bounds:?}, expected {expected_bounds:?}");
|
||||
assert!(bounds.max.dist(expected_bounds.max) < 0.002, "maximum: {bounds:?}, expected {expected_bounds:?}");
|
||||
let measured = solidcraft_kernel::measure(body).unwrap();
|
||||
let expected_measure = solidcraft_kernel::measure(&expected).unwrap();
|
||||
assert!((measured.volume - expected_measure.volume).abs() < 1e-5);
|
||||
assert!(measured.centroid.dist(expected_measure.centroid) < 1e-5);
|
||||
};
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"axis":[1,0,0],"angle":"45 deg","capture":false}));
|
||||
assert_pose(&s);
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_pose(&s);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_pose(&reopened);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_pose(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_transferred_extrusion_with_its_source_sketch_moves_once() {
|
||||
check_captured_profile_transfer(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_transferred_revolve_with_its_source_sketch_moves_once() {
|
||||
check_captured_profile_transfer(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_profile_frame_follows_transfer_edits_suppression_and_rollback() {
|
||||
let (mut s, component, occurrence, _) = transferred_profile(false);
|
||||
let creator = s.model.state().body("Part").unwrap().feature;
|
||||
let transfer = s.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
|
||||
let assert_x = |s: &Session, local: f64, owner: u64| {
|
||||
let source = s.model.result(creator).unwrap().state.body("Part").unwrap().body.exact_bounds().unwrap();
|
||||
assert!((source.min.x - local).abs() < 1e-6, "source frame: {source:?}");
|
||||
let state = s.world_state().unwrap();
|
||||
let body = state.body("Part").unwrap();
|
||||
assert_eq!(body.component, owner);
|
||||
assert!((body.body.exact_bounds().unwrap().min.x - 60.0).abs() < 1e-6);
|
||||
};
|
||||
assert_x(&s, 20.0, component);
|
||||
s.doc_mut().feature_mut(transfer).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
assert_x(&s, 60.0, 0);
|
||||
s.doc_mut().feature_mut(transfer).unwrap().suppressed = false;
|
||||
s.refresh();
|
||||
assert_x(&s, 20.0, component);
|
||||
let boundary = s.doc.feature_index(transfer);
|
||||
s.doc_mut().marker = boundary;
|
||||
s.refresh();
|
||||
assert_x(&s, 60.0, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_x(&s, 20.0, component);
|
||||
if let FeatureKind::BodyTransfer { transform, .. } = &mut s.doc_mut().feature_mut(transfer).unwrap().kind {
|
||||
*transform = solidcraft_doc::rigid(Vec3::new(-7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0);
|
||||
}
|
||||
s.refresh();
|
||||
assert_x(&s, 27.0, component);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_x(&reopened, 27.0, component);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capturing_one_transferred_profile_instance_keeps_its_peer_in_place() {
|
||||
let (mut s, _, occurrence, _) = transferred_profile(false);
|
||||
run(&mut s, "occurrence.copy", json!({"occurrence":occurrence,"translate":[100,0,0]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"capture":false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
let state = s.world_state().unwrap();
|
||||
let mut xs: Vec<f64> = state.bodies.iter().map(|body| body.body.exact_bounds().unwrap().min.x).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs.len(), 2);
|
||||
assert!((xs[0] - 60.0).abs() < 1e-6 && (xs[1] - 120.0).abs() < 1e-6, "{xs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_body_transfer_keeps_its_live_component_frame() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source","grounded":false}));
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Profile"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Profile","distance":5,"body_name":"Part"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
|
||||
let state = s.world_state().unwrap();
|
||||
let body = state.body("Part").unwrap();
|
||||
assert_eq!(body.component, 0);
|
||||
assert!((body.body.exact_bounds().unwrap().min.x - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -744,6 +744,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
names
|
||||
} else if pts.is_empty() {
|
||||
Vec::new()
|
||||
} else if matches!(kind, FeatureKind::Hole { .. }) {
|
||||
hole_face_names(s, p, &kind)?
|
||||
} else {
|
||||
solidcraft_doc::face_point_names(&s.doc, &s.model.state(), &kind, s.active_component)?
|
||||
}
|
||||
@@ -1082,6 +1084,22 @@ fn face_body_reached(s: &Session, sketch: u64) -> Result<Option<String>> {
|
||||
Ok(Some(body.name.clone()))
|
||||
}
|
||||
|
||||
/// A hole's face points, named on the bodies it drills where one of theirs holds the point
|
||||
/// (#7520): a face of another body in the same plane, a hidden one the hole leaves alone, would
|
||||
/// otherwise take the name of the face that was picked. Over every body, as before, elsewhere.
|
||||
fn hole_face_names(s: &Session, p: &Value, kind: &FeatureKind) -> Result<Vec<String>> {
|
||||
let st = s.model.state();
|
||||
let all = solidcraft_doc::face_point_names(&s.doc, &st, kind, s.active_component)?;
|
||||
let drilled = string_list_or_default(p, "participants");
|
||||
if drilled.is_empty() || drilled.iter().any(|b| b == "*") {
|
||||
return Ok(all);
|
||||
}
|
||||
let mut scoped = solidcraft_doc::ModelState::default();
|
||||
scoped.bodies = st.bodies.iter().filter(|b| drilled.contains(&b.name)).cloned().collect();
|
||||
let own = solidcraft_doc::face_point_names(&s.doc, &scoped, kind, s.active_component)?;
|
||||
Ok(own.into_iter().zip(all).map(|(own, any)| if own.is_empty() { any } else { own }).collect())
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
|
||||
/// it is one the active component works on ([`active_scope`]).
|
||||
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
@@ -2915,6 +2933,20 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// placed) cuts that body: the pick is already in that body's frame, and the
|
||||
// component's own bodies alone would leave nothing to cut.
|
||||
p["participants"] = json!([body]);
|
||||
} else if let Some(mut shown) = shown_scope(s) {
|
||||
// Like a cut (#3961), a hole leaves hidden bodies alone (#7520): while some are
|
||||
// hidden, it drills the shown ones, stored so showing the others later changes
|
||||
// nothing. The body whose face was picked is drilled though hidden (picked through
|
||||
// Select Other, or hidden with the dialog open): it is the one the hole is for.
|
||||
if !shown.is_empty()
|
||||
&& points.is_none()
|
||||
&& let Some(body) = hidden_body_picked_at(s, position, &shown)
|
||||
{
|
||||
shown.push(body);
|
||||
}
|
||||
if !shown.is_empty() {
|
||||
p["participants"] = json!(shown);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_feature(
|
||||
@@ -3000,16 +3032,36 @@ fn hole_reference_params(s: &Session, p: &Value, cmd: &str) -> Result<Vec<solidc
|
||||
/// component than the active one.
|
||||
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
let (_, body) = st
|
||||
.bodies
|
||||
let body = body_with_face_at(&st, p, |_| true)?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
}
|
||||
|
||||
/// The hidden body whose face holds `p`, when no shown body has a face there: a face shared by
|
||||
/// a shown and a hidden body is the shown one's, as a pick in the view would take it (#7520).
|
||||
fn hidden_body_picked_at(s: &Session, p: Vec3, shown: &[String]) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
if body_with_face_at(&st, p, |b| shown.contains(&b.name)).is_some() {
|
||||
return None;
|
||||
}
|
||||
let scope: Vec<&str> = active_scope(s, &st).into_iter().map(|b| b.name.as_str()).collect();
|
||||
body_with_face_at(&st, p, |b| scope.contains(&b.name.as_str())).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// The body, among those `keep` takes, with the face nearest `p` within its pick tolerance.
|
||||
fn body_with_face_at(
|
||||
st: &solidcraft_doc::ModelState,
|
||||
p: Vec3,
|
||||
keep: impl Fn(&solidcraft_doc::ModelBody) -> bool,
|
||||
) -> Option<&solidcraft_doc::ModelBody> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.filter(|b| !b.body.is_mesh() && keep(b))
|
||||
.filter_map(|b| {
|
||||
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
|
||||
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, b)| b)
|
||||
}
|
||||
|
||||
/// The points of a sketch a hole drills at when only the sketch is given.
|
||||
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -427,11 +427,6 @@ fn opened_design(
|
||||
if read_only {
|
||||
out["read_only"] = json!(true);
|
||||
}
|
||||
// A newer SolidCraft saved it: opened all the same, and the reply says so.
|
||||
if let Some(file) = &s.doc.newer_file {
|
||||
out["newer_file"] = file.report();
|
||||
out["warnings"] = json!(file.warnings());
|
||||
}
|
||||
let now = solidcraft_io::design_lock::now_secs();
|
||||
if let Some(lock) = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now) {
|
||||
out["open_elsewhere"] = json!({"host": lock.host, "user": lock.user, "app": lock.app, "opened": lock.opened});
|
||||
@@ -578,21 +573,7 @@ fn write_bytes(
|
||||
s.disk = solidcraft_io::design_lock::stamp(path);
|
||||
s.read_only = false;
|
||||
s.mark_saved();
|
||||
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
|
||||
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
|
||||
// couldn't go back, because the design was changed around it, is said.
|
||||
if s.doc.newer_file.is_some() {
|
||||
let mut lost = solidcraft_doc::newer_file::not_kept(&s.doc);
|
||||
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
|
||||
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
|
||||
}
|
||||
out["kept_format"] = json!(s.doc.format);
|
||||
if !lost.is_empty() {
|
||||
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
|
||||
out["not_kept"] = json!(lost);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -1411,60 +1392,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A design a newer SolidCraft saved opens through the command agents and the app use: the
|
||||
/// reply warns, what it can't read shows as that feature's error, work goes on around it, and
|
||||
/// saving keeps the feature and the file's number in both encodings.
|
||||
#[test]
|
||||
fn a_newer_design_opens_and_saving_keeps_what_it_cant_read() {
|
||||
let dir = std::env::temp_dir().join(format!("sc_newer_design_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
|
||||
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
|
||||
file["format"] = json!(newer);
|
||||
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
|
||||
file["features"].as_array_mut().unwrap().push(future.clone());
|
||||
file["future_section"] = json!([1, 2]);
|
||||
for name in ["newer.json", "newer.sc"] {
|
||||
let path = dir.join(name);
|
||||
let source = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
|
||||
let bytes = if name.ends_with(".sc") { solidcraft_io::write_design_container(&source).unwrap() } else { file.to_string().into_bytes() };
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mut opened = Session::default();
|
||||
let reply = opened.execute("doc.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(reply["errors"], 1, "{reply}");
|
||||
assert_eq!(reply["features"], 2, "{reply}");
|
||||
assert!(reply["newer_file"]["not_understood"].to_string().contains("gyroid_lattice"), "{reply}");
|
||||
let first = reply["warnings"][0].as_str().unwrap();
|
||||
assert_eq!(
|
||||
first,
|
||||
format!(
|
||||
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
|
||||
solidcraft_doc::format::FORMAT + 1,
|
||||
solidcraft_doc::format::FORMAT
|
||||
)
|
||||
);
|
||||
assert_eq!(opened.model.state().bodies.len(), 1, "the box still builds");
|
||||
let lattice = opened.doc.features.iter().position(|f| f.name == "Lattice1").unwrap();
|
||||
assert!(opened.model.results[lattice].error.as_deref().unwrap().contains("gyroid_lattice"));
|
||||
opened.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).unwrap();
|
||||
let saved = opened.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(saved["kept_format"], json!(newer), "{saved}");
|
||||
assert!(saved.get("not_kept").is_none(), "{saved}");
|
||||
let mut again = Session::default();
|
||||
again.execute("doc.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(again.doc.features.len(), 3);
|
||||
assert_eq!(again.model.state().bodies.len(), 2);
|
||||
let written: Value = serde_json::from_str(&again.doc.to_json()).unwrap();
|
||||
assert_eq!(written["format"], json!(newer));
|
||||
assert_eq!(written["future_section"], json!([1, 2]));
|
||||
assert!(written["features"].as_array().unwrap().contains(&future));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5467: a script, the CLI or the control channel never gets a prompt. A Save over a file
|
||||
/// another program or computer changed since it was opened is refused with an error naming
|
||||
/// `overwrite`, which replaces it; a design opened read-only only saves to another file.
|
||||
|
||||
@@ -27,7 +27,7 @@ mod appearance_chain;
|
||||
pub(super) mod appearance_gestures;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
|
||||
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! What a face measures on the surface behind it (#9086).
|
||||
//!
|
||||
//! A curved face's area is the area of the surface it lies on: a cylinder's band is
|
||||
//! r·θ·h and a whole sphere 4πr². The display mesh's triangles run inside a curve, so
|
||||
//! their sum falls short of it.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcore_record::brep::Curve;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Mesh, UnitVec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface};
|
||||
|
||||
/// The area of the patch of faces `patch` of `body`, worked out on the surface behind
|
||||
/// them: a cylinder's band, or a sphere. `None` where the surface does not give one, and
|
||||
/// the caller keeps the measure of the mesh.
|
||||
pub(crate) fn patch_area(body: &Body, mesh: &Mesh, patch: &[usize]) -> Option<f64> {
|
||||
match solidcraft_kernel::face_surface(body, *patch.first()?)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius } => cylinder_band(body, mesh, patch, origin, axis, radius),
|
||||
FaceSurface::Sphere { radius, .. } => sphere(body, patch, radius),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges on the border of the patch: the ones a face of the patch shares with a face
|
||||
/// outside it, and the ones it keeps to itself.
|
||||
fn border_edges(mesh: &Mesh, patch: &[usize]) -> Vec<usize> {
|
||||
mesh.edge_faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, faces)| {
|
||||
let inside = faces.iter().filter(|f| patch.contains(&(**f as usize))).count();
|
||||
inside > 0 && (faces.len() == 1 || inside < faces.len())
|
||||
})
|
||||
.map(|(edge, _)| edge)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether edge `edge` is a straight line parallel to `axis`.
|
||||
fn straight_along(body: &Body, edge: usize, axis: UnitVec3) -> bool {
|
||||
let Some(e) = body.record().edges.get(edge) else { return false };
|
||||
if !matches!(e.curve, Curve::Line { .. }) {
|
||||
return false;
|
||||
}
|
||||
let Ok(c) = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()) else { return false };
|
||||
let [t0, t1] = e.interval;
|
||||
let (Some(a), Some(b)) = (c.point(t0), c.point(t1)) else { return false };
|
||||
(b - a).normalized().is_some_and(|d| d.dot(axis.vec()).abs() >= 1.0 - 1e-9)
|
||||
}
|
||||
|
||||
/// The area of a cylindrical band: the radius of the surface, the turn the band covers
|
||||
/// and the distance between its two rims.
|
||||
fn cylinder_band(body: &Body, mesh: &Mesh, patch: &[usize], origin: Vec3, axis: UnitVec3, radius: f64) -> Option<f64> {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol = radius * 1e-6;
|
||||
// The whole patch lies on one cylinder.
|
||||
for f in patch {
|
||||
match solidcraft_kernel::face_surface(body, *f)? {
|
||||
FaceSurface::Cylinder { origin: o, axis: a, radius: r } => {
|
||||
if (r - radius).abs() > tol || o.dist(origin) > tol || a.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// The rims of the band: the circles that bound it, each across the axis.
|
||||
let mut rims: Vec<(f64, f64)> = Vec::new();
|
||||
for edge in border_edges(mesh, patch) {
|
||||
// An edge that is not a circle but a straight line along the axis (a seam, a straight
|
||||
// cut along the band) leaves the turn of the band to its rims alone. Any other edge
|
||||
// (the saddle rim of a bore through the wall) cuts the band: not ours to measure.
|
||||
let Some(circle) = solidcraft_kernel::edge_circle(body, edge) else {
|
||||
if straight_along(body, edge, axis) {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if circle.axis.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 || (circle.radius - radius).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let d = circle.center - origin;
|
||||
let height = d.dot(axis.vec());
|
||||
if (d - axis.vec() * height).len() > tol {
|
||||
return None;
|
||||
}
|
||||
rims.push((height, circle.angle));
|
||||
}
|
||||
// The two rims: the band runs between them, and both cover the same turn.
|
||||
let mut levels: Vec<(f64, f64)> = Vec::new();
|
||||
for (height, angle) in rims {
|
||||
match levels.iter_mut().find(|(h, _)| (*h - height).abs() <= tol) {
|
||||
Some((_, a)) => *a += angle,
|
||||
None => levels.push((height, angle)),
|
||||
}
|
||||
}
|
||||
if levels.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
levels.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let (low, low_turn) = levels[0];
|
||||
let (high, high_turn) = levels[1];
|
||||
let height = high - low;
|
||||
if !(height > 0.0) || (low_turn - high_turn).abs() > low_turn.max(high_turn) * 1e-6 {
|
||||
return None;
|
||||
}
|
||||
Some(radius * low_turn * height)
|
||||
}
|
||||
|
||||
/// The area of a sphere: 4πr², when the body is the whole of it.
|
||||
fn sphere(body: &Body, patch: &[usize], radius: f64) -> Option<f64> {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
// The whole patch lies on one sphere.
|
||||
for f in patch {
|
||||
match solidcraft_kernel::face_surface(body, *f)? {
|
||||
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
// The body is the whole sphere: every face of it lies on that sphere and the patch
|
||||
// holds them all. A body with a face of its own (a sphere that was cut) is a piece of
|
||||
// the sphere, whose area this does not work out: the measure of the mesh stands in
|
||||
// for it.
|
||||
for f in 0..body.record().faces.len() {
|
||||
match solidcraft_kernel::face_surface(body, f)? {
|
||||
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
|
||||
_ => return None,
|
||||
}
|
||||
if !patch.contains(&f) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(4.0 * PI * radius * radius)
|
||||
}
|
||||
@@ -350,7 +350,10 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
|
||||
let m = b.mesh();
|
||||
let patch = m.surface_patch(*index);
|
||||
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let on_mesh: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
// A curved face is measured on the surface it lies on, not on the triangles of
|
||||
// the display mesh, which run inside the curve.
|
||||
let area = super::measure_exact::patch_area(&b.body, &m, &patch).unwrap_or(on_mesh);
|
||||
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
|
||||
|
||||
@@ -74,6 +74,7 @@ mod libraries;
|
||||
pub mod mass_properties;
|
||||
mod material_properties;
|
||||
mod materials;
|
||||
mod measure_exact;
|
||||
mod measure_sel;
|
||||
mod measure_shape;
|
||||
mod mesh;
|
||||
@@ -261,16 +262,12 @@ impl CommandSpec {
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
let (label, icon) = match self.id {
|
||||
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
|
||||
_ => (self.label, self.icon),
|
||||
};
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label,
|
||||
label: self.label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon,
|
||||
icon: self.icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
|
||||
@@ -61,10 +61,7 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
|
||||
assert_eq!(d.render.setups["Studio"].width, 320);
|
||||
for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] {
|
||||
saved["format"] = json!(format!("solidcraft/{version}"));
|
||||
// A newer build's file opens, says so, and keeps its number.
|
||||
let newer = crate::doc::Document::from_json(&saved.to_string()).unwrap();
|
||||
assert_eq!(newer.newer_file.as_ref().unwrap().format, version);
|
||||
assert_eq!(newer.format, format!("solidcraft/{version}"));
|
||||
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ impl Effect {
|
||||
const READ_METHODS: &[&str] = &[
|
||||
"control.pending",
|
||||
"control.subscribe",
|
||||
"document.export",
|
||||
"document.inspect",
|
||||
"document.revision",
|
||||
"engine.commands",
|
||||
@@ -119,6 +120,7 @@ mod tests {
|
||||
for (m, p) in [
|
||||
("ui.inspect", json!({})),
|
||||
("ui.screenshot", json!({})),
|
||||
("document.export", json!({})),
|
||||
("document.inspect", json!({"measure": true})),
|
||||
("engine.execute", json!({"command": "sketch.inspect"})),
|
||||
("engine.execute", json!({"command": "view.status_items", "params": {}})),
|
||||
@@ -135,6 +137,8 @@ mod tests {
|
||||
("engine.batch", json!({"commands": []})),
|
||||
("ui.click", json!({"x": 1, "y": 2})),
|
||||
("app.quit", json!({})),
|
||||
("document.import", json!({"text": "{}"})),
|
||||
("document.saved", json!({"revision": 1})),
|
||||
] {
|
||||
assert!(!reads_only(m, &p), "{m} {p}");
|
||||
assert_eq!(effect(m, &p), Effect::Changes, "{m} {p}");
|
||||
|
||||
@@ -2366,6 +2366,8 @@ mod large_assembly_tests;
|
||||
#[cfg(test)]
|
||||
mod measure_centre_tests;
|
||||
#[cfg(test)]
|
||||
mod measure_surface_tests;
|
||||
#[cfg(test)]
|
||||
mod naming_history_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
@@ -2429,7 +2431,6 @@ fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &ren
|
||||
/// to be decided on here.
|
||||
fn same_but_features(a: &Document, b: &Document) -> bool {
|
||||
let Document {
|
||||
newer_file: _,
|
||||
render,
|
||||
format,
|
||||
format_pending,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Measure on a body's curved faces: the area comes from the exact surface, not from the
|
||||
//! display mesh's triangles, which run inside a curve (#9086).
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(v: &Value, want: f64, tol: f64) -> bool {
|
||||
v.as_f64().is_some_and(|x| (x - want).abs() <= tol)
|
||||
}
|
||||
|
||||
/// The first face whose analytic surface satisfies `want`, as a pick.
|
||||
fn face_of(s: &Session, want: impl Fn(solidcraft_kernel::FaceSurface) -> bool) -> Value {
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
|
||||
let f = (0..n).find(|&f| solidcraft_kernel::face_surface(&b.body, f).is_some_and(&want)).expect("face");
|
||||
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
|
||||
json!({"type": "face", "body": b.name, "index": f, "point": p})
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session, items: Vec<Value>) -> Value {
|
||||
run(s, "inspect.measure", json!({ "items": items }))
|
||||
}
|
||||
|
||||
/// A cylinder's side face is as big as its exact lateral area, 2πrh, in the same reply that
|
||||
/// gives its exact radius and diameter: the area came from the display mesh's triangles, which
|
||||
/// run inside the curve (#9086, the report's own cylinder).
|
||||
#[test]
|
||||
fn a_cylinders_side_face_is_as_big_as_its_exact_lateral_area() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
|
||||
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
|
||||
let r = measure(&mut s, vec![side]);
|
||||
let item = &r["items"][0];
|
||||
assert!(close(&item["diameter"], 10.0, 1e-9), "the same reply gives the exact diameter: {r}");
|
||||
assert!(close(&item["area_mm2"], TAU * 5.0 * 20.0, 1e-6), "the side face's area is its exact 2πrh: {r}");
|
||||
}
|
||||
|
||||
/// A hole's wall is as big as its exact lateral area, 2πrh: the area came from the display
|
||||
/// mesh's triangles, which run inside the curve (#9086).
|
||||
#[test]
|
||||
fn a_holes_wall_is_as_big_as_its_exact_lateral_area() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"}));
|
||||
let wall = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 3.0).abs() < 1e-6));
|
||||
let r = measure(&mut s, vec![wall]);
|
||||
let item = &r["items"][0];
|
||||
assert!(close(&item["diameter"], 6.0, 1e-9), "the same reply gives the exact diameter: {r}");
|
||||
assert!(close(&item["area_mm2"], TAU * 3.0 * 10.0, 1e-6), "the hole's wall is its exact 2πrh: {r}");
|
||||
}
|
||||
|
||||
/// A sphere's area is its exact 4πr², not the area of the display mesh's triangles (#9086).
|
||||
#[test]
|
||||
fn a_sphere_is_as_big_as_its_exact_surface() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.sphere", json!({"radius": 10, "base": [0, 0, 0], "operation": "new"}));
|
||||
let ball = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Sphere { radius, .. } if (radius - 10.0).abs() < 1e-6));
|
||||
let r = measure(&mut s, vec![ball]);
|
||||
let item = &r["items"][0];
|
||||
assert!(close(&item["diameter"], 20.0, 1e-9), "the same reply gives the exact diameter: {r}");
|
||||
assert!(close(&item["area_mm2"], 4.0 * std::f64::consts::PI * 100.0, 1e-4), "the sphere's area is its exact 4πr²: {r}");
|
||||
}
|
||||
|
||||
/// A planar face's area is unchanged: it is exact on the mesh already.
|
||||
///
|
||||
/// The face picked is the box's side at x = 40 (30 × 10), which the mesh measures exactly.
|
||||
#[test]
|
||||
fn a_flat_face_keeps_its_area() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let side = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == 2).and_then(|(t, _)| m.tri(t)).unwrap()[0];
|
||||
let pick = json!({"type": "face", "body": b.name, "index": 2, "point": side});
|
||||
let r = measure(&mut s, vec![pick]);
|
||||
assert!(close(&r["items"][0]["area_mm2"], 300.0, 1e-6), "{r}");
|
||||
}
|
||||
|
||||
/// The body's own volume is its exact πr²h, not the display mesh's (#9086).
|
||||
///
|
||||
/// The volume of a body is still measured on a tessellation of it: the exact volume of a
|
||||
/// body is measured on its surfaces, which is a follow-up to the faces' own areas.
|
||||
#[test]
|
||||
#[ignore = "the body's volume still comes from a tessellation: the exact volume of a body is a follow-up (#9086)"]
|
||||
fn a_cylinders_volume_is_its_exact_value() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
|
||||
let name = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let r = measure(&mut s, vec![json!({"type": "body", "name": name})]);
|
||||
assert!(close(&r["items"][0]["volume_mm3"], std::f64::consts::PI * 25.0 * 20.0, 1e-3), "{r}");
|
||||
}
|
||||
|
||||
/// The body's bounding box is its exact one: the tessellation's runs inside a curve (#9086).
|
||||
///
|
||||
/// The box of a body is still measured on a tessellation of it: the exact box of a body is
|
||||
/// measured on its surfaces, which is a follow-up to the faces' own areas.
|
||||
#[test]
|
||||
#[ignore = "the body's box still comes from a tessellation: the exact box of a body is a follow-up (#9086)"]
|
||||
fn a_cylinders_bounding_box_is_its_exact_one() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
|
||||
let r = run(&mut s, "inspect.measure", json!({}));
|
||||
let bb = &r["bodies"][0]["bbox"];
|
||||
assert!(close(&bb["min"][0], 35.0, 1e-6) && close(&bb["max"][0], 45.0, 1e-6), "the box's X runs from 35 to 45: {r}");
|
||||
assert!(close(&bb["min"][1], 5.0, 1e-6) && close(&bb["max"][1], 15.0, 1e-6), "the box's Y runs from 5 to 15: {r}");
|
||||
}
|
||||
|
||||
/// The report's own cylinder, measured as the report measured it (#9086).
|
||||
#[test]
|
||||
fn the_reports_cylinder_measures_exactly() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
|
||||
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
|
||||
let r = measure(&mut s, vec![side]);
|
||||
let area = r["items"][0]["area_mm2"].as_f64().unwrap();
|
||||
let want = TAU * 5.0 * 20.0;
|
||||
assert!((area - want).abs() / want < 1e-6, "the side face's area {area} against its exact {want}: {r}");
|
||||
let _ = Vec3::new(40.0, 10.0, 20.0);
|
||||
}
|
||||
|
||||
/// A bore through the cylinder's wall cuts a rim that is not a circle: the band is no longer
|
||||
/// the uncut 2πrh, so the exact shortcut gives way to the mesh's measure.
|
||||
#[test]
|
||||
fn a_bore_through_the_wall_is_not_measured_as_the_uncut_band() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [0, 0, 0], "operation": "new"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 14, "base": [-7, 0, 10], "axis": [1, 0, 0], "operation": "cut"}));
|
||||
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
|
||||
let r = measure(&mut s, vec![side]);
|
||||
let area = r["items"][0]["area_mm2"].as_f64().unwrap();
|
||||
assert!(area < TAU * 5.0 * 20.0 - 1.0, "the bored wall's area {area} is less than the uncut {}: {r}", TAU * 5.0 * 20.0);
|
||||
}
|
||||
@@ -3432,6 +3432,169 @@ fn hole_as_deep_as_the_plate() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7520: a hole that names no bodies leaves hidden ones alone, as a cut does (#3961), and keeps
|
||||
/// doing so once they are shown again. Its face is named on the body it drills, not on a hidden
|
||||
/// one whose face lies in the same plane; with nothing hidden, it still drills every body.
|
||||
#[test]
|
||||
fn a_hole_leaves_hidden_bodies_alone() {
|
||||
let setup = |hide: bool| {
|
||||
let mut s = Session::default();
|
||||
// Two bodies whose bottom faces share the plane z = 0, the hidden one made first.
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Plate"}));
|
||||
if hide {
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Block"], "visible": false}));
|
||||
}
|
||||
// The name of the plate's face that is picked, before the hole takes the point away.
|
||||
let picked =
|
||||
solidcraft_doc::naming::face_name_at_body(s.world_state().unwrap().body("Plate").unwrap(), solidcore_record::Vec3::new(20.0, 20.0, 0.0));
|
||||
let r = s.execute("solid.hole", &json!({"position": [20, 20, 0], "diameter": 5, "depth": 10, "name": "Hole1"})).unwrap();
|
||||
(s, r, picked)
|
||||
};
|
||||
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
|
||||
let (block, plate) = (40.0 * 40.0 * 20.0, 40.0 * 40.0 * 5.0);
|
||||
let (mut hidden, r, picked) = setup(true);
|
||||
assert_eq!(r["changed_bodies"], json!(["Plate"]));
|
||||
assert!((vol(&hidden, "Block") - block).abs() < 1e-6, "the hidden body is not drilled");
|
||||
assert!(vol(&hidden, "Plate") < plate - 1.0, "the shown one is");
|
||||
let hole = hidden.doc.find_feature("Hole1").unwrap();
|
||||
assert!(picked.is_some());
|
||||
assert_eq!((hole.participants.clone(), hole.face_names.first().cloned()), (vec!["Plate".to_string()], picked));
|
||||
run(&mut hidden, "browser.visibility", json!({"items": ["Block"], "visible": true}));
|
||||
assert!((vol(&hidden, "Block") - block).abs() < 1e-6, "shown again, it stays as it was");
|
||||
let (shown, r, _) = setup(false);
|
||||
assert_eq!(r["changed_bodies"], json!(["Block", "Plate"]), "with nothing hidden, both are drilled");
|
||||
assert!(vol(&shown, "Block") < block - 1.0 && vol(&shown, "Plate") < plate - 1.0);
|
||||
}
|
||||
|
||||
/// #7520 (review): the body whose face is picked is drilled even while it is hidden (picked
|
||||
/// through Select Other, or hidden with the dialog open), and editing a hole made with nothing
|
||||
/// hidden keeps drilling every body, though one is hidden at the time of the edit.
|
||||
#[test]
|
||||
fn a_hole_drills_its_picked_body_and_an_edit_keeps_its_scope() {
|
||||
let stack = || {
|
||||
let mut s = Session::default();
|
||||
// Two plates stacked: Lower z 0..5, Upper z 5..10.
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "body_name": "Lower"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 5], "length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Upper"}));
|
||||
s
|
||||
};
|
||||
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
|
||||
let plate = 40.0 * 40.0 * 5.0;
|
||||
let hole = |d: f64| json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": d, "depth": 20, "name": "Hole1"});
|
||||
|
||||
// The picked face is on the hidden Upper: it is drilled, and so is the shown Lower below.
|
||||
let mut s = stack();
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Upper"], "visible": false}));
|
||||
let r = s.execute("solid.hole", &hole(5.0)).unwrap();
|
||||
assert_eq!(r["changed_bodies"], json!(["Lower", "Upper"]), "the picked body is drilled though hidden");
|
||||
assert!(vol(&s, "Upper") < plate - 1.0 && vol(&s, "Lower") < plate - 1.0);
|
||||
|
||||
// Made with nothing hidden, then edited with Lower hidden: both stay drilled, at the new size.
|
||||
let mut s = stack();
|
||||
run(&mut s, "solid.hole", hole(5.0));
|
||||
let small = vol(&s, "Lower");
|
||||
assert!(small < plate - 1.0);
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Lower"], "visible": false}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Hole1", "command": "solid.hole", "params": {"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 6, "depth": 20}}),
|
||||
);
|
||||
assert!(s.doc.find_feature("Hole1").unwrap().participants.is_empty(), "the edit keeps the hole's scope");
|
||||
assert!(vol(&s, "Lower") < small - 1.0, "the hidden Lower keeps its hole, now wider");
|
||||
assert!(vol(&s, "Upper") < small - 1.0);
|
||||
}
|
||||
|
||||
/// #7520 (history): a hole made with a body hidden stores the bodies it drills, so it replays
|
||||
/// the same way: volumes match exactly after a save and reopen, a rebuild from scratch, undo and
|
||||
/// redo, showing the hidden body, renaming bodies and editing an earlier feature.
|
||||
#[test]
|
||||
fn a_hole_made_with_a_hidden_body_replays_the_same() {
|
||||
let build = |plate_height: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": plate_height, "operation": "new", "body_name": "Plate"}));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Block"], "visible": false}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 20, 0], "diameter": 5, "depth": 3, "name": "Hole1"}));
|
||||
s
|
||||
};
|
||||
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
|
||||
// A new session evaluated from the saved text alone, as a rebuild from scratch does.
|
||||
let scratch = |s: &Session| Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let s = build(5.0);
|
||||
let (block, plate) = (vol(&s, "Block"), vol(&s, "Plate"));
|
||||
assert_eq!(block, 40.0 * 40.0 * 20.0, "the hidden body is not drilled");
|
||||
assert!(plate < 40.0 * 40.0 * 5.0 - 1.0, "the shown one is");
|
||||
let same = |s: &Session, what: &str| {
|
||||
assert_eq!(vol(s, "Block"), block, "{what}: Block");
|
||||
assert_eq!(vol(s, "Plate"), plate, "{what}: Plate");
|
||||
};
|
||||
// (1) Save and reopen.
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-hole-history-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("hole.solidcraft");
|
||||
let mut saved = build(5.0);
|
||||
run(&mut saved, "file.save_as", json!({"path": file.to_string_lossy()}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path": file.to_string_lossy()}));
|
||||
same(&reopened, "reopened");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// (2) Rebuild from scratch.
|
||||
same(&scratch(&s), "rebuilt");
|
||||
// (3) Undo and redo.
|
||||
let mut u = build(5.0);
|
||||
run(&mut u, "edit.undo", json!({}));
|
||||
assert_eq!(vol(&u, "Plate"), 40.0 * 40.0 * 5.0, "undone: no hole");
|
||||
run(&mut u, "edit.redo", json!({}));
|
||||
same(&u, "redone");
|
||||
same(&scratch(&u), "redone and rebuilt");
|
||||
// (4) Show the hidden body, then rebuild.
|
||||
let mut shown = build(5.0);
|
||||
run(&mut shown, "browser.visibility", json!({"items": ["Block"], "visible": true}));
|
||||
same(&shown, "shown");
|
||||
same(&scratch(&shown), "shown and rebuilt");
|
||||
// (5) Rename the drilled body (and the hidden one), then rebuild.
|
||||
let mut renamed = build(5.0);
|
||||
run(&mut renamed, "body.rename", json!({"body": "Plate", "name": "Slab"}));
|
||||
run(&mut renamed, "body.rename", json!({"body": "Block", "name": "Core"}));
|
||||
for r in [&renamed, &scratch(&renamed)] {
|
||||
assert_eq!(vol(r, "Slab"), plate, "renamed: Slab");
|
||||
assert_eq!(vol(r, "Core"), block, "renamed: Core");
|
||||
}
|
||||
// (6) Edit an earlier feature (the plate's own box, then the hidden block's), then rebuild:
|
||||
// the same as a design made with the new size from the start.
|
||||
let feature = |s: &Session, body: &str| {
|
||||
let id = s.world_state().unwrap().body(body).unwrap().feature;
|
||||
s.doc.features.iter().find(|f| f.id == id).unwrap().name.clone()
|
||||
};
|
||||
let mut edited = build(5.0);
|
||||
let (plate_box, block_box) = (feature(&edited, "Plate"), feature(&edited, "Block"));
|
||||
run(&mut edited, "timeline.edit", json!({"feature": plate_box, "set": {"height": "8"}}));
|
||||
let fresh = build(8.0);
|
||||
for r in [&edited, &scratch(&edited)] {
|
||||
assert_eq!(vol(r, "Plate"), vol(&fresh, "Plate"), "plate edited");
|
||||
assert_eq!(vol(r, "Block"), block, "plate edited: Block");
|
||||
}
|
||||
run(&mut edited, "timeline.edit", json!({"feature": block_box, "set": {"height": "30"}}));
|
||||
for r in [&edited, &scratch(&edited)] {
|
||||
assert_eq!(vol(r, "Block"), 40.0 * 40.0 * 30.0, "block edited stays undrilled");
|
||||
assert_eq!(vol(r, "Plate"), vol(&fresh, "Plate"), "block edited: Plate");
|
||||
}
|
||||
}
|
||||
|
||||
/// #7520 (review): with every body hidden, a hole still drills all of them, as before.
|
||||
#[test]
|
||||
fn a_hole_with_every_body_hidden_drills_them_all() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Plate"}));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Block", "Plate"], "visible": false}));
|
||||
let r = s.execute("solid.hole", &json!({"position": [20, 20, 0], "diameter": 5, "depth": 3, "name": "Hole1"})).unwrap();
|
||||
assert_eq!(r["changed_bodies"], json!(["Block", "Plate"]));
|
||||
assert!(s.doc.find_feature("Hole1").unwrap().participants.is_empty());
|
||||
}
|
||||
|
||||
/// Appearances outlive later features: a coloured face keeps its colour after a fillet on a
|
||||
/// neighbouring edge; an imported STEP's body and face colours survive a hole.
|
||||
#[test]
|
||||
|
||||
@@ -397,6 +397,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
|
||||
pv_key: 0,
|
||||
pv_slot: empty(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: cam.view_proj(f64::from(width) / f64::from(height.max(1)), scene.radius.max(1.0)).to_f32(),
|
||||
back: cam.back().to_f32(),
|
||||
size_px: [width as f32, height as f32],
|
||||
|
||||
+57
-2
@@ -18,6 +18,7 @@ use crate::pbr;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod ao;
|
||||
pub use ao::GroundAo;
|
||||
mod clouds;
|
||||
pub use clouds::{CloudFilter, CloudStyle, ColorMode, DOT_SIZE, Dot, FILTERS, GpuCloud, cloud_dot};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -494,6 +495,8 @@ impl InstancedMesh {
|
||||
pub struct GpuScene {
|
||||
/// The colours every vertex, line and instance index into.
|
||||
pub palette: Arc<Palette>,
|
||||
/// Non-selectable virtual ground receiving viewport AO, separate from model triangles.
|
||||
pub ground_ao: Option<GroundAo>,
|
||||
/// Opaque triangles. A zero normal draws the colour flat (unlit).
|
||||
pub tris: Vec<u8>,
|
||||
/// Opaque triangles of open bodies, drawn as `tris` but left out of a section's crossing
|
||||
@@ -1017,6 +1020,9 @@ pub struct Frame {
|
||||
pub pv_slot: SceneSlot,
|
||||
/// Preview reselect: skip drawing the preview while Primary rolls it back.
|
||||
pub pv_hide: bool,
|
||||
/// World bounds of the active preview. Ground effects update their small receiver without
|
||||
/// changing the model scene's key or re-uploading model geometry/colours.
|
||||
pub preview_bounds: Option<solidcraft_geom::Aabb3>,
|
||||
/// Column-major view-projection.
|
||||
pub view_proj: [[f32; 4]; 4],
|
||||
/// Direction toward the eye (for lighting).
|
||||
@@ -1700,6 +1706,8 @@ struct Batches {
|
||||
tris: Option<Batch>,
|
||||
/// [`GpuScene::open_tris`].
|
||||
open_tris: Option<Batch>,
|
||||
ground_ao: Option<Batch>,
|
||||
ground_input: Option<GroundAo>,
|
||||
pbr: Option<Batch>,
|
||||
trans: Option<Batch>,
|
||||
shadow: Option<Batch>,
|
||||
@@ -1792,6 +1800,7 @@ impl Batches {
|
||||
geometry += data.len() as u64;
|
||||
upload(device, label, data, stride)
|
||||
};
|
||||
self.ground_input = sc.ground_ao;
|
||||
self.tris = own("sc_tris", &sc.tris, TRI_SIZE);
|
||||
self.open_tris = own("sc_open_tris", &sc.open_tris, TRI_SIZE);
|
||||
self.pbr = own("sc_pbr", &sc.pbr, PBR_SIZE);
|
||||
@@ -2077,6 +2086,8 @@ pub struct Renderer {
|
||||
ss_stats: DrawStats,
|
||||
/// The AO depth prepass resources (a 1 × 1 stand-in while ambient occlusion is off).
|
||||
ao: ao::Resources,
|
||||
ground_ao: wgpu::RenderPipeline,
|
||||
ground_ao_key: Option<(u64, u64, bool, u32, bool)>,
|
||||
tri: wgpu::RenderPipeline,
|
||||
/// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation,
|
||||
/// colours per vertex, indexed).
|
||||
@@ -2737,6 +2748,29 @@ impl Renderer {
|
||||
bind_group_layouts: &[Some(&bgl), Some(&access_bgl), Some(&ao_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let ground_ao = || {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_ground_ao"),
|
||||
layout: Some(&solid_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_tri"),
|
||||
buffers: &[Some(tri_layout())],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() },
|
||||
depth_stencil: depth(wgpu::CompareFunction::LessEqual, false),
|
||||
multisample: wgpu::MultisampleState { count: t.samples.max(1), ..Default::default() },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_ground_ao"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let built = [Some(tri_layout())];
|
||||
let evaluated = [Some(planned_layout()), Some(color_layout())];
|
||||
let placed = [Some(shared_layout()), Some(instance_layout())];
|
||||
@@ -2982,6 +3016,7 @@ impl Renderer {
|
||||
let cloud_pipes = jobs.spawn(cloud_pipes);
|
||||
let image = jobs.spawn(image);
|
||||
let ao = jobs.spawn(ao);
|
||||
let ground_ao = jobs.spawn(ground_ao);
|
||||
let oit = jobs.spawn(oit);
|
||||
let blits = jobs.spawn(blits);
|
||||
let resets = jobs.spawn(resets);
|
||||
@@ -3000,6 +3035,8 @@ impl Renderer {
|
||||
let oit = oit.join();
|
||||
Renderer {
|
||||
ao: ao.join(),
|
||||
ground_ao: ground_ao.join(),
|
||||
ground_ao_key: None,
|
||||
depth_reset,
|
||||
section_stencil,
|
||||
#[cfg(test)]
|
||||
@@ -3484,6 +3521,17 @@ impl Renderer {
|
||||
res.model.sort_glass(queue, cb.back);
|
||||
}
|
||||
res.model.cull(queue, &cb.view_proj, cb.size_px);
|
||||
let ground_visible = res.model.ground_input.is_some_and(|ground| ground.visible_from(cb.back));
|
||||
let ground_key = (cb.key, cb.pv_key, cb.pv_hide, cb.ao_radius.to_bits(), ground_visible);
|
||||
if res.ground_ao_key != Some(ground_key) {
|
||||
let preview = if cb.pv_hide { None } else { cb.preview_bounds };
|
||||
res.model.ground_ao = res
|
||||
.model
|
||||
.ground_input
|
||||
.filter(|_| ground_visible)
|
||||
.and_then(|ground| upload(device, "sc_ground_ao", &ground.vertices_with_radius(preview, cb.ao_radius), TRI_SIZE));
|
||||
res.ground_ao_key = Some(ground_key);
|
||||
}
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth()));
|
||||
if let Some(a) = &cb.access
|
||||
&& a.version != res.access_version
|
||||
@@ -3491,8 +3539,9 @@ impl Renderer {
|
||||
res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth);
|
||||
res.access_version = a.version;
|
||||
}
|
||||
let ao_binds = [scene_bind(&res.model, &res.bind), scene_bind(&res.preview, &res.bind)];
|
||||
res.ao.prepare(device, encoder, ao_binds, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, &res.preview]);
|
||||
let preview = if cb.pv_hide { &res.empty } else { &res.preview };
|
||||
let ao_binds = [scene_bind(&res.model, &res.bind), scene_bind(preview, &res.bind)];
|
||||
res.ao.prepare(device, encoder, ao_binds, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, preview]);
|
||||
// Images: upload new or changed textures, drop removed ones, rebuild the quads.
|
||||
res.textures.retain(|id, _| cb.images.iter().any(|i| i.id == *id));
|
||||
res.quads.clear();
|
||||
@@ -3838,6 +3887,9 @@ impl Renderer {
|
||||
pass.set_bind_group(0, scene_bind(pv, &res.bind), &[]);
|
||||
lines(pass, &res.line, &pv.lines);
|
||||
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
|
||||
if !cb.product && cb.ao_radius > 0.0 {
|
||||
tris(pass, &res.ground_ao, &m.ground_ao);
|
||||
}
|
||||
for (id, q) in res.quads.iter().filter(|_| !cb.product) {
|
||||
if let Some((_, bind)) = res.textures.get(id) {
|
||||
pass.set_pipeline(&res.image);
|
||||
@@ -4514,6 +4566,7 @@ mod tests {
|
||||
pv_key: 4,
|
||||
pv_slot: slot(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: [[0.0; 4]; 4],
|
||||
back: [0.0; 3],
|
||||
size_px: [800.0, 600.0],
|
||||
@@ -4973,6 +5026,7 @@ mod glass_tests {
|
||||
pv_key: 0,
|
||||
pv_slot: none(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
|
||||
back: [0.0, 0.0, 1.0],
|
||||
size_px: [SIZE as f32; 2],
|
||||
@@ -5150,6 +5204,7 @@ mod glass_tests {
|
||||
pv_key: 0,
|
||||
pv_slot: none(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
|
||||
back: [0.0, 0.0, 1.0],
|
||||
size_px: [SIZE as f32; 2],
|
||||
|
||||
+109
-3
@@ -1,4 +1,4 @@
|
||||
//! The optional AO passes. Only opaque model and preview triangles participate: their depth is
|
||||
//! The optional AO passes. Opaque model, preview and virtual ground triangles participate: their depth is
|
||||
//! drawn once into a single-sample depth map, and the occlusion term of the surface that depth
|
||||
//! holds is drawn into a texture of the same size and blurred by depth (#9327), which the lit
|
||||
//! fragment reads. The maps are independent of the window's MSAA, and the depth runs the same way
|
||||
@@ -30,6 +30,78 @@ pub(super) struct Maps {
|
||||
smooth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
/// A virtual receiver in the camera's up-axis frame. Geometry is built independently of the
|
||||
/// model batches, so moving a preview never re-uploads unchanged model colours or vertices.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GroundAo {
|
||||
bounds: solidcraft_geom::Aabb3,
|
||||
height: f64,
|
||||
up: solidcraft_render::UpAxis,
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
impl GroundAo {
|
||||
pub(super) fn visible_from(&self, back: [f32; 3]) -> bool {
|
||||
if !back.iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let cam = solidcraft_render::Camera { up: self.up, ..Default::default() };
|
||||
cam.to_frame(solidcore_record::Vec3::new(f64::from(back[0]), f64::from(back[1]), f64::from(back[2]))).z > 0.0
|
||||
}
|
||||
|
||||
pub fn new(bounds: solidcraft_geom::Aabb3, height: f64, up: solidcraft_render::UpAxis, radius: f32) -> Option<Self> {
|
||||
if bounds.is_empty() || !bounds.min.is_finite() || !bounds.max.is_finite() || !height.is_finite() || !radius.is_finite() || radius <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { bounds, height, up, radius })
|
||||
}
|
||||
|
||||
/// Two triangles (168 bytes), extended to cover an optional preview without moving the
|
||||
/// existing ground height. Invalid preview bounds are ignored.
|
||||
pub fn vertices(&self, preview: Option<solidcraft_geom::Aabb3>) -> Vec<u8> {
|
||||
self.vertices_with_radius(preview, self.radius)
|
||||
}
|
||||
|
||||
/// Cover the preview plus the current frame's sampling margin. Invalid radii draw nothing.
|
||||
pub fn vertices_with_radius(&self, preview: Option<solidcraft_geom::Aabb3>, radius: f32) -> Vec<u8> {
|
||||
use solidcore_record::Vec3;
|
||||
if !radius.is_finite() || radius <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cam = solidcraft_render::Camera { up: self.up, ..Default::default() };
|
||||
let mut coverage = self.bounds;
|
||||
if let Some(bb) = preview.filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
||||
for i in 0..8 {
|
||||
coverage.add(cam.to_frame(Vec3::new(
|
||||
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
||||
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
||||
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
||||
)));
|
||||
}
|
||||
}
|
||||
let margin = f64::from(radius) * 2.0;
|
||||
let points = [
|
||||
Vec3::new(coverage.min.x - margin, coverage.min.y - margin, self.height),
|
||||
Vec3::new(coverage.max.x + margin, coverage.min.y - margin, self.height),
|
||||
Vec3::new(coverage.max.x + margin, coverage.max.y + margin, self.height),
|
||||
Vec3::new(coverage.min.x - margin, coverage.max.y + margin, self.height),
|
||||
]
|
||||
.map(|p| cam.from_frame(p).to_f32());
|
||||
if !points.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let normal = cam.from_frame(Vec3::Z).to_f32();
|
||||
let mut bytes = Vec::with_capacity(6 * super::TRI_SIZE);
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
for value in points[i].iter().chain(&normal) {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Resources {
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
pub maps: Maps,
|
||||
@@ -87,7 +159,7 @@ fn draw_opaque(pass: &mut wgpu::RenderPass<'_>, pipelines: &OpaquePipelines, bin
|
||||
for (batch, bind) in batches.into_iter().zip(binds) {
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.set_pipeline(&pipelines.built);
|
||||
for tris in [&batch.tris, &batch.open_tris].into_iter().flatten() {
|
||||
for tris in [&batch.tris, &batch.open_tris, &batch.ground_ao].into_iter().flatten() {
|
||||
for (buffer, count) in &tris.parts {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..*count, 0..1);
|
||||
@@ -299,7 +371,41 @@ fn target_size(enabled: bool, pixels: [f32; 2], device_limit: u32) -> [u32; 2] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::target_size;
|
||||
use super::{GroundAo, target_size};
|
||||
|
||||
#[test]
|
||||
fn receiver_visibility_follows_the_camera_side_in_both_up_axes() {
|
||||
use solidcore_record::Vec3;
|
||||
let bounds = solidcraft_geom::Aabb3 { min: Vec3::ZERO, max: Vec3::new(3.0, 4.0, 12.0) };
|
||||
for up in [solidcraft_render::UpAxis::Y, solidcraft_render::UpAxis::Z] {
|
||||
let cam = solidcraft_render::Camera { up, ..Default::default() };
|
||||
let ground = GroundAo::new(bounds, 0.0, up, 1.0).unwrap();
|
||||
assert!(ground.visible_from(cam.from_frame(Vec3::Z).to_f32()));
|
||||
assert!(!ground.visible_from(cam.from_frame(-Vec3::Z).to_f32()));
|
||||
assert!(!ground.visible_from(cam.from_frame(Vec3::X).to_f32()));
|
||||
assert!(!ground.visible_from([f32::NAN; 3]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_growing_preview_keeps_the_current_sampling_margin_at_the_fixed_ground_height() {
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Aabb3;
|
||||
let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(3.0, 4.0, 12.0) };
|
||||
let ground = GroundAo::new(bounds, -0.014, solidcraft_render::UpAxis::Z, 1.0).unwrap();
|
||||
let preview = Aabb3 { min: Vec3::new(100.0, 200.0, -40.0), max: Vec3::new(103.0, 204.0, -28.0) };
|
||||
let mut drawn = Aabb3::EMPTY;
|
||||
for vertex in ground.vertices_with_radius(Some(preview), 10.0).as_chunks::<{ crate::view::TRI_SIZE }>().0 {
|
||||
let read = |i| f64::from(f32::from_le_bytes(vertex[i..i + 4].try_into().unwrap()));
|
||||
drawn.add(Vec3::new(read(0), read(4), read(8)));
|
||||
}
|
||||
assert_eq!((drawn.min.x, drawn.min.y, drawn.max.x, drawn.max.y), (-20.0, -20.0, 123.0, 224.0));
|
||||
assert!((drawn.min.z + 0.014).abs() < 1e-6 && (drawn.max.z + 0.014).abs() < 1e-6);
|
||||
assert_eq!(ground.vertices(Some(preview)), ground.vertices_with_radius(Some(preview), 1.0));
|
||||
for radius in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
|
||||
assert!(ground.vertices_with_radius(Some(preview), radius).is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn depth_map_is_bounded_and_released_when_disabled() {
|
||||
assert_eq!(target_size(true, [6000.0, 3000.0], 8192), [2048, 1024]);
|
||||
|
||||
@@ -107,6 +107,7 @@ impl Renderer {
|
||||
pv_key: 4,
|
||||
pv_slot: slot(None),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
|
||||
back: [0.0, 0.0, 1.0],
|
||||
size_px: [SIZE as f32; 2],
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Manual timestamp measurement: model-only AO versus the virtual-ground receiver.
|
||||
//! Run explicitly with --ignored, WGPU_BACKEND and SOLIDCRAFT_GPU=hardware|software.
|
||||
//! SOLIDCRAFT_BENCH_OUTPUT_DIR is the folder for JSON evidence. Not a CI speed threshold.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::json;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::{geom::Aabb3, render::Camera};
|
||||
use solidcraft_gpu as gpu;
|
||||
use solidcraft_gpu::view::{Frame, GpuScene, GpuTarget, Renderer};
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual CPU/GPU measurement; requires a timestamp-capable adapter"]
|
||||
fn measure_ground_ao_renderer_cost() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ao_comparison(0)?;
|
||||
ao_comparison(1)
|
||||
}
|
||||
fn ao_comparison(ground: u32) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (adapter, _) = gpu::select::pick(gpu::select::Policy::from_env().map_err(std::io::Error::other)?).ok_or("no matching adapter")?;
|
||||
let info = adapter.get_info();
|
||||
let features = wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS;
|
||||
if !adapter.features().contains(features) {
|
||||
return Err("adapter has no encoder timestamps".into());
|
||||
}
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("existing AO comparison"),
|
||||
required_features: features,
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let target = GpuTarget {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
depth: Some(wgpu::TextureFormat::Depth24PlusStencil8),
|
||||
samples: 4,
|
||||
tess: false,
|
||||
oit: false,
|
||||
};
|
||||
let mut session = solidcraft_engine::Session::default();
|
||||
for corner in [[-65, -10, 0], [45, -10, 0]] {
|
||||
session.execute("solid.box", &json!({"length":20,"width":20,"height":20,"corner":corner,"operation":"new"}))?;
|
||||
}
|
||||
session.execute("solid.torus", &json!({"major":14,"minor":4,"center":[0,0,9],"operation":"new"}))?;
|
||||
let meshes: Vec<_> = session.world_state()?.bodies.iter().map(|b| b.mesh()).collect();
|
||||
let bounds = meshes.iter().fold(Aabb3::EMPTY, |bounds, mesh| bounds.union(&mesh.bounds()));
|
||||
let radius = (bounds.diagonal() * 0.04).clamp(1e-4, 1e6) as f32;
|
||||
let cam = Camera {
|
||||
target: Vec3::new(8.379000844961002, 2.6776676413414724, 7.806862777456871),
|
||||
distance: 131.41653316382525,
|
||||
yaw: -0.5,
|
||||
pitch: 0.65,
|
||||
..Default::default()
|
||||
};
|
||||
let query =
|
||||
device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("existing AO timestamps"), ty: wgpu::QueryType::Timestamp, count: 4 });
|
||||
let buffer = |usage| device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 32, usage, mapped_at_creation: false });
|
||||
let resolve = buffer(wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC);
|
||||
let read = buffer(wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ);
|
||||
let software = info.device_type == wgpu::DeviceType::Cpu;
|
||||
let samples = if software { 12 } else { 30 };
|
||||
let warm = 5;
|
||||
let mut rows = Vec::new();
|
||||
for (width, height) in [(1670u32, 838u32), (3340, 1676)] {
|
||||
if software && width > 1670 {
|
||||
continue;
|
||||
}
|
||||
let mut scene = GpuScene::default();
|
||||
let color = scene.color([190, 195, 205, 255]);
|
||||
for mesh in &meshes {
|
||||
for tri in &mesh.triangles {
|
||||
for index in tri {
|
||||
let p = mesh.positions.get(*index as usize).ok_or("bad index")?;
|
||||
let n = mesh.normals.get(*index as usize).copied().unwrap_or(Vec3::Z);
|
||||
scene.tri(p.to_f32(), n.to_f32(), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ground > 0 {
|
||||
receiver(&mut scene, bounds_in_frame(&[bounds], &cam), &cam, f64::from(radius));
|
||||
}
|
||||
let mut frame = comparison_frame(scene, &cam, width, height, 1);
|
||||
let mut renderer = Renderer::new(&device, target, None);
|
||||
let attachment = |format| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("AO comparison target"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 4,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let color = attachment(target.format);
|
||||
let depth = attachment(wgpu::TextureFormat::Depth24PlusStencil8);
|
||||
// Keep each state stable after warm-up: disabling releases the depth image, so alternating
|
||||
// every frame would benchmark repeated allocation rather than normal steady AO use.
|
||||
for enabled in [false, true, false, true] {
|
||||
frame.ao_radius = if enabled { radius } else { 0.0 };
|
||||
let mut cpu = Vec::new();
|
||||
let mut prepass = Vec::new();
|
||||
let mut shade = Vec::new();
|
||||
let mut total = Vec::new();
|
||||
for sample in 0..samples + warm {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.write_timestamp(&query, 0);
|
||||
let started = Instant::now();
|
||||
renderer.prepare(&device, &queue, &mut encoder, &frame);
|
||||
let cpu_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
encoder.write_timestamp(&query, 1);
|
||||
encoder.write_timestamp(&query, 2);
|
||||
{
|
||||
let mut pass = encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("existing AO shaded model"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
|
||||
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Store }),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.forget_lifetime();
|
||||
renderer.paint(&frame, &mut pass, [0.0, 0.0, width as f32, height as f32]);
|
||||
}
|
||||
encoder.write_timestamp(&query, 3);
|
||||
encoder.resolve_query_set(&query, 0..4, &resolve, 0);
|
||||
encoder.copy_buffer_to_buffer(&resolve, 0, &read, 0, 32);
|
||||
let submitted = queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::Wait { submission_index: Some(submitted), timeout: Some(Duration::from_secs(30)) })?;
|
||||
let mapped = read.slice(..).get_mapped_range()?;
|
||||
let times: Vec<_> =
|
||||
mapped.as_chunks::<8>().0.iter().map(|b| u64::from_le_bytes(*b) as f64 * f64::from(queue.get_timestamp_period()) / 1e6).collect();
|
||||
if sample >= warm {
|
||||
let at = |i| times.get(i).copied().ok_or("missing timestamp");
|
||||
cpu.push(cpu_ms);
|
||||
prepass.push((at(1)? - at(0)?).max(0.0));
|
||||
shade.push((at(3)? - at(2)?).max(0.0));
|
||||
total.push((at(3)? - at(0)?).max(0.0));
|
||||
}
|
||||
drop(mapped);
|
||||
read.unmap();
|
||||
}
|
||||
rows.push(json!({"enabled":enabled,"width":width,"height":height,"samples":samples,
|
||||
"cpu_cached_prepare_ms":summary(&mut cpu),"gpu_prepare_ms":summary(&mut prepass),
|
||||
"gpu_model_pass_ms":summary(&mut shade),"gpu_total_ms":summary(&mut total)}));
|
||||
}
|
||||
}
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string().into());
|
||||
}
|
||||
let result = json!({"adapter":info.name,"backend":format!("{:?}",info.backend),"radius":radius,"ground":ground,
|
||||
"triangles":meshes.iter().map(|m|m.triangles.len()).sum::<usize>(),"rows":rows,
|
||||
"notes":"Actual Renderer, same cached opaque model, MSAA4, two off/on pairs after warm-up. ground=0: surface AO; 1: virtual floor; Floor composites require AO on. Projection is not part of this measurement. Stored MSAA4 attachments prevent unused-output elimination; presentation, MSAA resolve and final downsample are excluded. 3340x1676 is SSAA2 working resolution. GPU total includes prepare plus model/clear, not the application frame."});
|
||||
let label = match ground {
|
||||
1 => "ao-ground",
|
||||
2 => "ao-ground-projection",
|
||||
_ => "ao-baseline",
|
||||
};
|
||||
let output =
|
||||
std::env::var_os("SOLIDCRAFT_BENCH_OUTPUT_DIR").map(std::path::PathBuf::from).ok_or("set SOLIDCRAFT_BENCH_OUTPUT_DIR to an output folder")?;
|
||||
std::fs::create_dir_all(&output)?;
|
||||
std::fs::write(output.join(format!("{label}-comparison.json")), serde_json::to_vec_pretty(&result)?)?;
|
||||
println!("{label} comparison completed: {} {:?}", info.name, info.backend);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn summary(values: &mut [f64]) -> serde_json::Value {
|
||||
values.sort_by(f64::total_cmp);
|
||||
json!({"median":values.get(values.len()/2),"p95":values.get(values.len().saturating_mul(95)/100),"samples":values.len()})
|
||||
}
|
||||
|
||||
fn bounds_in_frame(bounds: &[Aabb3], cam: &Camera) -> Aabb3 {
|
||||
let mut combined = Aabb3::EMPTY;
|
||||
for b in bounds.iter().filter(|b| !b.is_empty()) {
|
||||
for i in 0..8 {
|
||||
combined.add(cam.to_frame(Vec3::new(
|
||||
if i & 1 == 0 { b.min.x } else { b.max.x },
|
||||
if i & 2 == 0 { b.min.y } else { b.max.y },
|
||||
if i & 4 == 0 { b.min.z } else { b.max.z },
|
||||
)));
|
||||
}
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
fn comparison_frame(scene: GpuScene, cam: &Camera, width: u32, height: u32, key: u64) -> Frame {
|
||||
let empty = || Arc::new(std::sync::Mutex::new(None));
|
||||
Frame {
|
||||
product_lights: None,
|
||||
upload_error: None,
|
||||
product: false,
|
||||
key,
|
||||
slot: Arc::new(std::sync::Mutex::new(Some(scene))),
|
||||
sketch_key: 0,
|
||||
sketch_slot: empty(),
|
||||
hl_key: 0,
|
||||
hl_slot: empty(),
|
||||
pv_key: 0,
|
||||
pv_slot: empty(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: cam.view_proj(f64::from(width) / f64::from(height), 200.0).to_f32(),
|
||||
back: cam.back().to_f32(),
|
||||
size_px: [width as f32, height as f32],
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
clouds: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
outline: false,
|
||||
eye: [0.0; 4],
|
||||
ao_radius: 0.0,
|
||||
ao_samples: gpu::view::AO_SAMPLES,
|
||||
hover_tint: [0.0; 3],
|
||||
selection_tint: [0.0; 3],
|
||||
hatch: Default::default(),
|
||||
sky: [0.0; 4],
|
||||
ssaa: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn receiver(sc: &mut GpuScene, b: Aabb3, cam: &Camera, radius: f64) {
|
||||
sc.ground_ao = solidcraft_gpu::view::GroundAo::new(b, b.min.z - 0.001 * (1.0 + b.diagonal()), cam.up, radius as f32);
|
||||
}
|
||||
+6
-181
@@ -343,16 +343,10 @@ fn write_container(
|
||||
let render = replace(doc.render.as_ref(), fields([("resources", Value::Object(resources))])).map_err(invalid)?;
|
||||
replacements.insert("render".into(), render);
|
||||
}
|
||||
let mut metadata = replace(doc, replacements).map_err(invalid)?;
|
||||
solidcraft_doc::newer_file::restore(doc, &mut metadata);
|
||||
let metadata = replace(doc, replacements).map_err(invalid)?;
|
||||
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
|
||||
writer.part("document.json", &metadata, true)?;
|
||||
// What a newer build's file holds beside the design goes back as it came.
|
||||
let container = doc.newer_file.as_ref().and_then(|file| file.container.clone()).unwrap_or_else(|| json!({"format": VERSION}));
|
||||
writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?;
|
||||
for (name, bytes) in doc.newer_file.iter().flat_map(|file| &file.parts) {
|
||||
writer.part(name, bytes, true)?;
|
||||
}
|
||||
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
|
||||
// The model cache is optional: one that does not fit the container keeps fewer states, or is
|
||||
// left out, never a failed save.
|
||||
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata, writer.space(2, budget)).ok());
|
||||
@@ -821,115 +815,16 @@ fn is_container_source(source: &mut FileSource) -> Result<bool> {
|
||||
Ok(is_design_container(&prefix))
|
||||
}
|
||||
|
||||
/// A newer build's container: a feature, picture or point cloud whose payload doesn't read here
|
||||
/// is kept as the file has it, and so is every part this build has no use for.
|
||||
fn hydrate_newer<R: Read + Seek>(doc: &mut Document, metadata: &Value, container: Value, reader: &mut PackageReader<R>) -> Result<()> {
|
||||
let file = Arc::make_mut(doc.newer_file.as_mut().ok_or_else(|| invalid("not a newer design"))?);
|
||||
for (index, feature) in doc.features.iter_mut().enumerate() {
|
||||
let Some(value) = metadata.get("features").and_then(|v| v.get(index)) else { continue };
|
||||
let readable = match &mut feature.kind {
|
||||
FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()),
|
||||
FeatureKind::MeshImport { meshes, .. } => {
|
||||
let mut check = meshes.clone();
|
||||
check.iter_mut().enumerate().try_for_each(|(i, mesh)| {
|
||||
let value = value.get("meshes").and_then(|v| v.get(i)).ok_or_else(|| invalid("missing mesh metadata"))?;
|
||||
decode_mesh(reader.blob(value, "mesh_blob", "mesh")?, mesh)
|
||||
})
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(why) = readable {
|
||||
file.not_understood.push(format!("feature `{}` ({why})", feature.name));
|
||||
feature.kind = FeatureKind::NewerFileFeature { raw: solidcraft_doc::newer_file::RawFeature(Box::new(value.clone())) };
|
||||
}
|
||||
}
|
||||
let canvases_read = doc.canvases.iter().enumerate().all(|(i, canvas)| {
|
||||
canvas.data.is_empty()
|
||||
&& matches!(canvas.format.as_str(), "png" | "jpeg")
|
||||
&& metadata.get("canvases").and_then(|v| v.get(i)).is_some_and(|v| reader.blob(v, "image_blob", &canvas.format).is_ok())
|
||||
});
|
||||
if !canvases_read {
|
||||
doc.canvases.clear();
|
||||
file.keep_section("canvases", metadata.get("canvases").cloned().unwrap_or_default(), Some(json!([])), "pictures this version can't read");
|
||||
}
|
||||
let clouds_read = doc.point_clouds.iter().enumerate().all(|(i, cloud)| {
|
||||
let mut check = cloud.clone();
|
||||
metadata.get("point_clouds").and_then(|v| v.get(i)).is_some_and(|v| match reader.blob(v, "points_blob", "points") {
|
||||
Ok(bytes) => decode_cloud(bytes, &mut check).is_ok(),
|
||||
Err(_) => false,
|
||||
})
|
||||
});
|
||||
if !clouds_read {
|
||||
doc.point_clouds.clear();
|
||||
file.keep_section("point_clouds", metadata.get("point_clouds").cloned().unwrap_or_default(), None, "point clouds this version can't read");
|
||||
}
|
||||
// The container's own note, when it says something this build doesn't know.
|
||||
if !is_known_container(&container) {
|
||||
file.container = Some(container);
|
||||
}
|
||||
// A blob a typed record uses comes back from that record; others only for what is kept.
|
||||
let names: Vec<String> = reader.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect();
|
||||
for name in names {
|
||||
let ours = matches!(name.as_str(), "document.json" | "container.json") || name.starts_with("cache/");
|
||||
let unused_blob = name.starts_with("blobs/") && !file.mentions(&name) && !foreign_mentions(&doc.features, &name);
|
||||
if !ours && !unused_blob {
|
||||
file.parts.push((name.clone(), reader.limited_part(&name, part_limit(&name))?.into()));
|
||||
}
|
||||
}
|
||||
// Looking counts against what a design may expand to only once: the reads below are the real ones.
|
||||
reader.hydrated = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `container.json` that says only a format this build writes or wrote.
|
||||
fn is_known_container(container: &Value) -> bool {
|
||||
*container == json!({"format": VERSION})
|
||||
|| *container == json!({"format": LEGACY_VERSION})
|
||||
|| container.get("content").is_some_and(|c| c == "drawing")
|
||||
}
|
||||
|
||||
/// A container format of ours, this build's or a later one's: `solidcraft-container/N`.
|
||||
pub(crate) fn is_container_version(format: &str) -> bool {
|
||||
format == LEGACY_VERSION || format.strip_prefix("solidcraft-container/").is_some_and(|n| n.parse::<u32>().is_ok())
|
||||
}
|
||||
|
||||
/// Whether a feature this build couldn't read refers to this blob.
|
||||
fn foreign_mentions(features: &[solidcraft_doc::Feature], name: &str) -> bool {
|
||||
fn names(value: &Value, name: &str) -> bool {
|
||||
match value {
|
||||
Value::String(s) => s == name,
|
||||
Value::Array(a) => a.iter().any(|x| names(x, name)),
|
||||
Value::Object(o) => o.values().any(|x| names(x, name)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
features.iter().any(|f| matches!(&f.kind, FeatureKind::NewerFileFeature { raw } if names(&raw.0, name)))
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
if drawing::container_kind(reader)? == FileKind::Drawing {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()));
|
||||
}
|
||||
let bytes = reader.document(max_design())?;
|
||||
check_hdr_manifest(&bytes)?;
|
||||
let mut metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
|
||||
// A newer build's file opens whatever it holds: what doesn't read here is kept (`newer_file`).
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
let newer_container = !is_known_container(&container);
|
||||
let newer = newer_container || std::str::from_utf8(&bytes).is_ok_and(solidcraft_doc::format::says_newer);
|
||||
// HDR's typed JSON representation is deliberately strict: no empty placeholders or
|
||||
// container-only blob fields may reach the document deserializer. A newer build's section that
|
||||
// doesn't pass stays as it is, which the document reader then leaves out.
|
||||
let hydrated_hdr = if newer {
|
||||
let mut hydrated = metadata.clone();
|
||||
let done = check_hdr_manifest(&bytes).and_then(|()| hydrate_hdr_resources(&mut hydrated, reader));
|
||||
if done.is_ok() {
|
||||
metadata = hydrated;
|
||||
}
|
||||
done.unwrap_or(false)
|
||||
} else {
|
||||
check_hdr_manifest(&bytes)?;
|
||||
hydrate_hdr_resources(&mut metadata, reader)?
|
||||
};
|
||||
// container-only blob fields may reach the document deserializer.
|
||||
let hydrated_hdr = hydrate_hdr_resources(&mut metadata, reader)?;
|
||||
let hydrated_text;
|
||||
let text = if hydrated_hdr {
|
||||
hydrated_text = serde_json::to_string(&metadata).map_err(invalid)?;
|
||||
@@ -940,16 +835,6 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
|
||||
// Migrations and all document validation run on the small parametric metadata.
|
||||
// Binary arrays have their own equivalent numeric/index validation below.
|
||||
let mut doc = Document::from_json(text)?;
|
||||
if newer_container && doc.newer_file.is_none() {
|
||||
let current = solidcraft_doc::format::FORMAT;
|
||||
let says = container.get("format").and_then(Value::as_str).unwrap_or("a later container").to_string();
|
||||
let mut file = solidcraft_doc::newer_file::NewerFile::default();
|
||||
(file.format, file.reads_up_to, file.unknown_changes) = (current, current, vec![says]);
|
||||
doc.newer_file = Some(Arc::new(file));
|
||||
}
|
||||
if doc.newer_file.is_some() {
|
||||
hydrate_newer(&mut doc, &metadata, container, reader)?;
|
||||
}
|
||||
for (index, feature) in doc.features.iter_mut().enumerate() {
|
||||
let value = metadata.get("features").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing feature metadata"))?;
|
||||
match &mut feature.kind {
|
||||
@@ -1783,60 +1668,6 @@ mod tests {
|
||||
doc
|
||||
}
|
||||
|
||||
/// A newer build's container: its feature this build can't read, that feature's blob, a part
|
||||
/// this build has never heard of and a payload in a newer encoding all open and save back.
|
||||
#[test]
|
||||
fn a_newer_builds_container_opens_and_saving_keeps_all_it_holds() {
|
||||
let doc = document();
|
||||
let mut entries = parts(&write_design_container(&doc).unwrap());
|
||||
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
|
||||
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
|
||||
metadata["format"] = json!(newer);
|
||||
let payload = b"a future payload".to_vec();
|
||||
let blob = format!("blobs/{}.lattice", digest(&payload));
|
||||
let future = json!({"id": 9000, "name": "Lattice1", "type": "gyroid_lattice", "lattice_blob": blob});
|
||||
metadata["features"].as_array_mut().unwrap().push(future.clone());
|
||||
metadata["future_section"] = json!({"keep": true});
|
||||
entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap());
|
||||
entries.insert(blob.clone(), payload.clone());
|
||||
entries.insert("future/notes.bin".into(), b"more".to_vec());
|
||||
// This blob nothing names any more: it doesn't stay around.
|
||||
entries.insert(format!("blobs/{}.lattice", digest(b"orphan")), b"orphan".to_vec());
|
||||
|
||||
let opened = crate::read_design(&package(&entries)).unwrap();
|
||||
assert_eq!(opened.features.len(), doc.features.len() + 1);
|
||||
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("Lattice1")));
|
||||
assert_eq!(opened.features[0], doc.features[0]);
|
||||
|
||||
let resaved = parts(&write_design_container(&opened).unwrap());
|
||||
let again: Value = serde_json::from_slice(&resaved["document.json"]).unwrap();
|
||||
assert_eq!(again["format"], json!(newer));
|
||||
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
|
||||
assert_eq!(again["future_section"], json!({"keep": true}));
|
||||
assert_eq!(resaved[&blob], payload);
|
||||
assert_eq!(resaved["future/notes.bin"], b"more");
|
||||
assert!(!resaved.contains_key(&format!("blobs/{}.lattice", digest(b"orphan"))));
|
||||
// And again: nothing is lost on the way round, the design's own blobs included.
|
||||
let twice = crate::read_design(&write_design_container(&opened).unwrap()).unwrap();
|
||||
assert_eq!(twice, opened);
|
||||
|
||||
// A payload in an encoding this build doesn't have costs its feature, not the design.
|
||||
let mut entries = entries.clone();
|
||||
let mesh = metadata["features"].as_array().unwrap().iter().flat_map(|f| f["meshes"].as_array().into_iter().flatten()).next().unwrap();
|
||||
let mesh_blob = mesh["mesh_blob"].as_str().unwrap().to_string();
|
||||
let mut encoded = entries[&mesh_blob].clone();
|
||||
encoded[0] ^= 0xff;
|
||||
let renamed = format!("blobs/{}.mesh", digest(&encoded));
|
||||
entries.remove(&mesh_blob);
|
||||
entries.insert(renamed.clone(), encoded.clone());
|
||||
let text = String::from_utf8(entries["document.json"].clone()).unwrap().replace(&mesh_blob, &renamed);
|
||||
entries.insert("document.json".into(), text.into_bytes());
|
||||
let opened = crate::read_design(&package(&entries)).unwrap();
|
||||
let said = &opened.newer_file.as_ref().unwrap().not_understood;
|
||||
assert!(said.iter().any(|what| what.contains("unsupported mesh blob version")), "{said:?}");
|
||||
assert_eq!(parts(&write_design_container(&opened).unwrap())[&renamed], encoded);
|
||||
}
|
||||
|
||||
fn parts(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
(0..zip.len())
|
||||
@@ -2034,13 +1865,7 @@ mod tests {
|
||||
entries.insert("../escape".into(), vec![]);
|
||||
assert!(crate::read_design(&package(&entries)).is_err());
|
||||
entries.remove("../escape");
|
||||
// A later container still holds a design: it opens, says so, and saving keeps its note.
|
||||
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999","more":1}"#.to_vec());
|
||||
let later = crate::read_design(&package(&entries)).unwrap();
|
||||
assert!(later.newer_file.as_ref().unwrap().notice().contains("solidcraft-container/999"));
|
||||
let resaved = parts(&write_design_container(&later).unwrap());
|
||||
assert_eq!(resaved["container.json"], entries["container.json"]);
|
||||
entries.insert("container.json".into(), br#"{"format":"another-tool/1"}"#.to_vec());
|
||||
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999"}"#.to_vec());
|
||||
assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("unsupported container version"));
|
||||
for length in (0..bytes.len()).step_by(17) {
|
||||
assert!(crate::read_design(&bytes[..length]).is_err());
|
||||
|
||||
@@ -49,15 +49,13 @@ pub fn file_kind(bytes: &[u8]) -> Result<FileKind> {
|
||||
/// What a container holds, by its `container.json`: no `content` is a design.
|
||||
pub(super) fn container_kind<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<FileKind> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if !container.get("format").and_then(Value::as_str).is_some_and(super::is_container_version) {
|
||||
if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
}
|
||||
match container.get("content") {
|
||||
None => Ok(FileKind::Design),
|
||||
Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing),
|
||||
// A later kind of content that still holds a design opens as one.
|
||||
Some(_) if reader.has_part("document.json") => Ok(FileKind::Design),
|
||||
Some(other) => Err(invalid(format!("it holds {other}, which this version doesn't know: update SolidCraft to open it"))),
|
||||
Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ fn a_drawing_container_refuses_what_it_cant_take() {
|
||||
let text = drawing().to_json().unwrap();
|
||||
let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#);
|
||||
let cases: [(Vec<u8>, &str); 6] = [
|
||||
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds \"sketch\""),
|
||||
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds 7"),
|
||||
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""),
|
||||
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"),
|
||||
(
|
||||
container(&[
|
||||
("container.json", drawing_json("\"drawing\"").as_bytes()),
|
||||
@@ -117,7 +117,10 @@ fn a_drawing_container_refuses_what_it_cant_take() {
|
||||
"also holds a design's document.json",
|
||||
),
|
||||
(container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"),
|
||||
(container(&[("container.json", &br#"{"content":"drawing","format":"another-tool/9"}"#[..]), ("drawing.json", text.as_bytes())]), "version"),
|
||||
(
|
||||
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]),
|
||||
"version",
|
||||
),
|
||||
(
|
||||
container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]),
|
||||
"damaged drawing",
|
||||
@@ -127,11 +130,7 @@ fn a_drawing_container_refuses_what_it_cant_take() {
|
||||
let e = error(read_drawing(&bytes));
|
||||
assert!(e.contains(says), "{says}: {e}");
|
||||
}
|
||||
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("this version doesn't know"));
|
||||
// A later container of ours still holds a drawing: it opens.
|
||||
let later =
|
||||
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]);
|
||||
assert!(read_drawing(&later).is_ok());
|
||||
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content"));
|
||||
}
|
||||
|
||||
/// Drawing files: the extension, and reading one from disk in either form.
|
||||
|
||||
@@ -342,6 +342,10 @@ mod tests {
|
||||
for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] {
|
||||
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
|
||||
}
|
||||
// How a host page keeps the design (#8517) is not the agent's.
|
||||
for m in ["document.export", "document.import", "document.saved"] {
|
||||
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! of it first
|
||||
//! - `engine.commands`: every command with tab, panel, params and enablement
|
||||
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
|
||||
//! - `document.export`, `document.import {text, name?}`, `document.saved {revision}`: the design
|
||||
//! for a host that keeps the files itself (#8517)
|
||||
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
|
||||
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation;
|
||||
//! `hold` keeps the button down that many frames before moving);
|
||||
@@ -821,6 +823,10 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
"engine.commands" => wrap(execute(app, "engine.commands", json!({}))),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
"document.revision" => wrap(app.session.execute("document.revision", p).map_err(|e| e.to_string())),
|
||||
// A host that keeps the files itself (an editing embed, #8517).
|
||||
"document.export" => wrap(crate::documents::export(app)),
|
||||
"document.import" => wrap(crate::documents::import(app, p)),
|
||||
"document.saved" => wrap(crate::documents::saved(app, p)),
|
||||
// The connection already counts as subscribed (`DesignWatch::join`), and its events start
|
||||
// from this answer: published first, so no event repeats it and none older follows.
|
||||
"control.subscribe" => {
|
||||
|
||||
@@ -132,7 +132,7 @@ fn reading(req: &ControlRequest) -> Option<(&'static str, Value)> {
|
||||
|
||||
/// Requests that change the design: they wait while one computes.
|
||||
pub(crate) fn changes_the_design(method: &str) -> bool {
|
||||
matches!(method, "engine.execute" | "command" | "engine.script" | "engine.batch")
|
||||
matches!(method, "engine.execute" | "command" | "engine.script" | "engine.batch" | "document.import" | "document.saved")
|
||||
}
|
||||
|
||||
/// Whether `doc` is the design `session` shows, as it is or edited since (an undo step away).
|
||||
|
||||
@@ -486,6 +486,49 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The active design as its file holds it (`document.export`): what a host that keeps the files
|
||||
/// itself writes (#8517). `text` is the design's JSON, the same a `.json` design file holds, and
|
||||
/// `revision` is the one `document.revision` names this state by.
|
||||
pub fn export(app: &mut SolveApp) -> Result<Value, String> {
|
||||
let revision = app.session.design_revision();
|
||||
let design = app.session.design();
|
||||
let text = String::from_utf8(solidcraft_engine::io::write_design(&design)).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"name": design.name, "revision": revision, "dirty": app.session.is_dirty(), "format": "json", "text": text}))
|
||||
}
|
||||
|
||||
/// Replace the active design with one a host kept (`document.import {text, name?}`): the text of
|
||||
/// a design file (its JSON, as `document.export` gives it). It opens saved, with nothing to undo,
|
||||
/// in the active design's place: as a design opened in a tab of its own, with that tab closed
|
||||
/// without asking. A text that does not read changes nothing.
|
||||
pub fn import(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
|
||||
if app.file_picker_busy() || app.file_load.busy() || app.docs.closing.is_some() || app.docs.quitting.is_some() || app.docs.batch.is_some() {
|
||||
return Err("a file is being opened or saved, or designs are being closed: try again".into());
|
||||
}
|
||||
let text = p.get("text").and_then(Value::as_str).ok_or("missing `text` (the design's JSON)")?;
|
||||
let mut doc = solidcraft_engine::io::read_design(text.as_bytes()).map_err(|e| e.to_string())?;
|
||||
if let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) {
|
||||
doc.name = name.to_string();
|
||||
}
|
||||
let replaced = app.docs.active;
|
||||
open_tab(app, Session::opened(doc, None))?;
|
||||
close(app, replaced, true);
|
||||
let errors = app.session.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
let revision = app.session.design_revision();
|
||||
Ok(json!({"name": app.session.doc.name, "features": app.session.doc.features.len(), "errors": errors, "revision": revision}))
|
||||
}
|
||||
|
||||
/// The host saved the design at `revision` (`document.saved`): it counts as saved from there.
|
||||
/// Refused when the design has changed since.
|
||||
pub fn saved(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
|
||||
let revision = p.get("revision").and_then(Value::as_u64).ok_or("missing `revision`")?;
|
||||
let now = app.session.design_revision();
|
||||
if revision != now {
|
||||
return Err(format!("the design changed since revision {revision} (now {now})"));
|
||||
}
|
||||
app.session.mark_saved();
|
||||
Ok(json!({"revision": revision, "dirty": app.session.is_dirty()}))
|
||||
}
|
||||
|
||||
/// A new, empty design in its own tab. One that is already empty keeps its tab and still
|
||||
/// takes Units of new designs (#1205): a launch starts on that empty design, so returning
|
||||
/// here used to leave it in millimetres.
|
||||
|
||||
@@ -439,7 +439,6 @@ fn recovered(app: &mut SolveApp, request: &Request) {
|
||||
let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir}));
|
||||
}
|
||||
app.set_status(format!("recovered {}: save it to keep it", request.path), false);
|
||||
app.say_newer_file();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1107,9 +1106,6 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
},
|
||||
false,
|
||||
);
|
||||
if !request.insert() {
|
||||
app.say_newer_file();
|
||||
}
|
||||
}
|
||||
request.answer(Ok(made.result));
|
||||
}
|
||||
|
||||
@@ -781,33 +781,3 @@ fn an_opened_design_comes_up_at_the_home_view() {
|
||||
let _ = std::fs::remove_dir_all(&other);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A design a newer SolidCraft saved opens in the window: the status line says so without a
|
||||
/// dialog, Messages names what wasn't read, and editing that feature says why it can't be edited.
|
||||
#[test]
|
||||
fn a_newer_design_opens_with_a_notice_and_no_dialog() {
|
||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||
let dir = scratch("newer_design");
|
||||
let mut design = Session::default();
|
||||
design.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&design.doc.to_json()).unwrap();
|
||||
let (reads, newer) = (solidcraft_engine::doc::format::FORMAT, solidcraft_engine::doc::format::FORMAT + 1);
|
||||
file["format"] = json!(solidcraft_engine::doc::format::format_string(newer));
|
||||
file["features"].as_array_mut().unwrap().push(json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice"}));
|
||||
let path = dir.join("newer.json");
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
|
||||
app.open_path(&path.to_string_lossy());
|
||||
|
||||
let (text, _, error) = app.status.clone().unwrap();
|
||||
assert_eq!(text, format!("Saved by a newer SolidCraft (format {newer}, this version reads up to {reads}). If something's missing, update."));
|
||||
assert!(!error);
|
||||
assert!(app.dialog.is_none());
|
||||
assert!(app.messages.iter().any(|m| m.text.contains("Lattice1") && m.text.contains("Saving keeps it")));
|
||||
assert_eq!(app.session.model.state().bodies.len(), 1);
|
||||
app.edit_feature(900);
|
||||
let (text, _, error) = app.status.clone().unwrap();
|
||||
assert!(error && text.contains("gyroid_lattice"), "{text}");
|
||||
assert!(app.dialog.is_none() && !app.ui.palette_open);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -375,6 +375,36 @@ struct Offscreen<'a> {
|
||||
ao_samples: u32,
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn render_ao_offscreen(
|
||||
scene: GpuScene,
|
||||
view_proj: [[f32; 4]; 4],
|
||||
back: [f32; 3],
|
||||
eye: [f32; 4],
|
||||
size: u32,
|
||||
tess: Option<&str>,
|
||||
ao_radius: f32,
|
||||
) -> Option<Vec<u8>> {
|
||||
offscreen(
|
||||
scene,
|
||||
None,
|
||||
view_proj,
|
||||
back,
|
||||
eye,
|
||||
size,
|
||||
Offscreen {
|
||||
tess,
|
||||
product: false,
|
||||
srgb: false,
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
ssaa: 1.0,
|
||||
ao_radius,
|
||||
ao_samples: solidcraft_gpu::view::AO_SAMPLES,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// [`render_offscreen`] of a scene supersampled `ssaa` times (1: off).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn render_offscreen_ssaa(
|
||||
@@ -461,6 +491,7 @@ fn offscreen(
|
||||
pv_key: 0,
|
||||
pv_slot: none(),
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj,
|
||||
back,
|
||||
size_px: [size as f32; 2],
|
||||
|
||||
@@ -12,10 +12,14 @@ use crate::{SolveApp, dialogs, help};
|
||||
|
||||
mod form;
|
||||
|
||||
/// The most an answer keeps, in characters (as `TextEdit::char_limit` counts them, not bytes).
|
||||
const MAX_ANSWER: usize = 2000;
|
||||
/// An answer with fewer characters left than this says how many.
|
||||
const ANSWER_ROOM: usize = 200;
|
||||
const MAX_BODY: usize = 24_000;
|
||||
// Room for all three answers and the original bounded diagnostic context.
|
||||
const MAX_REPORT: usize = 32_000;
|
||||
// Room for all three answers (up to four bytes a character) and the original bounded diagnostic
|
||||
// context.
|
||||
const MAX_REPORT: usize = 3 * 4 * MAX_ANSWER + MAX_BODY + 1000;
|
||||
const MAX_IMAGE_PIXELS: usize = 16_000_000;
|
||||
const ISSUE_URL: &str = "https://github.com/tangency-labs/solidcraft/issues/new";
|
||||
|
||||
@@ -182,7 +186,7 @@ impl Draft {
|
||||
}
|
||||
|
||||
fn report_body(&self) -> String {
|
||||
let answer = |text: &str| if text.trim().is_empty() { "Not provided".to_string() } else { limited(text, MAX_ANSWER) };
|
||||
let answer = |text: &str| if text.trim().is_empty() { "Not provided".to_string() } else { limited_chars(text, MAX_ANSWER) };
|
||||
let mut report = format!(
|
||||
"## Actual behavior\n{}\n\n## Reproduction\n{}\n\n## Expected behavior\n{}\n\n",
|
||||
answer(&self.description),
|
||||
@@ -299,6 +303,18 @@ fn limited(value: &str, limit: usize) -> String {
|
||||
value.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// [`truncate`] by characters: an answer's limit counts them, not bytes (#7191).
|
||||
fn truncate_chars(value: &mut String, limit: usize) {
|
||||
if let Some((end, _)) = value.char_indices().nth(limit) {
|
||||
value.truncate(end);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`limited`] by characters.
|
||||
fn limited_chars(value: &str, limit: usize) -> String {
|
||||
value.chars().take(limit).collect()
|
||||
}
|
||||
|
||||
fn block(value: &str) -> String {
|
||||
value.lines().map(|line| format!(" {line}\n")).collect()
|
||||
}
|
||||
@@ -1102,6 +1118,58 @@ mod tests {
|
||||
assert!(bounded.len() <= MAX_REPORT && bounded.ends_with("[technical details truncated]\n"));
|
||||
}
|
||||
|
||||
/// An answer keeps 2,000 characters in any script (#7191): the limit counted bytes, so one in
|
||||
/// Chinese kept 666. Wherever it is cut, no character is split.
|
||||
#[test]
|
||||
fn issue_report_answers_keep_2000_characters_in_any_script() {
|
||||
let mut draft = draft_for(&app(), None, None);
|
||||
draft.body = format!("{}END_CONTEXT", "d".repeat(MAX_BODY - 11));
|
||||
let mixed = |n: usize| "a現🚀é".chars().cycle().take(n).collect::<String>();
|
||||
for n in [MAX_ANSWER - 1, MAX_ANSWER, MAX_ANSWER + 1, MAX_ANSWER + 2, MAX_ANSWER + 3, 3000] {
|
||||
(draft.description, draft.steps, draft.expected) = (mixed(n), "現".repeat(n), "🚀".repeat(n));
|
||||
let kept = n.min(MAX_ANSWER);
|
||||
let answers = format!(
|
||||
"## Actual behavior\n{}\n\n## Reproduction\n{}\n\n## Expected behavior\n{}\n\n",
|
||||
mixed(kept),
|
||||
"現".repeat(kept),
|
||||
"🚀".repeat(kept)
|
||||
);
|
||||
let report = draft.report_body();
|
||||
assert!(report.starts_with(&answers), "{n} characters");
|
||||
assert!(report.len() <= MAX_REPORT && report.ends_with(&draft.body), "{n} characters: the technical details still fit");
|
||||
}
|
||||
}
|
||||
|
||||
/// Near the limit, an answer says how many characters are left: a paste past it is visibly
|
||||
/// cut (#7191).
|
||||
#[test]
|
||||
fn issue_report_answer_near_its_limit_says_how_many_characters_are_left() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.issue_report.draft = Some(draft_for(&h.app, None, None));
|
||||
h.frames(16);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
|
||||
h.frames(1);
|
||||
h.frame_with(vec![egui::Event::Paste("故障".repeat(1500))]);
|
||||
h.frames(4);
|
||||
let draft = h.app.issue_report.draft.as_mut().unwrap();
|
||||
assert_eq!(draft.description, "故障".repeat(MAX_ANSWER / 2), "3,000 Chinese characters pasted, 2,000 kept");
|
||||
// An answer that gets past the limit some other way is cut there too, between characters.
|
||||
let mixed = |n: usize| "a現🚀é".chars().cycle().take(n).collect::<String>();
|
||||
(draft.steps, draft.expected) = ("步".repeat(MAX_ANSWER - 150), mixed(MAX_ANSWER + 3));
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.issue_report.draft.as_ref().unwrap().expected, mixed(MAX_ANSWER));
|
||||
for note in ["0 characters left", "150 characters left"] {
|
||||
assert!(h.widget(note).is_some(), "{note}");
|
||||
}
|
||||
h.app.issue_report.draft.as_mut().unwrap().steps = "步".repeat(MAX_ANSWER - 1);
|
||||
h.frames(4);
|
||||
assert!(h.widget("1 character left").is_some());
|
||||
h.app.issue_report.draft.as_mut().unwrap().steps = "步".repeat(MAX_ANSWER - 200);
|
||||
h.frames(4);
|
||||
assert!(h.widget("1 character left").is_none() && h.widget("200 characters left").is_none(), "no note far from the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_requires_an_answer_before_browser_handoff_without_changing_the_design() {
|
||||
for (size, scale) in [([1600, 1000], 1.0), ([640, 480], 2.0)] {
|
||||
|
||||
@@ -17,7 +17,13 @@ fn answer(ui: &mut Ui, label: &str, hint: &str, text: &mut String, id: &str, row
|
||||
ui_kit::text(ui, tr_label(hint), ui_kit::Text::Note(hint));
|
||||
let response = ui_kit::text_area(ui, tr_label(label), Id::new(id), text, rows, MAX_ANSWER);
|
||||
crate::scenario::publish_handle(id, response.rect.center());
|
||||
super::truncate(text, MAX_ANSWER);
|
||||
super::truncate_chars(text, MAX_ANSWER);
|
||||
// What doesn't fit is not added: near the limit, say how much room is left (#7191).
|
||||
let left = MAX_ANSWER.saturating_sub(text.chars().count());
|
||||
if left < super::ANSWER_ROOM {
|
||||
let key = if left == 1 { "ui.report_characters_left_one" } else { "ui.report_characters_left_other" };
|
||||
ui_kit::text(ui, &crate::i18n::format(key, &[("count", &left.to_string())]), ui_kit::Text::Note(id));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
+12
-25
@@ -524,6 +524,11 @@ pub struct SolveApp {
|
||||
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
||||
/// Compact browser embed: retain model panels and joint controls without application chrome.
|
||||
pub embedded: bool,
|
||||
/// Editing embed (#8517): the host page owns the files and the window's chrome. No
|
||||
/// application bar, no start page, no New/Open/Save/Close or Preferences shortcuts; the
|
||||
/// ribbon, browser, timeline and properties stay, and the host reads and replaces the
|
||||
/// design through `document.export` / `document.import`.
|
||||
pub embed_editor: bool,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub(crate) shell: shell::State,
|
||||
@@ -930,6 +935,7 @@ impl SolveApp {
|
||||
fit_waits: 0,
|
||||
flat_view: Default::default(),
|
||||
embedded: false,
|
||||
embed_editor: false,
|
||||
home: home::HomeState::default(),
|
||||
space_mouse: Default::default(),
|
||||
file_load: file_load::FileLoad::default(),
|
||||
@@ -2183,12 +2189,6 @@ impl SolveApp {
|
||||
self.edit_sketch(id);
|
||||
return;
|
||||
}
|
||||
// Nothing here knows what it holds: it says so, and waits for the build that does.
|
||||
if let FeatureKind::NewerFileFeature { raw } = &f.kind {
|
||||
let why = raw.why();
|
||||
self.set_status(why, true);
|
||||
return;
|
||||
}
|
||||
let Some(idx) = self.session.doc.feature_index(id) else { return };
|
||||
let marker = self.finish_sketch_for_edit();
|
||||
self.tool = None;
|
||||
@@ -2493,23 +2493,6 @@ impl SolveApp {
|
||||
self.status_undo = false;
|
||||
}
|
||||
|
||||
/// Tell the user when the open design came from a newer SolidCraft: the notice on the status
|
||||
/// line, and what this version couldn't read in the messages.
|
||||
pub(crate) fn say_newer_file(&mut self) {
|
||||
let Some(file) = self.session.doc.newer_file.clone() else { return };
|
||||
for what in &file.not_understood {
|
||||
let text = i18n::format("document.newer_file_not_read", &[("what", what)]);
|
||||
self.messages.push(&text, false, now_ms());
|
||||
}
|
||||
let text = if file.format <= file.reads_up_to && !file.unknown_changes.is_empty() {
|
||||
let change = file.unknown_changes.first().map_or("", String::as_str);
|
||||
i18n::format("document.newer_file_change", &[("format", &file.format.to_string()), ("change", change)])
|
||||
} else {
|
||||
i18n::format("document.newer_file", &[("format", &file.format.to_string()), ("reads", &file.reads_up_to.to_string())])
|
||||
};
|
||||
self.set_status(text, false);
|
||||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match documents::reuse_path(self, path) {
|
||||
Ok(true) => return,
|
||||
@@ -2527,7 +2510,6 @@ impl SolveApp {
|
||||
self.cam.set_view(StandardView::Iso);
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &[], Some(&r));
|
||||
self.say_newer_file();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3128,6 +3110,11 @@ impl SolveApp {
|
||||
self.ui.show_browser = true;
|
||||
self.ui.show_timeline = true;
|
||||
}
|
||||
if self.embed_editor {
|
||||
// The host shows one design and has its own way to start one.
|
||||
self.ui.layout = shell::LayoutMode::Docked;
|
||||
self.home.open = false;
|
||||
}
|
||||
self.ui.layout.publish(ui.ctx());
|
||||
self.preferences.dialog_buttons.publish(ui.ctx());
|
||||
self.preferences.toast_place.publish(ui.ctx());
|
||||
@@ -3136,7 +3123,7 @@ impl SolveApp {
|
||||
self.poll_deferred(ui.ctx());
|
||||
self.poll_delete_check(ui.ctx());
|
||||
timeline::tick_playback(self, ui.ctx());
|
||||
if !self.embedded {
|
||||
if !self.embedded && !self.embed_editor {
|
||||
toolbar::app_bar(self, ui);
|
||||
}
|
||||
issue_report::CadInput::block(self, ui, report_input);
|
||||
|
||||
@@ -748,6 +748,23 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
// floating shell card that disables the underlying egui response.
|
||||
let captured_orbit = app.viewport.orbit_from.is_some() && button.is_some();
|
||||
let dragging = resp.dragged() || (inside && button.is_some()) || captured_orbit;
|
||||
// A middle or right drag keeps the mode it started with until its button is released, as a
|
||||
// left one does (`primary_nav`): letting go of Shift partway through a Shift+middle orbit
|
||||
// doesn't turn the rest into a pan (#9557). Off in Preferences, the keys held decide on every
|
||||
// frame, as before.
|
||||
let button_mode = match button {
|
||||
Some(b) if dragging && app.viewport.axis_drag.is_none() => {
|
||||
let kept = app.viewport.button_nav.filter(|(held, _)| *held == b && app.preferences.nav_drag_keeps_mode);
|
||||
let mode = kept.map_or_else(|| crate::prefs::nav_mode(app, b, mods), |(_, mode)| mode);
|
||||
app.viewport.button_nav = Some((b, mode));
|
||||
mode
|
||||
}
|
||||
Some(_) => None,
|
||||
None => {
|
||||
app.viewport.button_nav = None;
|
||||
None
|
||||
}
|
||||
};
|
||||
// Without orbit (a flat page), bindings that orbit do nothing.
|
||||
let allowed = |m: NavMode| caps.orbit || m != NavMode::Orbit;
|
||||
let motion = modifier_motion(app, ui, inside, hover, mods, picking).filter(|(m, _)| allowed(*m));
|
||||
@@ -756,7 +773,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
.or_else(|| {
|
||||
if dragging && app.viewport.axis_drag.is_none() {
|
||||
match button {
|
||||
Some(b) => crate::prefs::nav_mode(app, b, mods),
|
||||
Some(_) => button_mode,
|
||||
// A press held still that opened Select Other stays its own: egui calls it a
|
||||
// drag after 0.8 s, and moving to a row must not turn the view under the list.
|
||||
None if primary_down && !crate::select_other::is_open(app) => primary_nav,
|
||||
|
||||
@@ -1059,6 +1059,10 @@ preference_settings! {
|
||||
free_orbit_upright: bool = false, PAGE_NAVIGATION => check_tr("nav.free_orbit_upright.label");
|
||||
/// Off: a right drag that the mouse mapping would orbit leaves the camera alone (#6492).
|
||||
right_drag_orbits: bool = true, PAGE_NAVIGATION => check_tr("nav.right_drag_orbits.label");
|
||||
/// A middle or right drag keeps the mode it started with until its button is released, as a
|
||||
/// left drag with the mapping's keys does: letting go of Shift partway through a Shift+middle
|
||||
/// orbit doesn't turn it into a pan (#9557). Off: the keys held decide on every frame, as before.
|
||||
nav_drag_keeps_mode: bool = true, PAGE_NAVIGATION => check_tr("nav.drag_keeps_mode.label");
|
||||
/// View orientation widget: original signed axes (default) or compact cube.
|
||||
navigator_style: NavigatorStyle = NavigatorStyle::default(), PAGE_NAVIGATION;
|
||||
/// The custom view gizmo's colours: the cube's (themed) or the body's own (modeled).
|
||||
|
||||
@@ -110,6 +110,8 @@ fn mouse(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let response = setting::right_drag_orbits(app, ui).backing_on_hover_text(crate::i18n::tr("nav.right_drag_orbits.help"));
|
||||
crate::scenario::publish_target("prefs:right_drag_orbits", response.rect);
|
||||
}
|
||||
let keeps = setting::nav_drag_keeps_mode(app, ui).backing_on_hover_text(crate::i18n::tr("nav.drag_keeps_mode.help"));
|
||||
crate::scenario::publish_target("prefs:nav_drag_keeps_mode", keeps.rect);
|
||||
setting::radial_context_menu(app, ui).backing_on_hover_text(crate::i18n::tr("prefs.navigation.radial_help"));
|
||||
let label = app.tr("Middle double-click");
|
||||
let width = ui_kit::ContentWidth::label(ui, label);
|
||||
|
||||
@@ -1262,6 +1262,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7520: a Hole's preview leaves a hidden body out, drawn nor drilled, as OK does; before,
|
||||
/// it replaced the hidden body with a drilled one while the dialog was open.
|
||||
#[test]
|
||||
fn a_hole_preview_leaves_hidden_bodies_out() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 5, "body_name": "Plate"})).unwrap();
|
||||
s.execute("solid.box", &json!({"corner": [0, 0, 5], "length": 40, "width": 40, "height": 20, "operation": "new", "body_name": "Block"}))
|
||||
.unwrap();
|
||||
s.execute("browser.visibility", &json!({"items": ["Block"], "visible": false})).unwrap();
|
||||
let cmd = ("solid.hole".to_string(), json!({"position": [20, 20, 0], "diameter": 5, "depth": 10}));
|
||||
let built = compute(&s, &[cmd], colors()).unwrap();
|
||||
assert!(built.error.is_none(), "{:?}", built.error);
|
||||
assert_eq!(built.replaced, vec!["Plate".to_string()]);
|
||||
}
|
||||
|
||||
/// A plate in its own component, stood up by its placement (an imported part set upright)
|
||||
/// and edited with that component active: the extrusion's tool is drawn where the plate is
|
||||
/// shown, on its top face, not at right angles to it in the component's own frame.
|
||||
|
||||
@@ -2241,6 +2241,7 @@ fn paint_product(app: &mut SolveApp, ui: &mut egui::Ui, rect: egui::Rect, camera
|
||||
pv_key: 0,
|
||||
pv_slot: slot,
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: product_projection(camera, radius, rect, image).to_f32(),
|
||||
back: camera.back().to_f32(),
|
||||
size_px: [rect.width() * ppp, rect.height() * ppp],
|
||||
|
||||
@@ -304,7 +304,6 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solid
|
||||
}};
|
||||
}
|
||||
compare_authoring_fields!(
|
||||
newer_file,
|
||||
format,
|
||||
format_pending,
|
||||
name,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user