diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ba6bb80935..d66755db01 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Change -Describe the problem and resulting behavior. Link the issue when applicable. +Start with what this is, in plain words someone new to the project would understand: what it does and who it's for. Don't assume anyone knows the name of your tool, project or feature. Then describe the problem and resulting behavior. Link the issue when applicable. Changes a default: no @@ -14,6 +14,8 @@ What did you run, and what were the results? Include expected and measured value ## Screenshots of the behavior (when observable) +Put the picture or video here in the description. A file in the diff doesn't count: a maintainer has to be able to see what this is without opening the changed files. + For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful. Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2650b1ec53..c549570465 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,6 +1,6 @@ -# Two quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR -# for an issue a lower-numbered one already closes, and the "Changes a default:" line is filled in. -# Neither is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml). +# Three quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR +# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, and +# a PR adding to the showcase shows a picture. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml). name: PR checks on: @@ -48,6 +48,21 @@ jobs: const r = await checks.checkDefaults({ github, context, core }); core.info(JSON.stringify(r)); + picture: + name: Picture check + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts/pr-checks + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const checks = require('./scripts/pr-checks/pr-checks.js'); + const r = await checks.checkPicture({ github, context, core }); + core.info(JSON.stringify(r)); + # Labelling needs write access, which a fork PR's token never has, so it is its own job and best # effort: a fork PR just doesn't get the `changes-default` label from here. label: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8e3ae76cd9..393e081184 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -754,6 +754,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments. | docs/design/preferences/figures/view-shadow.dark.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 | | docs/design/preferences/figures/view-shadow.light.svg | SolidCraft contributors | original SolidCraft artwork drawn for the Preferences redesign (tracker #9863), extracted from the design board as a standalone SVG | MIT OR Apache-2.0 | | `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 | ## Code adapted from sibling projects diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54fed4165f..24ed0d02fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -347,6 +347,13 @@ without pushes. These automatic cleanup deadlines are separate from the daily pr `/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has taken are never freed automatically. +**A PR has to explain itself.** Start the description with what it is, in plain words someone new +to the project would understand: what it does and who it's for. Don't assume anyone knows the name +of your tool, project or feature. If there's anything to look at, show it in the description: a +picture or video has to be visible there without opening the changed files. "The GIF is the change" +doesn't count. A PR that doesn't do this waits until it does. For a PR that adds to +`docs/showcase.md` or `docs/screenshots/`, the Picture check fails until the description shows one. + Help reviewers see what the words mean. For observable CAD, UI or model behavior, please include actual descriptive screenshots or a short recording in the issue or PR. This evidence is required for changes or bugs that can be demonstrated in the app, including kernel, diff --git a/Cargo.lock b/Cargo.lock index 9a17497e9f..c48985d776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.30.0" +version = "0.30.1" dependencies = [ "block2 0.6.2", "eframe", @@ -4245,11 +4245,11 @@ dependencies = [ [[package]] name = "solidcraft-3dx-client" -version = "0.30.0" +version = "0.30.1" [[package]] name = "solidcraft-cli" -version = "0.30.0" +version = "0.30.1" dependencies = [ "image", "log", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.30.0" +version = "0.30.1" dependencies = [ "proc-macro2", "quote", @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.30.0" +version = "0.30.1" dependencies = [ "log", "serde", @@ -4287,7 +4287,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.30.0" +version = "0.30.1" dependencies = [ "image", "log", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "solidcraft-geom" -version = "0.30.0" +version = "0.30.1" dependencies = [ "serde", "serde_json", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.30.0" +version = "0.30.1" dependencies = [ "image", "log", @@ -4337,7 +4337,7 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.30.0" +version = "0.30.1" dependencies = [ "cadmpeg-core", "cadmpeg-ir", @@ -4362,7 +4362,7 @@ dependencies = [ [[package]] name = "solidcraft-kernel" -version = "0.30.0" +version = "0.30.1" dependencies = [ "log", "proptest", @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "solidcraft-mcp" -version = "0.30.0" +version = "0.30.1" dependencies = [ "image", "serde_json", @@ -4392,7 +4392,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.30.0" +version = "0.30.1" dependencies = [ "image", "moxcms", @@ -4407,7 +4407,7 @@ dependencies = [ [[package]] name = "solidcraft-sketch" -version = "0.30.0" +version = "0.30.1" dependencies = [ "harfrust", "serde", @@ -4420,18 +4420,18 @@ dependencies = [ [[package]] name = "solidcraft-space-mouse" -version = "0.30.0" +version = "0.30.1" dependencies = [ "serde", ] [[package]] name = "solidcraft-testkit" -version = "0.30.0" +version = "0.30.1" [[package]] name = "solidcraft-ui-egui" -version = "0.30.0" +version = "0.30.1" dependencies = [ "arboard", "egui", @@ -4462,7 +4462,7 @@ dependencies = [ [[package]] name = "solidcraft-web" -version = "0.30.0" +version = "0.30.1" dependencies = [ "eframe", "egui", @@ -6017,7 +6017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.30.0" +version = "0.30.1" dependencies = [ "proc-macro2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 29547a738e..553d19e134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.30.0" +version = "0.30.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index f6726e48f2..0ee1e258ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -662,7 +662,9 @@ completed drawings publish atomically. The browser still exports synchronously. pieces survive unfold/refold as one logical sheet group, including split flanged sheets. Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded - sampling. + sampling. A fold crosses the sheet's own outer edges the same way, slanted or on any curve, + with the outline exact in the flat pattern and its DXF, and flanges and other folds still go + on the panels. Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves exact holes and retained islands, including the union of multiple selected profiles, and diff --git a/SECURITY.md b/SECURITY.md index 00bd150493..23811aaeb1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ ## Supported versions SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please -update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before +update to the [latest release](https://github.com/tangency-labs/solidcraft/releases/latest) before reporting, and check whether the problem is still on `main`. | Version | Supported | @@ -15,18 +15,46 @@ reporting, and check whether the problem is still on `main`. ## Reporting a vulnerability Please **don't open a public issue** for a security problem. Report it privately through GitHub: -open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose +open the repository's [Security tab](https://github.com/tangency-labs/solidcraft/security) and choose **Report a vulnerability** -([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)). +([direct link](https://github.com/tangency-labs/solidcraft/security/advisories/new)). -Please include what you found, how to reproduce it (a design file, command or MCP request), the -version and platform, and the impact you expect. I'll acknowledge the report as soon as I can, -keep you updated in the advisory, and credit you when the fix is published unless you'd rather -stay anonymous. +Please include what you found, the steps you ran to reproduce it (a design file, command or MCP +request), the version and platform, and what an attacker gains. I'll acknowledge the report as soon +as I can, keep you updated in the advisory, and credit you when the fix is published unless you'd +rather stay anonymous. -Areas where reports are especially useful: +## What counts as a security issue -- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks) -- the MCP server and the control API (file access outside the allowed folders, command injection) -- the web app (anything reaching beyond the page's own storage) -- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap) +Advisories are for problems where someone gets something they shouldn't, without already having +access to your computer or your account. For example: + +- opening a design, STEP, STL or 3MF file from someone else runs code, or reads or overwrites files + it shouldn't +- a website or another computer can reach or control SolidCraft +- an agent gets past a prompt that promised to ask you first +- the web app reaches beyond the page's own storage +- a release package or an update can be tampered with +- an API key or token gets sent somewhere it shouldn't go + +## What to report as a normal bug + +Please [open an issue](https://github.com/tangency-labs/solidcraft/issues/new) for these. They're +still worth fixing, but they aren't security issues: + +- anything that needs a program already running on your computer, or the control token. A program + running as you can already do what you can. +- MCP's allowed folders. They guard against accidents, they aren't a security boundary, and an agent + with a shell of its own can already reach those files. Getting around them isn't a vulnerability. +- crashes, hangs, or heavy memory or CPU use, unless they also lead to something in the list above +- anything that only happens in an unusual setup you'd have to choose on purpose + +## Before you send it + +- **Run it.** Reproduce the problem on the latest release. If you only read the code, say so, and + expect it to wait. +- **Check it yourself.** If a scanner or an AI agent found it, read the code and confirm it first. + These tools tend to overrate what they find. +- **Say what's gained.** Name what the attacker needs and what they get. I set the final severity. + +Reports that don't fit will be closed with a short note, or moved to the issue tracker. diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index b210b63a9e..0473010ed5 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -172,7 +172,11 @@ fn main() -> ExitCode { Some("oracle") => oracle::run(&args[1..]), Some("fuzz") => fuzz::run(&args[1..]), Some("fuzz-one") => fuzz::one(&args[1..]), - Some("mcp") => cmd_mcp(&args[1..]), + Some("mcp") => { + let result = cmd_mcp(&args[1..]); + solidcraft_mcp::cleanup_scratch_dir(); + result + } Some("crashes") => crashes::run(&args[1..]), Some("icon") => cmd_icon(&args[1..]), Some("licences" | "licenses") => { diff --git a/apps/solidcraft-cli/tests/mcp_connect.rs b/apps/solidcraft-cli/tests/mcp_connect.rs index 1475f40d78..26e1ffb169 100644 --- a/apps/solidcraft-cli/tests/mcp_connect.rs +++ b/apps/solidcraft-cli/tests/mcp_connect.rs @@ -263,3 +263,110 @@ mod setup { assert!(json_of(&pkg.join("claude_desktop_config.json"))["mcpServers"]["solidcraft"].is_object()); } } + +/// Scratch files belong to the CLI process; exercise real startup, requests and shutdown. +mod scratch_cleanup { + use super::*; + use std::path::{Path, PathBuf}; + + fn command(case: &str) -> (PathBuf, Command) { + let dir = std::env::temp_dir().join(format!("solidcraft-mcp-lifecycle-{case}-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")); + command + .args(["mcp", "--headless"]) + .env("TMPDIR", &dir) + .env("TMP", &dir) + .env("TEMP", &dir) + .env("SOLIDCRAFT_RECOVERY_DIR", dir.join("recovery")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + (dir, command) + } + + fn scratch_folders(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.file_name().unwrap().to_string_lossy().starts_with("solidcraft-mcp-")) + .collect() + } + + fn initialize() -> Value { + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}) + } + + fn run(mut command: Command, request: Value) -> std::process::Output { + let mut child = command.spawn().unwrap(); + writeln!(child.stdin.take().unwrap(), "{request}").unwrap(); + child.wait_with_output().unwrap() + } + + #[test] + fn eof_removes_only_the_process_scratch_folder() { + let (dir, command) = command("eof"); + let unrelated = dir.join("solidcraft-mcp-unrelated"); + std::fs::create_dir(&unrelated).unwrap(); + let out = run(command, initialize()); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let reply: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(reply.get("result").is_some(), "{reply}"); + assert_eq!(scratch_folders(&dir), vec![unrelated]); + assert!(dir.join("recovery").is_dir(), "cleanup must leave the recovery folder alone"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn startup_error_removes_the_process_scratch_folder() { + let (dir, mut command) = command("startup-error"); + let out = command.args(["--param-check", "invalid"]).stdin(Stdio::null()).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!(stderr.contains("--param-check wants off, warn or strict"), "{stderr}"); + assert!(scratch_folders(&dir).is_empty(), "startup error left a scratch folder in {dir:?}"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn shutdown_preserves_nonempty_scratch_folder() { + let (dir, mut command) = command("nonempty"); + let mut child = command.spawn().unwrap(); + let mut input = child.stdin.take().unwrap(); + writeln!(input, "{}", initialize()).unwrap(); + let mut reply = String::new(); + BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap(); + let reply: Value = serde_json::from_str(&reply).unwrap(); + assert!(reply.get("result").is_some(), "{reply}"); + let scratch = scratch_folders(&dir); + assert_eq!(scratch.len(), 1); + let saved = scratch[0].join("keep.txt"); + std::fs::write(&saved, "saved output").unwrap(); + drop(input); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(std::fs::read_to_string(saved).unwrap(), "saved output"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failed_screenshot_scratch_folder_is_removed_without_a_path_scope() { + let (dir, mut command) = command("screenshot"); + command.arg("--allow-any-path"); + // A headless server cannot capture a window. The request still allocates a scratch + // path before the backend refuses it, without relying on a GPU or installed renderer. + let out = run( + command, + json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "screenshot", "arguments": {"source": "window"}}, + }), + ); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let reply: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(reply["result"]["isError"], true, "{reply}"); + assert!(reply["result"]["content"][0]["text"].as_str().unwrap().contains("the window gave no picture"), "{reply}"); + assert!(scratch_folders(&dir).is_empty(), "failed screenshot left a scratch folder in {dir:?}"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/apps/solidcraft/src/control_server.rs b/apps/solidcraft/src/control_server.rs index b3a197edf3..996d7c54a9 100644 --- a/apps/solidcraft/src/control_server.rs +++ b/apps/solidcraft/src/control_server.rs @@ -22,6 +22,10 @@ const MAX_BAD_LINES: usize = 8; /// How long a subscribed connection waits after a `design.changed` line before the next: what /// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985). const EVENT_GAP: Duration = Duration::from_millis(100); +/// How long a new connection has to show the token. A client connects when it has a request to +/// send, so this is plenty; one that doesn't send it loses the connection, and its seat among +/// [`MAX_CONNECTIONS`] goes to someone else. +const TOKEN_WAIT: Duration = Duration::from_secs(5); static OPEN: AtomicUsize = AtomicUsize::new(0); /// The token files this launch wrote (its control port, the agent chat's endpoint) and the @@ -225,23 +229,53 @@ fn unauthorized() -> Value { /// Refuse the connection: the reply, then the end of what we send, then what the client had /// already sent read and dropped (a little, briefly). Closing a socket with unread input resets /// the connection, and a reset can reach the client before it has read the refusal. -fn refuse(out: &mut TcpStream, reader: &mut BufReader) { +fn refuse(out: &mut TcpStream, reader: &mut BufReader) { use std::io::Read; let _ = send(out, &unauthorized()); let _ = out.shutdown(std::net::Shutdown::Write); - let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500))); + reader.get_mut().deadline = Some(Instant::now() + Duration::from_millis(500)); let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink()); } +/// What a client sends. Until it shows the token, all of its reads share one deadline, so neither +/// blank lines nor a line sent a byte at a time buy it more time. After that it may stay idle as +/// long as it likes. +struct ClientInput { + socket: TcpStream, + deadline: Option, +} + +impl ClientInput { + /// No deadline any more: the client showed the token. + fn admit(&mut self) -> std::io::Result<()> { + self.deadline = None; + self.socket.set_read_timeout(None) + } +} + +impl std::io::Read for ClientInput { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::ErrorKind::TimedOut.into()); + } + self.socket.set_read_timeout(Some(left))?; + } + self.socket.read(buf) + } +} + /// How long a request waits for the app's reply: most requests, and a batch (or every request, -/// on a patient connection). +/// on a patient connection); and how long a new connection has to show the token. #[derive(Clone, Copy)] struct Waits { short: Duration, long: Duration, + token: Duration, } -const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) }; +const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600), token: TOKEN_WAIT }; /// A request that timed out but is still running in the app (#1618): its id and method, and where /// its reply will land. @@ -543,7 +577,7 @@ fn serve_with( let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1; let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - let mut reader = BufReader::new(read); + let mut reader = BufReader::new(ClientInput { socket: read, deadline: Some(Instant::now() + waits.token) }); let mut bad = 0usize; let mut authenticated = false; let mut pending: Option = None; @@ -554,6 +588,10 @@ fn serve_with( let line = match read_line(&mut reader, MAX_LINE) { Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(), Ok(None) => break, + // Closed without a word: a client that connected early and only now sends its first + // request would read a line here as the reply to it. It finds the connection closed + // and opens another. + Err(e) if !authenticated && matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => break, Err(e) => { let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()})); break; @@ -565,7 +603,12 @@ fn serve_with( // The first request must carry the token; later ones on the same connection may. if !authenticated { match token_of(&line) { - Some(t) if control_auth::tokens_match(&t, token) => authenticated = true, + Some(t) if control_auth::tokens_match(&t, token) => { + if reader.get_mut().admit().is_err() { + break; + } + authenticated = true; + } _ => { refuse(&mut out, &mut reader); break; @@ -675,6 +718,80 @@ fn serve_with( mod tests { use super::*; + /// A connection that doesn't show the token in time is closed without a word, however it fills + /// the time. One that does keeps working when it idles for longer than that. + #[test] + fn the_token_has_a_time_limit_and_an_authenticated_client_may_idle() { + use std::io::Read; + let token = "0123456789abcdef0123456789abcdef"; + let limit = Duration::from_millis(500); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + }); + static RUNNING: Running = Running::new(); + let waits = Waits { short: Duration::from_secs(10), long: Duration::from_secs(10), token: limit }; + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH)); + } + }); + let open = || { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + s + }; + // Sends `drip` every 50 ms until the server closes; what it read, and when it closed. + let closed_after = |drip: &[u8]| { + let mut s = open(); + let start = Instant::now(); + s.set_read_timeout(Some(Duration::from_millis(50))).unwrap(); + let mut got = Vec::new(); + loop { + let mut buf = [0u8; 256]; + match s.read(&mut buf) { + Ok(0) => break, + Ok(n) => got.extend_from_slice(buf.get(..n).unwrap()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + assert!(start.elapsed() < Duration::from_secs(20), "still open"); + let _ = s.write_all(drip); + } + Err(_) => break, + } + } + (got, start.elapsed()) + }; + for drip in [&b""[..], b"\n", b"\r\n \n", b" ", b"{"] { + let (got, took) = closed_after(drip); + assert!(got.is_empty(), "{drip:?} was answered: {}", String::from_utf8_lossy(&got)); + assert!(took >= limit.saturating_sub(Duration::from_millis(50)), "{drip:?} closed after {took:?}"); + } + + let mut s = open(); + let mut lines = BufReader::new(s.try_clone().unwrap()); + let mut ask = |line: Value| -> Value { + writeln!(s, "{line}").unwrap(); + let mut l = String::new(); + lines.read_line(&mut l).unwrap(); + serde_json::from_str(&l).unwrap() + }; + assert_eq!(ask(json!({"id": 1, "method": "ui.view", "token": token}))["ok"], true); + std::thread::sleep(limit * 3); + assert_eq!(ask(json!({"id": 2, "method": "ui.view"}))["ok"], true, "idle for longer than the limit"); + + // A wrong token is still answered, at once. + let mut wrong = open(); + writeln!(wrong, "{}", json!({"id": 1, "method": "ui.view", "token": "nope"})).unwrap(); + let mut l = String::new(); + BufReader::new(wrong).read_line(&mut l).unwrap(); + assert!(l.contains("unauthorized"), "{l}"); + } + /// #1618: a request that outlasts its wait says it is still running; the connection keeps its /// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none /// is queued to run unseen behind it. @@ -698,7 +815,7 @@ mod tests { let _ = req.reply.send(json!({"ok": true, "result": req.method})); } }); - let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) }; + let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT }; static RUNNING: Running = Running::new(); std::thread::spawn(move || { if let Some(stream) = listener.incoming().flatten().next() { @@ -764,7 +881,7 @@ mod tests { let _ = req.reply.send(json!({"ok": true, "result": req.method})); } }); - let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) }; + let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60), token: TOKEN_WAIT }; std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); diff --git a/assets/icons/ui/design/requested_icons.txt b/assets/icons/ui/design/requested_icons.txt index 1789b23700..04f5dd9ec9 100644 --- a/assets/icons/ui/design/requested_icons.txt +++ b/assets/icons/ui/design/requested_icons.txt @@ -237,6 +237,7 @@ settings_monitor false settings_mouse false settings_palette false settings_plug false +settings_profile false sheet false sheet_rules false shell false diff --git a/assets/icons/ui/design/svg/flat/settings_profile.svg b/assets/icons/ui/design/svg/flat/settings_profile.svg new file mode 100644 index 0000000000..0546b069ea --- /dev/null +++ b/assets/icons/ui/design/svg/flat/settings_profile.svg @@ -0,0 +1,5 @@ + + Settings profile + + + diff --git a/assets/locales/ar.json b/assets/locales/ar.json index 9c15181f62..0d5d89e7cd 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -477,6 +477,7 @@ "Rectangular": "مستطيلي", "Rectangular Tube": "أنبوب مستطيل", "Reference face": "الوجه المرجعي", + "Reference plane": "المستوى المرجعي", "Relief Depth": "عمق التفريغ", "Relief Width": "عرض التفريغ", "Remove Tools": "إزالة الأدوات", @@ -494,7 +495,7 @@ "Rename Folder": "إعادة تسمية المجلد", "Render Stage": "مرحلة التصيير", "Reopen designs": "إعادة فتح التصاميم", - "Reopen the designs that were open when SolidCraft was last closed": "إعادة فتح التصاميم التي كانت مفتوحة عند آخر إغلاق لـ SolidCraft", + "Reopen the designs that were open when SolidCraft was last closed": "إعادة فتح التصاميم التي كانت مفتوحة آخر مرة", "Replace existing library file": "استبدال ملف المكتبة الموجود", "Report a bug — review a screenshot and open a GitHub draft": "الإبلاغ عن خطأ — راجع لقطة الشاشة وافتح مسودة GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "تم نسخ التقرير. احتفظ بهذا النص لإرساله على GitHub عند الاتصال بالإنترنت.", @@ -1376,7 +1377,7 @@ "dialog.selection_external_curve": "{sketch} · منحنى {id}", "dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.", "dialog.selection_midpoint": "نقطة المنتصف · {id}", - "dialog.selection_profile": "{sketch} · المقطع {number}", + "dialog.selection_profile": "{sketch} · مقطع {number}", "dialog.selection_step": "{step} من {total}: حدد {input}", "dialog.step": "الخطوة", "document.changed_on_disk": "تغيّر {name} على القرص", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي", "parts.family.dowel_pin": "مسمار توجيه أسطواني", "parts.family.hex_bolt": "برغي سداسي الرأس", + "parts.family.hex_cap_screw_inch": "برغي سداسي الرأس (بوصة)", "parts.family.hex_nut": "صامولة سداسية", + "parts.family.hex_nut_inch": "صامولة سداسية (بوصة)", "parts.family.set_screw": "برغي تثبيت بتجويف سداسي، طرف مسطح", "parts.family.socket_head_cap_screw": "برغي برأس أسطواني بتجويف سداسي", "parts.family.washer": "حلقة مسطحة", + "parts.family.washer_sae": "حلقة مسطحة، SAE (ضيقة)", + "parts.family.washer_uss": "حلقة مسطحة، USS (عريضة)", "parts.holes": "الثقوب (اختياري)", "parts.placement_hint": "اختر ثقبًا أو أكثر لتثبيت قطعة في كل منها، أو اضغط موافق لوضعها عند نقطة الأصل", + "parts.system": "نظام الوحدات", + "parts.system.inch": "بوصة", + "parts.system.metric": "متري", "pattern.alignment_2": "المحاذاة 2", "pattern.alignment_hint": "العدد يشمل الأصل. «توسيط» يوازن النسخ؛ و«قلب» يختار الجانب الإضافي عندما يكون العدد زوجيًا.", "pattern.direction_1": "الاتجاه 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "نص تجريبي للخط · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "تعذّرت قراءة الخط المحدد.", "prefs.general": "عام", - "prefs.general_note": "اللغة والحفظ والتحديثات.", + "prefs.general.active": "نشط", + "prefs.general.crash_reports": "تقارير الأعطال", + "prefs.general.edited": "معدّل", + "prefs.general.held": "المحفوظ", + "prefs.general.import": "استيراد...", + "prefs.general.last_check": "آخر تحقق: {time}", + "prefs.general.minutes": "دقائق", + "prefs.general.name_setup": "سمِّ هذا الإعداد", + "prefs.general.profile_name": "اسم ملف التعريف", + "prefs.general.save_new": "حفظ كجديد", + "prefs.general.step_import": "STEP والاستيراد", + "prefs.general.this_computer": "هذا الكمبيوتر", + "prefs.general_note": "ملفات التعريف واللغة والملفات والتحديثات.", "prefs.gesture_orbit": "تدوير العرض", "prefs.gesture_pan": "إزاحة العرض", "prefs.gesture_zoom": "تكبير", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "لون التمييز", "prefs.logo_theme": "ألوان السمة", "prefs.max_file_size": "أكبر ملف يمكن فتحه", - "prefs.max_file_size_design": "ملفات التصميم", + "prefs.max_file_size_design": "أكبر ملف للفتح: التصميم", "prefs.max_file_size_flag": "بدأ هذا التشغيل بالخيار --max-file-size {size}، ويسري حتى إغلاق SolidCraft. تسري هذه الإعدادات عند تشغيله دون هذا الخيار.", "prefs.max_file_size_note": "القيم الافتراضية: 512 MB و256 MB؛ الحد الأقصى 16 GB. يتطلب فتح ملف ذاكرة تعادل حجمه عدة مرات. قد يحتاج التصميم الذي يحتفظ باستيراد كبير إلى حد أعلى لملف التصميم لحفظه وإعادة فتحه.", - "prefs.max_file_size_step": "ملفات STEP والاستيراد", + "prefs.max_file_size_step": "أكبر ملف للفتح: STEP والاستيراد", "prefs.max_file_size_web": "يفتح تطبيق الويب ملفات STEP والاستيراد حتى 512 MB والتصاميم حتى 256 MB؛ لا يتسع تبويب المتصفح لملفات أكبر. يمكن رفع هذه الحدود في تطبيق سطح المكتب.", "prefs.megabytes": "MB", "prefs.menu_grid": "أيقونات كبيرة", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "النقر على وجه أو ركن أو سهم في المكعب، أو على محور في أداة المحاور، يوجّه العرض أيضًا نحو منتصف النموذج من المسافة الواحدة التي تُظهره كاملًا من كل جهة (كرته المحيطة). يستخدم العرض الرئيسي التكبير والمركز ذاتهما ما دام هذا مفعّلًا، فلا تغيّر العروض المختارة بعده سوى الاتجاه. عند الإيقاف: يدور العرض في مكانه محتفظًا بالهدف والتكبير.", "prefs.network_host_remove": "إزالة", "prefs.network_locations": "السماح بمواقع الشبكة في التصاميم", - "prefs.network_locations_note": "عند الإيقاف: الملفات المرتبطة بالتصميم على حاسوب آخر (\\\\server\\share) لا تُفتح إلا من الخوادم المدرجة هنا، أو بعد أن تسمح لأحدها عند السؤال.", + "prefs.network_locations_note": "للمؤسسات التي تحفظ التصاميم على خوادم الملفات. عند الإيقاف: الخوادم التي تسمح بها فقط.", "prefs.network_locations_section": "مواقع الشبكة", "prefs.not_on_gpu": "غير متاح على هذا المعالج الرسومي", "prefs.on": "تشغيل", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "تم تطبيق ملف التعريف.", "prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.", "prefs.profile_deleted": "تم حذف ملف التعريف.", - "prefs.profile_export": "تصدير…", + "prefs.profile_export": "تصدير", "prefs.profile_exported": "تم تصدير ملف التعريف.", - "prefs.profile_imported": "تم استيراد ملف التعريف. اختره في «ملف التعريف النشط» للتبديل إليه.", + "prefs.profile_imported": "تم استيراد ملف التعريف.", "prefs.profile_name_hint": "اسم ملف التعريف، مثل: حاسوب محمول", "prefs.profile_rename": "إعادة تسمية", "prefs.profile_renamed": "تمت إعادة تسمية ملف التعريف.", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير", "prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.", "prefs.reports_saved": "{n} محفوظة", + "prefs.restore_page_defaults": "استعادة الإعدادات الافتراضية لهذه الصفحة", "prefs.ribbon_click_ends_tool": "النقر على زر الأداة قيد التشغيل ينهيها", "prefs.ribbon_click_ends_tool_note": "النقر على زر الشريط المضيء لأداة الرسم المستخدمة ينهي الأداة، فيعود النقر في العرض إلى التحديد. عند الإيقاف: يبدأ النقر الأداة من جديد.", "prefs.ribbon_reveal": "إظهار مكان نتائج البحث في الشريط", "prefs.ribbon_reveal_note": "عند تمرير المؤشر فوق نتيجة في «البحث في الأوامر» يظهر مكان الأمر في الشريط: تبويبه أو زره أو صفه في القائمة المنسدلة للوحة. ويومض هناك الأمر الذي بدأ من البحث، ويُعلِّم الأمر الجاري من قائمة منسدلة لوحتَه.", "prefs.right_click": "نقر أيمن", - "prefs.saving": "الحفظ التلقائي والاستعادة", + "prefs.saving": "البدء والحفظ", "prefs.scale_note": "أو Ctrl + / Ctrl − في النافذة. الأزرار 28 px عند 100%.", "prefs.scale_text": "الحجم والنص", "prefs.search": "البحث في الإعدادات", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "شغّل هذا في الطرفية، ثم اضغط «اتصال» مرة أخرى:", "ui.agent_chat_auth_title": "تسجيل الدخول إلى الوكيل", "ui.agent_chat_cancel": "إلغاء", + "ui.agent_chat_cancel_timeout": "لم يستجب الوكيل لطلب الإيقاف، فتم قطع الاتصال به. أعد الاتصال للمتابعة.", "ui.agent_chat_cannot_start": "مثبّت، لكنه لا يبدأ: {error}", "ui.agent_chat_changes": "التغييرات: {n}، خطوة تراجع واحدة", "ui.agent_chat_changes_n": "{n} تغييرات", @@ -4456,6 +4478,8 @@ "ui.coming_later": "قريبًا", "ui.command_done": "تم {command}", "ui.command_palette_hint": "اكتب أمرًا، مثل {example} — أو اضغط S للبحث", + "ui.components_from_bodies_hint": "أنشئ مكونًا لكل جسم محدد، باسم ذلك الجسم، ضمن المكون النشط.", + "ui.components_from_bodies_required": "حدد جسمًا واحدًا على الأقل.", "ui.concentric": "متحد المركز", "ui.cone": "مخروط", "ui.conic_curve": "منحنى مخروطي", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "مدرج تحت {feature}", "ui.history_title": "السجل", "ui.history_toggle_names": "إظهار أسماء الميزات أو إخفاؤها", + "ui.history_waits_for_editor": "ينتظر التراجع والإعادة والسجل حتى يكتمل فتح محرر الميزة", "ui.hole": "ثقب", "ui.hole_tap_type": "نوع لولبة الثقب", "ui.hole_type": "نوع الثقب", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "لم يستجب {slicer} في الوقت المحدد. الملف محفوظ مسبقًا في {path}: أرسله مرة أخرى بعد أن يبدأ برنامج التقطيع.", "ui.slicer_resend_hint": "الإرسال مرة أخرى يستبدل الملف المفتوح في برنامج التقطيع.", "ui.slicer_single_instance": "فتح الملفات في برنامج تقطيع يعمل مسبقًا", - "ui.slicer_single_instance_tip": "يحمّل PrusaSlicer وSuperSlicer وCura الملف في النافذة المفتوحة بدلًا من تشغيل نسخة أخرى. أما برامج التقطيع الأخرى فتُشغَّل كما هي. يفعل PrusaSlicer 3.0 هذا دائمًا: لا يقبل مشغّله الملف بأي طريقة أخرى.", + "ui.slicer_single_instance_tip": "لبرامج التقطيع التي تفتح الملف في نافذة قيد التشغيل.", "ui.slicer_starting": "جارٍ تشغيل {slicer}…", "ui.slicer_still_starting": "لا يزال الانتظار جاريًا لبدء {slicer}. لم يُرسل أي شيء آخر.", "ui.slicer_stl_fallback": "تعذّرت كتابة ملف 3MF، لذا هذا ملف STL.", @@ -5218,7 +5243,7 @@ "update.available": "SolidCraft {version} متاح", "update.button": "تحديث", "update.button_version": "التحديث إلى {version}", - "update.check_automatically": "التحقق من التحديثات تلقائيًا", + "update.check_automatically": "التحقق عند البدء ومرة يوميًا", "update.check_for_updates": "التحقق من وجود تحديثات", "update.check_now": "تحقق الآن", "update.check_unavailable": "التحقق من التحديثات غير متاح لهذا التثبيت.", diff --git a/assets/locales/de.json b/assets/locales/de.json index 83eb45e573..c6fe501945 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -477,6 +477,7 @@ "Rectangular": "Rechteckig", "Rectangular Tube": "Rechteckrohr", "Reference face": "Referenzfläche", + "Reference plane": "Referenzebene", "Relief Depth": "Freistellungstiefe", "Relief Width": "Freistellungsbreite", "Remove Tools": "Werkzeuge entfernen", @@ -494,7 +495,7 @@ "Rename Folder": "Ordner umbenennen", "Render Stage": "Renderphase", "Reopen designs": "Designs erneut öffnen", - "Reopen the designs that were open when SolidCraft was last closed": "Die Designs erneut öffnen, die beim letzten Beenden von SolidCraft geöffnet waren", + "Reopen the designs that were open when SolidCraft was last closed": "Die zuletzt geöffneten Entwürfe erneut öffnen", "Replace existing library file": "Vorhandene Bibliotheksdatei ersetzen", "Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen", "Report copied. Keep this text to submit on GitHub when you are online.": "Bericht kopiert. Bewahren Sie diesen Text auf, um ihn auf GitHub einzureichen, sobald Sie online sind.", @@ -2088,12 +2089,19 @@ "parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant", "parts.family.dowel_pin": "Zylinderstift", "parts.family.hex_bolt": "Sechskantschraube", + "parts.family.hex_cap_screw_inch": "Sechskantschraube (Zoll)", "parts.family.hex_nut": "Sechskantmutter", + "parts.family.hex_nut_inch": "Sechskantmutter (Zoll)", "parts.family.set_screw": "Gewindestift mit Innensechskant und Kegelstumpf", "parts.family.socket_head_cap_screw": "Zylinderschraube mit Innensechskant", "parts.family.washer": "Unterlegscheibe", + "parts.family.washer_sae": "Unterlegscheibe, SAE (schmal)", + "parts.family.washer_uss": "Unterlegscheibe, USS (breit)", "parts.holes": "Bohrungen (optional)", "parts.placement_hint": "Wählen Sie eine oder mehrere Bohrungen, um in jede ein Teil einzusetzen, oder OK platziert es im Ursprung", + "parts.system": "Maßsystem", + "parts.system.inch": "Zoll", + "parts.system.metric": "Metrisch", "pattern.alignment_2": "Ausrichtung 2", "pattern.alignment_hint": "Die Anzahl enthält das Original. Die zentrierte Anordnung verteilt Kopien auf beide Seiten; bei gerader Anzahl legt die Richtungsumkehr die Seite mit der zusätzlichen Kopie fest.", "pattern.direction_1": "Richtung 1", @@ -2195,7 +2203,19 @@ "prefs.font_sample": "Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich · Ø12,50 mm · 45,0° · 0123456789", "prefs.font_unreadable": "Die ausgewählte Schriftdatei konnte nicht gelesen werden.", "prefs.general": "Allgemein", - "prefs.general_note": "Sprache, Speichern und Updates.", + "prefs.general.active": "Aktiv", + "prefs.general.crash_reports": "Absturzberichte", + "prefs.general.edited": "Geändert", + "prefs.general.held": "Enthalten", + "prefs.general.import": "Importieren...", + "prefs.general.last_check": "Zuletzt geprüft: {time}", + "prefs.general.minutes": "Minuten", + "prefs.general.name_setup": "Dieses Setup benennen", + "prefs.general.profile_name": "Profilname", + "prefs.general.save_new": "Als neu speichern", + "prefs.general.step_import": "STEP und Import", + "prefs.general.this_computer": "Dieser Computer", + "prefs.general_note": "Profile, Sprache, Dateien und Updates.", "prefs.gesture_orbit": "Ansicht drehen", "prefs.gesture_pan": "Ansicht verschieben", "prefs.gesture_zoom": "Ansicht zoomen", @@ -2250,10 +2270,10 @@ "prefs.logo_accent": "Akzentfarbe", "prefs.logo_theme": "Designfarben", "prefs.max_file_size": "Maximale Dateigröße zum Öffnen", - "prefs.max_file_size_design": "Designdateien", + "prefs.max_file_size_design": "Größte zu öffnende Datei: Entwurf", "prefs.max_file_size_flag": "Dieser Lauf wurde mit --max-file-size {size} gestartet. Der Wert gilt, bis SolidCraft geschlossen wird. Diese Einstellungen gelten beim Start ohne diese Option.", "prefs.max_file_size_note": "Standard: 512 MB und 256 MB; höchstens 16 GB. Das Öffnen einer Datei benötigt ein Mehrfaches ihrer Größe im Arbeitsspeicher. Ein Design mit großem Import benötigt zum Speichern und erneuten Öffnen eventuell eine höhere Grenze für Designdateien.", - "prefs.max_file_size_step": "STEP- und Importdateien", + "prefs.max_file_size_step": "Größte zu öffnende Datei: STEP und Import", "prefs.max_file_size_web": "Die Web-App öffnet STEP- und Importdateien bis 512 MB und Designs bis 256 MB: Ein Browser-Tab kann keine größeren Dateien aufnehmen. In der Desktop-App lassen sich diese Grenzen erhöhen.", "prefs.megabytes": "MB", "prefs.menu_grid": "Große Symbole", @@ -2270,7 +2290,7 @@ "prefs.navigator_fit_note": "Eine Fläche, Ecke oder ein Pfeil des Würfels oder eine Achse des Gizmos blickt auch auf die Mitte des Modells, aus dem einen Abstand, der es von jeder Seite einpasst (seine Hüllkugel). Home nutzt denselben Zoom und dieselbe Mitte, solange dies an ist, sodass danach gewählte Ansichten sich nur drehen. Aus: Die Ansicht dreht sich an Ort und Stelle und behält Ziel und Zoom.", "prefs.network_host_remove": "Entfernen", "prefs.network_locations": "Netzwerkspeicherorte in Designs zulassen", - "prefs.network_locations_note": "Deaktiviert: Verknüpfte Dateien eines Designs auf einem anderen Computer (\\\\server\\share) werden nur von den hier aufgeführten Servern geöffnet oder nachdem Sie einen Server auf Nachfrage ausdrücklich zugelassen haben.", + "prefs.network_locations_note": "Für Organisationen, die Entwürfe auf Dateiservern speichern. Aus: nur erlaubte Server.", "prefs.network_locations_section": "Netzwerkspeicherorte", "prefs.not_on_gpu": "Auf dieser GPU nicht verfügbar", "prefs.on": "An", @@ -2296,9 +2316,9 @@ "prefs.profile_applied": "Profil angewendet.", "prefs.profile_changed": "Die Einstellungen wurden seit dem Speichern des Profils geändert. „Speichern“ übernimmt sie ins Profil.", "prefs.profile_deleted": "Profil gelöscht.", - "prefs.profile_export": "Exportieren…", + "prefs.profile_export": "Exportieren", "prefs.profile_exported": "Profil exportiert.", - "prefs.profile_imported": "Profil importiert. Unter „Aktives Profil“ auswählen, um dazu zu wechseln.", + "prefs.profile_imported": "Profil importiert.", "prefs.profile_name_hint": "Profilname, z. B. Laptop", "prefs.profile_rename": "Umbenennen", "prefs.profile_renamed": "Profil umbenannt.", @@ -2318,12 +2338,13 @@ "prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden", "prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.", "prefs.reports_saved": "{n} gespeichert", + "prefs.restore_page_defaults": "Seitenstandardwerte wiederherstellen", "prefs.ribbon_click_ends_tool": "Klick auf die Schaltfläche des laufenden Werkzeugs beendet es", "prefs.ribbon_click_ends_tool_note": "Ein Klick auf die hervorgehobene Schaltfläche des aktiven Skizzenwerkzeugs beendet das Werkzeug, sodass Klicks in die Ansicht wieder auswählen. Aus: Der Klick startet das Werkzeug neu.", "prefs.ribbon_reveal": "Suchergebnisse im Menüband zeigen", "prefs.ribbon_reveal_note": "Steht der Zeiger über einem Ergebnis in Befehle suchen, zeigt das Menüband, wo der Befehl liegt: seine Registerkarte, seine Schaltfläche oder seine Zeile im Aufklappmenü eines Panels. Ein aus der Suche gestarteter Befehl blinkt dort, und ein laufender Befehl aus einem Aufklappmenü markiert sein Panel.", "prefs.right_click": "Rechtsklick", - "prefs.saving": "Automatisches Speichern und Wiederherstellung", + "prefs.saving": "Starten und Speichern", "prefs.scale_note": "Die Skalierung lässt sich auch mit Strg + / Strg − im Fenster ändern. Bei 100 % sind die Schaltflächen 28 px groß.", "prefs.scale_text": "Skalierung und Text", "prefs.search": "Einstellungen suchen", @@ -4121,6 +4142,7 @@ "ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:", "ui.agent_chat_auth_title": "Beim Agenten anmelden", "ui.agent_chat_cancel": "Abbrechen", + "ui.agent_chat_cancel_timeout": "Der Agent hat nicht auf Stopp reagiert und wurde getrennt. Zum Fortfahren erneut verbinden.", "ui.agent_chat_cannot_start": "Installiert, startet aber nicht: {error}", "ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt", "ui.agent_chat_changes_n": "{n} Änderungen", @@ -4451,6 +4473,8 @@ "ui.coming_later": "bald verfügbar", "ui.command_done": "{command} fertig", "ui.command_palette_hint": "Befehl eingeben, z. B. {example} — oder S drücken, um zu suchen", + "ui.components_from_bodies_hint": "Für jeden ausgewählten Körper eine gleichnamige Komponente unter der aktiven Komponente erstellen.", + "ui.components_from_bodies_required": "Mindestens einen Körper auswählen.", "ui.concentric": "Konzentrisch", "ui.cone": "Kegel", "ui.conic_curve": "Kegelschnitt", @@ -4753,6 +4777,7 @@ "ui.history_sketch_listed_under": "Aufgeführt unter {feature}", "ui.history_title": "Verlauf", "ui.history_toggle_names": "Merkmalsnamen ein- oder ausblenden", + "ui.history_waits_for_editor": "Rückgängig, Wiederholen und Verlauf warten, bis der Feature-Editor geöffnet ist", "ui.hole": "Bohrung", "ui.hole_tap_type": "Gewindetyp", "ui.hole_type": "Bohrungstyp", @@ -5064,7 +5089,7 @@ "ui.slicer_no_response": "{slicer} hat nicht rechtzeitig geantwortet. Die Datei ist bereits unter {path} gespeichert: Senden Sie sie erneut, sobald der Slicer gestartet ist.", "ui.slicer_resend_hint": "Erneutes Senden ersetzt die Datei, die der Slicer geöffnet hat.", "ui.slicer_single_instance": "Dateien in einem bereits laufenden Slicer öffnen", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer und Cura laden die Datei in das geöffnete Fenster, statt ein weiteres zu starten. Andere Slicer werden wie gewohnt gestartet. PrusaSlicer 3.0 tut das immer: Sein Starter kann eine Datei nicht anders übernehmen.", + "ui.slicer_single_instance_tip": "Für Slicer, die Dateien in ein laufendes Fenster laden.", "ui.slicer_starting": "{slicer} wird gestartet…", "ui.slicer_still_starting": "Es wird weiter darauf gewartet, dass {slicer} startet. Sonst wurde nichts gesendet.", "ui.slicer_stl_fallback": "Das 3MF konnte nicht geschrieben werden, daher ist dies ein STL.", @@ -5209,7 +5234,7 @@ "update.available": "SolidCraft {version} ist verfügbar", "update.button": "Update", "update.button_version": "Auf {version} aktualisieren", - "update.check_automatically": "Automatisch nach Updates suchen", + "update.check_automatically": "Beim Start und einmal täglich prüfen", "update.check_for_updates": "Nach Updates suchen", "update.check_now": "Jetzt suchen", "update.check_unavailable": "Für diese Installation ist keine Update-Prüfung verfügbar.", diff --git a/assets/locales/en-GB.json b/assets/locales/en-GB.json index 9342711665..f5b67b9cfd 100644 --- a/assets/locales/en-GB.json +++ b/assets/locales/en-GB.json @@ -76,12 +76,11 @@ "prefs.bottom_centre": "Bottom centre", "prefs.logo_theme": "Theme's colours", "prefs.max_file_size": "Largest file to open", - "prefs.max_file_size_design": "Design files", "prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.", "prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.", - "prefs.max_file_size_step": "STEP and import files", "prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.", "prefs.megabytes": "MB", + "prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow.", "prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour", "prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.", "prefs.sketch_construction_lines": "Extended construction line tools", diff --git a/assets/locales/en.json b/assets/locales/en.json index fb656afb2c..f0585f28eb 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -477,6 +477,7 @@ "Rectangular": "Rectangular", "Rectangular Tube": "Rectangular Tube", "Reference face": "Reference face", + "Reference plane": "Reference plane", "Relief Depth": "Relief Depth", "Relief Width": "Relief Width", "Remove Tools": "Remove Tools", @@ -494,7 +495,7 @@ "Rename Folder": "Rename Folder", "Render Stage": "Render Stage", "Reopen designs": "Reopen designs", - "Reopen the designs that were open when SolidCraft was last closed": "Reopen the designs that were open when SolidCraft was last closed", + "Reopen the designs that were open when SolidCraft was last closed": "Reopen the designs that were open last time", "Replace existing library file": "Replace existing library file", "Report a bug — review a screenshot and open a GitHub draft": "Report a bug — review a screenshot and open a GitHub draft", "Report copied. Keep this text to submit on GitHub when you are online.": "Report copied. Keep this text to submit on GitHub when you are online.", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "Button head cap screw", "parts.family.dowel_pin": "Parallel pin (dowel)", "parts.family.hex_bolt": "Hex head bolt / screw", + "parts.family.hex_cap_screw_inch": "Hex cap screw (inch)", "parts.family.hex_nut": "Hex nut", + "parts.family.hex_nut_inch": "Hex nut (inch)", "parts.family.set_screw": "Hex socket set screw, flat point", "parts.family.socket_head_cap_screw": "Socket head cap screw", "parts.family.washer": "Plain washer", + "parts.family.washer_sae": "Plain washer, SAE (narrow)", + "parts.family.washer_uss": "Plain washer, USS (wide)", "parts.holes": "Holes (optional)", "parts.placement_hint": "pick one or more holes to seat a part in each, or OK places it at the origin", + "parts.system": "Unit system", + "parts.system.inch": "Inch", + "parts.system.metric": "Metric", "pattern.alignment_2": "Alignment 2", "pattern.alignment_hint": "Counts include the original. Center balances copies; Flip chooses the extra side for even totals.", "pattern.direction_1": "Direction 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "The selected font could not be read.", "prefs.general": "General", - "prefs.general_note": "Language, saving and updates.", + "prefs.general.active": "Active", + "prefs.general.crash_reports": "Crash reports", + "prefs.general.edited": "Edited", + "prefs.general.held": "Held", + "prefs.general.import": "Import...", + "prefs.general.last_check": "Last checked: {time}", + "prefs.general.minutes": "Minutes", + "prefs.general.name_setup": "Name this setup", + "prefs.general.profile_name": "Profile name", + "prefs.general.save_new": "Save as new", + "prefs.general.step_import": "STEP and import", + "prefs.general.this_computer": "This computer", + "prefs.general_note": "Profiles, language, files and updates.", "prefs.gesture_orbit": "Orbit", "prefs.gesture_pan": "Pan", "prefs.gesture_zoom": "Zoom", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "Accent", "prefs.logo_theme": "Theme's colors", "prefs.max_file_size": "Largest file to open", - "prefs.max_file_size_design": "Design files", + "prefs.max_file_size_design": "Largest file to open: Design", "prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.", "prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.", - "prefs.max_file_size_step": "STEP and import files", + "prefs.max_file_size_step": "Largest file to open: STEP and import", "prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.", "prefs.megabytes": "MB", "prefs.menu_grid": "Large icons", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "A face, corner or arrow of the view cube, or an axis of the gizmo, also looks at the middle of the model from the one distance that fits it from every side (its bounding sphere). Home uses the same zoom and centre while this is on, so views chosen after it only turn. Off: the view turns in place, keeping its target and zoom.", "prefs.network_host_remove": "Remove", "prefs.network_locations": "Allow network locations in designs", - "prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.", + "prefs.network_locations_note": "For organizations that keep designs on file servers. Off: only servers you allow.", "prefs.network_locations_section": "Network locations", "prefs.not_on_gpu": "not on this GPU", "prefs.on": "On", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "Profile applied.", "prefs.profile_changed": "Settings changed since the profile was saved. Save keeps them in it.", "prefs.profile_deleted": "Profile deleted.", - "prefs.profile_export": "Export…", + "prefs.profile_export": "Export", "prefs.profile_exported": "Profile exported.", - "prefs.profile_imported": "Profile imported. Choose it under Active profile to switch to it.", + "prefs.profile_imported": "Profile imported.", "prefs.profile_name_hint": "Profile name, e.g. Laptop", "prefs.profile_rename": "Rename", "prefs.profile_renamed": "Profile renamed.", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget", "prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.", "prefs.reports_saved": "{n} saved", + "prefs.restore_page_defaults": "Restore page defaults", "prefs.ribbon_click_ends_tool": "Clicking the running tool's button ends it", "prefs.ribbon_click_ends_tool_note": "A click on the lit ribbon button of the sketch tool in use ends the tool, so clicks in the view select again. Off: the click starts the tool again.", "prefs.ribbon_reveal": "Show search results in the ribbon", "prefs.ribbon_reveal_note": "Hovering a result in Search commands shows where the command sits in the ribbon: its tab, its button or its row in a panel's drop-down. A command started from the search blinks there, and a running command from a drop-down marks its panel.", "prefs.right_click": "Right click", - "prefs.saving": "Autosave and recovery", + "prefs.saving": "Start and save", "prefs.scale_note": "Also Ctrl + / Ctrl − in the window. Buttons are 28 px at 100%.", "prefs.scale_text": "Scale and text", "prefs.search": "Search preferences", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:", "ui.agent_chat_auth_title": "Sign in to the agent", "ui.agent_chat_cancel": "Cancel", + "ui.agent_chat_cancel_timeout": "The agent did not respond to Stop and was disconnected. Reconnect to continue.", "ui.agent_chat_cannot_start": "Installed, but it does not start: {error}", "ui.agent_chat_changes": "Changes: {n}, one undo step", "ui.agent_chat_changes_n": "{n} changes", @@ -4456,6 +4478,8 @@ "ui.coming_later": "coming later", "ui.command_done": "{command} done", "ui.command_palette_hint": "Type a command, e.g. {example} — or press S to search", + "ui.components_from_bodies_hint": "Create one component per selected body, named after that body, under the active component.", + "ui.components_from_bodies_required": "Select at least one body.", "ui.concentric": "Concentric", "ui.cone": "Cone", "ui.conic_curve": "Conic Curve", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "Listed under {feature}", "ui.history_title": "History", "ui.history_toggle_names": "Show or hide feature names", + "ui.history_waits_for_editor": "Undo, Redo and History wait until the feature editor has opened", "ui.hole": "Hole", "ui.hole_tap_type": "Hole Tap Type", "ui.hole_type": "Hole Type", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "{slicer} didn't respond in time. The file is already saved at {path}: send it again once the slicer has started.", "ui.slicer_resend_hint": "Sending again replaces the file the slicer has open.", "ui.slicer_single_instance": "Open files in a slicer that is already running", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer and Cura load the file in the window that is open instead of starting another. Other slicers are started as they are. PrusaSlicer 3.0 always does this: its launcher can't take a file any other way.", + "ui.slicer_single_instance_tip": "For slicers that load a file into a running window.", "ui.slicer_starting": "Starting {slicer}…", "ui.slicer_still_starting": "Still waiting for {slicer} to start. Nothing else was sent.", "ui.slicer_stl_fallback": "The 3MF could not be written, so this is an STL.", @@ -5218,7 +5243,7 @@ "update.available": "SolidCraft {version} is available", "update.button": "Update", "update.button_version": "Update to {version}", - "update.check_automatically": "Check for updates automatically", + "update.check_automatically": "Check at start and once a day", "update.check_for_updates": "Check for updates", "update.check_now": "Check now", "update.check_unavailable": "Update checks are not available for this installation.", diff --git a/assets/locales/es.json b/assets/locales/es.json index 6845469cce..b259eff9fc 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -1806,12 +1806,19 @@ "parts.family.button_head_cap_screw": "Tornillo cabeza botón", "parts.family.dowel_pin": "Pasador cilíndrico", "parts.family.hex_bolt": "Tornillo de cabeza hexagonal", + "parts.family.hex_cap_screw_inch": "Tornillo de cabeza hexagonal (pulgadas)", "parts.family.hex_nut": "Tuerca hexagonal", + "parts.family.hex_nut_inch": "Tuerca hexagonal (pulgadas)", "parts.family.set_screw": "Prisionero con hexágono interior, punta plana", "parts.family.socket_head_cap_screw": "Tornillo cilíndrico con hexágono interior", "parts.family.washer": "Arandela plana", + "parts.family.washer_sae": "Arandela plana, SAE (estrecha)", + "parts.family.washer_uss": "Arandela plana, USS (ancha)", "parts.holes": "Agujeros (opcional)", "parts.placement_hint": "elige uno o más agujeros para colocar una pieza en cada uno, o Aceptar la coloca en el origen", + "parts.system": "Sistema de unidades", + "parts.system.inch": "Pulgadas", + "parts.system.metric": "Métrico", "pattern.alignment_2": "Alineación 2", "pattern.alignment_hint": "Los recuentos incluyen el original. Centrar equilibra las copias; Invertir elige el lado adicional cuando el total es par.", "pattern.direction_1": "Dirección 1", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index c61feb798c..7c622d46f4 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -2054,12 +2054,19 @@ "parts.family.button_head_cap_screw": "Vis à tête bombée à six pans creux", "parts.family.dowel_pin": "Goupille cylindrique", "parts.family.hex_bolt": "Vis à tête hexagonale", + "parts.family.hex_cap_screw_inch": "Vis à tête hexagonale (pouces)", "parts.family.hex_nut": "Écrou hexagonal", + "parts.family.hex_nut_inch": "Écrou hexagonal (pouces)", "parts.family.set_screw": "Vis sans tête à six pans creux, bout plat", "parts.family.socket_head_cap_screw": "Vis à tête cylindrique à six pans creux", "parts.family.washer": "Rondelle plate", + "parts.family.washer_sae": "Rondelle plate, SAE (étroite)", + "parts.family.washer_uss": "Rondelle plate, USS (large)", "parts.holes": "Trous (facultatif)", "parts.placement_hint": "choisissez un ou plusieurs trous pour y loger une pièce chacun, ou OK la place à l'origine", + "parts.system": "Système d'unités", + "parts.system.inch": "Pouces", + "parts.system.metric": "Métrique", "pattern.alignment_2": "Alignement 2", "pattern.alignment_hint": "La quantité inclut l’original. Le centrage répartit les copies de chaque côté ; si la quantité est paire, l’inversion choisit le côté qui reçoit la copie supplémentaire.", "pattern.direction_1": "Direction 1", diff --git a/assets/locales/he.json b/assets/locales/he.json index 87e13867eb..de8c0fc2c1 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.json @@ -477,6 +477,7 @@ "Rectangular": "מלבני", "Rectangular Tube": "צינור מלבני", "Reference face": "פאת ייחוס", + "Reference plane": "מישור ייחוס", "Relief Depth": "עומק הקלה", "Relief Width": "רוחב הקלה", "Remove Tools": "הסרת הכלים", @@ -494,7 +495,7 @@ "Rename Folder": "שינוי שם תיקייה", "Render Stage": "שלב הרינדור", "Reopen designs": "פתיחה מחדש של תכנים", - "Reopen the designs that were open when SolidCraft was last closed": "פתיחה מחדש של התכנים שהיו פתוחים כש־SolidCraft נסגרה בפעם האחרונה", + "Reopen the designs that were open when SolidCraft was last closed": "פתיחה מחדש של התכנונים שהיו פתוחים בפעם הקודמת", "Replace existing library file": "החלפת קובץ הספרייה הקיים", "Report a bug — review a screenshot and open a GitHub draft": "דיווח על באג — בדיקת צילום מסך ופתיחת טיוטה ב־GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "הדיווח הועתק. יש לשמור את הטקסט כדי לשלוח אותו ב-GitHub כשהחיבור לאינטרנט יהיה זמין.", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה", "parts.family.dowel_pin": "פין גלילי", "parts.family.hex_bolt": "בורג ראש משושה", + "parts.family.hex_cap_screw_inch": "בורג ראש משושה (אינץ')", "parts.family.hex_nut": "אום משושה", + "parts.family.hex_nut_inch": "אום משושה (אינץ')", "parts.family.set_screw": "בורג אלן ללא ראש, קצה שטוח", "parts.family.socket_head_cap_screw": "בורג ראש גלילי עם משושה פנימי", "parts.family.washer": "דסקית שטוחה", + "parts.family.washer_sae": "דסקית שטוחה, SAE (צרה)", + "parts.family.washer_uss": "דסקית שטוחה, USS (רחבה)", "parts.holes": "חורים (אופציונלי)", "parts.placement_hint": "בחרו חור אחד או יותר כדי להושיב חלק בכל אחד, או לחצו אישור כדי למקם אותו בראשית", + "parts.system": "מערכת יחידות", + "parts.system.inch": "אינץ'", + "parts.system.metric": "מטרי", "pattern.alignment_2": "יישור 2", "pattern.alignment_hint": "הספירה כוללת את המקור. מרכוז מאזן את העותקים; היפוך בוחר את הצד שמקבל את העותק הנוסף כשהסך זוגי.", "pattern.direction_1": "כיוון 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "טקסט לדוגמה בגופן · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "לא ניתן היה לקרוא את הגופן שנבחר.", "prefs.general": "כללי", - "prefs.general_note": "שפה, שמירה ועדכונים.", + "prefs.general.active": "פעיל", + "prefs.general.crash_reports": "דוחות קריסה", + "prefs.general.edited": "נערך", + "prefs.general.held": "נכלל", + "prefs.general.import": "ייבוא...", + "prefs.general.last_check": "בדיקה אחרונה: {time}", + "prefs.general.minutes": "דקות", + "prefs.general.name_setup": "תנו שם להגדרה זו", + "prefs.general.profile_name": "שם הפרופיל", + "prefs.general.save_new": "שמירה כחדש", + "prefs.general.step_import": "STEP וייבוא", + "prefs.general.this_computer": "מחשב זה", + "prefs.general_note": "פרופילים, שפה, קבצים ועדכונים.", "prefs.gesture_orbit": "סיבוב מבט", "prefs.gesture_pan": "הזזת מבט", "prefs.gesture_zoom": "זום", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "צבע הדגשה", "prefs.logo_theme": "צבעי ערכת הנושא", "prefs.max_file_size": "גודל קובץ מרבי לפתיחה", - "prefs.max_file_size_design": "קובצי תכן", + "prefs.max_file_size_design": "הקובץ הגדול ביותר לפתיחה: תכנון", "prefs.max_file_size_flag": "ההפעלה הנוכחית החלה עם --max-file-size {size}, שחל עד סגירת SolidCraft. הגדרות אלה חלות בהפעלה ללא אפשרות זו.", "prefs.max_file_size_note": "ברירות מחדל: 512 MB ו־256 MB; לכל היותר 16 GB. פתיחת קובץ דורשת זיכרון בנפח גדול פי כמה מגודלו. תכן ששומר ייבוא גדול עשוי לדרוש מגבלה גבוהה יותר לקובצי תכן כדי לשמור ולפתוח אותו מחדש.", - "prefs.max_file_size_step": "קובצי STEP וייבוא", + "prefs.max_file_size_step": "הקובץ הגדול ביותר לפתיחה: STEP וייבוא", "prefs.max_file_size_web": "יישום הדפדפן פותח קובצי STEP וייבוא עד 512 MB וקובצי תכן עד 256 MB: לשונית דפדפן אינה יכולה להכיל קבצים גדולים יותר. ביישום למחשב ניתן להעלות מגבלות אלה.", "prefs.megabytes": "MB", "prefs.menu_grid": "סמלים גדולים", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "לחיצה על פאה, פינה או חץ בקובייה, או על ציר בגיזמו, גם מביטה אל מרכז המודל מהמרחק האחד שמכיל אותו מכל צד (הכדור החוסם שלו). תצוגת בית משתמשת באותו זום ובאותו מרכז כל עוד זה פעיל, כך שתצוגות שנבחרות אחריה רק מסתובבות. כבוי: התצוגה מסתובבת במקומה ושומרת על היעד ועל הזום.", "prefs.network_host_remove": "הסרה", "prefs.network_locations": "התרת מיקומי רשת בתכנים", - "prefs.network_locations_note": "כבוי: קבצים מקושרים של תכן במחשב אחר (\\\\server\\share) נפתחים רק מהשרתים הרשומים כאן, או אחרי שמתירים שרת כשנשאלים.", + "prefs.network_locations_note": "לארגונים השומרים תכנונים בשרתי קבצים. כשהאפשרות כבויה: רק שרתים שאישרתם.", "prefs.network_locations_section": "מיקומי רשת", "prefs.not_on_gpu": "לא זמין במעבד הגרפי הזה", "prefs.on": "פועל", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "הפרופיל הוחל.", "prefs.profile_changed": "ההגדרות השתנו מאז שהפרופיל נשמר. „שמירה“ מכניסה אותן לפרופיל.", "prefs.profile_deleted": "הפרופיל נמחק.", - "prefs.profile_export": "ייצוא…", + "prefs.profile_export": "ייצוא", "prefs.profile_exported": "הפרופיל יוצא.", - "prefs.profile_imported": "הפרופיל יובא. בחרו בו תחת „פרופיל פעיל“ כדי לעבור אליו.", + "prefs.profile_imported": "הפרופיל יובא.", "prefs.profile_name_hint": "שם הפרופיל, למשל מחשב נייד", "prefs.profile_rename": "שינוי שם", "prefs.profile_renamed": "שם הפרופיל שונה.", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור", "prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.", "prefs.reports_saved": "{n} שמורים", + "prefs.restore_page_defaults": "שחזור ברירות המחדל של עמוד זה", "prefs.ribbon_click_ends_tool": "לחיצה על הלחצן של הכלי הפעיל מסיימת אותו", "prefs.ribbon_click_ends_tool_note": "לחיצה על לחצן הסרט המודגש של כלי השרטוט שבשימוש מסיימת את הכלי, כך שלחיצות בתצוגה בוחרות שוב. כבוי: הלחיצה מפעילה את הכלי מחדש.", "prefs.ribbon_reveal": "הצגת תוצאות חיפוש בסרט", "prefs.ribbon_reveal_note": "מעבר עם הסמן מעל תוצאה בחיפוש פקודות מראה היכן הפקודה נמצאת בסרט: הלשונית, הכפתור או השורה שלה בתפריט הנפתח של לוח. פקודה שהופעלה מהחיפוש מהבהבת שם, ופקודה פעילה מתפריט נפתח מסמנת את הלוח שלה.", "prefs.right_click": "לחיצה ימנית", - "prefs.saving": "שמירה אוטומטית ושחזור", + "prefs.saving": "הפעלה ושמירה", "prefs.scale_note": "גם Ctrl + / Ctrl − בחלון. הכפתורים בגודל 28 px ב־100%.", "prefs.scale_text": "קנה מידה וטקסט", "prefs.search": "חיפוש הגדרות", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "יש להריץ את זה במסוף ואז ללחוץ שוב על התחברות:", "ui.agent_chat_auth_title": "התחברות לסוכן", "ui.agent_chat_cancel": "ביטול", + "ui.agent_chat_cancel_timeout": "הסוכן לא הגיב לבקשת העצירה ונותק. יש להתחבר מחדש כדי להמשיך.", "ui.agent_chat_cannot_start": "מותקן, אך אינו מופעל: {error}", "ui.agent_chat_changes": "שינויים: {n}, צעד ביטול אחד", "ui.agent_chat_changes_n": "{n} שינויים", @@ -4456,6 +4478,8 @@ "ui.coming_later": "בקרוב", "ui.command_done": "{command} הושלם", "ui.command_palette_hint": "יש להקליד פקודה, למשל {example} — או ללחוץ S לחיפוש", + "ui.components_from_bodies_hint": "יצירת רכיב לכל גוף שנבחר, בשם הגוף, תחת הרכיב הפעיל.", + "ui.components_from_bodies_required": "יש לבחור גוף אחד לפחות.", "ui.concentric": "משותף מרכז", "ui.cone": "חרוט", "ui.conic_curve": "עקום חרוטי", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "מופיעה תחת {feature}", "ui.history_title": "ציר הזמן", "ui.history_toggle_names": "הצגה או הסתרה של שמות הפיצ'רים", + "ui.history_waits_for_editor": "ביטול, ביצוע מחדש וציר הזמן ממתינים עד שעורך הפעולה ייפתח", "ui.hole": "חור", "ui.hole_tap_type": "סוג הברגת חור", "ui.hole_type": "סוג חור", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "{slicer} לא הגיב בזמן. הקובץ כבר שמור ב־{path}: יש לשלוח אותו שוב אחרי שהסלייסר יופעל.", "ui.slicer_resend_hint": "שליחה חוזרת מחליפה את הקובץ שפתוח בסלייסר.", "ui.slicer_single_instance": "פתיחת קבצים בסלייסר שכבר פועל", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer ו־Cura טוענים את הקובץ בחלון הפתוח במקום להפעיל חלון נוסף. סלייסרים אחרים מופעלים כרגיל. PrusaSlicer 3.0 עושה זאת תמיד: המפעיל שלו לא יכול לקבל קובץ בדרך אחרת.", + "ui.slicer_single_instance_tip": "לסלייסרים שטוענים קובץ לחלון שכבר פועל.", "ui.slicer_starting": "מפעיל את {slicer}…", "ui.slicer_still_starting": "עדיין ממתין להפעלת {slicer}. שום דבר נוסף לא נשלח.", "ui.slicer_stl_fallback": "לא ניתן היה לכתוב את קובץ ה־3MF, ולכן זהו קובץ STL.", @@ -5218,7 +5243,7 @@ "update.available": "SolidCraft {version} זמין", "update.button": "עדכון", "update.button_version": "עדכון ל־{version}", - "update.check_automatically": "בדיקת עדכונים אוטומטית", + "update.check_automatically": "בדיקה בהפעלה ופעם ביום", "update.check_for_updates": "בדיקת עדכונים", "update.check_now": "בדיקה עכשיו", "update.check_unavailable": "בדיקת עדכונים אינה זמינה עבור התקנה זו.", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index db73875f2e..11285c91f5 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -477,6 +477,7 @@ "Rectangular": "長方形", "Rectangular Tube": "角形鋼管", "Reference face": "参照面", + "Reference plane": "参照平面", "Relief Depth": "リリーフ深さ", "Relief Width": "リリーフ幅", "Remove Tools": "ツールボディを削除", @@ -494,7 +495,7 @@ "Rename Folder": "フォルダーの名前を変更", "Render Stage": "レンダリング段階", "Reopen designs": "デザインを再度開く", - "Reopen the designs that were open when SolidCraft was last closed": "前回 SolidCraft を閉じたときに開いていたデザインを再度開く", + "Reopen the designs that were open when SolidCraft was last closed": "前回開いていたデザインを再び開く", "Replace existing library file": "既存のライブラリファイルを置き換える", "Report a bug — review a screenshot and open a GitHub draft": "バグを報告 — スクリーンショットを確認してGitHub下書きを作成", "Report copied. Keep this text to submit on GitHub when you are online.": "レポートをコピーしました。このテキストを保存し、オンラインになったらGitHubで送信してください。", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト", "parts.family.dowel_pin": "平行ピン", "parts.family.hex_bolt": "六角ボルト", + "parts.family.hex_cap_screw_inch": "六角ボルト(インチ)", "parts.family.hex_nut": "六角ナット", + "parts.family.hex_nut_inch": "六角ナット(インチ)", "parts.family.set_screw": "六角穴付き止めねじ 平先", "parts.family.socket_head_cap_screw": "六角穴付きボルト", "parts.family.washer": "平座金", + "parts.family.washer_sae": "平座金、SAE(小形)", + "parts.family.washer_uss": "平座金、USS(大形)", "parts.holes": "穴 (省略可)", "parts.placement_hint": "1 つ以上の穴を選ぶとそれぞれに部品を取り付けます。選ばずに OK を押すと原点に配置します", + "parts.system": "単位系", + "parts.system.inch": "インチ", + "parts.system.metric": "メートル", "pattern.alignment_2": "配置 2", "pattern.alignment_hint": "個数には元の図形が含まれます。中央配置ではコピーを両側に配置し、反転で偶数個の場合に多く配置する側を切り替えます。", "pattern.direction_1": "方向 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "いろはにほへと · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "選択したフォントを読み込めませんでした。", "prefs.general": "一般", - "prefs.general_note": "言語、保存、更新。", + "prefs.general.active": "使用中", + "prefs.general.crash_reports": "クラッシュレポート", + "prefs.general.edited": "変更あり", + "prefs.general.held": "保存対象", + "prefs.general.import": "インポート...", + "prefs.general.last_check": "最終確認: {time}", + "prefs.general.minutes": "分", + "prefs.general.name_setup": "この設定に名前を付ける", + "prefs.general.profile_name": "プロファイル名", + "prefs.general.save_new": "新規保存", + "prefs.general.step_import": "STEP とインポート", + "prefs.general.this_computer": "このコンピューター", + "prefs.general_note": "プロファイル、言語、ファイル、更新。", "prefs.gesture_orbit": "回転", "prefs.gesture_pan": "移動", "prefs.gesture_zoom": "ズーム", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "アクセント", "prefs.logo_theme": "テーマの色", "prefs.max_file_size": "開けるファイルの最大サイズ", - "prefs.max_file_size_design": "デザインファイル", + "prefs.max_file_size_design": "開くファイルの上限: デザイン", "prefs.max_file_size_flag": "今回は --max-file-size {size} を指定して起動したため、SolidCraft を閉じるまでその値が適用されます。この設定は、そのオプションなしで起動したときに適用されます。", "prefs.max_file_size_note": "既定値は 512 MB と 256 MB、上限は 16 GB です。ファイルを開くには、サイズの数倍のメモリが必要です。大きなインポートを保持するデザインの保存と再読み込みには、デザインファイルの上限を上げる必要がある場合があります。", - "prefs.max_file_size_step": "STEP・インポートファイル", + "prefs.max_file_size_step": "開くファイルの上限: STEP・インポート", "prefs.max_file_size_web": "ウェブ版で開けるのは STEP・インポートファイルが 512 MB、デザインが 256 MB までです。ブラウザーのタブはそれ以上のファイルを保持できません。デスクトップ版では上限を上げられます。", "prefs.megabytes": "MB", "prefs.menu_grid": "大きなアイコン", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "キューブの面・角・矢印やギズモの軸をクリックすると、どの方向からでも全体が収まる一定の距離(外接球)からモデルの中心を見ます。オンの間はホームビューも同じズームと中心を使うため、その後に選んだ視点は向きだけが変わります。オフ: 視点はその場で回転し、注視点とズームを保ちます。", "prefs.network_host_remove": "削除", "prefs.network_locations": "デザイン内のネットワーク上の場所を許可", - "prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。", + "prefs.network_locations_note": "ファイルサーバーでデザインを管理する組織向け。オフの場合は許可したサーバーのみ。", "prefs.network_locations_section": "ネットワーク上の場所", "prefs.not_on_gpu": "この GPU は非対応", "prefs.on": "オン", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "プロファイルを適用しました。", "prefs.profile_changed": "プロファイルの保存後に設定が変更されています。「保存」でプロファイルに反映されます。", "prefs.profile_deleted": "プロファイルを削除しました。", - "prefs.profile_export": "エクスポート…", + "prefs.profile_export": "エクスポート", "prefs.profile_exported": "プロファイルをエクスポートしました。", - "prefs.profile_imported": "プロファイルをインポートしました。切り替えるには「使用中のプロファイル」で選択してください。", + "prefs.profile_imported": "プロファイルをインポートしました。", "prefs.profile_name_hint": "プロファイル名(例: ノートPC)", "prefs.profile_rename": "名前を変更", "prefs.profile_renamed": "プロファイルの名前を変更しました。", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用", "prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。", "prefs.reports_saved": "{n} 件保存済み", + "prefs.restore_page_defaults": "このページを既定値に戻す", "prefs.ribbon_click_ends_tool": "実行中のツールのボタンをクリックすると終了する", "prefs.ribbon_click_ends_tool_note": "使用中のスケッチツールの点灯したリボンボタンをクリックするとツールが終了し、ビューのクリックで再び選択できます。オフ: クリックするとツールが最初から開始されます。", "prefs.ribbon_reveal": "検索結果をリボンに表示", "prefs.ribbon_reveal_note": "コマンド検索で結果にポインターを合わせると、そのコマンドがリボンのどこにあるか(タブ、ボタン、またはパネルのドロップダウン内の行)を表示します。検索から開始したコマンドはそこで点滅し、ドロップダウンから実行中のコマンドはそのパネルに印が付きます。", "prefs.right_click": "右クリック", - "prefs.saving": "自動保存と復元", + "prefs.saving": "起動と保存", "prefs.scale_note": "ウィンドウ内では Ctrl + / Ctrl − でも変更できます。100% のボタンは 28 px です。", "prefs.scale_text": "拡大率と文字", "prefs.search": "設定を検索", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:", "ui.agent_chat_auth_title": "エージェントにサインイン", "ui.agent_chat_cancel": "キャンセル", + "ui.agent_chat_cancel_timeout": "エージェントが停止要求に応答しなかったため、接続を切断しました。続行するには再接続してください。", "ui.agent_chat_cannot_start": "インストールされていますが、起動しません: {error}", "ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)", "ui.agent_chat_changes_n": "{n} 件の変更", @@ -4456,6 +4478,8 @@ "ui.coming_later": "準備中", "ui.command_done": "{command} 完了", "ui.command_palette_hint": "コマンドを入力(例: {example})— または S キーで検索", + "ui.components_from_bodies_hint": "選択した各ボディと同じ名前のコンポーネントを、アクティブなコンポーネントの下に作成します。", + "ui.components_from_bodies_required": "ボディを1つ以上選択してください。", "ui.concentric": "同心", "ui.cone": "円錐", "ui.conic_curve": "円錐曲線", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "{feature} の下に表示", "ui.history_title": "履歴", "ui.history_toggle_names": "フィーチャー名を表示・非表示", + "ui.history_waits_for_editor": "元に戻す、やり直す、履歴は、フィーチャーエディターが開くまで待機します", "ui.hole": "穴", "ui.hole_tap_type": "ねじ穴タイプ", "ui.hole_type": "穴タイプ", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "{slicer} が時間内に応答しませんでした。ファイルは {path} に保存済みです。スライサーが起動したら、もう一度送ってください。", "ui.slicer_resend_hint": "もう一度送ると、スライサーで開いているファイルが置き換わります。", "ui.slicer_single_instance": "すでに起動しているスライサーでファイルを開く", - "ui.slicer_single_instance_tip": "PrusaSlicer、SuperSlicer、Cura は、別のウィンドウを起動せず、開いているウィンドウでファイルを読み込みます。ほかのスライサーはそのまま起動します。PrusaSlicer 3.0 は常にこの動作です。ランチャーはほかの方法ではファイルを受け取れません。", + "ui.slicer_single_instance_tip": "実行中のウィンドウにファイルを読み込むスライサー用。", "ui.slicer_starting": "{slicer} を起動しています…", "ui.slicer_still_starting": "まだ {slicer} の起動を待っています。ほかには何も送信していません。", "ui.slicer_stl_fallback": "3MF を書き込めなかったため、STL で送信しました。", @@ -5218,7 +5243,7 @@ "update.available": "SolidCraft {version} が利用できます", "update.button": "アップデート", "update.button_version": "{version} にアップデート", - "update.check_automatically": "アップデートを自動的に確認", + "update.check_automatically": "起動時と1日1回確認", "update.check_for_updates": "アップデートを確認", "update.check_now": "今すぐ確認", "update.check_unavailable": "このインストールではアップデートを確認できません。", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 179185e02b..edef0a5da8 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -407,6 +407,7 @@ "Recover unsaved design?": "저장되지 않은 디자인을 복구할까요?", "Rectangular": "직사각형", "Reference face": "참조 면", + "Reference plane": "참조 평면", "Relief Depth": "릴리프 깊이", "Relief Width": "릴리프 너비", "Remove Tools": "공구 제거", @@ -422,6 +423,7 @@ "Rename Feature": "피처 이름 바꾸기", "Rename Folder": "폴더 이름 바꾸기", "Render Stage": "렌더링 단계", + "Reopen the designs that were open when SolidCraft was last closed": "지난번에 열려 있던 디자인 다시 열기", "Replace existing library file": "기존 라이브러리 파일 바꾸기", "Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기", "Report text copied. Paste it into GitHub and submit your report there.": "보고서 텍스트를 복사했습니다. GitHub에 붙여넣고 그곳에서 보고서를 제출하세요.", @@ -1591,7 +1593,19 @@ "prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "선택한 글꼴을 읽을 수 없습니다.", "prefs.general": "일반", - "prefs.general_note": "언어, 저장 및 업데이트.", + "prefs.general.active": "사용 중", + "prefs.general.crash_reports": "충돌 보고서", + "prefs.general.edited": "수정됨", + "prefs.general.held": "포함 항목", + "prefs.general.import": "가져오기...", + "prefs.general.last_check": "마지막 확인: {time}", + "prefs.general.minutes": "분", + "prefs.general.name_setup": "이 설정의 이름", + "prefs.general.profile_name": "프로필 이름", + "prefs.general.save_new": "새로 저장", + "prefs.general.step_import": "STEP 및 가져오기", + "prefs.general.this_computer": "이 컴퓨터", + "prefs.general_note": "프로필, 언어, 파일 및 업데이트.", "prefs.gesture_orbit": "회전", "prefs.gesture_pan": "이동", "prefs.gesture_zoom": "확대/축소", @@ -1628,10 +1642,10 @@ "prefs.live_input": "실시간 입력", "prefs.live_preview": "실시간 미리보기", "prefs.max_file_size": "열 수 있는 최대 파일 크기", - "prefs.max_file_size_design": "디자인 파일", + "prefs.max_file_size_design": "열 파일의 최대 크기: 디자인", "prefs.max_file_size_flag": "이번 실행은 --max-file-size {size}로 시작했으며 SolidCraft를 닫을 때까지 적용됩니다. 이 설정은 해당 옵션 없이 시작할 때 적용됩니다.", "prefs.max_file_size_note": "기본값: 512 MB 및 256 MB, 최대 16 GB. 파일을 열려면 파일 크기의 몇 배에 달하는 메모리가 필요합니다. 큰 가져오기 데이터를 보관하는 디자인은 저장하고 다시 열기 위해 디자인 파일 한도를 높여야 할 수 있습니다.", - "prefs.max_file_size_step": "STEP 및 가져오기 파일", + "prefs.max_file_size_step": "열 파일의 최대 크기: STEP 및 가져오기", "prefs.max_file_size_web": "웹 앱은 STEP 및 가져오기 파일을 512 MB까지, 디자인을 256 MB까지 엽니다. 브라우저 탭은 더 큰 파일을 담을 수 없습니다. 데스크톱 앱에서는 이 한도를 높일 수 있습니다.", "prefs.megabytes": "MB", "prefs.minutes": "분", @@ -1643,6 +1657,7 @@ "prefs.navigation_note": "마우스, 트랙패드, 3D 마우스로 뷰를 움직이는 방식입니다.", "prefs.navigator_fit": "뷰 탐색기로 뷰를 바꾼 뒤 모델 맞춤", "prefs.navigator_fit_note": "큐브의 면, 모서리, 화살표나 기즈모의 축을 클릭하면 어느 방향에서든 전체가 들어가는 한 거리(경계 구)에서 모델의 중심을 바라봅니다. 켜져 있는 동안 홈 뷰도 같은 확대/축소와 중심을 쓰므로 그 뒤에 고른 뷰는 방향만 바뀝니다. 끄면 뷰가 제자리에서 회전하며 대상과 확대/축소를 유지합니다.", + "prefs.network_locations_note": "파일 서버에 디자인을 보관하는 조직용입니다. 끄면 허용한 서버만 사용합니다.", "prefs.not_on_gpu": "이 GPU에서는 사용할 수 없음", "prefs.on": "켜짐", "prefs.opaque": "불투명", @@ -1659,6 +1674,8 @@ "prefs.preview_tint_new_faces_help": "바디를 변경하는 피처의 미리 보기에서 추가되는 면을 미리 보기 색으로 그릴 수 있습니다. 끄면 바디가 원래 색으로 그려집니다.", "prefs.print_face_highlight": "출력 바닥면 강조", "prefs.print_face_highlight_tip": "뷰에서 바디의 출력 바닥면을 색칠하고 윤곽을 그리는 강도입니다. 0 %이면 강조하지 않습니다.", + "prefs.profile_export": "내보내기", + "prefs.profile_imported": "프로필을 가져왔습니다.", "prefs.project_options_dialog": "Project 옵션 대화상자", "prefs.project_options_dialog_note": "켜기: 투영 전에 지오메트리 또는 바디와 투영 링크를 선택합니다. 끄기: 기존 직접 선택 작업 흐름을 유지합니다.", "prefs.quality_default": "기본값", @@ -1666,10 +1683,11 @@ "prefs.quality_slowest": "가장 느림", "prefs.quality_smoother": "더 부드럽게", "prefs.reports_saved": "{n}개 저장됨", + "prefs.restore_page_defaults": "페이지 기본값 복원", "prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료", "prefs.ribbon_click_ends_tool_note": "사용 중인 스케치 도구의 강조된 리본 버튼을 클릭하면 도구가 종료되어 뷰에서 클릭하면 다시 선택됩니다. 끄기: 클릭하면 도구가 다시 시작됩니다.", "prefs.right_click": "오른쪽 클릭", - "prefs.saving": "자동 저장 및 복구", + "prefs.saving": "시작 및 저장", "prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.", "prefs.scale_text": "배율 및 텍스트", "prefs.search": "설정 검색", @@ -2961,6 +2979,7 @@ "ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:", "ui.agent_chat_auth_title": "에이전트에 로그인", "ui.agent_chat_cancel": "취소", + "ui.agent_chat_cancel_timeout": "에이전트가 중지 요청에 응답하지 않아 연결이 끊어졌습니다. 계속하려면 다시 연결하세요.", "ui.agent_chat_cannot_start": "설치되어 있지만 시작되지 않습니다: {error}", "ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번", "ui.agent_chat_changes_of": "변경 {n}/{limit}", @@ -3236,6 +3255,8 @@ "ui.coming_later": "추후 지원 예정", "ui.command_done": "{command} 완료", "ui.command_palette_hint": "명령 입력(예: {example}) — 또는 S 키를 눌러 검색", + "ui.components_from_bodies_hint": "활성 구성요소 아래에 선택한 각 바디와 같은 이름의 구성요소를 만듭니다.", + "ui.components_from_bodies_required": "바디를 하나 이상 선택하세요.", "ui.concentric": "동심", "ui.cone": "원뿔", "ui.conic_curve": "원뿔 곡선", @@ -3792,6 +3813,7 @@ "ui.slice": "슬라이스", "ui.slicer": "슬라이서", "ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거", + "ui.slicer_single_instance_tip": "실행 중인 창에 파일을 불러오는 슬라이서용입니다.", "ui.slot": "슬롯", "ui.smooth": "부드럽게", "ui.smooth_wheel_zoom": "부드러운 휠 확대/축소", @@ -3921,7 +3943,7 @@ "update.available": "SolidCraft {version} 사용 가능", "update.button": "업데이트", "update.button_version": "{version}(으)로 업데이트", - "update.check_automatically": "업데이트 자동 확인", + "update.check_automatically": "시작 시 및 하루에 한 번 확인", "update.check_for_updates": "업데이트 확인", "update.check_now": "지금 확인", "update.check_unavailable": "이 설치에서는 업데이트를 확인할 수 없습니다.", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index b2b100f071..5d43cf7f79 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -115,7 +115,7 @@ "Circular": "Круговой", "Circumference": "Окружность", "Clockwise": "По часовой стрелке", - "Close draft": "Закрыть анализ уклонов", + "Close draft": "Закрыть черновик", "Collapse": "Свернуть", "Collisions": "Столкновения", "Colour": "Цвет", @@ -775,6 +775,8 @@ "at the face": "у грани", "bom.group_children": "Вложенные детали в строке своей детали", "browser.body_name": "Тело{number}", + "browser.calibrate_click_a": "Щёлкните по изображению, чтобы задать точку A, или введите её координаты.", + "browser.calibrate_click_b": "Щёлкните по изображению, чтобы задать точку B, или введите её координаты.", "browser.calibrate_title": "Калибровка: {name}", "browser.capture_many": "Компоненты ({count}) перемещены, но их положения не зафиксированы.", "browser.capture_note": "«Зафиксировать положение» сохраняет новое положение; «Вернуть положение» восстанавливает прежнее.", @@ -1553,6 +1555,16 @@ "help.sketch.constraint.symmetry": "Делает две точки или кривые зеркальными друг для друга относительно линии или центральной точки. Используйте, чтобы профиль оставался симметричным при изменении одной стороны.", "help.sketch.constraint.tangent": "Заставляет две кривые касаться, например линию и дугу. Используйте для плавных стыков без излома.", "help.sketch.construction": "Переключает кривые между обычной и вспомогательной геометрией, которую профили не учитывают. Используйте вспомогательные кривые как помощники для разметки, ориентиры и размеры.", + "help.sketch.construction_line": "Создайте бесконечную вспомогательную прямую через две точки. С её помощью можно разметить эскиз, не разделяя профили.", + "help.sketch.construction_line.angle": "Создайте бесконечную вспомогательную прямую под углом к оси X эскиза. Укажите точку и введите угол либо задайте направление.", + "help.sketch.construction_line.bisector": "Выберите две пересекающиеся прямые эскиза, затем одну из двух биссектрис. Результат изменяется вместе с исходными прямыми.", + "help.sketch.construction_line.horizontal": "Создайте бесконечную горизонтальную вспомогательную прямую через точку. Её горизонтальное ограничение сохраняется при последующих изменениях.", + "help.sketch.construction_line.parallel": "Выберите прямую эскиза, затем разместите параллельную бесконечную вспомогательную прямую. Щелчок правой кнопкой — выбрать другую исходную прямую.", + "help.sketch.construction_line.perpendicular": "Выберите прямую эскиза, затем разместите перпендикулярную бесконечную вспомогательную прямую. Щелчок правой кнопкой — выбрать другую исходную прямую.", + "help.sketch.construction_line.tangent_at": "Создайте касательную в точке окружности или дуги окружности либо в конечной точке открытого сплайна или коники. Выберите кривую, затем точку касания.", + "help.sketch.construction_line.tangent_external": "Выберите окружность или дугу, затем внешнюю точку и решение для касательной. В предпросмотре показаны только точки касания на нарисованной дуге.", + "help.sketch.construction_line.tangent_two": "Выберите две окружности или дуги, затем в предпросмотре выберите общую касательную. Точки касания должны лежать на нарисованных кривых.", + "help.sketch.construction_line.vertical": "Создайте бесконечную вертикальную вспомогательную прямую через точку. Её вертикальное ограничение сохраняется при последующих изменениях.", "help.sketch.copy": "Копирует выбранные кривые и точки эскиза вместе с ограничениями и размерами между ними. Вставьте их в этот эскиз или в другой.", "help.sketch.create": "Начинает новый 2D-эскиз на плоскости или плоской грани модели. Каждое «Выдавливание», «Вращение» и «Сдвиг» начинается с эскиза.", "help.sketch.cut": "Копирует выбранную геометрию эскиза и удаляет её из эскиза одним шагом отмены. Вставьте её в другое место этого эскиза или в другой эскиз.", @@ -2219,6 +2231,8 @@ "prefs.overall_speed": "Общая скорость", "prefs.parameter_list_rows": "Строк в списке параметров", "prefs.parameter_list_rows_tip": "Стрелка вниз в пустом поле значения открывает список параметров, сначала избранные, не больше этого числа. Меньшее число делает список лёгким в большом дизайне.", + "prefs.persistent_ribbon_pressed": "Подсвечивать активные команды на ленте", + "prefs.persistent_ribbon_pressed_help": "Подсвечивает команды на ленте, пока их действие включено. Отключите, чтобы сохранить прежний нейтральный вид и справку по командам.", "prefs.perspective_fov": "Угол обзора", "prefs.placement": "Размещение", "prefs.prefer_free_sketch_points": "Предпочитать подвижные точки эскиза, когда точки перекрываются", @@ -2282,6 +2296,8 @@ "prefs.sketch_exit_view_note": "Куда переходит вид после «Завершить эскиз», «Отмена» или двойного Esc.", "prefs.sketch_face_activates_part": "Эскиз на грани детали делает эту деталь активной", "prefs.sketch_face_activates_part_note": "Эскиз, начатый на грани тела другого компонента, сначала активирует этот компонент, поэтому эскиз принадлежит ему и в браузере остаётся с этой деталью. После этого деталь остаётся активной. Выкл.: эскиз принадлежит активному компоненту.", + "prefs.sketch_open_look_at": "Поворачивать камеру к эскизу при его открытии", + "prefs.sketch_open_look_at_note": "Выкл.: при создании или редактировании эскиза вид не меняется. «Взгляд на» поворачивает вид к эскизу, когда это нужно.", "prefs.sketch_recent_snap": "Автопривязка к недавним центрам и серединам", "prefs.sketch_recent_snap_note": "При рисовании курсор привязывается к месту пересечения направляющих двух последних центров окружностей или середин прямых отрезков. Выкл.: эти направляющие остаются только визуальными, а курсор не смещается.", "prefs.sketch_selection": "Эскиз и выделение", @@ -2389,6 +2405,10 @@ "properties.title": "Свойства: {name}", "recovery.days_ago": "{count} дн. назад", "recovery.detail": "{file} · операций: {features} · автосохранение {when}", + "recovery.discard_all": "Отбросить все", + "recovery.discard_all_detail": "Это действие нельзя отменить.", + "recovery.discard_all_title": "Отбросить автосохранения ({count})?", + "recovery.discard_all_title_one": "Отбросить 1 автосохранение?", "recovery.hours_ago": "{count} ч назад", "recovery.just_now": "только что", "recovery.min_ago": "{count} мин назад", diff --git a/assets/locales/vi.json b/assets/locales/vi.json index d86963ace3..4c1893a042 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -477,6 +477,7 @@ "Rectangular": "Hình chữ nhật", "Rectangular Tube": "Thép hộp chữ nhật", "Reference face": "Bề mặt tham chiếu", + "Reference plane": "Mặt phẳng tham chiếu", "Relief Depth": "Độ sâu rãnh thoát", "Relief Width": "Chiều rộng rãnh thoát", "Remove Tools": "Loại bỏ công cụ cắt", @@ -494,7 +495,7 @@ "Rename Folder": "Đổi tên thư mục", "Render Stage": "Giai đoạn kết xuất", "Reopen designs": "Mở lại thiết kế", - "Reopen the designs that were open when SolidCraft was last closed": "Mở lại các thiết kế đã mở khi đóng SolidCraft lần trước", + "Reopen the designs that were open when SolidCraft was last closed": "Mở lại các thiết kế đã mở lần trước", "Replace existing library file": "Thay thế tệp thư viện hiện có", "Report a bug — review a screenshot and open a GitHub draft": "Báo cáo sự cố — xem lại ảnh chụp màn hình và mở bản nháp trên GitHub", "Report copied. Keep this text to submit on GitHub when you are online.": "Đã sao chép báo cáo. Hãy giữ lại nội dung này để gửi lên GitHub khi có kết nối mạng.", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm", "parts.family.dowel_pin": "Chốt trụ", "parts.family.hex_bolt": "Bu lông đầu lục giác", + "parts.family.hex_cap_screw_inch": "Bu lông đầu lục giác (inch)", "parts.family.hex_nut": "Đai ốc lục giác", + "parts.family.hex_nut_inch": "Đai ốc lục giác (inch)", "parts.family.set_screw": "Vít trí lục giác chìm, đầu bằng", "parts.family.socket_head_cap_screw": "Bu lông lục giác chìm đầu trụ", "parts.family.washer": "Vòng đệm phẳng", + "parts.family.washer_sae": "Vòng đệm phẳng, SAE (hẹp)", + "parts.family.washer_uss": "Vòng đệm phẳng, USS (rộng)", "parts.holes": "Lỗ (tùy chọn)", "parts.placement_hint": "chọn một hoặc nhiều lỗ để lắp một chi tiết vào mỗi lỗ, hoặc nhấn OK để đặt tại gốc tọa độ", + "parts.system": "Hệ đơn vị", + "parts.system.inch": "Inch", + "parts.system.metric": "Hệ mét", "pattern.alignment_2": "Căn chỉnh 2", "pattern.alignment_hint": "Số lượng bao gồm cả bản gốc. Căn giữa sẽ cân bằng các bản sao; Đổi hướng sẽ chọn phía bổ sung cho tổng số chẵn.", "pattern.direction_1": "Hướng 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "Kiểm tra phông chữ · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "Không thể đọc tệp phông chữ đã chọn.", "prefs.general": "Cài đặt chung", - "prefs.general_note": "Ngôn ngữ, lưu tệp và cập nhật.", + "prefs.general.active": "Đang dùng", + "prefs.general.crash_reports": "Báo cáo sự cố", + "prefs.general.edited": "Đã sửa", + "prefs.general.held": "Được lưu", + "prefs.general.import": "Nhập...", + "prefs.general.last_check": "Kiểm tra lần cuối: {time}", + "prefs.general.minutes": "Phút", + "prefs.general.name_setup": "Đặt tên cấu hình này", + "prefs.general.profile_name": "Tên hồ sơ", + "prefs.general.save_new": "Lưu thành mới", + "prefs.general.step_import": "STEP và nhập", + "prefs.general.this_computer": "Máy tính này", + "prefs.general_note": "Hồ sơ, ngôn ngữ, tệp và cập nhật.", "prefs.gesture_orbit": "Xoay góc nhìn", "prefs.gesture_pan": "Trượt góc nhìn", "prefs.gesture_zoom": "Thu phóng", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "Màu nhấn", "prefs.logo_theme": "Màu của chủ đề", "prefs.max_file_size": "Kích thước tệp tối đa để mở", - "prefs.max_file_size_design": "Tệp thiết kế", + "prefs.max_file_size_design": "Tệp lớn nhất có thể mở: Thiết kế", "prefs.max_file_size_flag": "Lần chạy này bắt đầu với --max-file-size {size}, có hiệu lực đến khi đóng SolidCraft. Các cài đặt này áp dụng khi khởi động không có tùy chọn đó.", "prefs.max_file_size_note": "Mặc định: 512 MB và 256 MB; tối đa 16 GB. Mở tệp cần lượng bộ nhớ gấp nhiều lần kích thước tệp. Thiết kế giữ dữ liệu nhập lớn có thể cần giới hạn tệp thiết kế cao hơn để lưu và mở lại.", - "prefs.max_file_size_step": "Tệp STEP và tệp nhập", + "prefs.max_file_size_step": "Tệp lớn nhất có thể mở: STEP và nhập", "prefs.max_file_size_web": "Ứng dụng web mở tệp STEP và tệp nhập tối đa 512 MB, thiết kế tối đa 256 MB: thẻ trình duyệt không chứa được tệp lớn hơn. Ứng dụng máy tính cho phép tăng các giới hạn này.", "prefs.megabytes": "MB", "prefs.menu_grid": "Biểu tượng lớn", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "Nhấp vào một mặt, góc hoặc mũi tên của khối lập phương, hoặc một trục của gizmo, cũng nhìn vào tâm mô hình từ một khoảng cách duy nhất chứa trọn nó từ mọi phía (khối cầu bao). Khi bật, góc nhìn chuẩn (Home) dùng cùng mức thu phóng và tâm đó, nên các góc nhìn chọn sau chỉ xoay. Tắt: góc nhìn xoay tại chỗ, giữ nguyên điểm nhìn và mức thu phóng.", "prefs.network_host_remove": "Xóa", "prefs.network_locations": "Cho phép các vị trí mạng trong thiết kế", - "prefs.network_locations_note": "Khi tắt: các tệp liên kết của thiết kế trên máy tính khác (\\\\server\\share) chỉ được mở từ các máy chủ được liệt kê tại đây, hoặc sau khi bạn đồng ý khi được hỏi.", + "prefs.network_locations_note": "Dành cho tổ chức lưu thiết kế trên máy chủ tệp. Khi tắt: chỉ các máy chủ bạn cho phép.", "prefs.network_locations_section": "Vị trí mạng", "prefs.not_on_gpu": "GPU này không hỗ trợ", "prefs.on": "Bật", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "Đã áp dụng hồ sơ.", "prefs.profile_changed": "Cài đặt đã thay đổi kể từ khi lưu hồ sơ. “Lưu” sẽ ghi chúng vào hồ sơ.", "prefs.profile_deleted": "Đã xóa hồ sơ.", - "prefs.profile_export": "Xuất tệp…", + "prefs.profile_export": "Xuất", "prefs.profile_exported": "Đã xuất hồ sơ.", - "prefs.profile_imported": "Đã nhập hồ sơ. Chọn hồ sơ trong “Hồ sơ đang dùng” để chuyển sang.", + "prefs.profile_imported": "Đã nhập hồ sơ.", "prefs.profile_name_hint": "Tên hồ sơ, ví dụ Laptop", "prefs.profile_rename": "Đổi tên", "prefs.profile_renamed": "Đã đổi tên hồ sơ.", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất", "prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.", "prefs.reports_saved": "Đã lưu {n}", + "prefs.restore_page_defaults": "Khôi phục mặc định của trang", "prefs.ribbon_click_ends_tool": "Nhấp vào nút của công cụ đang chạy sẽ kết thúc nó", "prefs.ribbon_click_ends_tool_note": "Nhấp vào nút ruy-băng đang sáng của công cụ phác thảo đang dùng sẽ kết thúc công cụ, để các cú nhấp trong khung nhìn lại chọn đối tượng. Tắt: cú nhấp khởi động lại công cụ.", "prefs.ribbon_reveal": "Hiện kết quả tìm kiếm trên ribbon", "prefs.ribbon_reveal_note": "Khi rê chuột lên một kết quả trong Tìm lệnh, ribbon cho biết lệnh nằm ở đâu: tab, nút hoặc dòng của nó trong menu thả xuống của một bảng. Lệnh bắt đầu từ tìm kiếm sẽ nhấp nháy ở đó, và lệnh đang chạy từ menu thả xuống sẽ đánh dấu bảng của nó.", "prefs.right_click": "Nhấp chuột phải", - "prefs.saving": "Tự động lưu và khôi phục", + "prefs.saving": "Khởi động và lưu", "prefs.scale_note": "Cũng có thể dùng Ctrl + / Ctrl − trong cửa sổ. Các nút có kích thước 28 px ở mức 100%.", "prefs.scale_text": "Tỉ lệ và văn bản", "prefs.search": "Tìm kiếm cài đặt", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "Chạy lệnh này trong cửa sổ dòng lệnh, sau đó nhấp Kết nối lại:", "ui.agent_chat_auth_title": "Đăng nhập vào AI Agent", "ui.agent_chat_cancel": "Hủy bỏ", + "ui.agent_chat_cancel_timeout": "Tác nhân không phản hồi yêu cầu Dừng nên đã bị ngắt kết nối. Hãy kết nối lại để tiếp tục.", "ui.agent_chat_cannot_start": "Đã cài đặt nhưng không khởi động được: {error}", "ui.agent_chat_changes": "Thay đổi: {n}, trong 1 bước hoàn tác", "ui.agent_chat_changes_n": "{n} thay đổi", @@ -4456,6 +4478,8 @@ "ui.coming_later": "sắp ra mắt", "ui.command_done": "Hoàn tất lệnh {command}", "ui.command_palette_hint": "Nhập một lệnh, ví dụ: {example} — hoặc nhấn S để tìm kiếm", + "ui.components_from_bodies_hint": "Tạo một thành phần cho mỗi vật thể đã chọn, đặt tên theo vật thể đó, bên dưới thành phần đang hoạt động.", + "ui.components_from_bodies_required": "Chọn ít nhất một vật thể.", "ui.concentric": "Đồng tâm", "ui.cone": "Hình nón", "ui.conic_curve": "Đường cong Conic", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "Được liệt kê dưới {feature}", "ui.history_title": "Lịch sử", "ui.history_toggle_names": "Hiện hoặc ẩn tên tính năng", + "ui.history_waits_for_editor": "Hoàn tác, Làm lại và Lịch sử sẽ chờ cho đến khi trình chỉnh sửa tính năng mở xong", "ui.hole": "Tạo lỗ", "ui.hole_tap_type": "Kiểu ta-rô ren lỗ", "ui.hole_type": "Loại lỗ", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "{slicer} không phản hồi kịp thời. Tệp đã được lưu tại {path}: hãy gửi lại sau khi phần mềm cắt lớp khởi động xong.", "ui.slicer_resend_hint": "Gửi lại sẽ thay thế tệp đang mở trong phần mềm cắt lớp.", "ui.slicer_single_instance": "Mở tệp trong phần mềm cắt lớp đang chạy sẵn", - "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer và Cura sẽ tải tệp vào cửa sổ đang mở thay vì khởi động cửa sổ mới. Các phần mềm cắt lớp khác được khởi động như bình thường. PrusaSlicer 3.0 luôn hoạt động theo cách này: trình khởi chạy của nó không thể nhận tệp theo cách nào khác.", + "ui.slicer_single_instance_tip": "Dành cho phần mềm cắt lớp tải tệp vào cửa sổ đang chạy.", "ui.slicer_starting": "Đang khởi động {slicer}…", "ui.slicer_still_starting": "Vẫn đang chờ {slicer} khởi động. Chưa có dữ liệu nào khác được gửi.", "ui.slicer_stl_fallback": "Không thể ghi tệp 3MF nên định dạng xuất là STL.", @@ -5218,7 +5243,7 @@ "update.available": "Đã có phiên bản SolidCraft {version}", "update.button": "Cập nhật", "update.button_version": "Cập nhật lên {version}", - "update.check_automatically": "Tự động kiểm tra bản cập nhật", + "update.check_automatically": "Kiểm tra khi khởi động và mỗi ngày một lần", "update.check_for_updates": "Kiểm tra bản cập nhật", "update.check_now": "Kiểm tra ngay", "update.check_unavailable": "Không thể kiểm tra bản cập nhật cho bản cài đặt này.", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 419d2646df..013504d52b 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -477,6 +477,7 @@ "Rectangular": "矩形", "Rectangular Tube": "矩形管", "Reference face": "参考面", + "Reference plane": "参考平面", "Relief Depth": "止裂槽深度", "Relief Width": "止裂槽宽度", "Remove Tools": "移除工具", @@ -494,7 +495,7 @@ "Rename Folder": "重命名文件夹", "Render Stage": "渲染阶段", "Reopen designs": "重新打开设计", - "Reopen the designs that were open when SolidCraft was last closed": "重新打开上次关闭 SolidCraft 时打开的设计", + "Reopen the designs that were open when SolidCraft was last closed": "重新打开上次打开的设计", "Replace existing library file": "替换现有库文件", "Report a bug — review a screenshot and open a GitHub draft": "报告缺陷 — 复核截图并开启 GitHub 草稿", "Report copied. Keep this text to submit on GitHub when you are online.": "报告已复制。请保留此文本,联网后在 GitHub 上提交。", @@ -2091,12 +2092,19 @@ "parts.family.button_head_cap_screw": "圆头内六角螺钉", "parts.family.dowel_pin": "圆柱销", "parts.family.hex_bolt": "六角头螺栓", + "parts.family.hex_cap_screw_inch": "六角头螺栓(英制)", "parts.family.hex_nut": "六角螺母", + "parts.family.hex_nut_inch": "六角螺母(英制)", "parts.family.set_screw": "内六角平端紧定螺钉", "parts.family.socket_head_cap_screw": "内六角圆柱头螺钉", "parts.family.washer": "平垫圈", + "parts.family.washer_sae": "平垫圈,SAE(窄型)", + "parts.family.washer_uss": "平垫圈,USS(宽型)", "parts.holes": "孔(可选)", "parts.placement_hint": "选择一个或多个孔,在每个孔中各装一个零件;或按确定将其放在原点", + "parts.system": "单位制", + "parts.system.inch": "英制", + "parts.system.metric": "公制", "pattern.alignment_2": "对齐 2", "pattern.alignment_hint": "数量包含原始图形。居中时副本分布在两侧;数量为偶数时,反转可切换多一个副本的一侧。", "pattern.direction_1": "方向 1", @@ -2198,7 +2206,19 @@ "prefs.font_sample": "字体示例 · Ø12.50 mm · 45.0° · 0123456789", "prefs.font_unreadable": "无法读取所选字体。", "prefs.general": "常规", - "prefs.general_note": "语言、保存和更新。", + "prefs.general.active": "当前", + "prefs.general.crash_reports": "崩溃报告", + "prefs.general.edited": "已修改", + "prefs.general.held": "包含", + "prefs.general.import": "导入...", + "prefs.general.last_check": "上次检查:{time}", + "prefs.general.minutes": "分钟", + "prefs.general.name_setup": "为此配置命名", + "prefs.general.profile_name": "配置文件名称", + "prefs.general.save_new": "另存为新配置", + "prefs.general.step_import": "STEP 和导入", + "prefs.general.this_computer": "此计算机", + "prefs.general_note": "配置文件、语言、文件和更新。", "prefs.gesture_orbit": "环绕", "prefs.gesture_pan": "平移", "prefs.gesture_zoom": "缩放", @@ -2255,10 +2275,10 @@ "prefs.logo_accent": "强调色", "prefs.logo_theme": "主题颜色", "prefs.max_file_size": "可打开的最大文件", - "prefs.max_file_size_design": "设计文件", + "prefs.max_file_size_design": "可打开的最大文件:设计", "prefs.max_file_size_flag": "本次启动指定了 --max-file-size {size},该值在关闭 SolidCraft 前有效。不带此选项启动时才会应用这些设置。", "prefs.max_file_size_note": "默认值:512 MB 和 256 MB;上限为 16 GB。打开文件需要数倍于文件大小的内存。保留大型导入数据的设计可能需要提高设计文件上限,才能保存并重新打开。", - "prefs.max_file_size_step": "STEP 和导入文件", + "prefs.max_file_size_step": "可打开的最大文件:STEP 和导入", "prefs.max_file_size_web": "网页版可打开最大 512 MB 的 STEP 和导入文件,以及最大 256 MB 的设计:浏览器标签页无法容纳更大的文件。桌面版可提高这些上限。", "prefs.megabytes": "MB", "prefs.menu_grid": "大图标", @@ -2275,7 +2295,7 @@ "prefs.navigator_fit_note": "点击立方体的面、角或箭头,或坐标轴指示器的轴时,也会从各个方向都能容纳模型的同一距离(其包围球)注视模型中心。开启期间主视图使用相同的缩放和中心,因此之后选择的视图只改变方向。关闭:视图原地旋转,保持目标点和缩放。", "prefs.network_host_remove": "删除", "prefs.network_locations": "允许设计中的网络位置", - "prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。", + "prefs.network_locations_note": "适用于将设计存放在文件服务器上的组织。关闭时:仅允许您授权的服务器。", "prefs.network_locations_section": "网络位置", "prefs.not_on_gpu": "此 GPU 不支持", "prefs.on": "开启", @@ -2301,9 +2321,9 @@ "prefs.profile_applied": "已应用配置文件。", "prefs.profile_changed": "保存配置文件后设置已更改。“保存”会将其写入配置文件。", "prefs.profile_deleted": "已删除配置文件。", - "prefs.profile_export": "导出…", + "prefs.profile_export": "导出", "prefs.profile_exported": "已导出配置文件。", - "prefs.profile_imported": "已导入配置文件。在“当前配置文件”中选择它即可切换。", + "prefs.profile_imported": "已导入配置文件。", "prefs.profile_name_hint": "配置文件名称,例如 笔记本", "prefs.profile_rename": "重命名", "prefs.profile_renamed": "已重命名配置文件。", @@ -2323,12 +2343,13 @@ "prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算", "prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。", "prefs.reports_saved": "已保存 {n} 份", + "prefs.restore_page_defaults": "恢复本页默认值", "prefs.ribbon_click_ends_tool": "单击正在运行的工具的按钮将结束该工具", "prefs.ribbon_click_ends_tool_note": "单击正在使用的草图工具的高亮功能区按钮会结束该工具,在视图中单击即可再次选择。关闭:单击会重新启动该工具。", "prefs.ribbon_reveal": "在功能区中显示搜索结果", "prefs.ribbon_reveal_note": "将指针悬停在“搜索命令”的结果上时,会显示该命令在功能区中的位置:其选项卡、按钮或面板下拉菜单中的行。从搜索启动的命令会在那里闪烁,从下拉菜单运行的命令会标记其面板。", "prefs.right_click": "右键单击", - "prefs.saving": "自动保存和恢复", + "prefs.saving": "启动和保存", "prefs.scale_note": "也可在窗口中使用 Ctrl + / Ctrl −。100% 时按钮高度为 28 像素。", "prefs.scale_text": "缩放与文字", "prefs.search": "搜索设置", @@ -4126,6 +4147,7 @@ "ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:", "ui.agent_chat_auth_title": "登录智能体", "ui.agent_chat_cancel": "取消", + "ui.agent_chat_cancel_timeout": "代理未响应停止请求,已断开连接。请重新连接以继续。", "ui.agent_chat_cannot_start": "已安装,但无法启动:{error}", "ui.agent_chat_changes": "{n} 项更改,一步撤销", "ui.agent_chat_changes_n": "{n} 项更改", @@ -4456,6 +4478,8 @@ "ui.coming_later": "即将推出", "ui.command_done": "{command} 完成", "ui.command_palette_hint": "输入命令,例如 {example} — 或按 S 搜索", + "ui.components_from_bodies_hint": "在活动组件下,为每个选定实体创建一个以该实体命名的组件。", + "ui.components_from_bodies_required": "请至少选择一个实体。", "ui.concentric": "同心", "ui.cone": "圆锥体", "ui.conic_curve": "圆锥曲线", @@ -4761,6 +4785,7 @@ "ui.history_sketch_listed_under": "列在 {feature} 下", "ui.history_title": "历史", "ui.history_toggle_names": "显示或隐藏特征名称", + "ui.history_waits_for_editor": "撤销、重做和历史将等待特征编辑器打开", "ui.hole": "孔", "ui.hole_tap_type": "孔攻丝类型", "ui.hole_type": "孔类型", @@ -5073,7 +5098,7 @@ "ui.slicer_no_response": "{slicer} 未在规定时间内响应。文件已保存到 {path}:切片软件启动后请再次发送。", "ui.slicer_resend_hint": "再次发送会替换切片软件中已打开的文件。", "ui.slicer_single_instance": "在已运行的切片软件中打开文件", - "ui.slicer_single_instance_tip": "PrusaSlicer、SuperSlicer 和 Cura 会在已打开的窗口中加载文件,而不是再启动一个。其他切片软件按原样启动。PrusaSlicer 3.0 始终如此:其启动器无法以其他方式接收文件。", + "ui.slicer_single_instance_tip": "适用于将文件加载到正在运行的窗口中的切片软件。", "ui.slicer_starting": "正在启动 {slicer}…", "ui.slicer_still_starting": "仍在等待 {slicer} 启动。没有发送其他内容。", "ui.slicer_stl_fallback": "无法写入 3MF,因此发送的是 STL。", @@ -5218,7 +5243,7 @@ "update.available": "SolidCraft {version} 已发布", "update.button": "更新", "update.button_version": "更新到 {version}", - "update.check_automatically": "自动检查更新", + "update.check_automatically": "启动时及每天检查一次", "update.check_for_updates": "检查更新", "update.check_now": "立即检查", "update.check_unavailable": "此安装版本无法检查更新。", diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 140b23f6b4..44477ff5af 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -35,6 +35,7 @@ fn near_z(c: vec4) -> f32 { @group(1) @binding(2) var acc_t: texture_2d; @group(2) @binding(0) var ao_depth: texture_2d; +@group(2) @binding(1) var ao_map: texture_2d; fn clipped(p: vec3) -> bool { return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w; @@ -62,7 +63,7 @@ fn out_color(c: vec4) -> vec4 { } struct TOut { - @builtin(position) pos: vec4, + @invariant @builtin(position) pos: vec4, @location(0) n: vec3, @location(1) c: vec4, @location(2) wp: vec3, @@ -107,7 +108,7 @@ fn vs_tri_inst( /// stores every vertex's outputs: more of them cost more than the triangles' shared vertices /// save). struct FOut { - @builtin(position) pos: vec4, + @invariant @builtin(position) pos: vec4, @location(0) rp: vec3, @location(1) c: vec4, }; @@ -209,6 +210,12 @@ fn fs_ao_depth_flat(i: FOut, @builtin(front_facing) front: bool) -> AoDepth { return opaque_depth(t, front); } +@fragment +fn fs_ao_term_flat(i: FOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + let t = flat_fragment(i, front); + return ao_term(t, front); +} + /// Opaque model surfaces: cut by the section plane/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour. @fragment fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { @@ -346,11 +353,89 @@ fn opaque_depth(i: TOut, front: bool) -> AoDepth { return AoDepth(depth, bitcast(depth)); } +/// The term for an opaque surface's fragment, on the prepass's depth. A section's cut face has none. +fn ao_term(i: TOut, front: bool) -> vec4 { + if (clipped(i.wp) || length(i.n) < 0.5) { discard; } + if (!front && length(u.clip.xyz) > 0.5) { return vec4(1.0); } + return vec4(occlusion_term(i)); +} + +@fragment +fn fs_ao_term(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + return ao_term(i, front); +} + +@vertex +fn vs_ao_blur(@builtin(vertex_index) v: u32) -> @builtin(position) vec4 { + let p = vec2(f32((v << 1u) & 2u), f32(v & 2u)); + return vec4(p * 2.0 - 1.0, 0.0, 1.0); +} + +/// The depth map at `p`, reading 0 near and 1 far whichever way the buffer runs. +fn map_depth(p: vec2) -> f32 { + let at = clamp(p, vec2(0), vec2(textureDimensions(ao_depth)) - vec2(1)); + let d = bitcast(textureLoad(ao_depth, at, 0).x); + return select(d, 1.0 - d, reversed()); +} + +/// The raw term (`ao_map` in this pass) averaged over a 4 x 4 window, which holds every turn of +/// [`ao_turn`]'s pattern once. A neighbour counts when it lies on this pixel's plane, the slope +/// taken from the nearer side across each axis, so no surface's shade crosses an edge. +@fragment +fn fs_ao_blur(@builtin(position) pos: vec4) -> @location(0) vec4 { + let c = vec2(pos.xy); + let d = map_depth(c); + if (d >= 1.0) { return vec4(1.0); } + let left = d - map_depth(c + vec2(-1, 0)); + let right = map_depth(c + vec2(1, 0)) - d; + let above = d - map_depth(c + vec2(0, -1)); + let below = map_depth(c + vec2(0, 1)) - d; + let dx = select(right, left, abs(left) < abs(right)); + let dy = select(below, above, abs(above) < abs(below)); + let tolerance = 1.5 * (abs(dx) + abs(dy)) + 1e-6; + let last = vec2(textureDimensions(ao_map)) - vec2(1); + var sum = 0.0; + var weight = 0.0; + for (var y = -2; y < 2; y = y + 1) { + for (var x = -2; x < 2; x = x + 1) { + let p = c + vec2(x, y); + let s = map_depth(p); + if (s < 1.0 && abs(s - (d + dx * f32(x) + dy * f32(y))) <= tolerance) { + sum = sum + textureLoad(ao_map, clamp(p, vec2(0), last), 0).x; + weight = weight + 1.0; + } + } + } + return vec4(sum / max(weight, 1.0)); +} + +/// A pixel's turn of the sample pattern: every 4 x 4 window holds each of 16 turns once. +fn ao_turn(pos: vec2) -> f32 { + let x = u32(pos.x) & 3u; + let y = u32(pos.y) & 3u; + // WGSL won't mix shifts and `|` without parentheses; naga takes it, browsers refuse it (#11080). + let v = (((x ^ y) & 1u) << 3u) | ((y & 1u) << 2u) | (((x ^ y) >> 1u) << 1u) | (y >> 1u); + return (f32(v) + 0.5) / 16.0; +} + +/// The lit pass's occlusion: the blurred term where this point falls in the map, whatever the +/// window's viewport offset and supersampling. +fn occlusion(i: TOut) -> f32 { + let size = vec2(textureDimensions(ao_map)); + // The maps are 1 × 1 stand-ins until a frame draws into them (the product preview draws no + // prepass, and AO off releases them): they tell nothing, so leave the surface unoccluded. + if (u.ao.x <= 0.0 || length(i.n) < 0.5 || size.x < 2 || size.y < 2) { return 1.0; } + let q = u.vp * vec4(i.wp, 1.0); + if (q.w <= 0.0) { return 1.0; } + let uv = vec2(q.x / q.w * 0.5 + 0.5, 0.5 - q.y / q.w * 0.5); + return textureLoad(ao_map, clamp(vec2(uv * vec2(size)), vec2(0), size - vec2(1)), 0).x; +} + // Cosine-weighted hemisphere samples, the pattern turned by a different angle at each pixel. // Projecting the sample and a point one radius toward the eye supplies a depth range in both // orthographic and perspective views; distant foreground geometry cannot cast a dark halo onto // a background surface. -fn occlusion(i: TOut) -> f32 { +fn occlusion_term(i: TOut) -> f32 { let radius = u.ao.x; if (radius <= 0.0 || length(i.n) < 0.5) { return 1.0; } var n = normalize(i.n); @@ -364,10 +449,9 @@ fn occlusion(i: TOut) -> f32 { if (size.x < 2 || size.y < 2) { return 1.0; } // The same offsets at every pixel made each sample darken its own shifted copy of a nearby // part's outline: faint ghosts of the part where the radius is large on screen (#4167), - // stepped bands along contacts where it is small (#3407). Turned by interleaved gradient - // noise (Jimenez 2014), neighbouring pixels sample different points and the copies break up - // into an even grain. - let turn = fract(52.9829189 * fract(dot(i.pos.xy, vec2(0.06711056, 0.00583715)))); + // stepped bands along contacts where it is small (#3407). Turned, neighbouring pixels sample + // different points, the copies break up into grain, and the blur takes the grain out (#9327). + let turn = ao_turn(i.pos.xy); let t = tangent * cos(turn * 6.28318531) + bitangent * sin(turn * 6.28318531); let b = cross(n, t); // One comparison for both depth directions: depths read as 0 near / 1 far (mirrored when diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 442dde5435..7415c0636e 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -75,6 +75,7 @@ impl Tokens { font_compass: 13.5, font_subtitle: 15.0, font_intro: 16.0, + font_page_heading: 20.0, font_article_heading: 17.0, font_title: 22.0, font_display: 26.0, @@ -228,6 +229,8 @@ impl Tokens { selected_bg: Color32::from_rgb(232, 241, 250), figure_ground: Color32::from_rgb(247, 248, 250), figure_line: Color32::from_rgb(111, 124, 143), + figure_highlight: Color32::WHITE, + figure_shadow: Color32::BLACK, figure_face_top: Color32::from_rgb(255, 255, 255), figure_face_left: Color32::from_rgb(228, 233, 240), figure_face_right: Color32::from_rgb(201, 210, 222), @@ -241,8 +244,9 @@ impl Tokens { figure_axis_z: Color32::from_rgb(47, 111, 208), figure_ok: Color32::from_rgb(47, 125, 91), figure_warn: Color32::from_rgb(201, 138, 0), + figure_conflict: Color32::from_rgb(184, 74, 0), pref_well: Color32::from_rgb(238, 241, 245), - pref_note: Color32::from_rgb(184, 74, 0), + pref_note: Color32::from_rgb(183, 74, 0), pref_note_fill: Color32::from_rgb(251, 235, 221), pref_on_accent: Color32::from_rgb(255, 255, 255), pref_ok: Color32::from_rgb(39, 107, 44), @@ -380,6 +384,7 @@ impl Tokens { t.figure_axis_z = Color32::from_rgb(108, 168, 247); t.figure_ok = Color32::from_rgb(127, 209, 174); t.figure_warn = Color32::from_rgb(232, 194, 74); + t.figure_conflict = Color32::from_rgb(240, 162, 122); t.pref_well = Color32::from_rgb(47, 52, 62); t.pref_note = Color32::from_rgb(240, 162, 122); t.pref_note_fill = Color32::from_rgb(74, 51, 38); diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 1ac32ad43d..72c9ee26a2 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -202,9 +202,12 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) { } // Named origin planes and construction planes are the component's own. PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {} - PlaneRef::Tangent { face, at } => { + PlaneRef::Tangent { face, at, reference, .. } => { *at = apply_point(m, *at); map_geo_ref(m, face); + if let Some(r) = reference { + map_geo_ref(m, r); + } } PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index aa99f21c37..8101620a5d 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -333,7 +333,12 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> { checked_geo(b, depth + 1)?; checked_geo(c, depth + 1)?; } - PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?, + PlaneRef::Tangent { face, reference, .. } => { + checked_geo(face, depth + 1)?; + if let Some(r) = reference { + checked_geo(r, depth + 1)?; + } + } PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?, _ => {} } diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index 6957e47085..2da42d0ef8 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -116,6 +116,11 @@ pub struct Construct { pub feature: u64, pub name: String, pub geom: ConstructGeom, + /// The bodies it was built from, when every pick it reads lies on a body + /// ([`source_bodies`]): a later Move of all of them carries it along (#10127). Empty when + /// it also reads something fixed (a point, a plane, a sketch), which keeps it in place. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bodies: Vec, } /// Everything a reference needs to be resolved. @@ -497,6 +502,48 @@ fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> { } } +/// A point over `s` where the face turns towards `towards` (a reference plane's normal): around +/// the axis of a cylinder, cone or torus, turned `angle` about it; straight at it on a sphere. Of +/// the two sides, the one `at` is on (#8822). +fn facing(s: FaceSurface, at: Vec3, towards: Vec3, angle: f64) -> Result { + let side = |d: Vec3, w: Vec3| if d.dot(w) < 0.0 { d * -1.0 } else { d }; + let around = |axis: Vec3, w: Vec3| -> Result<(Vec3, f64)> { + let d = (towards - axis * towards.dot(axis)) + .normalized() + .ok_or_else(|| bad("the reference is square to the face's axis: every plane tangent to the face meets it at a right angle"))?; + let d = d * angle.cos() + axis.cross(d) * angle.sin(); + let r = w - axis * w.dot(axis); + Ok((side(d, r), r.len())) + }; + if !angle.is_finite() { + return Err(bad("the angle is not a number")); + } + Ok(match s { + FaceSurface::Plane { .. } => at, + FaceSurface::Cylinder { origin, axis, radius } => { + let w = at - origin; + let (d, _) = around(axis.into(), w)?; + origin + axis * w.dot(axis) + d * radius + } + FaceSurface::Cone { apex, axis, .. } => { + let w = at - apex; + let (d, r) = around(axis.into(), w)?; + apex + axis * w.dot(axis) + d * r.max(1e-6) + } + // On the outer equator, where the tube faces away from the axis. + FaceSurface::Torus { center, axis, major, minor } => { + let (d, _) = around(axis.into(), at - center)?; + center + d * (major + minor) + } + FaceSurface::Sphere { center, radius } => { + if angle.abs() > 1e-12 { + return Err(bad("a sphere has no axis to turn the plane about: leave the angle out")); + } + center + side(towards, at - center) * radius + } + }) +} + fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> { match g { GeoRef::Face { at, name } => { @@ -538,6 +585,46 @@ fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option { Some(((p + u * s) + (q + v * t)) * 0.5) } +/// The bodies a construction built from `refs` lies on: each face, edge and vertex pick's body, +/// and the bodies of the constructions it is built from. Empty when any pick is not on a body +/// (a fixed point, the origin, a plane, a world axis, a sketch) or is not found: such a +/// construction is held by something a move of the bodies leaves where it is, so it stays. +pub(crate) fn source_bodies(ctx: &Ctx, refs: &[&GeoRef]) -> Vec { + let mut out: Vec = Vec::new(); + for g in refs { + let found: Vec = match g { + GeoRef::Face { at, name } => face_of(ctx.st, *at, name).map(|(b, ..)| vec![b.definition_name().to_string()]).unwrap_or_default(), + GeoRef::Edge { at, name } => edge_pick(ctx.st, *at, name).map(|(b, ..)| vec![b.definition_name().to_string()]).unwrap_or_default(), + GeoRef::Vertex { .. } => { + point(ctx, g).ok().and_then(|p| vertex_body(ctx.st, p)).map(|b| vec![b.definition_name().to_string()]).unwrap_or_default() + } + GeoRef::Axis { name } | GeoRef::ConstructPoint { name } => { + ctx.st.construct.iter().find(|c| c.name.as_str() == AsRef::::as_ref(name)).map(|c| c.bodies.clone()).unwrap_or_default() + } + _ => Vec::new(), + }; + if found.is_empty() { + return Vec::new(); + } + for b in found { + if !out.contains(&b) { + out.push(b); + } + } + } + out +} + +/// The body with a vertex (an end of one of its edges) at `p`. +fn vertex_body(st: &ModelState, p: Vec3) -> Option<&ModelBody> { + bodies(st).find(|b| { + let tol = (b.body.size() * 1e-3).max(1e-3); + b.body + .edges(tol) + .is_ok_and(|edges| edges.iter().flat_map(|e| [e.points.first(), e.points.last()]).flatten().any(|v| v.dist(p) <= 1e-6 * (1.0 + p.len()))) + }) +} + pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> { match d { AxisDef::ThroughFace { face } => line(ctx, face), @@ -637,9 +724,17 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result { } Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate) } - PlaneRef::Tangent { face, at } => { + PlaneRef::Tangent { face, at, reference, angle } => { let (s, _) = face_surface_at(ctx, face)?; - let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?; + let at = match reference { + Some(r) => { + let towards = plane(ctx, r)?.normal().normalized().ok_or_else(degenerate)?; + let angle = angle.as_ref().map(|a| Document::eval_in(ctx.vals, a, Kind::Angle)).transpose()?.unwrap_or(0.0); + facing(s, *at, towards, angle)? + } + None => *at, + }; + let (on, n) = surface_normal(s, at).ok_or_else(|| bad("the point is on the face's axis"))?; Plane::from_normal(on, n).ok_or_else(degenerate) } PlaneRef::Perpendicular { plane: base, line: l } => { @@ -781,7 +876,11 @@ impl PlaneRef { PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } | PlaneRef::AxisPoint { axis: a, point: b } => vec![a.as_mut(), b.as_mut()], PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()], PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()], - PlaneRef::Tangent { face, .. } => vec![face.as_mut()], + PlaneRef::Tangent { face, reference, .. } => { + let mut v = vec![face.as_mut()]; + v.extend(reference.as_deref_mut()); + v + } PlaneRef::AlongPath { path, .. } => vec![path.as_mut()], PlaneRef::ThroughPoint { base, point } => { let mut refs = base.refs_mut(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index f48f083b81..978d7254b7 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -101,8 +101,17 @@ pub enum PlaneRef { AxisPoint { axis: Box, point: Box }, /// Through three points. ThreePoints { a: Box, b: Box, c: Box }, - /// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it. - Tangent { face: Box, at: Vec3 }, + /// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it. With + /// a `reference` (a plane or planar face), it touches where the face turns towards the + /// reference instead, turned by `angle` about the face's axis, on the side of `at` (#8822). + Tangent { + face: Box, + at: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + reference: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + angle: Option, + }, /// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it. AlongPath { path: Box, t: Expr }, /// Through a straight edge (or line, axis) and square to a plane (or planar face). @@ -1208,9 +1217,10 @@ pub enum FeatureKind { body: Option, }, /// A perforation field (#732): round holes of `diameter` on a staggered (60°) or square grid of `pitch` over the - /// whole flat sheet, kept `margin` (default: the diameter) clear of its edges, cut-outs and bend zones. Stored like - /// a cosmetic thread: the holes go to the flat pattern, the DXF and the measured volume, not into the B-rep, so a - /// panel can carry tens of thousands of them. + /// whole flat sheet, or inside the closed profiles of `sketch` when it is given, kept `margin` (default: the + /// diameter) clear of its edges, cut-outs, bend zones and the outline. Stored like a cosmetic thread: the holes go + /// to the flat pattern, the DXF and the measured volume, not into the B-rep, so a panel can carry tens of thousands + /// of them. SheetPerforate { #[walk(length = "Diameter")] diameter: Expr, @@ -1223,6 +1233,13 @@ pub enum FeatureKind { pattern: Option, #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, + /// The field's outline: a sketch on a face of the sheet. None: the whole sheet. + #[serde(default, skip_serializing_if = "Option::is_none")] + sketch: Option, + /// Which of the outline sketch's profiles bound the field. None: all of them. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[walk(profiles_of = sketch)] + profiles: Option, }, /// Fold a sheet along a line on its base face: a sketch line (followed when the sketch /// changes), else the points `a`, `b`. @@ -2223,7 +2240,11 @@ pub enum PatternKind { reversed2: bool, }, Circular { - origin: Position, + /// A point on the axis the copies turn about: numbers, or length expressions it follows + /// as the parameters change (#10852). + #[serde(with = "point_expr")] + #[walk(placement)] + origin: PointExpr, axis: Dir, #[walk(unitless = "Quantity")] count: Expr, diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index f16456499c..e560d2c0b2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -40,6 +40,11 @@ pub struct ModelBody { joint_faces: Arc>, /// Lazy canonical reference triangles for a proper rigid occurrence placement. mesh_placement: Option>, + /// For a mirrored copy: its faces as a world state shows them, a rigid move of the source's + /// [`placed_mesh::OppositeHand`]. Display only: the model reads the copy's own triangles. + mirrored_mesh: Option>, + /// The first mirrored copy made of this body, shared by its clones. + opposite_hand: Arc>, /// The display plan (none when the body has no plan: a mesh body, or one the planner /// refuses), shared like the mesh. A GPU viewport draws it. plan: Arc<[OnceLock>>; 4]>, @@ -86,6 +91,8 @@ impl ModelBody { mesh: Arc::new(OnceLock::new()), joint_faces: Arc::new(OnceLock::new()), mesh_placement: None, + mirrored_mesh: None, + opposite_hand: Arc::new(OnceLock::new()), plan: Arc::new(Default::default()), plan_mesh: Arc::new(Default::default()), key: 0, @@ -458,7 +465,12 @@ fn thread_on_impl( let (body, cyl) = st .bodies .iter() - .filter_map(|b| kernel::cylinder_face_at(&b.body, p).map(|c| (b.name.clone(), c))) + .filter(|b| !more::beyond_cylinder_reach(b, p)) + .filter_map(|b| { + #[cfg(test)] + cost_tests::count(|c| c.cylinder_search += 1); + kernel::cylinder_face_at(&b.body, p).map(|c| (b.name.clone(), c)) + }) .min_by(|a, b| { let dist = |c: &kernel::CylinderFace| ((p - c.axis_point - c.axis * (p - c.axis_point).dot(c.axis)).len() - c.radius).abs(); dist(&a.1).total_cmp(&dist(&b.1)) @@ -993,6 +1005,7 @@ impl ModelState { && let Some(lines) = olds.first().and_then(|x| x.body.split_lines()) { b.body = b.body.clone().with_split_lines(Some(lines.clone())); + b.mirrored_mesh = None; } // An imported look outlives the operations on the body. if b.body.paint().is_none() @@ -1810,6 +1823,8 @@ fn hash_placement(m: &Mat, h: &mut impl Hasher) { fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec], bodies: &[crate::BodyName]) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); doc.component_transform(f.component).iter().flatten().for_each(|x| x.to_bits().hash(&mut h)); + // A feature added after the mirror that builds on its copies needs them made for that. + placement_reuse::copies_only_shown(doc, f).hash(&mut h); for placed in placements.iter().map(|path| mirror_copy_frame(doc, path)) { placed.is_some().hash(&mut h); if let Some((component, frame, picked)) = placed { @@ -2413,6 +2428,16 @@ impl Model { _ => mats, }; next.stamp(&state, f, key, mats); + // Evaluated again only for what came before it (a mirror moved by a drag). + if let Some(old) = self.results.get(i).filter(|old| old.id == f.id) { + let made = old.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| (b.name.as_str(), b)); + let made: std::collections::HashMap<&str, &ModelBody> = made.collect(); + for b in next.bodies.iter_mut().filter(|b| b.feature == f.id) { + if let Some(old) = made.get(b.name.as_str()) { + b.keep_meshes_of(old); + } + } + } for b in &mut next.bodies { if !b.transferred { b.component = match doc.body_components.get(&b.name) { @@ -3288,10 +3313,29 @@ fn apply_traced_with( let affected: std::collections::BTreeMap = target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| (b.name.clone(), b.body.lumps().map_or(1, |l| l.len()))).collect(); let mut remove = Vec::new(); + // A cut hands a body its tools cannot reach back as it was, but only after a whole + // boolean: in a design of many bodies that was most of a hole's time (#10225). Such a + // body is left as it is. Its box and the tools' are their meshes' grown past how far + // the exact faces can curve out from them (a dome rises above the outline of its + // edges), so a tool that touches a body, or only comes near it, still meets it in the + // boolean. A body shown in another frame, and a mesh body, are cut as before; an + // intersect removes the bodies it misses, and is left as it was. + let reach: Option>> = + (op == Operation::Cut && !tools.iter().any(|t| t.body.is_mesh())).then(|| tools.iter().map(|t| more::body_box(&t.body)).collect()); for &ti in &target_idx { let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue }; // The tools meet the target where it is shown: in its frame (#3383). let into = state.bodies.get(ti).and_then(|b| tool_frame(&b.name)); + if let (Some(reach), None, Some(mb)) = (&reach, into, state.bodies.get(ti)) + && !reach.is_empty() + && !mb.body.is_mesh() + && let Some(target) = more::mesh_box(mb) + && reach.iter().all(|tool| more::boxes_apart(&Some(target), tool)) + { + continue; + } + #[cfg(test)] + cost_tests::count(|c| c.cut += 1); for t in &tools { let t = &tool_in(t, into)?; acc = match acc { @@ -3892,8 +3936,9 @@ fn body_at(st: &ModelState, body: &Option, points: &[Vec3]) -> /// A hole stays on its face when the model changes: a position now inside a body moves back /// against the drilling direction to the surface, one now above it moves down onto it. Only the -/// bodies in `scope` count (every body when it is empty, see [`hole_scope`]). -fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 { +/// bodies in `scope` count (every body when it is empty, see [`hole_scope`]). Edit Hole seeds from +/// this point so the dialog's face pick matches where evaluation drills (#10689). +pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 { let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max); let eps = size * 1e-6; let mut best: Option = None; @@ -3941,6 +3986,18 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option ((v - axis * v.dot(axis)).normalized(), None) } Some(kernel::FaceSurface::Sphere { center, .. }) => ((p - center).normalized(), None), + // Square to the line through the apex, not the nearest facet's normal (#10698). + Some(kernel::FaceSurface::Cone { apex, axis, half_angle }) => { + let v = p - apex; + let out = (v - axis * v.dot(axis)).normalized(); + (out.and_then(|u| (u * half_angle.cos() - axis * half_angle.sin()).normalized()), None) + } + // Away from the nearest point of the ring the tube goes round. + Some(kernel::FaceSurface::Torus { center, axis, major, .. }) => { + let v = p - center; + let ring = (v - axis * v.dot(axis)).normalized().map(|u| center + u * major); + (ring.and_then(|q| (p - q).normalized()), None) + } _ => (None, None), }; let n = match exact { @@ -5277,11 +5334,15 @@ const APART_GAP: f64 = 1e-3; /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. /// `to_body` is the body a To Object extent runs to (an extrude), when it found one. +/// `copy` builds the tool of a pattern's or mirror's source: the state already holds the source +/// feature's own material, so a join end that reads as buried in the target there may be bare +/// material where the copy lands — its ends are left as drawn (#6452). fn feature_tools_with_constructor( doc: &Document, vals: &BTreeMap, f: &Feature, st: &ModelState, + copy: bool, constructor: &mut Option, to_body: &mut Option, ) -> Result> { @@ -5468,7 +5529,26 @@ fn feature_tools_with_constructor( let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) }; // To a curved (or slanted) face: run right through the target body, then keep what // lies before its surface (the tool less the body, the piece at the start). - let curved = curved_face(&tst, to_face, Some(n)); + let mut curved = curved_face(&tst, to_face, Some(n)); + // A Cut to the far side of a curved wall (a window through a duct's side): ending the + // tool on that face leaves it lying in the wall's face, where the boolean fails, and + // the tool less the body keeps only the air before the wall, which cuts nothing. Where + // the target's material ends at the face under the whole profile, the tool ends flat + // in the gap beyond it instead: it takes what lies before the face and meets no face. + if *operation == Operation::Cut + && off == 0.0 + && let (Some(bi), Some((_, fi))) = (curved, to_face) + && let Some(tb) = tst.bodies.get(bi) + && let Some(end) = cut_end_past_face(tb, fi, &ss.plane, ®ions, if hi > 0.0 { 1.0 } else { -1.0 }) + && (if hi > 0.0 { end > lo } else { end < hi }) + { + if hi > 0.0 { + hi = end; + } else { + lo = end; + } + curved = None; + } if let Some(bi) = curved && let Some(tb) = tst.bodies.get(bi) { @@ -5523,7 +5603,7 @@ fn feature_tools_with_constructor( } } let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) }; - let join_surroundings = if *operation == Operation::Join && !touching_batch { + let join_surroundings = if *operation == Operation::Join && !touching_batch && !copy { // Trimming may discard a tool's material, so only bodies this feature // actually joins can establish that the discarded end is already filled. let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets }; @@ -6521,11 +6601,19 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap, st: &Model } } crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_ref, axis_feature } => { - // On an edge or round face: about it as it is now; on a construction axis: where it is. + // On an edge or round face: about it as it is now; on a construction axis: where it is; + // else through its origin, whose expressions follow the parameters (#10852). let (origin, axis) = match (axis_ref, axis_feature) { (Some(g), _) => crate::construct::model_axis(&construction_state(doc, st, component), g)?, (None, Some(name)) => construction_axis_in(Some(doc), st, name, component)?, - (None, None) => (origin.0, axis.0), + (None, None) => { + let [x, y, z] = origin; + let o = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?); + if ![o.x, o.y, o.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { + return Err(DocError::Invalid("the pattern's axis origin is out of range".into())); + } + (o, axis.0) + } }; let n = count(c)?; let total = val(vals, angle, Kind::Angle)?; @@ -6600,6 +6688,35 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) { } /// Whether a copy has no material to cut in any of its eligible targets. +/// The bodies a hole cuts: those its feature names, else its component's and its own (as +/// `apply_traced` picks a cut's targets). +fn hole_targets<'a>(st: &'a ModelState, f: &Feature) -> Vec<&'a ModelBody> { + let all = f.participants.len() == 1 && f.participants.first().is_some_and(|t| t == "*"); + st.bodies + .iter() + .filter(|b| all || if f.participants.is_empty() { b.component == f.component || b.feature == f.id } else { f.participants.contains(&b.name) }) + .collect() +} + +/// Whether hole tool `tool` is clear of every body the hole cuts, by their boxes (#10701). Boxes +/// only: a probe boolean for every hole of a large hole pattern would double its work, and the +/// bodies' meshes are there already (the hole found its face on them). A body of another +/// component is in another frame: the hole is then not known to miss. +fn hole_misses(st: &ModelState, f: &Feature, tool: &Body) -> bool { + let targets = hole_targets(st, f); + let bounds = tool.outline_bounds(); + if targets.is_empty() + || targets.iter().any(|b| b.component != f.component) + || bounds.is_empty() + || !bounds.min.is_finite() + || !bounds.max.is_finite() + { + return false; + } + let tool_box = Some(bounds.inflated(1e-6)); + targets.iter().all(|b| more::boxes_apart(&tool_box, &more::mesh_box(b))) +} + fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced]) -> bool { let targets = if targets.is_empty() { &f.participants } else { targets }; let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); @@ -6900,7 +7017,7 @@ fn replay( // points follow their named faces, as the feature's own did (#2121): a To Object // extrude's copies reach the face where it is now, not where it was picked. let moved = with_named_face_points(doc, st, src).map(|(g, _)| g); - let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None, &mut None)?; + let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, true, &mut None, &mut None)?; for (k, m) in mats.iter().enumerate() { let start = copies.len(); for t in &tools { @@ -7107,6 +7224,73 @@ fn trim_to_body(tools: Vec, target: &ModelBody, dist_to_start: impl Fn(V Ok(out) } +/// For a Cut To the curved face `fi` of `target`: the height above `plane` (along its normal) at +/// which a flat-ended tool over `regions`, travelling along `travel` × the normal (±1), stops in +/// the gap beyond that face. Rays over the profiles must each cross the face once, with material +/// just before it and none just after; the end is halfway across the narrowest gap to the next +/// material (at most the usual run past a curved target). `None` when that does not hold. +fn cut_end_past_face(target: &ModelBody, fi: usize, plane: &Plane, regions: &[Region2], travel: f64) -> Option { + let m = target.mesh(); + let n = plane.normal(); + let dir = n * travel; + let size = target.body.size(); + let eps = (size * 1e-4).max(1e-6); + let margin = size * 0.05 + 1.0; + // Sample points: the outline of each profile and a grid over its inside. + let mut samples: Vec = Vec::new(); + for r in regions { + let ring: Vec = r.outer.polyline(1e-2).into_iter().collect(); + let step = (ring.len() / 64).max(1); + samples.extend(ring.iter().step_by(step).copied()); + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for q in &ring { + lo = Vec2::new(lo.x.min(q.x), lo.y.min(q.y)); + hi = Vec2::new(hi.x.max(q.x), hi.y.max(q.y)); + } + for i in 0..12 { + for j in 0..12 { + let q = Vec2::new(lo.x + (hi.x - lo.x) * (i as f64 + 0.5) / 12.0, lo.y + (hi.y - lo.y) * (j as f64 + 0.5) / 12.0); + if r.contains(q) { + samples.push(q); + } + } + } + } + let height = |q: Vec3| (q - plane.origin).dot(n); + let reach = size * 4.0 + 1.0; + let (mut face_end, mut gap_end) = (f64::MIN, f64::MAX); + for q2 in samples { + let q = plane.to_world(q2); + // Every crossing of the line through `q`, in travel order, from well before the body. + let back = travel * height(q) + reach + size; + let mut o = q - dir * back; + let mut hits: Vec<(f64, usize)> = Vec::new(); + while let Some((t, tri)) = m.raycast(o, dir) { + let at = o + dir * t; + hits.push((travel * height(at), *m.tri_face.get(tri)? as usize)); + o = at + dir * eps; + if hits.len() > 256 { + return None; + } + } + let on_face: Vec = (0..hits.len()).filter(|&k| hits[k].1 == fi).collect(); + let [k] = on_face[..] else { return None }; + let e = hits[k].0; + let at = |t: f64| plane.origin + n * (travel * t) + (q - plane.origin - n * height(q)); + if !m.contains(at(e - eps * 10.0)) || m.contains(at(e + eps * 10.0)) { + return None; + } + face_end = face_end.max(e); + let next = hits.iter().map(|h| h.0).filter(|&t| t > e + eps * 10.0).fold(f64::MAX, f64::min); + gap_end = gap_end.min(next); + } + if face_end == f64::MIN || gap_end - face_end < eps * 100.0 { + return None; + } + let end = face_end + ((gap_end - face_end) * 0.5).min(margin); + Some(travel * end) +} + /// One point on each face of a body (a triangle centre, so it lies on the face). fn face_samples(b: &ModelBody) -> Vec { let m = b.mesh(); @@ -7432,7 +7616,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Sweep { operation, targets, .. } => { let mut constructor = None; let mut to_body = None; - let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body)?; + let tools = feature_tools_with_constructor(doc, vals, f, st, false, &mut constructor, &mut to_body)?; // Cuts through sheet metal become cut-outs of its flat pattern. if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { let bodies: Vec = tools.iter().map(|t| t.body.clone()).collect(); @@ -7459,7 +7643,23 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let handled = sheet_hole_cut(st, f, &tools, warning)?; let tools: Vec = tools.into_iter().map(|(tool, _)| tool).collect(); if handled.is_empty() { + // A hole moved off the material (a changed position, parameter or sketch point) + // cuts nothing: it says so, as a pattern does of its copies (#10701). + let missed = tools.iter().filter(|tool| hole_misses(st, f, &tool.body)).count(); + let total = tools.len(); + let targets: Vec = hole_targets(st, f).iter().map(|b| b.name.clone()).collect(); apply_traced(doc, st, f, tools, Operation::Cut, &[])?; + match (missed, total) { + (0, _) => {} + (_, 1) => append_warning(warning, "the hole removes no material".into()), + _ => append_warning(warning, format!("{missed} of {total} holes remove no material")), + } + // A hole wider than the part takes all of it away (#10702): almost never what a + // hole means, so it says which bodies are gone. + let gone: Vec<&str> = targets.iter().map(String::as_str).filter(|n| st.body(n).is_none()).collect(); + if !gone.is_empty() { + append_warning(warning, format!("the hole removes all of {}", gone.join(", "))); + } } else { apply_beside_sheet_cut(doc, st, f, tools, Operation::Cut, &[], &handled)?; } @@ -7620,23 +7820,38 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: // Picks on bodies of other (moved) components are where those bodies are shown. None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?, }; - st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) }); + st.construct.push(crate::construct::Construct { + feature: f.id, + name: f.name.clone(), + geom: crate::construct::ConstructGeom::Plane(pl), + bodies: Vec::new(), + }); Ok(()) } FeatureKind::ConstructionAxis { def } => { - let (origin, dir) = - crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?; + let cst = construction_state(doc, st, f.component); + let ctx = crate::construct::Ctx { doc, vals, st: &cst, depth: 0 }; + let (origin, dir) = crate::construct::resolve_axis(&ctx, def)?; + let bodies = crate::construct::source_bodies(&ctx, &def.refs()); st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Axis { origin, dir }, + bodies, }); Ok(()) } FeatureKind::ConstructionPoint { def } => { - let p = - crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?; - st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) }); + let cst = construction_state(doc, st, f.component); + let ctx = crate::construct::Ctx { doc, vals, st: &cst, depth: 0 }; + let p = crate::construct::resolve_point(&ctx, def)?; + let bodies = crate::construct::source_bodies(&ctx, &def.refs()); + st.construct.push(crate::construct::Construct { + feature: f.id, + name: f.name.clone(), + geom: crate::construct::ConstructGeom::Point(p), + bodies, + }); Ok(()) } FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => { @@ -7809,6 +8024,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } let m = mirror_matrix(&pl); let mut output = 0; + // Copies evaluated on each frame a placement is dragged, which nothing cuts or joins: + // searching their revolved faces again was nearly all that took. + let shown_only = !*combine && !placements.is_empty() && placement_reuse::copies_only_shown(doc, f); for (k, n) in bodies.iter().enumerate() { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { continue }; @@ -7825,7 +8043,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } None => (None, m, m), }; - let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, if *combine { own } else { copy })?, 1); + let copied = if shown_only { + kernel::place_matrix(&mb.body, copy) + } else { + kernel::transform_matrix(&mb.body, if *combine { own } else { copy }) + }; + let mirrored = moved_copy(&mb, copied?, 1); if *combine { let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)? .ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?; @@ -7847,6 +8070,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let name = copy_body_name(st, f, output, &mb.name); copy_sheet(st, &mb.name, &name, ©); let mut body = ModelBody::traced_new(name, mirrored, f.id); + if !placements.is_empty() { + body.mirrored_mesh = placed_mesh::PlacedMesh::mirrored(&mb, &body.body, ©).map(Arc::new); + } if let Some(c) = component.filter(|c| *c != f.component) { // Kept in that component as a moved body is (and through later features). body.component = c; @@ -8105,6 +8331,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: *slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature); } } + if !*copy { + move_constructions_with(doc, st, bodies, &crate::rigid(t, Vec3::ZERO, axis, ang)); + } Ok(()) } FeatureKind::MeshConvert { body, tolerance } => { @@ -8240,6 +8469,36 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: state_in_frame(doc, st, comp) } +/// Construction axes and points built on bodies a Move moves go with them (#10127): an axis +/// through a cylinder turns with the cylinder, as its faces do. Only those built from the moved +/// bodies alone (`Construct::bodies`) follow; one also held by a fixed point, a plane or another +/// body stays. `m` is the move in the bodies' own frame; each construction is kept in its +/// component's frame (`construction_state`), so the move is carried over into that frame. +fn move_constructions_with(doc: &Document, st: &mut ModelState, moved: &[crate::BodyName], m: &Mat) { + let frame_of = |feature: u64| { + let comp = doc.feature(feature).map(|f| f.component).unwrap_or(0); + if comp == 0 && !doc.occurrences.is_empty() { crate::IDENTITY } else { doc.component_transform(comp) } + }; + let world: BTreeMap<&str, Mat> = + st.bodies.iter().filter(|b| moved.iter().any(|n| *n == b.name)).map(|b| (b.name.as_str(), body_world(doc, b))).collect(); + let mut placed = Vec::new(); + for (k, c) in st.construct.iter().enumerate() { + let Some(body) = c.bodies.first().and_then(|b| world.get(b.as_str())) else { continue }; + if !c.bodies.iter().all(|b| world.contains_key(b.as_str())) { + continue; + } + let frame = frame_of(c.feature); + let (Some(body_inv), Some(frame_inv)) = (crate::mat_inverse(body), crate::mat_inverse(&frame)) else { continue }; + let in_frame = crate::mat_mul(&crate::mat_mul(&frame_inv, body), &crate::mat_mul(m, &crate::mat_mul(&body_inv, &frame))); + placed.push((k, c.geom.placed(&in_frame))); + } + for (k, geom) in placed { + if let Some(c) = st.construct.get_mut(k) { + c.geom = geom; + } + } +} + /// Whether `plane` is given by the origin planes and numbers alone. fn plane_is_fixed(plane: &PlaneRef) -> bool { match plane { @@ -8525,7 +8784,8 @@ impl PlacedBodies { if let Some(placed) = self.taken(b, m, &name) { return placed; } - match kernel::place_matrix(&b.body, *m) { + let body = if crate::is_identity(m) { Ok(b.body.clone()) } else { kernel::place_matrix(&b.body, *m) }; + match body { Ok(body) => { let mut fresh = ModelBody::new(name, body, b.feature); fresh.mesh_placement = placed_mesh::PlacedMesh::new(b, m).map(Arc::new); @@ -8631,6 +8891,12 @@ fn placed_key(b: &ModelBody, m: &crate::Mat) -> (usize, [u64; 16]) { (Arc::as_ptr(&b.mesh) as usize, bits) } +/// Whether a world state shows `b`, placed by `m`, apart from the model's body. A mirrored copy +/// always is: it is drawn from its source's opposite hand, and the model keeps its own triangles. +fn placed_apart(b: &ModelBody, m: &crate::Mat) -> bool { + !crate::is_identity(m) || b.mirrored_mesh.is_some() +} + /// [`world_state`], reusing the bodies `placed` holds from an earlier call and leaving it holding /// this call's. pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut PlacedBodies) -> ModelState { @@ -8685,7 +8951,7 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut for (comp, path, m) in &placements { for b in bodies_of(*comp) { let bm = body_matrix(b, m); - if !crate::is_identity(&bm) { + if placed_apart(b, &bm) { jobs.push((b, bm, names.name(&b.name, *comp, path))); } } @@ -8703,7 +8969,7 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut let bm = body_matrix(b, &m); // Identical geometry also shares its display mesh cell with the definition. // A changed placement keeps a distinct mesh, at that placement's coordinates. - let mut placed = if crate::is_identity(&bm) { + let mut placed = if !placed_apart(b, &bm) { let mut placed = b.clone(); placed.name = names.name(&b.name, comp, &path); placed diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs index a851e33b9d..7cbaa0025d 100644 --- a/crates/doc/src/eval_cost_tests.rs +++ b/crates/doc/src/eval_cost_tests.rs @@ -16,7 +16,8 @@ use crate::eval::{PlacementReads, body_world, hash_placement, placement_digest}; use crate::*; /// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies -/// measured, bodies searched for by name, features searched for by id. +/// measured, bodies searched for by name, features searched for by id, bodies a cut (or +/// intersect) ran its booleans on, and bodies searched for the cylinder a tap marking lies on. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct Counts { pub moved: usize, @@ -25,11 +26,23 @@ pub(super) struct Counts { pub measures: usize, pub body_searches: usize, pub feature_searches: usize, + pub cut: usize, + pub cylinder_search: usize, } thread_local! { - static COUNTS: Cell = - const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0, measures: 0, body_searches: 0, feature_searches: 0 }) }; + static COUNTS: Cell = const { + Cell::new(Counts { + moved: 0, + digested: 0, + joint_faces: 0, + measures: 0, + body_searches: 0, + feature_searches: 0, + cut: 0, + cylinder_search: 0, + }) + }; } pub(super) fn count(f: impl FnOnce(&mut Counts)) { @@ -1083,3 +1096,251 @@ fn a_placed_tap_follows_its_body_offset() { apply_point(&mat_mul(&doc.component_transform(comp), &rigid(Vec3::new(0.0, 0.0, 7.0), Vec3::ZERO, Vec3::Z, 0.0)), Vec3::new(1.0, 1.0, 0.0)); assert!(tap.axis_point.dist(expected) < 1e-9, "{:?} {expected:?}", tap.axis_point); } + +/// What adding a feature of `kind` at the end of `doc` did to the bodies already there: how many +/// its cut ran booleans on, and which it left exactly as they were (the same geometry, its name, +/// key and face names carried over). A cut that cannot reach a body hands it back as it was, and +/// must not pay a whole boolean to find that out (#10225); the sheet metal hole (#10226) and the +/// cosmetic thread (#10227) check their own shortcuts with it too. +pub(super) struct CutWork { + pub cut: usize, + pub kept: Vec, + pub model: Model, +} + +pub(super) fn cut_work(doc: &mut Document, kind: FeatureKind) -> CutWork { + let mut model = Model::new(); + model.evaluate(doc); + let id = doc.add_feature(kind, None).unwrap(); + let work = counted(|| model.evaluate(doc)); + let result = model.result(id).unwrap(); + assert_eq!(result.error, None, "the feature builds"); + let before = model.state_before(id); + let kept = result + .state + .bodies + .iter() + .filter(|b| { + before + .body(&b.name) + .is_some_and(|old| old.body.same(&b.body) && old.key == b.key && crate::naming::face_names(old) == crate::naming::face_names(b)) + }) + .map(|b| b.name.clone()) + .collect(); + CutWork { cut: work.cut, kept, model } +} + +/// A box body named `name`, `size` long on each side from `corner`. +fn block(doc: &mut Document, name: &str, corner: Vec3, size: [f64; 3]) { + let id = doc + .add_feature( + FeatureKind::Box { + corner: point_expr(corner), + length: size[0].to_string().into(), + width: size[1].to_string().into(), + height: size[2].to_string().into(), + operation: Operation::NewBody, + }, + None, + ) + .unwrap(); + doc.feature_mut(id).unwrap().body_names = vec![name.into()]; +} + +/// A cylinder that cuts, standing on `base` along Z. +fn cutter(base: Vec3, radius: f64, height: f64) -> FeatureKind { + FeatureKind::Cylinder { + base: point_expr(base), + axis: Dir(Vec3::Z), + radius: radius.to_string().into(), + height: height.to_string().into(), + operation: Operation::Cut, + references: Vec::new(), + } +} + +/// A cut leaves the bodies its tool cannot reach as they were, without a boolean on each: in a +/// design of many bodies those booleans were most of a hole's time (#10225). +#[test] +fn a_cut_leaves_bodies_it_cannot_reach_as_they_were() { + let mut doc = Document::new("far"); + block(&mut doc, "Near", Vec3::ZERO, [50.0, 40.0, 30.0]); + // 0.5 mm clear of the tool, and far off. + block(&mut doc, "Beside", Vec3::new(13.5, 0.0, 0.0), [20.0, 40.0, 30.0]); + for i in 0..5 { + block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]); + } + // Near and Beside overlap; the hole goes through Near only, beside Beside. + let work = cut_work(&mut doc, cutter(Vec3::new(10.0, 20.0, -5.0), 3.0, 40.0)); + assert_eq!(work.cut, 1, "only the body the tool reaches goes through the boolean"); + assert_eq!(work.kept, ["Beside", "Far0", "Far1", "Far2", "Far3", "Far4"]); + assert!(volume(&work.model, "Near") < 60_000.0 - 800.0, "and it is cut"); +} + +/// A cut still goes through the boolean wherever its tool reaches a body or comes close: on a +/// face, along a line, a hair beside it, into a dome above its edge circle. (Bodies its tool +/// cannot reach are left out of it: `a_cut_leaves_bodies_it_cannot_reach_as_they_were`.) +#[test] +fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() { + // A cylinder whose side is tangent to the face x = 50, one a hair clear of it, one 0.1 mm in. + for (x, cuts) in [(53.0, false), (53.0005, false), (52.9, true)] { + let mut doc = Document::new("graze"); + block(&mut doc, "A", Vec3::ZERO, [50.0, 40.0, 30.0]); + let work = cut_work(&mut doc, cutter(Vec3::new(x, 20.0, 0.0), 3.0, 30.0)); + assert_eq!(work.cut, 1, "the tool at x = {x} goes through the boolean"); + let v = volume(&work.model, "A"); + assert_eq!(v < 60_000.0 - 1.0, cuts, "the tool at x = {x} cuts material: {v}"); + assert!(!cuts || work.kept.is_empty(), "a body cut is a new body"); + } + // A box meeting the block only along its top edge. + let mut doc = Document::new("edge"); + block(&mut doc, "A", Vec3::ZERO, [50.0, 40.0, 30.0]); + let along = FeatureKind::Box { + corner: point_expr(Vec3::new(50.0, 0.0, 30.0)), + length: "10".into(), + width: "40".into(), + height: "10".into(), + operation: Operation::Cut, + }; + assert_eq!(cut_work(&mut doc, along).cut, 1); + // A dome: a sphere's cap rising 10 mm above the circle it stands on. A thin tool drilled into + // its top near the apex, well above that circle, cuts it. + let mut doc = Document::new("dome"); + doc.add_feature(FeatureKind::Sphere { center: point_expr(Vec3::new(0.0, 0.0, -10.0)), radius: "20".into(), operation: Operation::NewBody }, None) + .unwrap(); + let below = FeatureKind::Box { + corner: point_expr(Vec3::new(-30.0, -30.0, -40.0)), + length: "60".into(), + width: "60".into(), + height: "40".into(), + operation: Operation::Cut, + }; + doc.add_feature(below, None).unwrap(); + let mut model = Model::new(); + model.evaluate(&doc); + let dome = model.state().bodies[0].name.clone(); + let whole = volume(&model, &dome); + let work = cut_work(&mut doc, cutter(Vec3::new(2.0, 1.0, 8.0), 1.0, 5.0)); + assert_eq!(work.cut, 1); + assert!(volume(&work.model, &dome) < whole - 1.0, "the drill cuts into the dome"); + assert!(work.kept.is_empty()); +} + +/// A cylinder body named `name` standing on `base` along Z. +fn shaft(doc: &mut Document, name: &str, base: Vec3, radius: f64, height: f64) { + let id = doc + .add_feature( + FeatureKind::Cylinder { + base: point_expr(base), + axis: Dir(Vec3::Z), + radius: radius.to_string().into(), + height: height.to_string().into(), + operation: Operation::NewBody, + references: Vec::new(), + }, + None, + ) + .unwrap(); + doc.feature_mut(id).unwrap().body_names = vec![name.into()]; +} + +/// The work of adding `kind` at the end of `doc`, and the tap markings the design then has. +fn marking_work(doc: &mut Document, kind: FeatureKind) -> (Counts, Vec) { + let mut model = Model::new(); + model.evaluate(doc); + let id = doc.add_feature(kind, None).unwrap(); + let work = counted(|| model.evaluate(doc)); + assert_eq!(model.result(id).unwrap().error, None, "the feature builds"); + (work, model.state().threads.clone()) +} + +/// A cosmetic tap marking at `p`. +fn marking(p: Vec3) -> FeatureKind { + FeatureKind::Thread { face: FacePick(p), designation: Some("M6".into()), length: None, modeled: false, handedness: ThreadHandedness::Right } +} + +/// A tap marking looks for its cylinder in the bodies near it only: each body searched was +/// tessellated again, so many markings in a design of many bodies took quadratic time (#10227). +#[test] +fn a_tap_marking_searches_only_the_bodies_near_it() { + let mut doc = Document::new("far"); + shaft(&mut doc, "Shaft", Vec3::ZERO, 3.0, 6.0); + for i in 0..5 { + block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]); + } + let (work, threads) = marking_work(&mut doc, marking(Vec3::new(3.0, 0.0, 3.0))); + assert_eq!(work.cylinder_search, 1, "only the shaft is searched"); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].body, "Shaft"); + assert_eq!(threads[0].designation, "M6"); + assert!((threads[0].radius - 3.0).abs() < 1e-6 && !threads[0].internal); + + // A tapped hole in a plate among far bodies: only the plate is searched for its wall. + let mut doc = Document::new("tapped"); + block(&mut doc, "Plate", Vec3::ZERO, [60.0, 60.0, 12.0]); + for i in 0..5 { + block(&mut doc, &format!("Far{i}"), Vec3::new(200.0 * (i + 1) as f64, 0.0, 0.0), [50.0, 40.0, 30.0]); + } + let hole = FeatureKind::Hole { + position: FacePick(Vec3::new(30.0, 30.0, 12.0)), + at: None, + direction: Dir(-Vec3::Z), + diameter: "5".into(), + depth: Some("10".into()), + hole: HoleKind::default(), + points: None, + thread: Some("M6".into()), + thread_depth: None, + handedness: ThreadHandedness::Right, + to: None, + references: Vec::new(), + }; + let (work, threads) = marking_work(&mut doc, hole); + assert_eq!(work.cylinder_search, 1, "only the plate is searched"); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].body, "Plate"); + assert!(threads[0].internal && (threads[0].radius - 2.5).abs() < 1e-6); +} + +/// Bodies within reach of a tap marking are still searched: the shaft it lies on, a block 3 mm +/// off (within a twentieth of the block's size, where the cylinder search itself looks), and one +/// 3.6 mm off (just past it, but within the margin the boxes keep); a block 4.5 mm off is not. +/// The marking stays on the shaft. +#[test] +fn a_tap_marking_still_searches_the_bodies_within_its_reach() { + let mut doc = Document::new("near"); + shaft(&mut doc, "Shaft", Vec3::ZERO, 3.0, 6.0); + // Each block is 50 x 40 x 30 (a twentieth of its size is 3.54 mm), its face x = 3 + gap. + for (name, gap) in [("Close", 3.0), ("Edge", 3.6), ("Clear", 4.5)] { + block(&mut doc, name, Vec3::new(3.0 + gap, -20.0, 0.0), [50.0, 40.0, 30.0]); + } + let (work, threads) = marking_work(&mut doc, marking(Vec3::new(3.0, 0.0, 3.0))); + assert_eq!(work.cylinder_search, 3, "the shaft, Close and Edge are searched"); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].body, "Shaft"); + + // A pin in a hole a hair wider: a marking on the pin, and one on the hole's wall. + for (p, body, internal) in [(Vec3::new(2.0, 0.0, 5.0), "Pin", false), (Vec3::new(2.5, 0.0, 5.0), "Plate", true)] { + let mut doc = Document::new("pin"); + block(&mut doc, "Plate", Vec3::new(-20.0, -20.0, 0.0), [40.0, 40.0, 10.0]); + let bore = FeatureKind::Cylinder { + base: point_expr(Vec3::new(0.0, 0.0, -1.0)), + axis: Dir(Vec3::Z), + radius: "2.5".into(), + height: "12".into(), + operation: Operation::Cut, + references: Vec::new(), + }; + doc.add_feature(bore, None).unwrap(); + shaft(&mut doc, "Pin", Vec3::ZERO, 2.0, 10.0); + let mut kind = marking(p); + if let FeatureKind::Thread { designation, .. } = &mut kind { + *designation = None; + } + let (work, threads) = marking_work(&mut doc, kind); + assert_eq!(work.cylinder_search, 2, "both bodies are searched for {body}"); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].body, body); + assert_eq!(threads[0].internal, internal); + } +} diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 9cc8952423..8a81b4f2bd 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -684,7 +684,7 @@ pub(super) fn sheet_eval( tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } - FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => { + FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body, sketch, profiles } => { // Without a body name the field goes on the newest sheet, as the command's own body lookup does. let i = match body { Some(_) => sheet_for(st, body, None)?, @@ -706,8 +706,18 @@ pub(super) fn sheet_eval( "perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})" ))); } + // The outline sketch's profiles, where they lie on the sheet now. + let outline = match sketch { + Some(sk) => { + let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?; + let regions = select_profiles(ss, profiles.as_ref().unwrap_or(&ProfileSel::All))?; + let sh = st.sheets.get(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; + Some(sh.perforation_outline(&ss.plane, ®ions)?) + } + None => None, + }; let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; - sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false }); + sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false, outline }); Ok(()) } FeatureKind::SheetHem { edges, length, gap, flip, body } => { @@ -1559,13 +1569,13 @@ fn sheet_prism_cut( /// A box around `body`, grown past its mesh's deflection from the exact faces (and a hair /// more): bodies whose boxes are apart do not meet. None when it can't be worked out. -fn body_box(body: &Body) -> Option { +pub(super) fn body_box(body: &Body) -> Option { let tol = (body.size() * 1e-3).max(1e-4); padded_box(&body.tessellate(tol).ok()?, tol) } /// [`body_box`] of a model body, from the mesh it keeps. -fn mesh_box(mb: &ModelBody) -> Option { +pub(super) fn mesh_box(mb: &ModelBody) -> Option { padded_box(&mb.mesh(), mb.display_tol()) } @@ -1574,8 +1584,25 @@ fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option { (!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| b.inflated(2.0 * tol + 1e-6)) } +/// Whether [`kernel::cylinder_face_at`] is known to find no face of `mb` at `p`. It takes a +/// face only when its own mesh of the body comes within a twentieth of the body's size of the +/// point. That mesh lies in the body's box ([`mesh_box`]) give or take its own deflection, which +/// the reach allows for, so a box farther away than that holds no face for it. Working that out from the mesh the body keeps +/// spares a tessellation of every body in the design for each tap marking (#10227). A mesh +/// body, and a body whose box is unknown, are searched as before. +pub(super) fn beyond_cylinder_reach(mb: &ModelBody, p: Vec3) -> bool { + if mb.body.is_mesh() { + return false; + } + let Some(b) = mesh_box(mb) else { return false }; + let size = mb.body.size(); + let reach = size * 0.05 + 2.0 * (size * 1e-3).max(1e-3); + let off = (b.min - p).max(p - b.max).max(Vec3::ZERO); + off.len() > reach +} + /// Whether two boxes are known to be apart (an unknown box is never apart). -fn boxes_apart(a: &Option, b: &Option) -> bool { +pub(super) fn boxes_apart(a: &Option, b: &Option) -> bool { let (Some(a), Some(b)) = (a, b) else { return false }; a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z } diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index bcd484d7df..8a95cda3a1 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -304,6 +304,28 @@ pub fn value_in_unit(v: Value, unit: &str) -> f64 { } } +/// The number a reply reports for a value: millimetres for a length, degrees for an angle, +/// as-is for a unit-less or compound value. One meaning of `value` in every reply (#10747). +pub fn reported_value(v: Value) -> f64 { + match v.kind() { + Some(Kind::Angle) => v.v.to_degrees(), + _ => v.v, + } +} + +/// The number a person would read for `v`: the shortest decimal that reads back after +/// rounding off the last few bits (12 significant digits), so `30 deg` stored as +/// 29.999999999999996 reports 30 and `0.1 + 0.2` reports 0.3. Only what is *reported* +/// is rounded; stored values and evaluation keep full precision (#10747). +pub fn round_reported(v: f64) -> f64 { + if !v.is_finite() { + return v; + } + // `:.11e` keeps 12 significant digits; parsing back gives the f64 nearest that decimal, + // which serializes as the shortest form that reads back to it. + format!("{v:.11e}").parse().unwrap_or(v) +} + fn unit(name: &str) -> Option { UNITS.iter().find(|(n, _, _)| *n == name).map(|(_, k, s)| match k { Kind::Angle => Value::angle(*s), @@ -950,7 +972,11 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result { let a = one()?; unit_fn(a.v.abs(), a) } - "sign" => Value::num(one()?.v.signum()), + "sign" => { + let x = one()?.v; + // `f64::signum` gives 1 for +0 and -1 for -0; the sign of zero is 0. + Value::num(if x == 0.0 { 0.0 } else { x.signum() }) + } "floor" | "ceil" | "round" | "trunc" => { let a = one()?; // Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter). @@ -1408,6 +1434,16 @@ mod tests { } } + /// `sign(0)` is 0, not 1 (Rust's `signum` takes the sign bit, so it calls zero positive). + #[test] + fn sign_of_zero_is_zero() { + for (e, want) in + [("sign(0)", 0.0), ("sign(-0)", 0.0), ("sign(0 mm)", 0.0), ("sign(5)", 1.0), ("sign(-2 mm)", -1.0), ("sign(width - 40)", 0.0)] + { + assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}"); + } + } + /// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901). #[test] fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() { @@ -1637,4 +1673,36 @@ mod tests { defs[2].expr = "bad +".into(); assert!(eval_params(&defs, 1.0).1.contains_key("c")); } + + /// #10747: reported numbers are the shortest decimals that read back after rounding off + /// the last few bits (12 significant digits); stored values keep full precision. + #[test] + fn round_reported_reads_as_a_person_would() { + for (noisy, read) in [ + (30f64.to_radians().to_degrees(), 30.0), + (0.1 + 0.2, 0.3), + (3.0 * 25.4, 76.2), + (1.2 * 25.4, 30.48), + (29.999999999999996, 30.0), + (0.30000000000000004, 0.3), + (-0.1 - 0.2, -0.3), + ] { + assert_eq!(round_reported(noisy), read, "{noisy}"); + } + // A number with real digits of its own keeps them up to 12 significant digits. + assert_eq!(round_reported(1.234567890123), 1.23456789012); + assert_eq!(round_reported(1.0000000000001), 1.0); + // Zero and non-finite values pass through. + assert_eq!(round_reported(0.0), 0.0); + assert!(round_reported(f64::INFINITY).is_infinite()); + assert!(round_reported(f64::NAN).is_nan()); + } + + /// #10747: `value` means millimetres and degrees in every reply. + #[test] + fn reported_value_is_mm_and_degrees() { + assert_eq!(reported_value(Value::length(50.8)), 50.8); + assert_eq!(round_reported(reported_value(Value::angle(30f64.to_radians()))), 30.0); + assert_eq!(reported_value(Value::num(4.0)), 4.0); + } } diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index 27037f33e2..6e148707ff 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -148,7 +148,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})), ( "sheet_perforate", - json!({"type": "sheet_perforate", "diameter": "perforate.diameter", "pitch": "perforate.pitch", "margin": "perforate.margin"}), + json!({ + "type": "sheet_perforate", "diameter": "perforate.diameter", "pitch": "perforate.pitch", "margin": "perforate.margin", + "sketch": 1, "profiles": {"type": "all"} + }), ), ( "sheet_fold", diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index fc766b6a53..81b6373626 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -107,7 +107,7 @@ pattern_circular inputs: Quantity = circ.count (Unitless) Total angle = circ.angle (Angle) - expressions: circ.count, circ.angle + expressions: circ.count, circ.angle, 0, 0, 0 pattern_path inputs: Quantity = path.count (Unitless) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index ce58eadb27..a7e5ba3bf4 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -47,6 +47,7 @@ pattern_rectangular moves: pattern.dir1[0], pattern.dir1[1], pattern.dir2[1], pattern.dir2[2] pattern_circular moves: pattern.axis[0], pattern.axis[2], pattern.origin[0], pattern.origin[1], pattern.origin[2] + placement: 0, 0, 0 pattern_path mirror moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2] @@ -133,6 +134,7 @@ sheet_flange sheet_hem moves: edges[0][0], edges[0][1], edges[0][2] sheet_perforate + profiles: 1 all sheet_fold moves: a[0], a[1], a[2], b[1], b[2], fixed[0], fixed[1], fixed[2] sheet_path diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index 303007b9d2..7e61b3ed10 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -143,6 +143,8 @@ sheet_flange sheet_hem remap_bodies: body sheet_perforate + sketches: [1] + remap_refs: sketch remap_bodies: body sheet_fold sketches: [7] diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 46cc22376c..da9560f8dd 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -724,6 +724,13 @@ pub fn read(s: &str) -> Result { let mut grouped_features = std::collections::BTreeSet::new(); let joint_ids: std::collections::BTreeSet<_> = d.assembly.joints.iter().map(|joint| joint.id).collect(); let mut grouped_joints = std::collections::BTreeSet::new(); + // A folder entry naming a feature or joint the design doesn't have, or one a folder already + // holds, is dropped, and the design opens: folders only group the timeline, so nothing else + // is lost. Editing a feature inside a folder used to leave such entries (#9373). + for group in &mut d.timeline_groups { + group.joints.retain(|joint| joint_ids.contains(joint) && grouped_joints.insert(*joint)); + group.features.retain(|feature| feature_ids.contains(feature) && grouped_features.insert(*feature)); + } for group in &d.timeline_groups { if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) { return Err(DocError::Invalid("damaged design: invalid timeline folder".into())); @@ -731,12 +738,6 @@ pub fn read(s: &str) -> Result { if group.parent.is_some_and(|parent| parent == group.id) { return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into())); } - if group.joints.iter().any(|joint| !joint_ids.contains(joint) || !grouped_joints.insert(*joint)) { - return Err(DocError::Invalid("damaged design: invalid joint in a timeline folder".into())); - } - if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) { - return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into())); - } } for group in &d.timeline_groups { if let Some(parent) = group.parent { diff --git a/crates/doc/src/format/v48/circular-pattern-origin-expressions.rs b/crates/doc/src/format/v48/circular-pattern-origin-expressions.rs new file mode 100644 index 0000000000..7919fbe64e --- /dev/null +++ b/crates/doc/src/format/v48/circular-pattern-origin-expressions.rs @@ -0,0 +1,4 @@ +//! A circular pattern's axis `origin` can hold length expressions (strings) as well as numbers, +//! so a pattern about a parameter-driven point follows the parameter (#10852). An origin of +//! plain numbers is saved as before, and older designs need no transformation. The last release +//! refuses this newer format and asks for an update instead of failing to read the origin. diff --git a/crates/doc/src/format/v48/sheet-perforation-outline.rs b/crates/doc/src/format/v48/sheet-perforation-outline.rs new file mode 100644 index 0000000000..423133a3bd --- /dev/null +++ b/crates/doc/src/format/v48/sheet-perforation-outline.rs @@ -0,0 +1,4 @@ +//! A perforation field can be bounded by a sketch: a `sheet_perforate` feature saves an optional `sketch` and +//! `profiles` (its outline), and a sheet's perforation the outline in flat coordinates (`outline`). Holes go only +//! inside the outline. Older designs need no transformation: a field without an outline covers the whole sheet as +//! before. The last release refuses a design saved with this (the newer format number). diff --git a/crates/doc/src/format/v48/tangent-plane-reference.rs b/crates/doc/src/format/v48/tangent-plane-reference.rs new file mode 100644 index 0000000000..5f8ce101b4 --- /dev/null +++ b/crates/doc/src/format/v48/tangent-plane-reference.rs @@ -0,0 +1,5 @@ +//! A tangent construction plane can save a `reference` (a plane or planar face) and an `angle` +//! (#8822): the plane touches the face where it turns towards the reference, turned by the angle +//! about the face's axis. An older design has neither, and its tangent planes touch where they +//! were picked, as before. The last release refuses a design that has them (it asks for an +//! update) instead of opening those planes at the picked point, away from their reference. diff --git a/crates/doc/src/joints_relations.rs b/crates/doc/src/joints_relations.rs index cd3f5fe0ff..c3c0f1a0cf 100644 --- a/crates/doc/src/joints_relations.rs +++ b/crates/doc/src/joints_relations.rs @@ -16,14 +16,11 @@ const TURNS: [f64; 4] = [0.0, std::f64::consts::FRAC_PI_2, std::f64::consts::PI, /// Starts tried at most (angle values beyond the first three start at 0). const MAX_STARTS: usize = 64; -/// The values of relation `j` that move B least from where it is now: B's joint frame as it sits -/// in A's, met as nearly as the relation allows (its origin as near as it can be to where it is, -/// and its axes, each weighed as a point a part's size along it). `None` for a joint that is not a relation, or whose -/// origins can't be found. +/// The values of joint `j` that move B least from where it is now: B's joint frame as it sits +/// in A's, met as nearly as the joint allows (its origin as near as it can be to where it is, +/// and its axes, each weighed as a point a part's size along it). Used for relations and for +/// typed joints that close a loop. `None` when the origins or finite values can't be found. pub fn relation_values(doc: &Document, st: &ModelState, j: &Joint) -> Option> { - if !j.kind.is_relation() { - return None; - } let world = |occ: u64| if occ == 0 { IDENTITY } else { occurrence_world(doc, occ) }; let fa = mat_mul(&world(j.a.occurrence), &resolve_origin(doc, st, &j.a).ok()?); let fb = mat_mul(&world(j.b.occurrence), &resolve_origin(doc, st, &j.b).ok()?); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bd89b493b9..4a48dd9e3f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -86,8 +86,8 @@ pub use assembly::{ pub use document::*; pub use eval::{ EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error, - edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, join_pieces_added, map_wire, - metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_for_handle, + edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, hole_on_surface, import_body_names, join_pieces_added, + map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_for_handle, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing, }; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index b480999fac..a605622ea5 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -90,7 +90,11 @@ pub struct ParamRow { pub name: String, pub expression: String, pub unit: String, + /// Always millimetres for a length, degrees for an angle, whatever the parameter's own + /// unit — the same meaning `value` has in every reply (#10747). pub value: Option, + /// The value in the parameter's own `unit`, as the Parameters window shows it. + pub display_value: Option, pub error: Option, pub comment: String, /// "user", "sketch" (a dimension) or "feature". @@ -367,14 +371,20 @@ impl Document { } } let used_by = |n: &str| -> Vec { users.get(n).cloned().unwrap_or_default() }; - let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }); + let value = |n: &str, k: Kind| { + vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }).map(expr::round_reported) + }; + // `value` is mm / degrees in every row (#10747); the parameter's own unit goes to + // `display_value`. Both carry the number a person would read, not the float noise. + let unified = |n: &str| vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v))); let mut rows = Vec::new(); for p in &self.params { rows.push(ParamRow { name: p.name.clone(), expression: p.expr.clone(), unit: p.unit.clone(), - value: self.param_display_value(&vals, &p.name), + value: unified(&p.name), + display_value: self.param_display_value(&vals, &p.name).map(expr::round_reported), error: errs.get(&p.name).cloned(), comment: p.comment.clone(), source: if p.model { "sketch" } else { "user" }, @@ -396,6 +406,8 @@ impl Document { expression: e, unit: unit_of(k).into(), value: value(name, k), + // A feature input's own unit is mm / deg, so its display value is its value. + display_value: value(name, k), error: errs.get(name).cloned(), comment: self.param_comments.get(name).cloned().unwrap_or_default(), source: "feature", @@ -415,6 +427,7 @@ impl Document { expression: p.text.clone(), unit: crate::text_params::TEXT_UNIT.into(), value: None, + display_value: None, error: None, comment: p.comment.clone(), source: "user", @@ -460,7 +473,8 @@ impl Document { Ok(()) } - /// User parameters as CSV (`name,unit,expression,value,comment`). + /// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the + /// number in the `unit` column, rounded for reading (#10747); import reads `expression`. pub fn params_csv(&self) -> String { let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = String::from("name,unit,expression,value,comment,favorite\n"); @@ -470,7 +484,7 @@ impl Document { q(&r.name), q(&r.unit), q(&r.expression), - r.value.map(|v| format!("{v}")).unwrap_or_default(), + r.display_value.or(r.value).map(|v| format!("{v}")).unwrap_or_default(), q(&r.comment), r.favorite ); diff --git a/crates/doc/src/placed_mesh.rs b/crates/doc/src/placed_mesh.rs index 4a945b38a2..11d61e61a7 100644 --- a/crates/doc/src/placed_mesh.rs +++ b/crates/doc/src/placed_mesh.rs @@ -16,23 +16,57 @@ pub(super) struct PlacedMesh { /// each placement draws them moved, so a part is planned once however often it is placed. plans: Arc<[OnceLock>>; 4]>, plan_meshes: Arc<[OnceLock>>; 4]>, + /// The definition's own opposite hand: a copy of a copy is a rigid move of that. + opposite_hand: Arc>, transform: crate::Mat, pub(super) tolerance: f64, } +/// A body's opposite hand: the first mirrored copy made of it, and the matrix that made it. A +/// reflection reverses each face's edge walk, so a copy can't be drawn from its source's triangles. +/// Two reflections do cancel, though: every later mirrored copy of the body, wherever dragging a +/// placement puts it, is a proper rigid move of this one, whose triangles are made once. +#[derive(Debug)] +pub(super) struct OppositeHand { + /// The body it mirrors, to tell a holder whose body was replaced. + source: Body, + definition: ModelBody, + matrix: crate::Mat, +} + impl PlacedMesh { + /// Where `copy` (`source` transformed by `matrix`) stands relative to `source`'s opposite + /// hand. `None` shows it from its own mesh: a matrix that isn't a reflection of the first. + /// A copy of a copy goes by what its source is drawn from, which outlives that source. + pub(super) fn mirrored(source: &ModelBody, copy: &Body, matrix: &crate::Mat) -> Option { + let (cell, body, matrix) = match source.mirrored_mesh.as_deref() { + Some(drawn) => (&drawn.opposite_hand, &drawn.body, crate::mat_mul(matrix, &drawn.transform)), + None => (&source.opposite_hand, &source.body, *matrix), + }; + let hand = cell.get_or_init(|| OppositeHand { + source: body.clone(), + definition: ModelBody::new(String::new(), copy.clone(), source.feature), + matrix, + }); + if !same_triangles(&hand.source, body) { + return None; + } + Self::new(&hand.definition, &crate::mat_mul(&matrix, &crate::mat_inverse(&hand.matrix)?)) + } + /// Share the original definition, never a chain of previously placed meshes. A source's /// OnceLock lives with its immutable geometry, so edits get a new source automatically. pub(super) fn new(source: &ModelBody, transform: &crate::Mat) -> Option { if !proper_rigid(transform) { return None; } - Some(match source.mesh_placement.as_deref() { + Some(match source.mesh_placement.as_deref().or(source.mirrored_mesh.as_deref()) { Some(original) => Self { body: original.body.clone(), mesh: original.mesh.clone(), plans: original.plans.clone(), plan_meshes: original.plan_meshes.clone(), + opposite_hand: original.opposite_hand.clone(), transform: crate::mat_mul(transform, &original.transform), tolerance: original.tolerance, }, @@ -41,6 +75,7 @@ impl PlacedMesh { mesh: source.mesh.clone(), plans: source.plan.clone(), plan_meshes: source.plan_mesh.clone(), + opposite_hand: source.opposite_hand.clone(), transform: *transform, tolerance: source.display_tol(), }, @@ -98,6 +133,29 @@ impl PlacedMesh { } } +/// Whether two bodies mesh to the same triangles: the same shape, split along the same lines. +fn same_triangles(a: &Body, b: &Body) -> bool { + a.same_shape(b) + && match (a.split_lines(), b.split_lines()) { + (Some(x), Some(y)) => Arc::ptr_eq(x, y), + (None, None) => true, + _ => false, + } +} + +impl ModelBody { + /// Keep the meshes `old` holds when this body is the same shape: a feature evaluated again + /// that gives the same solids (an import, from its shared file) doesn't mesh them again. + pub(super) fn keep_meshes_of(&mut self, old: &ModelBody) { + if same_triangles(&self.body, &old.body) { + self.mesh = old.mesh.clone(); + self.plan = old.plan.clone(); + self.plan_mesh = old.plan_mesh.clone(); + self.opposite_hand = old.opposite_hand.clone(); + } + } +} + /// The display plan of a definition `body` at quality `q`, made once in `cell`. pub(super) fn definition_plan( body: &Body, diff --git a/crates/doc/src/placement_reuse.rs b/crates/doc/src/placement_reuse.rs index efa71dc232..511efb503c 100644 --- a/crates/doc/src/placement_reuse.rs +++ b/crates/doc/src/placement_reuse.rs @@ -68,6 +68,21 @@ fn local_plane(plane: &PlaneRef, depth: usize) -> bool { } } +/// Whether no feature after the mirror `f` can cut, join or otherwise build on bodies: only +/// features that read none, and further mirrors, which copy them. A boolean depends on how a +/// copy's revolved faces are turned, so a copy it may reach is made with +/// [`solidcraft_kernel::transform_matrix`]; one that is only shown needs no search for that. It +/// depends on the design alone, so a design evaluates the same however it was reached. +pub(super) fn copies_only_shown(doc: &Document, f: &Feature) -> bool { + let mut later = doc.features.iter().skip_while(|g| g.id != f.id).skip(1); + let mut components = std::collections::HashMap::with_capacity(doc.features.len()); + for g in &doc.features { + components.entry(g.id).or_insert(g.component); + } + let copies = |g: &Feature| matches!(&g.kind, FeatureKind::Mirror { features, combine: false, .. } if features.is_empty()); + later.all(|g| copies(g) || local_geometry(g, &components)) +} + pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap) -> bool { // Persistent picked-face/edge attachments can move an otherwise literal input. if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() { diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 3887765b79..f1497a6607 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -208,7 +208,7 @@ fn resolve_uncached( LinkSource::Intersection { a, b } => { let (ma, fa) = crate::project3d::mesh_of(st, a).ok_or_else(|| lost(&a.describe()))?; let (mb, fb) = crate::project3d::mesh_of(st, b).ok_or_else(|| lost(&b.describe()))?; - wires = crate::project3d::mesh_intersection(&ma, fa, &mb, fb); + wires = crate::project3d::mesh_intersection(&ma, fa.as_deref(), &mb, fb.as_deref()); Vec::new() } LinkSource::Iso { body, at, dir } => { diff --git a/crates/doc/src/project3d.rs b/crates/doc/src/project3d.rs index 42f23a37c5..68f0a0f2e6 100644 --- a/crates/doc/src/project3d.rs +++ b/crates/doc/src/project3d.rs @@ -12,15 +12,17 @@ use crate::eval::ModelState; /// Most triangle pairs examined by one intersection. const MAX_PAIRS: usize = 20_000_000; -/// The mesh (and face, for a face source) of a body or face reference. -pub(crate) fn mesh_of(st: &ModelState, src: &LinkSource) -> Option<(Arc, Option)> { +/// The mesh of a body or face reference and, for a face, the faces of its surface patch: the B-rep +/// pieces joined across hidden seams, as the viewport picks and highlights it (#9009). +pub(crate) fn mesh_of(st: &ModelState, src: &LinkSource) -> Option<(Arc, Option>)> { match src { LinkSource::Body { body } => Some((st.body(body)?.mesh(), None)), LinkSource::Face { body, at } => { let b = st.body(body).or_else(|| st.bodies.iter().min_by(|x, y| dist_to(&x.mesh(), *at).total_cmp(&dist_to(&y.mesh(), *at))))?; let m = b.mesh(); let (f, _, _) = crate::project::nearest_face(&m, *at)?; - Some((m, Some(f))) + let patch: Vec = m.surface_patch(f as usize).into_iter().filter_map(|i| u32::try_from(i).ok()).collect(); + Some((m, Some(patch))) } _ => None, } @@ -30,8 +32,9 @@ fn dist_to(m: &Mesh, p: Vec3) -> f64 { crate::project::nearest_face(m, p).map(|x| x.2).unwrap_or(f64::INFINITY) } -fn tris(m: &Mesh, face: Option) -> Vec<[Vec3; 3]> { - m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| face.is_none_or(|x| x == **f)).filter_map(|(t, _)| m.tri(t)).collect() +/// The triangles of the faces in `faces` (sorted ascending), or of the whole mesh for `None`. +fn tris(m: &Mesh, faces: Option<&[u32]>) -> Vec<[Vec3; 3]> { + m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| faces.is_none_or(|fs| fs.binary_search(f).is_ok())).filter_map(|(t, _)| m.tri(t)).collect() } /// Ray–triangle hit parameter (Möller–Trumbore), any sign. @@ -60,7 +63,7 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option { /// miss the face split the result. pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec> { let Some(d) = dir.normalized() else { return Vec::new() }; - let ts = tris(m, Some(f)); + let ts = tris(m, Some(std::slice::from_ref(&f))); let size = m.bounds().diagonal().max(1e-9); // Resample so the projected curve follows the surface. let step = size / 200.0; @@ -138,8 +141,8 @@ fn tri_tri(a: &[Vec3; 3], b: &[Vec3; 3]) -> Option<(Vec3, Vec3)> { (pr(hi) - pr(lo) > 1e-12).then_some((lo, hi)) } -/// Where two meshes (or faces of them) meet, as polylines. -pub(crate) fn mesh_intersection(ma: &Mesh, fa: Option, mb: &Mesh, fb: Option) -> Vec> { +/// Where two meshes (or face patches of them) meet, as polylines. +pub(crate) fn mesh_intersection(ma: &Mesh, fa: Option<&[u32]>, mb: &Mesh, fb: Option<&[u32]>) -> Vec> { let (ta, tb) = (tris(ma, fa), tris(mb, fb)); let bbox = |t: &[Vec3; 3]| (t[0].min(t[1]).min(t[2]), t[0].max(t[1]).max(t[2])); // Uniform grid over B. diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index d529700f8a..074a2425d5 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -529,6 +529,10 @@ pub struct SheetFlange { /// (flat coordinates, counter-clockwise). #[serde(default, skip_serializing_if = "Option::is_none")] pub exact: Option, + /// A fold's bend zone as drawn, when a curved edge of the sheet crosses it (flat coordinates, + /// counter-clockwise): `zone` is this instead of straight ends. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zone_shape: Option, /// Which feature made it: `F.` (persistent edge names). #[serde(default, skip_serializing_if = "String::is_empty")] pub tag: String, @@ -688,6 +692,10 @@ pub struct SheetPerforation { /// copy, whose holes are the mirror image of its source's. #[serde(skip_serializing_if = "std::ops::Not::not")] pub from_right: bool, + /// The field's outline in flat coordinates (see [`SheetBody::perforation_outline`]): holes only inside these + /// regions, margin clear of their edges, on a grid that starts from their bounding box. None: the whole flat. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outline: Option>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -830,6 +838,205 @@ fn rect(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Region2 { Region2 { outer: lp, holes: Vec::new() } } +/// How a fold's bend runs along its line `d`: where its start line starts and ends, how far its far +/// edge runs on past those ends, and which ends are cut askew (`SheetFlange::ext`, `tear`). +type FoldEnds = (f64, f64, (f64, f64), (bool, bool)); + +/// The ends of a fold's zone (`strip`, between the lines at `s0` and `s0 + ba` along `n`) when +/// straight edges of the sheet slant across it, so that it folds as a bend with torn ends. Every +/// corner must lie on one of the two lines: a curved edge has corners in between, and an S of the +/// same area as a straight end would pass on area alone. +fn slanted_zone(strip: &[Vec2], n: Vec2, d: Vec2, s0: f64, ba: f64) -> Option { + let on_line = |q: &Vec2, s: f64| (q.dot(n) - s).abs() <= ZONE_SQUARE_TOL; + if !strip.iter().all(|q| on_line(q, s0) || on_line(q, s0 + ba)) { + return None; + } + let span = |s: f64| { + let mut along = strip.iter().filter(|q| on_line(q, s)).map(|q| q.dot(d)); + let first = along.next()?; + Some(along.fold((first, first), |(lo, hi), u| (lo.min(u), hi.max(u)))) + }; + let ((a0, a1), (e0, e1)) = (span(s0)?, span(s0 + ba)?); + let (start, far) = (a1 - a0, e1 - e0); + let trapezoid = (start + far) / 2.0 * ba; + if start <= ZONE_SQUARE_TOL + || far <= ZONE_SQUARE_TOL + || (trapezoid - signed_area(strip).abs()).abs() > 1e-6 * start.max(far).max(1.0) * ba.max(1.0) + { + return None; + } + let end = |on: f64| if on.abs() > ZONE_SQUARE_TOL { (on, true) } else { (0.0, false) }; + let ((on0, torn0), (on1, torn1)) = (end(a0 - e0), end(e1 - a1)); + Some((a0, a1, (on0, on1), (torn0, torn1))) +} + +/// A turn sharper than this (its sine) between two edges of an outline is a corner. Smooth curves +/// and their chords turn less: a chord at the sheet's 0.001 mm tolerance turns about 5° on a 1 mm +/// radius. +const CORNER_TURN: f64 = 0.17; + +/// Where two edges of an outline meet at a corner. +fn outline_corners(outline: &Loop2) -> Vec { + let segs = &outline.segs; + let mut corners = Vec::new(); + for (i, next) in segs.iter().enumerate() { + let Some(prev) = segs.get((i + segs.len() - 1) % segs.len()) else { continue }; + let (t0, t1) = (prev.end_tangent(), next.start_tangent()); + if t0.cross(t1).abs() > CORNER_TURN || t0.dot(t1) < 0.0 { + corners.push(next.start()); + } + } + corners +} + +/// `seg` in pieces, cut where it crosses the line `level` along `n`, into `out`. A curve that only +/// touches the line stays whole. +fn cut_at_level(seg: &Seg2, n: Vec2, level: f64, out: &mut Vec) { + const SAMPLES: usize = 64; + const MAX_CROSSINGS: usize = 16; + const ON_LINE: f64 = 1e-9; + let off = |s: &Seg2, t: f64| s.point_at(t).dot(n) - level; + let mut rest = *seg; + for _ in 0..MAX_CROSSINGS { + // The first change of side, skipping samples on the line itself. + let (mut last, mut crossing) = (None::<(f64, f64)>, None); + for k in 0..=SAMPLES { + let t = k as f64 / SAMPLES as f64; + let v = off(&rest, t); + if v.abs() <= ON_LINE { + continue; + } + match last { + Some((was, before)) if before * v < 0.0 => { + crossing = Some((was, t, before)); + break; + } + _ => last = Some((t, v)), + } + } + let Some((mut lo, mut hi, before)) = crossing else { break }; + // On the distance itself: the cut lands on the line, and what's left starts on it. + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if off(&rest, mid) * before > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + let (first, second) = rest.split_at((lo + hi) / 2.0); + out.push(first); + rest = second; + } + out.push(rest); +} + +/// A panel's outline cut exactly across a fold whose zone a curved edge crosses, each piece +/// keeping its exact curves. +#[derive(Clone, Debug, PartialEq)] +struct CurvedFold { + fixed: Loop2, + zone: Loop2, + moving: Loop2, + ends: FoldEnds, +} + +/// `outline` cut into the side short of the zone between the lines at `s0` and `s0 + ba` along `n`, +/// the zone, and the side past it. It must run from one side through the zone to the other and +/// back through the zone, once: a notch, several tabs on the line or a zone that reaches the +/// outline's tip is `None`. Both of the zone's ends count as torn: their edges aren't square across. +fn curved_fold(outline: &Loop2, n: Vec2, d: Vec2, s0: f64, ba: f64) -> Option { + const ON_LINE: f64 = 1e-6; + if outline.segs.len() > MAX_FLAT_POINTS { + return None; + } + let outline = outline.ccw(); + let mut pieces = Vec::new(); + for seg in &outline.segs { + let mut near = Vec::new(); + cut_at_level(seg, n, s0, &mut near); + for piece in &near { + cut_at_level(piece, n, s0 + ba, &mut pieces); + } + } + // The band each piece lies in: 0 short of the zone, 1 in it, 2 past it. A piece along either + // line is an edge square across the bend, not one crossing it. + let mut bands = Vec::with_capacity(pieces.len()); + for piece in &pieces { + let v = piece.mid().dot(n); + if !v.is_finite() || (v - s0).abs() <= ON_LINE || (v - s0 - ba).abs() <= ON_LINE { + return None; + } + bands.push(if v < s0 { + 0 + } else if v > s0 + ba { + 2 + } else { + 1 + }); + } + // Round the outline from where it enters the side short of the zone. + let count = pieces.len(); + let first = (0..count).find(|&i| bands.get(i) == Some(&0) && bands.get((i + count - 1) % count) != Some(&0))?; + let mut runs: Vec<(i32, Vec)> = Vec::new(); + for k in 0..count { + let i = (first + k) % count; + let (band, piece) = (*bands.get(i)?, *pieces.get(i)?); + match runs.last_mut() { + Some((b, run)) if *b == band => run.push(piece), + _ => runs.push((band, vec![piece])), + } + } + let [(0, near), (1, there), (2, far), (1, back)] = runs.as_slice() else { return None }; + let ends_of = |run: &[Seg2]| Some((run.first()?.start(), run.last()?.end())); + let ((near0, near1), (there0, there1), (far0, far1), (back0, back1)) = (ends_of(near)?, ends_of(there)?, ends_of(far)?, ends_of(back)?); + let on = |q: Vec2, s: f64| (q.dot(n) - s).abs() <= ON_LINE; + if !(on(near0, s0) && on(near1, s0) && on(far0, s0 + ba) && on(far1, s0 + ba)) { + return None; + } + let closed = |run: &[Seg2], to: Vec2, from: Vec2| { + let mut segs = run.to_vec(); + segs.push(Seg2::Line { a: to, b: from }); + segs + }; + let mut zone = closed(there, there1, back0); + zone.extend_from_slice(back); + zone.push(Seg2::Line { a: back1, b: there0 }); + let span = |p: Vec2, q: Vec2| (p.dot(d).min(q.dot(d)), p.dot(d).max(q.dot(d))); + let ((a0, a1), (e0, e1)) = (span(near0, near1), span(far0, far1)); + if a1 - a0 <= ON_LINE || e1 - e0 <= ON_LINE { + return None; + } + Some(CurvedFold { + fixed: Loop2 { segs: closed(near, near1, near0) }, + zone: Loop2 { segs: zone }, + moving: Loop2 { segs: closed(far, far1, far0) }, + ends: (a0, a1, (a0 - e0, e1 - a1), (true, true)), + }) +} + +/// Two counter-clockwise loops joined along a straight edge they share, running opposite ways: +/// every curve of both exactly as it was, which the planar booleans wouldn't hand back. +fn join_along_seam(a: &Loop2, b: &Loop2) -> Option { + const SAME: f64 = 1e-6; + if a.segs.len().saturating_mul(b.segs.len()) > MAX_FLAT_POINTS { + return None; + } + for (i, ea) in a.segs.iter().enumerate() { + let Seg2::Line { a: p, b: q } = *ea else { continue }; + for (j, eb) in b.segs.iter().enumerate() { + let Seg2::Line { a: r, b: s } = *eb else { continue }; + if p.dist(s) <= SAME && q.dist(r) <= SAME { + let mut segs = Vec::with_capacity(a.segs.len() + b.segs.len()); + segs.extend(a.segs.iter().skip(i + 1).chain(a.segs.iter().take(i)).cloned()); + segs.extend(b.segs.iter().skip(j + 1).chain(b.segs.iter().take(j)).cloned()); + return Some(Loop2 { segs }); + } + } + } + None +} + /// Round to a 1e-9 mm grid (keeps trimmed edges exactly where the kernel expects them). fn snap(v: Vec2) -> Vec2 { let r = |x: f64| (x * 1e9).round() / 1e9; @@ -1087,6 +1294,9 @@ impl SheetBody { /// Flat region of a flange's bend zone: square across the bend at both ends, but at a torn /// end its far edge ends where the panel's does. pub fn zone(&self, f: &SheetFlange) -> Region2 { + if let Some(lp) = &f.zone_shape { + return Region2 { outer: lp.ccw(), holes: Vec::new() }; + } let ba = self.allowance(f); let (u0, u1) = self.zone_span(f); let (a, b) = (f.p + f.d * u0, f.p + f.d * u1); @@ -1103,6 +1313,32 @@ impl SheetBody { (if f.tear.0 { -f.ext.0 } else { u0 }, if f.tear.1 { f.len + f.ext.1 } else { u1 }) } + /// How far a bend's zone reaches along the bend from `p`: a torn end runs askew, and a curved + /// edge may bulge past both of its ends. + pub(crate) fn zone_reach(&self, f: &SheetFlange) -> (f64, f64) { + if let Some(lp) = &f.zone_shape { + return lp.polyline(1e-3).iter().map(|q| (*q - f.p).dot(f.d)).fold((f64::MAX, f64::MIN), |(lo, hi), u| (lo.min(u), hi.max(u))); + } + let ((u0, u1), (e0, e1)) = (self.zone_span(f), self.zone_ends(f)); + (u0.min(e0), u1.max(e1)) + } + + /// Where a bend's centre line starts and ends, along the bend from `p`: between the middles + /// of the bend's two ends (a torn end is askew), or where a curved edge crosses it. + fn centre_line(&self, f: &SheetFlange) -> (f64, f64) { + if let Some(lp) = &f.zone_shape { + let mut at = Vec::new(); + for e in &lp.segs { + line_hits(f.p + f.n * (self.allowance(f) / 2.0), f.d, e, &mut at); + } + if let (Some(lo), Some(hi)) = (at.iter().copied().reduce(f64::min), at.iter().copied().reduce(f64::max)) { + return (lo, hi); + } + } + let ((u0, u1), (e0, e1)) = (self.zone_span(f), self.zone_ends(f)); + ((u0 + e0) / 2.0, (u1 + e1) / 2.0) + } + /// Folded (in the flat frame, as `fold`), the point of a bend's end (the one at `p`, or the /// other one when `at_end`) at the turn `phi` of the bend and the height `z` in the sheet. A /// torn end runs evenly along the bend while the bend turns, from its start line's end to @@ -1141,7 +1377,9 @@ impl SheetBody { if let Some(shape) = &f.shape && let Some((lo, hi)) = edge_span(shape, f.p + f.n * self.allowance(f)) { - return (span.0.max(lo), span.1.min(hi)); + // A torn end runs askew to where the panel's end starts, so the panel's root doesn't + // limit the bend there. + return (if f.tear.0 { span.0 } else { span.0.max(lo) }, if f.tear.1 { span.1 } else { span.1.min(hi) }); } span } @@ -1925,6 +2163,60 @@ impl SheetBody { Ok(regions) } + /// The sheet's panels and bend zones. Where a free-form edge crosses a fold, the zone and the + /// panels either side are already joined along their seams, so the edge stays the curve it + /// was drawn as: merged by the planar booleans it would come back as fitted arcs. A seam + /// another feature changed is left to them. + fn flat_pieces(&self) -> Result> { + let mut owners: Vec> = Vec::new(); + let mut slots: Vec> = Vec::new(); + for (pi, r) in self.panels() { + owners.push(pi); + slots.push(Some(planar_region(&r)?)); + } + let panel_slot = |pi: Option| owners.iter().position(|o| *o == pi); + let panels = slots.len(); + for f in &self.flanges { + slots.push(Some(planar_region(&self.zone(f))?)); + } + // Which slot now holds each: joined pieces live in the one they joined. + let mut held_by: Vec = (0..slots.len()).collect(); + let free_form = |slots: &[Option], at: usize| slots.get(at).and_then(Option::as_ref).is_some_and(|r| !r.outer.is_line_arc()); + for (i, f) in self.flanges.iter().enumerate() { + // A fold's pieces share whole seams; an edge flange's bend needn't span its edge. + if f.shape.is_none() { + continue; + } + let zone = panels + i; + let sides = [panel_slot(f.parent), panel_slot(Some(i))]; + if !free_form(&slots, zone) && !sides.iter().flatten().any(|&p| free_form(&slots, held_by.get(p).copied().unwrap_or(p))) { + continue; + } + for side in sides.into_iter().flatten() { + let at = |held_by: &[usize], k: usize| held_by.get(k).copied().unwrap_or(k); + let (a, b) = (at(&held_by, zone), at(&held_by, side)); + if a == b { + continue; + } + let (Some(Some(ra)), Some(Some(rb))) = (slots.get(a), slots.get(b)) else { continue }; + let Some(joined) = join_along_seam(&ra.outer, &rb.outer) else { continue }; + let holes = ra.holes.iter().chain(&rb.holes).cloned().collect(); + if let Some(slot) = slots.get_mut(a) { + *slot = Some(Region2 { outer: joined, holes }); + } + if let Some(slot) = slots.get_mut(b) { + *slot = None; + } + for k in &mut held_by { + if *k == b { + *k = a; + } + } + } + } + Ok(slots.into_iter().flatten().collect()) + } + pub(crate) fn flat_material(&self) -> Result> { let mut work = FlatWork::new(); // Merge structural pieces before cutting: clipping each panel first introduces @@ -1938,16 +2230,12 @@ impl SheetBody { } let mut regions = Vec::new(); let mut panel_points = 0usize; - for (_, r) in self.panels() { - let r = planar_region(&r)?; + for r in self.flat_pieces()? { panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1); if panel_points > MAX_FLAT_POINTS { return Err(flat_budget_error()); } regions.push(r); - } - for f in &self.flanges { - regions.push(planar_region(&self.zone(f))?); region_complexity(®ions)?; } let regions = material_union(regions, &mut work)?; @@ -2079,9 +2367,7 @@ impl SheetBody { let mut out = Vec::new(); for (i, f) in self.flanges.iter().enumerate() { let ba = self.allowance(f); - // The centre line runs between the middles of the bend's two ends (a torn end is askew). - let ((u0, u1), (e0, e1)) = (self.zone_span(f), self.zone_ends(f)); - let (u0, u1) = ((u0 + e0) / 2.0, (u1 + e1) / 2.0); + let (u0, u1) = self.centre_line(f); let a = f.p + f.n * (ba / 2.0) + f.d * u0; for (s0, s1) in clear_of(a, f.d, u1 - u0, &cuts) { out.push(BendInfo { @@ -2104,9 +2390,45 @@ impl SheetBody { self.flanges.iter().map(|f| self.zone(f)).collect() } + /// A perforation field's outline (#732) in flat coordinates: closed regions of a sketch in `plane` (world). An + /// unfolded sheet takes them as drawn on the flat; a folded one through each panel whose material the plane lies + /// in, clipped to that panel, so an outline on one face never reaches a flange in the flat. Refused when the + /// plane lies in no panel. + pub fn perforation_outline(&self, plane: &Plane, regions: &[Region2]) -> Result> { + let on = |pi: Option| { + let w = self.world(pi); + let n = apply_vector(&w, Vec3::Z); + let z = (plane.origin - apply_point(&w, Vec3::ZERO)).dot(n); + n.cross(plane.normal()).len() < 1e-6 && (-LINEAR_TOL..=self.t + LINEAR_TOL).contains(&z) + }; + if self.flat { + if !on(None) { + return Err(DocError::Invalid("the perforation's outline sketch must lie on the flat sheet".into())); + } + return regions.iter().map(|r| self.region_to_flat(None, plane, r)).collect(); + } + let mut work = FlatWork::new(); + let mut out = Vec::new(); + let mut found = false; + for (pi, panel) in self.parallel_panels(plane) { + if !on(pi) { + continue; + } + found = true; + let mapped = regions.iter().map(|r| self.region_to_flat(pi, plane, r)).collect::>>()?; + out.extend(material_boolean(std::slice::from_ref(&panel), &mapped, kernel::BoolOp::Intersect, &mut work)?); + } + if !found { + return Err(DocError::Invalid("the perforation's outline sketch must lie on a face of the sheet".into())); + } + Ok(out) + } + /// Hole centres of a perforation field (#732), flat coordinates: a staggered (60°, odd rows shifted half a pitch) /// or square grid from the flat bounding box's lower-left corner plus margin + radius; a hole is kept where it is - /// at least margin clear of the material's edges and cut-outs and of every bend zone. + /// at least margin clear of the material's edges and cut-outs and of every bend zone. A field with an outline + /// keeps its holes inside the outline and margin clear of its edges too, on a grid from the lower-left corner of + /// the outline's bounding box (within the flat's). pub fn perforation_holes(&self, p: &SheetPerforation) -> Result> { let regions = self.flat_regions()?; let zones = self.bend_zones(); @@ -2127,6 +2449,20 @@ impl SheetBody { if !(lo.x <= hi.x) || !(p.pitch > 0.0) { return Ok(Vec::new()); } + // The rows and columns span the flat, or the outline's part of it; the bucket grid below stays the flat's. + let mut field = Aabb2 { min: lo, max: hi }; + if let Some(outline) = &p.outline { + let n = segs.len(); + for r in outline { + for l in std::iter::once(&r.outer).chain(&r.holes) { + segs.extend(l.polyline(1e-3).edges().map(|(&a, &b)| (a, b))); + } + } + field = field.intersection(&segs.get(n..).unwrap_or(&[]).iter().map(|(a, _)| *a).collect::()); + if !(field.min.x <= field.max.x && field.min.y <= field.max.y) { + return Ok(Vec::new()); + } + } // Segments bucketed in a grid, so each hole only checks the edges near it. let cell = (2.0 * clear).max(p.pitch).max(1.0); let (nx, ny) = (((hi.x - lo.x) / cell) as usize + 1, ((hi.y - lo.y) / cell) as usize + 1); @@ -2153,20 +2489,22 @@ impl SheetBody { let dy = if p.square { p.pitch } else { p.pitch * 3f64.sqrt() / 2.0 }; let mut out = Vec::new(); let mut row = 0usize; + let Aabb2 { min: flo, max: fhi } = field; loop { - let y = lo.y + clear + row as f64 * dy; - if y > hi.y - clear + 1e-9 { + let y = flo.y + clear + row as f64 * dy; + if y > fhi.y - clear + 1e-9 { break; } let shift = if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 }; // Steps across the row from its first hole; a mirrored field counts from the right. let mut step = 0.0; - while lo.x + clear + shift + step <= hi.x - clear + 1e-9 { - let x = if p.from_right { hi.x - clear - shift - step } else { lo.x + clear + shift + step }; + while flo.x + clear + shift + step <= fhi.x - clear + 1e-9 { + let x = if p.from_right { fhi.x - clear - shift - step } else { flo.x + clear + shift + step }; let c = Vec2::new(x, y); let (i, j) = idx(x, y); let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]); if regions.iter().any(|r| r.contains(c)) + && p.outline.as_ref().is_none_or(|o| o.iter().any(|r| r.contains(c))) && !zones.iter().any(|z| z.contains(c)) && near.iter().all(|&k| segs.get(k).is_none_or(|&s| dist(c, s) >= clear - 1e-9)) { @@ -2287,6 +2625,31 @@ impl SheetBody { best } + /// Where the base's outline already runs along the bend line (through `o`, along `d`) beside a + /// picked edge that spans [ua, ub] along d: the end of the nearest such run before it and the + /// start of the nearest after it (none: unbounded). Trimmed back to that line, the picked edge + /// merges with those runs, as beside the steps of a stepped outline; the flange stays off them. + fn bend_line_runs(&self, o: Vec2, d: Vec2, ua: f64, ub: f64) -> (f64, f64) { + let n = Vec2::new(-d.y, d.x); + let pts = self.base.outer.ccw().polyline(1e-3); + let m = pts.len(); + let (mut lo, mut hi) = (f64::MIN, f64::MAX); + for i in 0..m { + let (Some(&p), Some(&q)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; + if (p - o).dot(n).abs() > 1e-6 || (q - o).dot(n).abs() > 1e-6 { + continue; + } + let (u0, u1) = (p.dot(d).min(q.dot(d)), p.dot(d).max(q.dot(d))); + if u1 <= ua + 1e-9 { + lo = lo.max(u1); + } + if u0 >= ub - 1e-9 { + hi = hi.min(u0); + } + } + (lo, hi) + } + /// What `cuts` (the sheet's cut-outs) leave of a panel's edge a→b: the stretch nearest `pick` /// (a world point), as distances from `a` along the edge; of two as near, the first. `None` /// where they have taken the edge whole. @@ -2379,10 +2742,8 @@ impl SheetBody { return Err(DocError::Invalid("not supported yet: a partial side-edge flange beside a cut-out".into())); } let clipped = if side || original.shape.is_some() || original.exact.is_some() { + // A panel with curves gives way to its 0.001 mm chords here. let region = self.panel(original); - if !region.outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })) { - return Err(DocError::Invalid("not supported yet: a flange setback on a curved flange panel".into())); - } let points = clip(®ion.outer.ccw().polyline(1e-3), a + inward * by, inward); if points.len() < 3 { return Err(DocError::Invalid("the flange's setback removes the whole panel".into())); @@ -2632,17 +2993,23 @@ impl SheetBody { let (s0, s1) = self .edge_left(panel, a, b, e.pick, &cuts) .ok_or_else(|| DocError::Invalid(format!("{}a cut-out has taken that edge away; pick an edge the sheet still has", on(k))))?; + // Where the base already runs along the bend line beside this edge (a stepped outline): + // the flange stays off it, though trimming merges the two. + let run = match panel { + None => self.bend_line_runs(a - outward * setback, d, a.dot(d), b.dot(d)), + Some(_) => (f64::MIN, f64::MAX), + }; let cut = (s0 > 1e-9, s1 < a.dist(b) - 1e-9); let (a, b) = (if cut.0 { a + d * s0 } else { a }, if cut.1 { a + d * s1 } else { b }); - found.push((panel, a, b, d, top, setback, leg, e.angle, cut)); + found.push((panel, a, b, d, top, setback, leg, e.angle, cut, run)); } - for (panel, a, b, _, _, setback, _, _, cut) in &found { + for (panel, a, b, _, _, setback, _, _, cut, _) in &found { self.trim(*panel, *a, *b, *setback, *cut)?; } let mut made = Vec::new(); // Whether a cut-out has freed each new bend's ends (start, end) of the base beside them. let mut freed = Vec::new(); - for (panel, a, b, d, top, setback, leg, angle, cut) in found { + for (panel, a, b, d, top, setback, leg, angle, cut, run) in found { let outward = Vec2::new(d.y, -d.x); let line_o = a - outward * setback; // The trimmed edge: along d, on the moved line. @@ -2691,6 +3058,16 @@ impl SheetBody { .ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))? } }; + // Not over the base's own edge where it already ran along the bend line (beyond a + // step of the outline): the flange stops where the picked edge did. + let (start, len) = { + let u = start.dot(d); + let (u0, u1) = (u.max(run.0), (u + len).min(run.1)); + if !(u1 - u0 > 1e-9) { + return Err(DocError::Invalid("the flange's edge was trimmed away".into())); + } + (start + d * (u0 - u), u1 - u0) + }; // Where a cut-out ends the edge, the flange ends with it. let (start, len) = if cut.0 || cut.1 { let u = (start - a).dot(d); @@ -2718,6 +3095,7 @@ impl SheetBody { leg: leg.max(0.0), shape: None, exact: None, + zone_shape: None, tag: String::new(), ext: (0.0, 0.0), slope: (0.0, 0.0), @@ -3616,6 +3994,7 @@ impl SheetBody { tear: (false, false), shape: None, exact: None, + zone_shape: None, tag: String::new(), }); Ok(self.flanges.len() - 1) @@ -3629,7 +4008,8 @@ impl SheetBody { /// flat). On the base the side holding `fixed` (default: the larger side) stays put; on a /// flange the side toward its own bend does. `flip` folds the other way. A `fixed` point that /// touches no material still picks the side it lies on, and sets `warning` (see - /// [`FOLD_FIXED_OFF_SHEET`]). + /// [`FOLD_FIXED_OFF_SHEET`]). The outline may cross the zone on straight slanted edges or on + /// any curve; a sharp corner in the zone is refused, by name. #[allow(clippy::too_many_arguments)] pub fn add_fold( &mut self, @@ -3684,11 +4064,12 @@ impl SheetBody { let pts = dedup(&r.outer.ccw().polyline(1e-3)); let (a2, b2) = (Vec2::new(fa.x, fa.y), Vec2::new(fb.x, fb.y)); if pts.contains((a2 + b2) * 0.5) || polys_cross(&pts, &[a2, b2, (a2 + b2) * 0.5]) { - found = Some((pi, inv, a2, b2, pts)); + found = Some((pi, inv, a2, b2, pts, r.outer)); break; } } - let (panel, inv, a2, b2, pts) = found.ok_or_else(|| DocError::Invalid("the fold line must lie on a flat face of the sheet".into()))?; + let (panel, inv, a2, b2, pts, outline) = + found.ok_or_else(|| DocError::Invalid("the fold line must lie on a flat face of the sheet".into()))?; let d = (b2 - a2).normalized().ok_or_else(|| DocError::Invalid("the fold line has no length".into()))?; let mut n = Vec2::new(-d.y, d.x); let l0 = a2.dot(n); @@ -3741,6 +4122,7 @@ impl SheetBody { tear: (false, false), shape: None, exact: None, + zone_shape: None, tag: String::new(), }; let ba = self.allowance(&probe); @@ -3771,26 +4153,46 @@ impl SheetBody { let angle = if flip { -angle } else { angle }; return self.fold_tabs(panel, &pts, (n, d), (s0, ba), span, (angle, radius), &strips); } - // The bend zone must be a rectangle across the panel (a slanted or curved edge would - // run across the bend as a helix, which the sheet's faces can't carry yet). + // The bend zone is a rectangle across the panel, or straight edges of the sheet slant + // across it, or curved ones do. Those ends fold to helices and curves, as torn ends and + // cut-outs across a bend do. let us: Vec = strip.iter().map(|q| q.dot(d)).collect(); let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max)); + let mut ends: FoldEnds = (u0, u1, (0.0, 0.0), (false, false)); + let mut zone_shape = None; + // An outline with curves is cut exactly, wherever the curves are, so they stay as drawn in + // the flat pattern; a cut that doesn't come out whole falls back on the chords. + let cut = if outline.segs.iter().all(|e| matches!(e, Seg2::Line { .. })) { None } else { curved_fold(&outline, n, d, s0, ba) }; if ((u1 - u0) * ba - signed_area(&strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) { - // A corner of the outline inside the zone (e.g. a fold right at a tab's root): name - // it and how far the line has to move to clear it. - let corners: Vec<(Vec2, f64)> = pts.iter().map(|q| (*q, q.dot(n) - s0)).filter(|(_, s)| *s > 1e-6 && *s < ba - 1e-6).collect(); - if let Some(&(c, _)) = corners.first() { - let on = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(*s)); - let back = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(ba - *s)); - let w = apply_point(&self.world(panel), Vec3::new(c.x, c.y, self.t)); - return Err(DocError::Invalid(format!( - "the bend zone ({ba:.3} mm wide) reaches a corner of the sheet's outline at ({:.3}, {:.3}, {:.3}): \ - move the fold line at least {on:.3} mm toward the side that folds or {back:.3} mm toward the side that stays", - w.x, w.y, w.z - ))); + if let Some(slanted) = slanted_zone(&strip, n, d, s0, ba) { + ends = slanted; + } else { + // A corner of the outline inside the zone (e.g. a fold right at a tab's root): name + // it and how far the line has to move to clear it. + let corners: Vec<(Vec2, f64)> = + outline_corners(&outline).into_iter().map(|q| (q, q.dot(n) - s0)).filter(|(_, s)| *s > 1e-6 && *s < ba - 1e-6).collect(); + if let Some(&(c, _)) = corners.first() { + let on = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(*s)); + let back = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(ba - *s)); + let w = apply_point(&self.world(panel), Vec3::new(c.x, c.y, self.t)); + return Err(DocError::Invalid(format!( + "the bend zone ({ba:.3} mm wide) reaches a corner of the sheet's outline at ({:.3}, {:.3}, {:.3}): \ + move the fold line at least {on:.3} mm toward the side that folds or {back:.3} mm toward the side that stays", + w.x, w.y, w.z + ))); + } + let Some(curved) = &cut else { + return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into())); + }; + ends = curved.ends; + zone_shape = Some(curved.zone.clone()); } - return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into())); } + let (u0, u1, ext, tear) = ends; + let (fixed_exact, moving_exact) = match cut { + Some(cut) => (Some(cut.fixed), Some(cut.moving)), + None => (None, None), + }; let up = !flip; let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max); let p0 = n * s0 + d * u0; @@ -3801,11 +4203,14 @@ impl SheetBody { v.into_iter().map(snap).collect::>() }; match panel { - None => self.base = Region2 { outer: Loop2::polygon(&fixed_part).ccw(), holes: self.base.holes.clone() }, + None => { + self.base = + Region2 { outer: fixed_exact.clone().unwrap_or_else(|| Loop2::polygon(&fixed_part)).ccw(), holes: self.base.holes.clone() } + } Some(j) => { if let Some(f) = self.flanges.get_mut(j) { f.shape = Some(ccw(fixed_part.clone())); - f.exact = None; + f.exact = fixed_exact; } } } @@ -3818,11 +4223,12 @@ impl SheetBody { angle: if up { angle } else { -angle }, radius, leg, - ext: (0.0, 0.0), + ext, slope: (0.0, 0.0), - tear: (false, false), + tear, shape: Some(ccw(moving)), - exact: None, + exact: moving_exact, + zone_shape, tag: String::new(), }); let i = self.flanges.len() - 1; @@ -3967,6 +4373,7 @@ impl SheetBody { tear: (false, false), shape: Some(ccw(moving)), exact: None, + zone_shape: None, tag: String::new(), }); } @@ -4723,6 +5130,9 @@ impl SheetBody { if let Some(exact) = &mut f.exact { *exact = flip_x_loop(exact); } + if let Some(zone) = &mut f.zone_shape { + *zone = flip_x_loop(zone); + } } for r in self.holes.iter_mut().chain(self.labels.iter_mut()).chain(self.pockets.iter_mut().map(|p| &mut p.region)) { *r = flip_x_region(r); @@ -4733,6 +5143,9 @@ impl SheetBody { } for p in &mut self.perforations { p.from_right = !p.from_right; + for r in p.outline.iter_mut().flatten() { + *r = flip_x_region(r); + } } for (_, a, b) in &mut self.base_curves { (*a, *b) = (flip_x(*b), flip_x(*a)); @@ -4864,6 +5277,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt tear: (false, false), shape: None, exact: None, + zone_shape: None, tag: String::new(), }; let ba = sheet.allowance(&f); @@ -5540,6 +5954,145 @@ mod tests { } } + /// Any closed curve the sheet's outlines can hold: a circle, conics, cubics, and a mix. + fn curved_outlines() -> Vec<(&'static str, Loop2)> { + let w = std::f64::consts::FRAC_1_SQRT_2; + let v = |x: f64, y: f64| Vec2::new(x, y); + let conic = |a: Vec2, apex: Vec2, b: Vec2| Seg2::Conic { a, apex, b, w }; + let ellipse = vec![ + conic(v(80.0, 0.0), v(80.0, 40.0), v(0.0, 40.0)), + conic(v(0.0, 40.0), v(-80.0, 40.0), v(-80.0, 0.0)), + conic(v(-80.0, 0.0), v(-80.0, -40.0), v(0.0, -40.0)), + conic(v(0.0, -40.0), v(80.0, -40.0), v(80.0, 0.0)), + ]; + let cubic = |p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2| Seg2::Cubic { p0, p1, p2, p3 }; + // An uneven blob: its sides aren't symmetric about the fold or each other. + let spline = vec![ + cubic(v(70.0, -5.0), v(75.0, 30.0), v(30.0, 55.0), v(0.0, 40.0)), + cubic(v(0.0, 40.0), v(-30.0, 25.0), v(-90.0, 45.0), v(-85.0, 0.0)), + cubic(v(-85.0, 0.0), v(-80.0, -45.0), v(-20.0, -60.0), v(0.0, -35.0)), + cubic(v(0.0, -35.0), v(20.0, -10.0), v(65.0, -40.0), v(70.0, -5.0)), + ]; + // A D: a straight side the fold runs across, and an arc it also crosses. + let d_shape = vec![ + Seg2::Line { a: v(-50.0, -50.0), b: v(0.0, -50.0) }, + Seg2::Arc { center: v(0.0, 0.0), radius: 50.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Line { a: v(0.0, 50.0), b: v(-50.0, 50.0) }, + Seg2::Line { a: v(-50.0, 50.0), b: v(-50.0, -50.0) }, + ]; + vec![ + ("circle", Loop2::circle(v(0.0, 0.0), 50.0)), + ("ellipse", Loop2 { segs: ellipse }), + ("spline", Loop2 { segs: spline }), + ("D", Loop2 { segs: d_shape }), + ] + } + + #[test] + fn a_curved_outline_is_cut_exactly_across_a_fold_whatever_the_curve() { + let (n, d) = (Vec2::new(1.0, 0.0), Vec2::new(0.0, -1.0)); + for (name, lp) in curved_outlines() { + for (s0, ba) in [(-2.8, 5.6), (-30.0, 12.0), (10.0, 1.5)] { + let cut = curved_fold(&lp, n, d, s0, ba).unwrap_or_else(|| panic!("{name} at {s0}")); + // The pieces are the outline: nothing lost, nothing doubled, and all closed. + let area = |l: &Loop2| l.signed_area(); + let sum = area(&cut.fixed) + area(&cut.zone) + area(&cut.moving); + assert!((sum - lp.signed_area().abs()).abs() < 1e-6, "{name} at {s0}: {sum} vs {}", lp.signed_area()); + for piece in [&cut.fixed, &cut.zone, &cut.moving] { + assert!(piece.is_closed(1e-9) && piece.is_ccw(), "{name} at {s0}: {piece:?}"); + } + // Each lies wholly in its band, on the outline's own curves: sampled densely. + let band = |l: &Loop2, lo: f64, hi: f64| l.polyline(1e-3).iter().all(|q| q.dot(n) >= lo - 1e-6 && q.dot(n) <= hi + 1e-6); + assert!(band(&cut.fixed, f64::MIN, s0) && band(&cut.zone, s0, s0 + ba) && band(&cut.moving, s0 + ba, f64::MAX), "{name} at {s0}"); + // A curve is cut, not replaced by chords: the same kind of segment comes back. + let curves = |l: &Loop2| l.segs.iter().filter(|e| !matches!(e, Seg2::Line { .. })).count(); + assert!(curves(&cut.fixed) + curves(&cut.zone) + curves(&cut.moving) >= curves(&lp), "{name} at {s0}"); + assert!(cut.ends.3 == (true, true), "{name}: both ends are torn"); + } + } + } + + /// What keeps a flat pattern true: unrolling a bend keeps lengths on its neutral surface, so + /// a curved edge is as long folded as in the flat there, and only there. + #[test] + fn a_curved_edge_keeps_its_length_on_the_neutral_surface() { + for (name, lp) in curved_outlines() { + let mut s = plate(10.0, 10.0); + s.base = Region2 { outer: lp, holes: Vec::new() }; + let t = s.t; + let mut warning = None; + let (a, b) = (Vec3::new(5.0, -200.0, t), Vec3::new(5.0, 200.0, t)); + let i = + s.add_fold(a, b, std::f64::consts::FRAC_PI_2, 3.0, "centerline", false, None, &mut warning).unwrap_or_else(|e| panic!("{name}: {e}")); + let zone = s.flanges[i].zone_shape.clone().unwrap_or_else(|| panic!("{name}: no exact zone")); + let neutral = t - s.k * t; + let mut edges = 0; + for e in zone.segs.iter().filter(|e| !matches!(zone_edge_kind(&s.flanges[i], e), ZoneEdge::Along)) { + edges += 1; + const N: usize = 400; + let at = |k: usize| e.point_at(k as f64 / N as f64); + let flat: f64 = (0..N).map(|k| at(k).dist(at(k + 1))).sum(); + let folded = |z: f64| -> f64 { (0..N).map(|k| s.map3(Piece::Zone(i), at(k), z).dist(s.map3(Piece::Zone(i), at(k + 1), z))).sum() }; + assert!((folded(neutral) - flat).abs() < 1e-3, "{name}: {} folded vs {flat} flat", folded(neutral)); + // The test can tell: on the inside of the bend the edge is shorter. + assert!(flat - folded(t) > 0.05, "{name}: {} on the inside vs {flat} flat", folded(t)); + } + assert!(edges >= 2, "{name}: {zone:?}"); + } + } + + #[test] + fn a_fold_across_an_outline_that_doesnt_pass_through_once_is_refused() { + let (n, d) = (Vec2::new(1.0, 0.0), Vec2::new(0.0, -1.0)); + let v = |x: f64, y: f64| Vec2::new(x, y); + let circle = Loop2::circle(v(0.0, 0.0), 50.0); + // The zone reaches the tip: nothing lies past it, or short of it. + assert!(curved_fold(&circle, n, d, 49.0, 5.0).is_none()); + assert!(curved_fold(&circle, n, d, -54.0, 5.0).is_none()); + // Wholly off the sheet. + assert!(curved_fold(&circle, n, d, 60.0, 5.0).is_none()); + // Two tabs on the line: the outline crosses the zone four times. + let tabs = Loop2::polygon(&[ + v(-10.0, -30.0), + v(10.0, -30.0), + v(10.0, -5.0), + v(-10.0, -5.0), + v(-10.0, 5.0), + v(10.0, 5.0), + v(10.0, 30.0), + v(-10.0, 30.0), + v(-20.0, 30.0), + v(-20.0, -30.0), + ]); + assert!(curved_fold(&tabs, n, d, -2.0, 4.0).is_none()); + // An edge square across the bend, along the zone's line. + let along = Loop2::polygon(&[v(-10.0, -10.0), v(2.0, -10.0), v(2.0, 10.0), v(-10.0, 10.0)]); + assert!(curved_fold(&along, n, d, -2.0, 4.0).is_none()); + // Numbers that aren't. + assert!(curved_fold(&circle, n, d, f64::NAN, 5.0).is_none()); + assert!(curved_fold(&Loop2 { segs: Vec::new() }, n, d, 0.0, 5.0).is_none()); + } + + #[test] + fn a_fold_zone_with_straight_ends_is_told_from_a_curved_or_cornered_one() { + let (n, d) = (Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)); + let zone = |pts: &[(f64, f64)]| slanted_zone(&pts.iter().map(|&(x, y)| Vec2::new(x, y)).collect::>(), n, d, 0.0, 4.0); + // Narrowing, widening, leaning one way, and square at one end. + assert_eq!(zone(&[(0.0, 0.0), (4.0, 1.0), (4.0, 9.0), (0.0, 10.0)]), Some((0.0, 10.0, (-1.0, -1.0), (true, true)))); + assert_eq!(zone(&[(0.0, 1.0), (4.0, 0.0), (4.0, 10.0), (0.0, 9.0)]), Some((1.0, 9.0, (1.0, 1.0), (true, true)))); + assert_eq!(zone(&[(0.0, 0.0), (4.0, 2.0), (4.0, 12.0), (0.0, 10.0)]), Some((0.0, 10.0, (-2.0, 2.0), (true, true)))); + assert_eq!(zone(&[(0.0, 0.0), (4.0, 0.0), (4.0, 9.0), (0.0, 10.0)]), Some((0.0, 10.0, (0.0, -1.0), (false, true)))); + // An S across the zone encloses what the straight end would: its corners give it away. + assert_eq!(zone(&[(0.0, 0.0), (4.0, 1.0), (4.0, 9.0), (2.0, 10.0), (2.0, 9.0), (0.0, 10.0)]), None); + // A curved end, as a polyline. + assert_eq!(zone(&[(0.0, 0.0), (4.0, 0.0), (4.0, 9.0), (3.0, 9.8), (1.0, 9.8), (0.0, 9.0)]), None); + // Nothing to bend across: the zone comes to a point on either line, or isn't there. + assert_eq!(zone(&[(0.0, 0.0), (4.0, 5.0), (0.0, 10.0)]), None); + assert_eq!(zone(&[(0.0, 5.0), (4.0, 0.0), (4.0, 10.0)]), None); + assert_eq!(zone(&[]), None); + assert_eq!(zone(&[(f64::NAN, 0.0), (4.0, 0.0), (4.0, 10.0), (0.0, 10.0)]), None); + } + #[test] fn a_slanted_flat_edge_folds_to_its_exact_helix() { // A 72° flange, inside: the bend's axis runs along x through y = setback, z = T + R, diff --git a/crates/doc/src/sheet_convert.rs b/crates/doc/src/sheet_convert.rs index ef9fd60ecd..1a1c2624d9 100644 --- a/crates/doc/src/sheet_convert.rs +++ b/crates/doc/src/sheet_convert.rs @@ -429,7 +429,8 @@ fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option { /// Face `g`'s outline with each picked chamfer along it taken out: the edge along the chamfer /// moves to where `g`'s plane meets the plane of the face across the chamfer (its corners -/// sliding along the edges before and after it), and runs along that face. +/// sliding along the edges before and after it, or stepping down to it where the face goes on +/// past the chamfer's end), and runs along that face. fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], tol: f64) -> Result { for c in chamfers { let other = if c.a == g { @@ -447,29 +448,57 @@ fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], to let &[k] = along.as_slice() else { return err("not supported yet: a chamfer picked as a bend that runs along a face more than once"); }; - // Its corners slide along the edges before and after it: those must be straight. - if [k + m - 1, k + 1].iter().any(|i| face.round.get(i % m).is_some_and(Option::is_some)) { - return err("not supported yet: a round edge of a face that ends at a chamfer picked as a bend"); - } - // Each end of the edge slides along the edge on its other side, out to the other plane. - let slide = |corner: usize, before: usize| -> Option { + let (Some(&p0), Some(&q0)) = (face.pts.get(k), face.pts.get((k + 1) % m)) else { return err("a face of the sheet has no outline") }; + let Some(e) = (q0 - p0).normalized() else { return err("a face of the sheet has an edge without length") }; + // Each end of the edge slides along the straight edge on its other side, out to the + // other plane. + let slide = |corner: usize, before: usize, edge: usize| -> Option { + if face.round.get(edge % m).is_some_and(Option::is_some) { + return None; + } let (p, q) = (*face.pts.get(corner % m)?, *face.pts.get(before % m)?); let d = (p - q).normalized()?; let s = (o - p).dot(n) / d.dot(n); - (s.is_finite() && s >= -tol).then_some(p + d * s) + (d.cross(e).len() > 1e-6 && s.is_finite() && s >= -tol).then_some(p + d * s) }; - let (Some(p), Some(q)) = (slide(k, k + m - 1), slide(k + 1, k + 2)) else { - return err("a face beside a chamfer picked as a bend does not reach the face across it"); + // Where that edge is round or runs on along the chamfer's line (the face goes on past + // the chamfer's end), the end keeps its corner and steps down square to the edge onto + // the other plane: a step the bend's setback takes up again. + let w = face.n.cross(e); + let drop = |p: Vec3| -> Result { + let s = (o - p).dot(n) / w.dot(n); + if s.is_finite() { Ok(p + w * s) } else { err("a face beside a chamfer picked as a bend does not reach the face across it") } }; - if let Some(x) = face.pts.get_mut(k) { - *x = p; - } - if let Some(x) = face.pts.get_mut((k + 1) % m) { - *x = q; - } - if let Some(x) = face.across.get_mut(k) { - *x = Some(other); + let (next_across, next_round) = (face.across.get((k + 1) % m).copied().flatten(), face.round.get((k + 1) % m).copied().flatten()); + let start: Vec<(Vec3, Option)> = match slide(k, k + m - 1, k + m - 1) { + Some(p) => vec![(p, Some(other))], + None => vec![(p0, None), (drop(p0)?, Some(other))], + }; + let end: Vec<(Vec3, Option, Option<(Vec3, Vec3)>)> = match slide(k + 1, k + 2, k + 1) { + Some(q) => vec![(q, next_across, next_round)], + None => vec![(drop(q0)?, None, None), (q0, next_across, next_round)], + }; + let (mut pts, mut across, mut round) = (Vec::with_capacity(m + 2), Vec::with_capacity(m + 2), Vec::with_capacity(m + 2)); + for i in 0..m { + if i == k { + for (p, a) in &start { + pts.push(*p); + across.push(*a); + round.push(None); + } + } else if i == (k + 1) % m { + for (p, a, r) in &end { + pts.push(*p); + across.push(*a); + round.push(*r); + } + } else { + pts.push(face.pts.get(i).copied().unwrap_or_default()); + across.push(face.across.get(i).copied().flatten()); + round.push(face.round.get(i).copied().flatten()); + } } + (face.pts, face.across, face.round) = (pts, across, round); } Ok(face) } @@ -489,8 +518,10 @@ fn rip_cut(x: &Face, y: &Face, e: Vec3, t: f64, gap: f64) -> Result { /// A counter-clockwise outline with each edge moved in by its own amount. Moved edges meet /// where their lines cross; two edges in one line that move by different amounts are joined by -/// a square step. Returns the corners and, per edge, the edge it was (none for a step). -fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec, Vec>)> { +/// a square step. An edge left shorter than `tol` (a step down to a bend's line that the bend's +/// setback takes up) is dropped, with the corner it shares with the edge that moved further. +/// Returns the corners and, per edge, the edge it was (none for a step). +fn inset(pts: &[Vec2], by: &[f64], tol: f64) -> Result<(Vec, Vec>)> { let n = pts.len(); if n < 3 { return err("a face of the sheet has no outline"); @@ -518,6 +549,15 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec, Vec>)> { } from.push(Some(i)); } + let moved_by = |from: &[Option], j: usize| from.get(j).copied().flatten().and_then(|i| by.get(i)).map_or(0.0, |b| b.abs()); + while out.len() > 3 { + let m = out.len(); + let short = (0..m).find(|j| matches!((out.get(*j), out.get((j + 1) % m)), (Some(a), Some(b)) if a.dist(*b) <= tol)); + let Some(j) = short else { break }; + let corner = if moved_by(&from, (j + m - 1) % m) > moved_by(&from, (j + 1) % m) { j } else { (j + 1) % m }; + out.remove(corner); + from.remove(j); + } // Every edge still runs the way it did, and the outline is still one simple loop. let m = out.len(); let kept = (0..m).all(|j| match (from.get(j).copied().flatten().and_then(moved), out.get(j), out.get((j + 1) % m)) { @@ -899,7 +939,7 @@ pub fn convert_body( } let first = panel.face.pts.first().copied().unwrap_or_default(); let face_at = |e: DocError| DocError::Invalid(format!("{e} (the face at {})", at(first))); - let (trimmed, from) = inset(&pts, &by).map_err(face_at)?; + let (trimmed, from) = inset(&pts, &by, tol).map_err(face_at)?; let exact = drawn(&trimmed, &from, &pts, &round).map_err(face_at)?; match fl { None => sh.base.outer = exact.unwrap_or_else(|| Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::>())), @@ -1011,6 +1051,7 @@ pub fn convert_body( slope: (0.0, 0.0), shape: None, exact: None, + zone_shape: None, tag: String::new(), tear: (false, false), }); @@ -1046,21 +1087,21 @@ mod tests { fn edges_move_in_by_their_own_amounts_and_step_along_one_line() { let v = Vec2::new; let square = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]; - let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0]).unwrap(); + let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0], 1e-6).unwrap(); assert_eq!(p, vec![v(0.0, 1.0), v(10.0, 1.0), v(10.0, 8.0), v(0.0, 8.0)]); assert_eq!(from, vec![Some(0), Some(1), Some(2), Some(3)]); // Two edges in one line that move by different amounts: a square step between them, // so 5 x 2 goes from the 10 x 10. let split = [v(0.0, 0.0), v(5.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]; - let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0]).unwrap(); + let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0], 1e-6).unwrap(); assert_eq!(p, vec![v(0.0, 0.0), v(5.0, 0.0), v(5.0, 2.0), v(10.0, 2.0), v(10.0, 10.0), v(0.0, 10.0)]); assert_eq!(from, vec![Some(0), None, Some(1), Some(2), Some(3), Some(4)]); // A slanted end keeps its line: the corner slides along it (2 up a 45° edge is 2 along). let slanted = [v(0.0, 0.0), v(10.0, 0.0), v(15.0, 5.0), v(0.0, 5.0)]; - let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0]).unwrap(); + let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0], 1e-6).unwrap(); assert!(p[1].dist(v(12.0, 2.0)) < 1e-12 && p[0].dist(v(0.0, 2.0)) < 1e-12, "{p:?}"); // Setbacks longer than the face leave nothing of it. - assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0]).is_err()); - assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0]).is_err()); + assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0], 1e-6).is_err()); + assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0], 1e-6).is_err()); } } diff --git a/crates/doc/src/sheet_face_names.rs b/crates/doc/src/sheet_face_names.rs index 3e37736f5f..f2e4917e9a 100644 --- a/crates/doc/src/sheet_face_names.rs +++ b/crates/doc/src/sheet_face_names.rs @@ -68,8 +68,8 @@ impl SheetBody { let Some(inverse) = mat_inverse(&world) else { continue }; let q = apply_point(&inverse, p) - o; let u = q.dot(d); - let (u0, u1) = self.zone_span(f); - if u < u0 - tol || u > u1 + tol { + let (lo, hi) = self.zone_reach(f); + if u < lo - tol || u > hi + tol { continue; } let radial = q - d * u; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 3141559b67..3a7b2594ce 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -596,6 +596,33 @@ fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() { assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design"); } +/// #9373: a timeline folder that names a feature or joint the design doesn't have, or a feature +/// a folder already lists, no longer stops the design opening. Those entries are dropped and +/// everything else is kept. Editing a feature inside a folder used to save such entries. +#[test] +fn a_folder_naming_missing_or_repeated_features_still_opens() { + let mut doc = Document::new("stale folder entries"); + let sketch = || FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }; + let a = doc.add_feature(sketch(), Some("A")).unwrap(); + let b = doc.add_feature(sketch(), Some("B")).unwrap(); + let c = doc.add_feature(sketch(), Some("C")).unwrap(); + let missing = doc.next_id + 10; + doc.timeline_groups.push(TimelineGroup { id: 100, name: "Repeats".into(), parent: None, features: vec![a, b, b], joints: vec![] }); + doc.timeline_groups.push(TimelineGroup { + id: 101, + name: "Stale".into(), + parent: None, + features: vec![b, c, missing, missing, missing], + joints: vec![missing, missing], + }); + let opened = Document::from_json(&doc.to_json()).expect("the design opens"); + assert_eq!(opened.features.iter().map(|f| f.id).collect::>(), vec![a, b, c]); + assert_eq!(opened.timeline_groups[0].features, vec![a, b], "listed once"); + assert_eq!(opened.timeline_groups[1].features, vec![c], "no missing feature, nor one the first folder holds"); + assert!(opened.timeline_groups[1].joints.is_empty()); + assert_eq!(opened.timeline_groups.iter().map(|g| g.name.as_str()).collect::>(), ["Repeats", "Stale"], "both folders are kept"); +} + /// A marker after the last feature leaves every feature active, as no marker does, so a feature /// added then is active too, not added rolled back (#7741: editing the last feature from the /// History runs its command with the marker there). diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index 542195ddc7..cf72525522 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -335,6 +335,7 @@ impl Walk for PlaneRef { p = base; } PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)), + PlaneRef::Tangent { angle: Some(angle), .. } => return f(("Angle", angle, Kind::Angle, ExprRole::Input)), // A plane seen from behind keeps its base's expressions (a sketch made with // `flip` on an offset plane did not follow the offset's parameter). PlaneRef::Flipped { base, .. } | PlaneRef::ThroughPoint { base, .. } => p = base, @@ -361,6 +362,7 @@ impl Walk for PlaneRef { p = base; } PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)), + PlaneRef::Tangent { angle: Some(angle), .. } => return f(("Angle", angle, Kind::Angle, ExprRole::Input)), // A plane seen from behind keeps its base's expressions (a sketch made with // `flip` on an offset plane did not follow the offset's parameter). PlaneRef::Flipped { base, .. } | PlaneRef::ThroughPoint { base, .. } => p = base, @@ -415,7 +417,13 @@ impl PlaneRef { b.walk_feature_refs(f, depth + 1); c.walk_feature_refs(f, depth + 1); } - Self::Tangent { face, .. } | Self::AlongPath { path: face, .. } => face.walk_feature_refs(f, depth + 1), + Self::Tangent { face, reference, .. } => { + face.walk_feature_refs(f, depth + 1); + if let Some(r) = reference { + r.walk_feature_refs(f, depth + 1); + } + } + Self::AlongPath { path, .. } => path.walk_feature_refs(f, depth + 1), Self::Origin { .. } | Self::Custom { .. } | Self::Placed { .. } | Self::Face { .. } => {} } } @@ -446,7 +454,13 @@ impl PlaneRef { b.walk_feature_refs_mut(f, depth + 1); c.walk_feature_refs_mut(f, depth + 1); } - Self::Tangent { face, .. } | Self::AlongPath { path: face, .. } => face.walk_feature_refs_mut(f, depth + 1), + Self::Tangent { face, reference, .. } => { + face.walk_feature_refs_mut(f, depth + 1); + if let Some(r) = reference { + r.walk_feature_refs_mut(f, depth + 1); + } + } + Self::AlongPath { path, .. } => path.walk_feature_refs_mut(f, depth + 1), Self::Origin { .. } | Self::Custom { .. } | Self::Placed { .. } | Self::Face { .. } => {} } } diff --git a/crates/engine/src/cmd/component_reparent.rs b/crates/engine/src/cmd/component_reparent.rs index 35edd2d0b6..c2563d1972 100644 --- a/crates/engine/src/cmd/component_reparent.rs +++ b/crates/engine/src/cmd/component_reparent.rs @@ -3,8 +3,8 @@ //! once is one definition: what goes in it goes in every instance, placed by the one instance //! named (`parent_path`, else the active placement) and in the same place in the others. Paths //! through the placement change: the session's paths (selection, active and hidden placements, -//! hidden folders) follow it, and a move that would leave a feature failing that worked before -//! (a face attachment found through the old path) is refused. +//! hidden folders) and the design's face attachments found through it follow it, and a move that +//! would still leave a feature failing that worked before is refused. use super::placing::{parent_path, placement_of}; use super::*; @@ -71,6 +71,37 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { { c.parent = to; } + // Face attachments found through it follow it too (#11063): a sketch on a face of a body + // inside it kept the old path, which no longer starts at the design, and the move was refused. + // A path through it by way of any placement of its old parent (a parent placed more than once + // is one definition) now runs through its one new place. A path that would grow past the 64 + // placements a design keeps is refused here rather than saved where it can't be read back. + let mut too_deep = false; + let mut rebase = |path: &mut Vec| { + if let Some(at) = path.iter().position(|x| *x == id) { + let moved: Vec = new_path.iter().chain(&path[at + 1..]).copied().collect(); + too_deep |= moved.len() > 64; + *path = moved; + } + }; + for f in &mut scratch.doc_mut().features { + match &mut f.kind { + FeatureKind::Emboss { face: Some(face), .. } => { + rebase(&mut face.path); + rebase(&mut face.author_path); + } + FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } => { + if let Some(anchor) = crate::cmd::sketch::face::anchor_mut(plane) { + rebase(&mut anchor.path); + rebase(&mut anchor.author_path); + } + } + _ => {} + } + } + if too_deep { + return Err(bad(cmd, format!("moving {} would nest a face attachment more than 64 placements deep", o.name))); + } // The paths through it follow it before the model is worked out again: that drops a selected // or active placement whose path no longer leads anywhere. for sel in &mut scratch.selection { diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index 610de9e941..8bbb72f81b 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("plane_tangent") .contract(&PLANE_TANGENT_CONTRACT) .output(output_schema_for::) - .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"), + .params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); reference?: plane or planar face ref (the plane touches where the face turns towards it: parallel to it, on the side of `at`); angle?: expr (with reference, default 0 deg: turned that far about the face's axis; not on a sphere); name?"), CommandSpec::new("construct.plane.along_path", "Plane Along Path", crate::checked!("construct.plane.along_path", plane_along)) .at("SOLID", "CONSTRUCT") .icon("plane_along_path") @@ -408,7 +408,20 @@ fn plane_tangent(s: &mut Session, p: &Value) -> Result { (None, GeoRef::Face { at, .. }) => *at, _ => return Err(bad(cmd, "`at` is required: where the plane touches the face")), }; - plane(s, p, PlaneRef::Tangent { face: Box::new(face), at }) + let reference = match p.get("reference").filter(|v| !v.is_null()) { + Some(_) => Some(Box::new(geo(s, p.get("reference"), cmd, "reference")?)), + None => None, + }; + let angle = match p.get("angle").filter(|v| !v.is_null()) { + Some(_) if reference.is_none() => return Err(bad(cmd, "`angle` turns the plane from a `reference`: give one")), + Some(_) => { + let angle = req_expr(cmd, p, "angle")?; + check_expr(s, &angle, solidcraft_doc::expr::Kind::Angle, cmd, "angle")?; + Some(angle) + } + None => None, + }; + plane(s, p, PlaneRef::Tangent { face: Box::new(face), at, reference, angle }) } fn plane_along(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fce8f62cb5..d18833add0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1330,7 +1330,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result { s.selection.push(x); } } - s.revision += 1; + s.refresh_display(); Ok(json!({"selected": s.selection.len()})) } @@ -1387,7 +1387,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result { fn select_clear(s: &mut Session, _p: &Value) -> Result { s.selection.clear(); - s.revision += 1; + s.refresh_display(); Ok(json!({"selected": 0})) } @@ -1767,6 +1767,15 @@ fn redefine(s: &mut Session, p: &Value) -> Result { { *after = before.clone(); } + // A perforation keeps its outline through an edit that names no sketch (the Perforate dialog has no outline). + if let (FeatureKind::SheetPerforate { sketch: Some(before), profiles: picked, .. }, FeatureKind::SheetPerforate { sketch, profiles, .. }) = + (&old.kind, &mut new.kind) + && sketch.is_none() + && params.get("sketch").is_none() + { + *sketch = Some(*before); + *profiles = picked.clone(); + } // A boundary surface's loop of sketch curves and its curve network are one command's two // forms: an edit may turn one into the other. let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. }); @@ -1794,6 +1803,11 @@ fn redefine(s: &mut Session, p: &Value) -> Result { }) .collect(); trial.doc_mut().features.retain(|f| f.id != made); + // Made right after the edited feature, the trial joined the timeline folder holding both: + // it leaves it too, or its id stays there, and is given out again below (#9373). + for group in &mut trial.doc_mut().timeline_groups { + group.features.retain(|f| *f != made); + } let added = trial.doc.features.len().saturating_sub(s.doc.features.len()); trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker }); if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) { diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index 45c4db6d06..2a039e0209 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -643,6 +643,29 @@ fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() { assert!(s.doc.timeline_groups.is_empty()); } +/// #9373: editing a feature inside a timeline folder left the id of the edit's trial feature in +/// the folder and gave that id out again, so every edit there added it once more, and a feature +/// made later took it and was listed twice: the saved design then would not open. Edited, the +/// folder keeps its own features, once each, and the design opens again. +#[test] +fn editing_a_feature_inside_a_folder_leaves_the_folder_as_it_was() { + let mut s = Session::default(); + for (name, height) in [("Base", 10), ("Post", 20), ("Pin", 30)] { + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": height, "operation": "new", "name": name})); + } + let ids: Vec = s.doc.features.iter().map(|f| f.id).collect(); + run(&mut s, "timeline.group", json!({"name": "Parts", "features": ids})); + for width in [5, 6, 7] { + run(&mut s, "timeline.redefine", json!({"feature": "Post", "command": "solid.box", "params": {"length": 4, "width": width, "height": 20}})); + } + assert_eq!(s.doc.timeline_groups[0].features, ids, "no trial feature is left in the folder"); + let made = + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new", "name": "Later"}))["feature"].as_u64().unwrap(); + assert!(!s.doc.timeline_groups[0].features.contains(&made), "a feature made after the folder is not in it"); + let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(reopened.timeline_groups, s.doc.timeline_groups); +} + #[test] fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() { let mut s = Session::default(); @@ -1559,11 +1582,13 @@ fn timeline_joint_folders_are_mixed_saved_and_undoable() { let saved = s.doc.to_json(); let reopened = solidcraft_doc::Document::from_json(&saved).unwrap(); assert_eq!(reopened.to_json(), saved); + // A folder naming a joint the design doesn't have, or one twice, opens without that entry + // (#9373). let mut damaged: Value = serde_json::from_str(&saved).unwrap(); damaged["timeline_groups"][0]["joints"] = json!([u64::MAX]); - assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err()); + assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap().timeline_groups[0].joints.is_empty()); damaged["timeline_groups"][0]["joints"] = json!([joint, joint]); - assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err()); + assert_eq!(solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap().timeline_groups[0].joints, [joint]); let destination = run(&mut s, "timeline.group", json!({"name":"Destination"}))["group"].as_u64().unwrap(); run(&mut s, "timeline.move_to_group", json!({"joints":[joint], "group":destination})); assert!(s.doc.timeline_groups[0].joints.is_empty()); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 18be03cd04..b1c34042ba 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -91,7 +91,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ) .at("SOLID", "CREATE") .icon("pattern_circ") - .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"), + .params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir} (origin coordinates may be length expressions; the pattern follows them) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"), CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path) .at("SOLID", "CREATE") .icon("pattern_path") @@ -167,7 +167,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("presspull") .key("Q") .params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"), - CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"), + CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"), ]; /// Where an extrude starts and which way is positive: a point inside the (first) profile or @@ -1804,16 +1804,73 @@ fn combine(s: &mut Session, p: &Value) -> Result { } fn move_bodies(s: &mut Session, p: &Value) -> Result { + if let Some(paths) = copied_components(p)? { + return copy_components(s, p, paths); + } if let Some(plan) = crate::frames::placements_moved(s, p) { return move_placements(s, p, plan); } + // Bodies in differently placed frames: one Move feature per frame, each moving its bodies + // by the world vector and turn mapped into that frame (#9660). + if let Some(moves) = crate::frames::split_move(s, p) { + return move_split(s, &moves); + } move_body_feature(s, p) } -/// A move of several placements of one part (`frames::placements_moved`, #4768): each placement -/// moved in the world by the move's translation and turn (about the world origin, as the Move -/// feature turns bodies), and the root component's bodies listed moved as bodies, in one step. -fn move_placements(s: &mut Session, p: &Value, plan: crate::frames::PlacementMove) -> Result { +/// The moves of `frames::split_move`, in one step, with one reply: the features made, and the +/// bodies made, changed and removed by any of them. +fn move_split(s: &mut Session, moves: &[Value]) -> Result { + let mut features = Vec::new(); + let mut lists: [(&str, Vec); 3] = [("created_bodies", Vec::new()), ("changed_bodies", Vec::new()), ("removed_bodies", Vec::new())]; + let mut last = Value::Null; + for p in moves { + let reply = move_body_feature(s, p)?; + features.push(reply.get("feature").cloned().unwrap_or(Value::Null)); + for (key, list) in &mut lists { + for name in reply.get(*key).and_then(Value::as_array).into_iter().flatten() { + if !list.contains(name) { + list.push(name.clone()); + } + } + } + last = reply; + } + if let Some(out) = last.as_object_mut() { + if let Some(first) = features.first() { + out.insert("feature".into(), first.clone()); + } + out.insert("features".into(), Value::Array(features)); + for (key, list) in lists { + out.insert(key.into(), Value::Array(list)); + } + } + Ok(last) +} + +/// The occurrence paths a copying move copies (`occurrences`, root first), `None` when it names +/// none. A component is only ever copied, so the move must set `copy`. +fn copied_components(p: &Value) -> Result>>> { + let cmd = "solid.move"; + let Some(list) = p.get("occurrences") else { return Ok(None) }; + let paths: Vec> = list + .as_array() + .into_iter() + .flatten() + .map(|path| path.as_array().and_then(|ids| ids.iter().map(Value::as_u64).collect::>>()).filter(|ids| !ids.is_empty())) + .collect::>() + .ok_or_else(|| bad(cmd, "`occurrences` must list occurrence paths, each a list of occurrence ids"))?; + if paths.is_empty() || paths.len() > 1000 { + return Err(bad(cmd, "`occurrences` must list 1…1000 occurrence paths")); + } + if p.get("copy").and_then(Value::as_bool) != Some(true) { + return Err(bad(cmd, "`occurrences` are copied: set `copy` to true")); + } + Ok(Some(paths)) +} + +/// The translation (mm) and turn (radians, if any) of a move in the world. +fn world_move(s: &Session, p: &Value) -> Result<(Vec, Option)> { let cmd = "solid.move"; let length = |v: &Value| -> Result { match v { @@ -1831,6 +1888,79 @@ fn move_placements(s: &mut Session, p: &Value, plan: crate::frames::PlacementMov Some(a) => Some(s.doc.eval(&a, Kind::Angle).map_err(|e| bad(cmd, format!("angle: {e}")))?), None => None, }; + Ok((translate, angle)) +} + +/// A copying move of components (`occurrences`, with `copy`): each picked placement's component is +/// copied as an independent component, as Paste New copies it (`component.paste_new`), placed where +/// the placement is; the copy then moves by the move's translation and turn in the world, as +/// `occurrence.move` moves a placement. The bodies listed are copied as bodies (see +/// `move_body_feature`) in the same command, so the whole move is one undo step. +fn copy_components(s: &mut Session, p: &Value, paths: Vec>) -> Result { + let cmd = "solid.move"; + let (translate, angle) = world_move(s, p)?; + let paste = super::find_command("component.paste_new").ok_or_else(|| EngineError::UnknownCommand("component.paste_new".into()))?; + let mover = super::find_command("occurrence.move").ok_or_else(|| EngineError::UnknownCommand("occurrence.move".into()))?; + // A component inside another one copied here comes with its parent's copy. + let mut paths: Vec> = paths.into_iter().fold(Vec::new(), |mut out, path| { + if !out.contains(&path) { + out.push(path); + } + out + }); + let all = paths.clone(); + paths.retain(|p| !all.iter().any(|q| q.len() < p.len() && p.starts_with(q))); + // The bodies are given in the world; their own component's frame turns them, as a move of bodies + // alone is turned, so they are mapped the same way, before any copy is made. + let has_bodies = match p.get("bodies") { + None => false, + Some(Value::Array(bodies)) => !bodies.is_empty(), + Some(_) => return Err(bad(cmd, "`bodies` must list bodies")), + }; + let mut rest = p.clone(); + if let Some(object) = rest.as_object_mut() { + object.remove("occurrences"); + } + // Bodies in differently placed frames split by frame (`frames::split_move`, #9660), as a move of them alone does. + let split = crate::frames::split_move(s, &rest); + let bodies = (has_bodies && split.is_none()).then(|| crate::frames::to_local(s, cmd, &rest).unwrap_or_else(|| rest.clone())); + let mut copies = Vec::new(); + for path in &paths { + let Some(&source) = path.last() else { continue }; + let component = + s.doc.occurrences.iter().find(|o| o.id == source).map(|o| o.component).ok_or_else(|| bad(cmd, "`occurrences` names no occurrence"))?; + let made = paste.run_nested(s, &json!({"component": component, "occurrence": source, "path": path}))?; + let copy = made.get("occurrence").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the copy has no occurrence"))?; + // The copy is placed by the same parent placement as its source. + let mut at = path[..path.len() - 1].to_vec(); + at.push(copy); + let mut q = json!({"occurrence": copy, "path": at, "space": "world", "translate": translate}); + if let Some(axis) = p.get("axis") { + q["axis"] = axis.clone(); + } + if let Some(angle) = angle { + q["angle"] = json!(angle); + } + mover.run_nested(s, &q)?; + copies.push(copy); + } + // The reply is a solid.move reply: the bodies' feature reply, with the copies made alongside. + let mut out = match (split, bodies) { + (Some(moves), _) => move_split(s, &moves)?, + (None, Some(local)) => move_body_feature(s, &local)?, + (None, None) => json!({}), + }; + if let Some(object) = out.as_object_mut() { + object.insert("occurrences".into(), json!(copies)); + } + Ok(out) +} + +/// A move of several placements of one part (`frames::placements_moved`, #4768): each placement +/// moved in the world by the move's translation and turn (about the world origin, as the Move +/// feature turns bodies), and the root component's bodies listed moved as bodies, in one step. +fn move_placements(s: &mut Session, p: &Value, plan: crate::frames::PlacementMove) -> Result { + let (translate, angle) = world_move(s, p)?; let spec = super::find_command("occurrence.move").ok_or_else(|| EngineError::UnknownCommand("occurrence.move".into()))?; let mut moved = Vec::new(); for path in &plan.paths { @@ -2046,16 +2176,32 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result { finish_pattern(s, p, features, pattern, bodies) } +/// The line a circular pattern turns about, as [`axis_line`] reads it, except that the origin of +/// `{origin, dir}` may be length expressions, kept so the pattern follows them (#10852). +fn pattern_axis(s: &Session, p: &Value, cmd: &str) -> Result<(PointExpr, Vec3, Option, Option)> { + if let Some(a @ Value::Object(o)) = p.get("axis") + && o.contains_key("origin") + && !super::is_model_axis(a) + { + // Read under its full name, so a refusal says which origin it means. + let origin = point_param(s, &json!({ "axis.origin": o.get("origin") }), "axis.origin", cmd)?; + let dir = a.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?; + return Ok((origin.unwrap_or_else(|| solidcraft_doc::point_expr(Vec3::ZERO)), dir, None, None)); + } + let (origin, dir, axis_ref, axis_feature) = axis_line(s, p, cmd)?; + Ok((solidcraft_doc::point_expr(origin), dir, axis_ref, axis_feature)) +} + fn pattern_circ(s: &mut Session, p: &Value) -> Result { let cmd = "solid.pattern.circular"; let (features, bodies) = pattern_sources(s, p, cmd)?; - let (origin, axis, axis_ref, axis_feature) = axis_line(s, p, cmd)?; + let (origin, axis, axis_ref, axis_feature) = pattern_axis(s, p, cmd)?; let count = req_expr(cmd, p, "count")?; check_count(s, &count, cmd, "count")?; let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); check_expr(s, &angle, Kind::Angle, cmd, "angle")?; let pattern = solidcraft_doc::PatternKind::Circular { - origin: Position(origin), + origin, axis: Dir(axis), count, angle, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9b2df48fdc..cb3ce3711b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -44,12 +44,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("save") .key("Ctrl+S") .noundo() - .params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"), + .params("path? (default: current file; a name that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"), CommandSpec::new("file.save_as", "Save As", save_as) .icon("save") .key("Ctrl+Shift+S") .noundo() - .params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"), + .params("path (a design file name, .sc as a rule; one that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"), CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore") .noundo() .params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"), @@ -472,8 +472,38 @@ fn rename_design(s: &mut Session, from: &str, to: &str) { } } -fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool, overwrite: bool) -> Result { +/// The kind of file `doc.open` reads `path` as when that is not a design, by its extension. A +/// design saved under such a name would not open again as the design (#9745). +fn other_file_kind(path: &str) -> Option<&'static str> { + if solidcraft_io::is_step_path(path) { + Some("a STEP or other CAD exchange file") + } else if solidcraft_io::is_mesh_path(path) { + Some("a mesh") + } else if super::drawing_import_command(path).is_some() { + Some("a 2D drawing") + } else if solidcraft_io::is_point_cloud_path(path) { + Some("a point cloud") + } else if solidcraft_io::is_drawing_path(path) { + Some("a SolidCraft drawing") + } else { + None + } +} + +/// Refuse to save a design under a name [`other_file_kind`] opens as another kind of file, +/// pointing to `file.export` when that writes the format. +fn check_design_name(cmd: &str, path: &str) -> Result<()> { + let Some(kind) = other_file_kind(path) else { return Ok(()) }; + let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let export = if solidcraft_io::Format::from_name(&ext).is_ok() { format!(", or write a .{ext} file with file.export") } else { String::new() }; + Err(bad(cmd, format!("{path}: a .{ext} file opens as {kind}, not as a design; save the design as .{}{export}", solidcraft_io::DESIGN_EXT))) +} + +#[allow(clippy::too_many_arguments)] +fn write(s: &mut Session, cmd: &str, path: &str, format: &str, compression: &str, preview: bool, cache: bool, overwrite: bool) -> Result { + // A path outside the agent's folders says so first, whatever its name. solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?; + check_design_name(cmd, path)?; if s.path.as_deref() == Some(path) { if s.read_only { return Err(EngineError::Other(format!("{path}: the design was opened read-only; save it to another file with file.save_as"))); @@ -662,6 +692,7 @@ fn save(s: &mut Session, p: &Value) -> Result { }; write( s, + "file.save", &path, str_(p, "format").unwrap_or(default_design_format(&path)), str_(p, "compression").unwrap_or("fast"), @@ -679,6 +710,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result { let path = path_arg(p, "file.save_as")?.to_string(); write( s, + "file.save_as", &path, str_(p, "format").unwrap_or(default_design_format(&path)), str_(p, "compression").unwrap_or("fast"), @@ -1889,6 +1921,36 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_design_is_not_saved_under_a_name_that_opens_as_another_file() { + // #9745: a design saved as `part.step` or `part.stl` was refused or opened as a broken mesh. + let dir = name_dir("other-kind"); + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + for name in + ["part.step", "part.STP", "part.igs", "part.stl", "part.obj", "part.3mf", "part.glb", "part.dxf", "part.svg", "part.ply", "part.scdwg"] + { + let path = dir.join(name); + for command in ["file.save_as", "file.save"] { + let e = s.execute(command, &json!({"path":path})).unwrap_err().to_string(); + assert!(e.contains("not as a design") && e.contains(".sc"), "{command} {name}: {e}"); + assert!(!path.exists(), "{command} {name} wrote the file"); + } + } + let e = s.execute("file.save_as", &json!({"path":dir.join("part.stl")})).unwrap_err().to_string(); + assert!(e.contains("file.export"), "STL is an export format: {e}"); + assert!(s.path.is_none() && s.is_dirty(), "a refused save changes nothing"); + // Names that open again as the design still save, and what they write opens as it. + for name in ["part.sc", "part.json", "part.txt", "part"] { + let path = dir.join(name); + s.execute("file.save_as", &json!({"path":path})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path":path})).unwrap(); + assert_eq!(reopened.doc.features.len(), 1, "{name} reopens as the design"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn save_as_keeps_a_name_the_user_chose() { let dir = name_dir("named"); diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 2099373278..3648dadbd9 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use solidcraft_doc::expr::Kind; +use solidcraft_doc::expr::{self, Kind}; use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower}; use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef}; use solidcraft_geom::{Aabb3, Vec3}; @@ -232,7 +232,8 @@ fn sol_json(sol: &joints::Solution, p: &Value) -> Value { } /// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with -/// its name and limits. +/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full +/// precision. pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec { j.kind .dofs() @@ -240,8 +241,8 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec { .enumerate() .map(|(i, n)| { let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i)); - json!({"name": n, "value": if angle { v.to_degrees() } else { v }, "unit": if angle { "deg" } else { "mm" }, - "limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [a.to_degrees(), b.to_degrees()] } else { [a, b] })}) + json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" }, + "limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })}) }) .collect() } @@ -697,11 +698,47 @@ fn check_sides(a: &JointOrigin, b: &JointOrigin, cmd: &str) -> Result<()> { Ok(()) } +/// A path already joins the two sides, including paths through grounded occurrences. +fn closes_loop(s: &Session, j: &Joint) -> bool { + let mut at: BTreeMap> = BTreeMap::new(); + for joint in s.doc.assembly.joints.iter().filter(|j| !j.suppressed).take(joints::MAX_JOINTS) { + at.entry(joint.a.occurrence).or_default().push(joint.b.occurrence); + at.entry(joint.b.occurrence).or_default().push(joint.a.occurrence); + } + // The solver fixes the root and every grounded occurrence in the same world frame. + for o in s.doc.occurrences.iter().filter(|o| o.grounded) { + at.entry(0).or_default().push(o.id); + at.entry(o.id).or_default().push(0); + } + let mut seen = BTreeSet::from([j.a.occurrence]); + let mut pending = vec![j.a.occurrence]; + while let Some(occ) = pending.pop() { + if occ == j.b.occurrence { + return true; + } + for other in at.get(&occ).into_iter().flatten() { + if seen.insert(*other) { + pending.push(*other); + } + } + } + false +} + fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result { if s.doc.assembly.joints.len() >= 10_000 { return Err(EngineError::Other("too many joints".into())); } check_sides(&j.a, &j.b, "joint.create")?; + // Relations and loop-closing typed joints start where their parts are. A typed joint in + // an open chain keeps its usual initial values; explicitly supplied values also keep theirs. + if p.get("values").is_none() + && p.get("value").is_none() + && (j.kind.is_relation() || closes_loop(s, &j)) + && let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j) + { + j.values = q; + } // It starts at its values inside its limits. A value moved to a limit moves the part as it // is joined, which the answer says. let mut q = j.values.clone(); @@ -731,7 +768,10 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result { // A new joint starts shown, even if a stale visibility entry came from an older design. s.visibility.hidden_joints.retain(|h| *h != id); s.doc_mut().assembly.joints.push(j); - let sol = resolve(s); + let mut sol = resolve(s); + if sol.conflicts.iter().any(|(c, ..)| *c == id) && close_by_half_turn(s, id, &sol) { + sol = resolve(s); + } let mut out = sol_json(&sol, p); out["joint"] = json!(id); out["name"] = json!(name); @@ -741,6 +781,95 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result { Ok(out) } +/// Sets of angle values a new loop-closing joint tries a half turn round, at most. +const HALF_TURN_TRIALS: usize = 16; + +/// A new joint that closes a loop is met from where the loop's parts sit. Where its parts only +/// meet with one of them turned a half turn about a joint on the way between them (a second +/// planar joint mating two faces that point the same way, #10331), the loop solve starts at a +/// pose turned exactly away and sees no way round, and the joint is left as a conflict. As the +/// first joint of a chain moves its part into place, turn the new joint's own angle, each angle +/// on the shortest joint path between its two parts, or one of each, a half turn, and keep the +/// first that meets it with no new conflict or error elsewhere than `before` had (stored, for +/// the caller's resolve). False, and nothing changed, when none does. Values a motion link or an +/// expression sets, and turns their limits stop short, are left alone. +fn close_by_half_turn(s: &mut Session, id: u64, before: &joints::Solution) -> bool { + let asm = &s.doc.assembly; + let Some(new) = asm.joints.iter().find(|j| j.id == id) else { return false }; + let (from, to) = (new.a.occurrence, new.b.occurrence); + // Breadth first over the other joints, from A's occurrence to B's. + let mut reached: BTreeMap> = BTreeMap::from([(from, None)]); + let mut frontier = vec![from]; + while !frontier.is_empty() && !reached.contains_key(&to) { + let mut next = Vec::new(); + for at in frontier { + for (k, j) in asm.joints.iter().enumerate().filter(|(_, j)| j.id != id && !j.suppressed) { + let other = if j.a.occurrence == at { + j.b.occurrence + } else if j.b.occurrence == at { + j.a.occurrence + } else { + continue; + }; + if let std::collections::btree_map::Entry::Vacant(e) = reached.entry(other) { + e.insert(Some(k)); + next.push(other); + } + } + } + frontier = next; + } + let mut path = Vec::new(); + let mut at = to; + while let Some(Some(k)) = reached.get(&at).copied() { + let Some(j) = asm.joints.get(k) else { break }; + path.push(k); + at = if j.a.occurrence == at { j.b.occurrence } else { j.a.occurrence }; + } + let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect(); + // The angle values that may turn: on the path, and the new joint's own (turned exactly + // away about its axis, its own value sees no way round either). + let angles = |ks: &[usize]| -> Vec<(usize, usize)> { + ks.iter() + .filter_map(|k| asm.joints.get(*k).map(|j| (*k, j))) + .flat_map(|(k, j)| (0..j.kind.dofs().len()).filter(move |i| j.kind.is_angle(*i)).map(move |i| (k, j, i))) + .filter(|(_, j, i)| j.exprs.value(*i).is_none() && !linked.contains(&(j.id, *i))) + .map(|(k, _, i)| (k, i)) + .collect() + }; + let own = asm.joints.iter().position(|j| j.id == id).map(|k| angles(&[k])).unwrap_or_default(); + let mut trials: Vec> = own.iter().map(|v| vec![*v]).collect(); + for v in angles(&path) { + trials.push(vec![v]); + trials.extend(own.iter().map(|w| vec![v, *w])); + } + trials.truncate(HALF_TURN_TRIALS); + let st = s.model.state(); + 'trial: for turns in trials { + let mut doc = (*s.doc).clone(); + for (k, i) in turns { + let Some(j) = doc.assembly.joints.get_mut(k) else { continue 'trial }; + j.values.resize(j.kind.dofs().len(), 0.0); + let Some(q) = j.values.get(i).copied() else { continue 'trial }; + let mut turned = j.values.clone(); + if let Some(slot) = turned.get_mut(i) { + *slot = q + std::f64::consts::PI; + } + joints::clamp(j, &mut turned); + if turned.get(i).is_none_or(|v| (v - q - std::f64::consts::PI).abs() > 1e-9) { + continue 'trial; + } + j.values = turned; + } + let sol = joints::solve_holding_with_analysis(&doc, &st, &BTreeMap::new(), joints::Analysis::Placement); + if sol.conflicts.len() < before.conflicts.len() && !sol.conflicts.iter().any(|(c, ..)| *c == id) && sol.errors.len() <= before.errors.len() { + s.doc_mut().assembly.joints = doc.assembly.joints; + return true; + } + } + false +} + fn joint_origin(s: &mut Session, p: &Value) -> Result { let cmd = "joint.origin"; let origin = origin_param(s, Some(p), cmd)?; @@ -798,13 +927,6 @@ fn joint(s: &mut Session, p: &Value) -> Result { suppressed: false, exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) }, }; - // A relation given no values starts from where its parts are: B moves no further than it must. - if p.get("values").is_none() - && p.get("value").is_none() - && let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j) - { - j.values = q; - } // No angle given: B keeps its turn about the joint's axis where a half turn would otherwise // come only from how its origin's x was chosen. (An angle relation's angle is its tilt.) if angle.is_none() && !j.kind.is_relation() { @@ -982,7 +1104,7 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: & continue; } let angle = j.kind.is_angle(i); - let shown = |v: f64| if angle { v.to_degrees() } else { v }; + let shown = |v: f64| expr::round_reported(if angle { v.to_degrees() } else { v }); let name = names.get(i).copied().unwrap_or("value"); let why = if let Some(l) = s.doc.assembly.links.iter().find(|l| l.b == j.id && l.ib == i) { let coupling = match &l.coupling { @@ -1515,7 +1637,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { // A relation whose type, offset, tilt or flip changes starts again from where its parts are. let reseat = kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides; if reseat { - let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).cloned(); + let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned(); if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j)) && let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id) { diff --git a/crates/engine/src/cmd/joints_relations_tests.rs b/crates/engine/src/cmd/joints_relations_tests.rs index e858ea0f22..2c09d1eaec 100644 --- a/crates/engine/src/cmd/joints_relations_tests.rs +++ b/crates/engine/src/cmd/joints_relations_tests.rs @@ -60,6 +60,43 @@ fn clean(r: &Value) -> bool { r["conflicts"] == json!([]) && r["errors"] == json!([]) } +#[test] +fn closing_planar_joint_starts_from_the_current_placement() { + let mut s = Session::default(); + for name in ["Base", "Part"] { + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 6})); + run(&mut s, "component.activate", json!({"component": "root"})); + } + let base = occ(&s, "Base"); + run(&mut s, "occurrence.ground", json!({"occurrence": base, "grounded": true})); + let r = run( + &mut s, + "joint.create", + json!({"type": "planar", "name": "First", "flip": true, "angle": 0, + "a": {"occurrence": "Base", "point": [0, 0, 0], "x": [1, 0, 0]}, + "b": {"occurrence": "Part", "point": [0, 0, 0], "x": [1, 0, 0]}, "values": [50, 30, 120]}), + ); + assert!(clean(&r), "{r}"); + // Check a frame, so a turn as well as a translation is observable. + let points = [Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::Z]; + let before = points.map(|p| place(&s, "Part", p)); + let at = before[0]; + let x = before[1] - at; + let r = run( + &mut s, + "joint.create", + json!({"type": "planar", "name": "Closing", "flip": true, "angle": 0, + "a": {"occurrence": "Base", "point": [0, 0, 0], "x": [1, 0, 0]}, + "b": {"occurrence": "Part", "point": [at.x, at.y, at.z], "x": [x.x, x.y, x.z]}}), + ); + assert!(clean(&r), "{r}"); + for (p, want) in points.into_iter().zip(before) { + let got = place(&s, "Part", p); + assert!(near(got, want, 1e-6), "{p:?}: {got:?}, expected {want:?}"); + } +} + #[test] fn stacked_relations_place_a_part_and_follow_the_panel() { let mut s = panel(); diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 64abf18e20..44d38676f7 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -680,6 +680,104 @@ fn values_left_out_of_a_drive_follow_the_loop() { assert!(near(place(&s, "Plate", Vec3::ZERO), Vec3::new(10.0, 10.0, 0.0)), "{:?}", place(&s, "Plate", Vec3::ZERO)); } +/// Two boards 400 x 100 x 20 for #10331: yellow at the origin, grounded, and red 300 mm along Y. +fn boards() -> Session { + let mut s = Session::default(); + for (name, corner) in [("Yellow", [0.0, 0.0, 0.0]), ("Red", [0.0, 300.0, 0.0])] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name, "grounded": false})); + run(&mut s, "solid.box", json!({"length": 400, "width": 100, "height": 20, "corner": corner})); + } + let y = occ(&s, "Yellow"); + run(&mut s, "occurrence.ground", json!({"occurrence": y, "grounded": true})); + s +} + +/// #10331: a board on a planar joint, mated by a second planar joint at a face that points the +/// same way as the face it meets. It meets it turned a half turn on the first joint, the way the +/// first joint moves its part into place, in either pick order, and still slides along the +/// line the two planes share. Faces that can never meet stay a conflict, and nothing moves. +#[test] +fn a_second_planar_joint_turns_its_part_a_half_turn_to_meet() { + // Each case: the yellow face, the red face (world points once red lies on yellow, at + // x 0..400, y 0..100, z 20..40), and where red's origin ends up. + let cases = [ + ("fronts", [200.0, 0.0, 10.0], [200.0, 0.0, 30.0], Some(Vec3::new(400.0, 300.0, 20.0))), + ("ends", [0.0, 50.0, 10.0], [0.0, 50.0, 30.0], Some(Vec3::new(0.0, 400.0, 20.0))), + ("top against front", [200.0, 0.0, 10.0], [200.0, 50.0, 40.0], None), + ]; + for red_first in [false, true] { + for (case, yellow_face, red_face, expected) in cases { + let mut s = boards(); + let planar = |s: &mut Session, name: &str, yellow: [f64; 3], red: [f64; 3]| { + let (yellow, red) = (json!({"occurrence": "Yellow", "face": yellow}), json!({"occurrence": "Red", "face": red})); + let (a, b) = if red_first { (red, yellow) } else { (yellow, red) }; + run(s, "joint.create", json!({"type": "planar", "a": a, "b": b, "name": name})) + }; + let r = planar(&mut s, "Top", [200.0, 50.0, 20.0], [200.0, 350.0, 0.0]); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{case}: {r}"); + let on_top = place(&s, "Red", Vec3::ZERO); + let r = planar(&mut s, "Side", yellow_face, red_face); + let label = format!("{case}, red picked first: {red_first}"); + let Some(expected) = expected else { + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(1), "{label}: {r}"); + assert!(near(place(&s, "Red", Vec3::ZERO), on_top), "{label}: {:?}", place(&s, "Red", Vec3::ZERO)); + continue; + }; + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{label}: {r}"); + assert!(place(&s, "Red", Vec3::ZERO).dist(expected) < 1e-6, "{label}: {:?}", place(&s, "Red", Vec3::ZERO)); + assert_eq!(dof_of(&mut s, "Red"), 1, "{label}"); + let axes = solidcraft_doc::joints::motion_axes(&s.doc, &s.model.state(), occ(&s, "Red")); + assert!(axes.len() == 1 && !axes.iter().any(|a| a.angular), "{label}: {axes:?}"); + } + } +} + +/// #10331, with the board turned on the table first (the report's board was turned 40 degrees): +/// a second planar joint between any side of the yellow board and any side of the red one meets, +/// whichever turn the red board needs on the first joint to get there. +#[test] +fn any_two_sides_of_turned_boards_meet_on_a_second_planar_joint() { + // The sides' centres, the red ones where the red board was made. + let reds = [ + ("red front", Vec3::new(200.0, 300.0, 10.0)), + ("red back", Vec3::new(200.0, 400.0, 10.0)), + ("red left end", Vec3::new(0.0, 350.0, 10.0)), + ("red right end", Vec3::new(400.0, 350.0, 10.0)), + ]; + let yellows = [ + ("yellow front", [200.0, 0.0, 10.0]), + ("yellow back", [200.0, 100.0, 10.0]), + ("yellow left end", [0.0, 50.0, 10.0]), + ("yellow right end", [400.0, 50.0, 10.0]), + ]; + for turn in [40.0f64, 0.0, 137.0] { + for (yn, yp) in yellows { + for (rn, rl) in reds { + let mut s = boards(); + run( + &mut s, + "occurrence.move", + json!({"occurrence": "Red:1", "space": "world", "axis": [0, 0, 1], "angle": format!("{turn} deg"), "origin": [200, 350, 0]}), + ); + let top = place(&s, "Red", Vec3::new(200.0, 350.0, 0.0)); + run( + &mut s, + "joint.create", + json!({"type": "planar", "a": {"occurrence": "Yellow", "face": [200, 50, 20]}, "b": {"occurrence": "Red", "face": [top.x, top.y, top.z]}, "name": "Top"}), + ); + let rp = place(&s, "Red", rl); + let r = run( + &mut s, + "joint.create", + json!({"type": "planar", "a": {"occurrence": "Yellow", "face": yp}, "b": {"occurrence": "Red", "face": [rp.x, rp.y, rp.z]}, "name": "Side"}), + ); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "turned {turn} degrees, {yn} against {rn}: {r}"); + } + } + } +} + /// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. Returns the crank /// pin and the wrist pin. fn slider_crank() -> (Session, Vec3, Vec3) { @@ -2472,3 +2570,21 @@ fn driving_one_cylindrical_coordinate_preserves_the_other_expression_and_reports assert!(s.execute("joint.drive", &json!({"joint":joint,"index":bad,"value":0})).is_err()); } } + +/// #10747: driving a joint to 30 reports 30, not 29.999999999999996; the joint keeps +/// full precision. +#[test] +fn driving_an_angle_reports_the_number_a_person_would_read() { + let mut s = two_cubes(); + run( + &mut s, + "joint.create", + json!({"type": "revolute", "a": {"occurrence": "A", "point": [5.0, 5.0, 10.0]}, "b": {"occurrence": "B", "point": [25.0, 5.0, 10.0]}, "name": "Turn"}), + ); + let r = run(&mut s, "joint.drive", json!({"joint": "Turn", "value": 30})); + assert_eq!(r["values"][0]["value"], json!(30.0), "{r}"); + assert_eq!(r["requested"][0]["value"], json!(30.0), "{r}"); + // Only what is reported is rounded: the stored value keeps full precision. + let j = s.doc.assembly.joints.iter().find(|j| j.name == "Turn").unwrap(); + assert_ne!(j.values[0].to_degrees(), 30.0, "the stored angle keeps its bits"); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3880743956..a7d3ba5474 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj pub use features::{auto_operation, extrude_reach, shown_scope}; pub use file::open_steps::OpenSteps; pub use join_pieces::{Addition, addition}; +pub use parts::part_length_label; pub use sketch::dimension_roles; pub(crate) use sketch::face::face_pick_form; pub use sketch_construction::{ diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index 18dc2bff43..48182507bd 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -1303,6 +1303,54 @@ fn reparent_keeps_the_world_position_and_follows_paths() { /// A component can't go inside itself or what it holds. Out of a parent placed more than once it /// moves from the placement named, keeping that one's place in the world. +/// #11063: a part with a hole cut from a sketch on its disc's top face moves into another +/// component. The sketch's face attachment was found through the part's old placement path, +/// which no longer starts at the design once the part is inside the other component, and the +/// move was refused ("would break Sketch2"). The attachment follows the move, and the hole stays. +#[test] +fn reparent_carries_a_sketch_on_a_face_inside_the_moved_part() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Component1", "activate": false})); + run(&mut s, "component.create", json!({"name": "Component2"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Sketch1"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Sketch1", "distance": 3, "operation": "new"})); + run(&mut s, "sketch.create", json!({"plane": {"face": [0, 5, 3]}, "name": "Sketch2"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 3})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Sketch2", "distance": -3, "operation": "cut"})); + let volume = |s: &Session| s.world_state().bodies.iter().map(|b| b.mesh().measure().volume.abs()).sum::(); + let before = volume(&s); + let r = run(&mut s, "occurrence.reparent", json!({"occurrence": "Component2:1", "parent": "Component1"})); + assert_eq!(r["path"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "every feature still builds"); + assert!((volume(&s) - before).abs() < 1e-6, "the disc keeps its hole: {} vs {before}", volume(&s)); + let reopened = Session::new(crate::doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(reopened.model.results.iter().all(|r| r.error.is_none()), "and after reopening"); +} + +/// A parent placed twice is one definition: a sketch on the child's face made through the +/// second placement moves along when the child is moved out through the first one, to the +/// child's one new place. +#[test] +fn reparent_carries_a_face_sketch_made_through_another_placement_of_the_parent() { + let mut s = Session::default(); + let (parent, first_parent) = created(&mut s, "Assembly"); + let (_, child) = created(&mut s, "Knob"); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "Knob"})); + let second_parent = run(&mut s, "occurrence.copy", json!({"component": parent, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "sketch.create", json!({"plane": {"face": [25, 5, 10], "path": [second_parent, child]}, "name": "OnTop"})); + run(&mut s, "sketch.finish", json!({})); + let out = run(&mut s, "occurrence.reparent", json!({"occurrence": child, "parent": "root", "path": [first_parent, child]})); + assert_eq!(out["path"], json!([child])); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "the sketch still finds its face"); + let sketch = s.doc.features.iter().find(|f| f.name == "OnTop").unwrap(); + let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("OnTop is no sketch") }; + let anchor = crate::cmd::sketch::face::anchor(plane).unwrap(); + assert_eq!(anchor.path, vec![child], "{anchor:?}"); +} + #[test] fn reparent_refuses_cycles_and_moves_out_of_a_named_instance() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/offset_topology_tests.rs b/crates/engine/src/cmd/offset_topology_tests.rs index 1d418e1fa7..e6c792a848 100644 --- a/crates/engine/src/cmd/offset_topology_tests.rs +++ b/crates/engine/src/cmd/offset_topology_tests.rs @@ -107,3 +107,44 @@ fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity() assert_eq!(session.redo.len(), redo); } } + +/// #10984: a dog bone whose round ends touch at a cusp on each side. Drawn 2 mm outside, the +/// outline meets in each notch outside the bone, where the two ends' copies cross before +/// their ends. One notch ran both copies on to their far crossing instead, a loop into the +/// bone, as both crossings are as far from the notch. +#[test] +fn an_outline_outside_a_dog_bone_meets_outside_both_notches() { + let mut session = Session::default(); + run(&mut session, "sketch.create", json!({"plane":"XY","name":"Bone"})); + let sketch = session.active_sketch.unwrap(); + run(&mut session, "sketch.line", json!({"points":[[-10,-10],[10,-10]]})); + run(&mut session, "sketch.arc.center_point", json!({"center":[20,-10],"start":[10,-10],"end":[20,0]})); + run(&mut session, "sketch.arc.center_point", json!({"center":[20,10],"start":[20,0],"end":[10,10]})); + run(&mut session, "sketch.line", json!({"points":[[10,10],[-10,10]]})); + run(&mut session, "sketch.arc.center_point", json!({"center":[-20,10],"start":[-10,10],"end":[-20,0]})); + run(&mut session, "sketch.arc.center_point", json!({"center":[-20,-10],"start":[-20,0],"end":[-10,-10]})); + run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":2,"side":[0,-40]})); + let state = session.model.state(); + let sk = &state.sketch(sketch).unwrap().sketch; + let record = sk.offsets.last().unwrap(); + assert_eq!(record.outputs.len(), 6); + let mut notches: Vec = Vec::new(); + for id in &record.outputs { + let curve = &sk.curves[sk.curve_index(id).unwrap()]; + let (a, b) = match curve.kind { + solidcraft_sketch::CurveKind::Line { a, b } | solidcraft_sketch::CurveKind::Arc { a, b, .. } => (a, b), + _ => panic!("{id} is no line or arc"), + }; + for p in [sk.points[a].pos, sk.points[b].pos] { + if p.y.abs() < 1e-6 { + notches.push(p.x); + } + } + } + notches.sort_by(f64::total_cmp); + // The copies have radius 12 about centres 20 apart: they cross 12² − 10² = 44, √44 off + // the notch's line, outside it: x = ±(20 + √44). + let outside = 20.0 + 44f64.sqrt(); + assert_eq!(notches.len(), 4, "{notches:?}"); + assert!(notches.iter().zip([-outside, -outside, outside, outside]).all(|(x, want)| (x - want).abs() < 1e-6), "{notches:?}"); +} diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index d8b89d0e5e..cda10522b7 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -23,8 +23,8 @@ pub static COMMANDS: &[CommandSpec] = &[ activation; driven parameters and restrictions wait for a configuration edit or activation)", ), CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params( - "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ - source, feature/input, comment, favorite, uses, used_by, error) and cycles", + "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \ + reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles", ), CommandSpec::new("parameters.add", "Add User Parameter", add) .icon("new") @@ -177,8 +177,8 @@ fn change_param(s: &mut Session, p: &Value) -> Result { if let Some(t) = s.doc.text_param(&name) { out["text"] = json!(t.text); } - out["value"] = json!(vals.get(&name).map(|v| v.v)); - out["display_value"] = json!(s.doc.param_display_value(&vals, &name)); + out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v)))); + out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported)); out["recomputed"] = json!(s.model.last_recomputed); out["restored"] = json!(s.model.last_restored); out["errors"] = json!(failing(s)); @@ -280,7 +280,8 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) - } s.settle_value_edit(solved)?; let (vals, _) = s.doc.param_values(); - let values: serde_json::Map = list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| v.v)))).collect(); + let values: serde_json::Map = + list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect(); let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)}); if !switched.is_empty() { out["switched"] = json!(switched); @@ -343,7 +344,7 @@ fn add(s: &mut Session, p: &Value) -> Result { return Ok(json!({"name": name, "unit": solidcraft_doc::text_params::TEXT_UNIT, "text": t.text, "value": null})); } Ok( - json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| v.v), "display_value": s.doc.param_display_value(&vals, &name)}), + json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))), "display_value": s.doc.param_display_value(&vals, &name).map(expr::round_reported)}), ) } @@ -426,7 +427,9 @@ fn export(s: &mut Session, p: &Value) -> Result { .param_rows() .into_iter() .filter(|r| r.source == "user") - .map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.value, "comment": r.comment, "favorite": r.favorite})) + // The `value` field keeps the number in the parameter's own unit, as the CSV + // column does, rounded for reading (#10747). + .map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.display_value.or(r.value), "comment": r.comment, "favorite": r.favorite})) .collect(); serde_json::to_string_pretty(&rows).map_err(|e| bad(cmd, e.to_string()))? } @@ -501,7 +504,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result { (None, Kind::Angle) => v.to_degrees(), (None, _) => v, }; - json!({"ok": true, "value": v, "kind": k, "display_value": shown, "references": refs}) + json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs}) } Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}), }; @@ -526,7 +529,8 @@ fn complete(s: &mut Session, p: &Value) -> Result { // parameters, then the others. rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user")); for r in rows.into_iter().take(200) { - let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into()); + // The completion shows the parameter's own unit, so it reads `display_value`. + let v = r.display_value.or(r.value).map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into()); let mut detail = format!("{} = {v}", r.expression); if let (Some(f), Some(i)) = (&r.feature, r.input) { detail.push_str(&format!(" ({f} {i})")); diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 04642c6bc6..e4f3b16c68 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -60,7 +60,9 @@ fn width_follows_an_expression() { run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"})); assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9); let l = run(&mut s, "parameters.list", json!({})); - assert_eq!(row(&l, "len")["value"], 5.0, "{l}"); + // `value` is mm in every reply; the own-unit number is `display_value` (#10747). + assert_eq!(row(&l, "len")["value"], 127.0, "{l}"); + assert_eq!(row(&l, "len")["display_value"], 5.0, "{l}"); assert_eq!(row(&l, "len")["unit"], "in"); // A failing parameter fails the features using it, with its error. let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"})); @@ -389,7 +391,8 @@ fn text_parameter_imports_accept_mixed_case_units() { assert_eq!(label["comment"], "quoted label"); assert!(label["value"].is_null()); assert_eq!(row(&list, "width")["unit"], "in"); - assert_eq!(row(&list, "width")["value"], 1.0); + assert_eq!(row(&list, "width")["value"], 25.4); + assert_eq!(row(&list, "width")["display_value"], 1.0); assert_eq!(s.doc.param_values().0["width"].v, 25.4); } // Export still uses the canonical marker and round-trips the unquoted text. @@ -933,8 +936,8 @@ fn a_parameter_added_without_a_unit_takes_the_design_unit() { let r = run(s, "parameters.add", p); (r["unit"].as_str().unwrap_or("?").to_string(), r["value"].as_f64().unwrap_or(f64::NAN), r["display_value"].as_f64().unwrap_or(f64::NAN)) }; - let close = |got: (String, f64, f64), unit: &str, mm: f64, shown: f64| { - assert!(got.0 == unit && rel(got.1, mm) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {mm} mm ({shown})"); + let close = |got: (String, f64, f64), unit: &str, val: f64, shown: f64| { + assert!(got.0 == unit && rel(got.1, val) < 1e-9 && rel(got.2, shown) < 1e-9, "{got:?}, expected {unit} {val} ({shown})"); }; // A millimetre design is as it was. let mut s = Session::default(); @@ -951,8 +954,8 @@ fn a_parameter_added_without_a_unit_takes_the_design_unit() { // One that refers to another parameter: its bare number is an inch too. close(added(&mut s, json!({"name": "more", "expression": "v + 1"})), "in", 533.4, 21.0); close(added(&mut s, json!({"name": "half", "expression": "v / 2"})), "in", 254.0, 10.0); - // An angle stays in degrees, a unit-less count stays unit-less. - close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30f64.to_radians(), 30.0); + // An angle reports degrees like every other reply, a unit-less count stays unit-less. + close(added(&mut s, json!({"name": "turn", "expression": "30 deg"})), "deg", 30.0, 30.0); close(added(&mut s, json!({"name": "n", "expression": "4", "unit": ""})), "", 4.0, 4.0); close(added(&mut s, json!({"name": "twice", "expression": "n * 2"})), "", 8.0, 8.0); // parameters.change and parameters.import make a new parameter the same way. @@ -1195,3 +1198,51 @@ fn a_configuration_row_that_shrinks_a_dimensioned_line_to_a_point_is_refused() { run(&mut s, "config.activate", json!({"row": "Default"})); assert!((far_x(&s, feature) - 30.0).abs() < 1e-6); } + +/// #10747: `value` means millimetres (and degrees) in every reply — add, change and list — +/// with the parameter's own unit in `display_value`. Reported numbers carry no float noise; +/// stored values keep full precision. +#[test] +fn value_means_the_same_unit_in_every_reply() { + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + let a = run(&mut s, "parameters.add", json!({"name": "L", "expression": "2"})); + assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(50.8), Some(2.0)), "{a}"); + // The change reply is rounded too: 3 in is 76.2, not 76.19999999999999. + let c = run(&mut s, "parameters.change", json!({"name": "L", "expression": "3"})); + assert_eq!((c["value"].as_f64(), c["display_value"].as_f64()), (Some(76.2), Some(3.0)), "{c}"); + run(&mut s, "parameters.add", json!({"name": "d1", "expression": "L"})); + run(&mut s, "parameters.add", json!({"name": "d2", "expression": "1 in"})); + let l = run(&mut s, "parameters.list", json!({})); + // Every row's `value` is mm, whatever the parameter's own unit (#10747). + for (name, mm) in [("L", 76.2), ("d1", 76.2), ("d2", 25.4)] { + assert_eq!(row(&l, name)["value"], mm, "{name}: {l}"); + } + assert_eq!(row(&l, "L")["display_value"], 3.0, "{l}"); + assert_eq!(row(&l, "d2")["display_value"], 1.0, "{l}"); + // An angle reports degrees like every other reply, without the noise. + let a = run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"})); + assert_eq!((a["value"].as_f64(), a["display_value"].as_f64()), (Some(30.0), Some(30.0)), "{a}"); + // Only what is reported is rounded: the stored value keeps full precision — the raw + // own-unit conversion still says 29.999999999999996. + let (vals, _) = s.doc.param_values(); + assert_eq!(s.doc.param_display_value(&vals, "ang"), Some(29.999999999999996), "the stored angle keeps its bits"); +} + +/// #10747: exports carry the numbers a person would read, not float noise. The `value` +/// column keeps the number in the parameter's own unit, as it always did. +#[test] +fn exports_round_reported_numbers() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "ang", "expression": "30 deg"})); + run(&mut s, "parameters.add", json!({"name": "w", "expression": "1.2 in"})); + run(&mut s, "parameters.add", json!({"name": "s", "expression": "0.1 + 0.2"})); + let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string(); + assert!(csv.contains("ang,deg,30 deg,30,,false"), "{csv}"); + assert!(csv.contains("w,mm,1.2 in,30.48,,false"), "{csv}"); + assert!(csv.contains("s,mm,0.1 + 0.2,0.3,,false"), "{csv}"); + let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string(); + let rows: Vec = serde_json::from_str(&json_text).unwrap_or_default(); + let vals: Vec = rows.iter().filter_map(|r| r["value"].as_f64()).collect(); + assert_eq!(vals, vec![30.0, 30.48, 0.3], "{json_text}"); +} diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index 1a9c9a907d..692144050b 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -1,5 +1,6 @@ -//! Standard parts: ISO metric fasteners, dowel pins and ball bearings, generated in code from -//! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as +//! Standard parts: ISO metric and ASME/ANSI inch fasteners, dowel pins and ball bearings, +//! generated in code from the dimensions in the public standards tables (no vendor geometry). +//! Inch parts are tabled in inches and built in millimetres like the rest. Each part is inserted as //! its own component, built by ordinary features (so it stays parametric and editable), with a //! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the //! hole's and seats it on the face. Inserted at several holes it is still one component, placed @@ -16,20 +17,24 @@ use crate::params::{bad, num, str_, vec3}; use crate::{EngineError, Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("parts.library", "Standard Parts", library).icon("library") + CommandSpec::new("parts.library", "Standard Parts", library) + .icon("library") .noundo() - .params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"), - CommandSpec::new("parts.fastener", "Insert Fastener", insert_atomic).at("SOLID", "INSERT").icon("fastener").params( - "the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?", - ), + .params("family? → families (name, standard, system: metric|inch, sizes, lengths) and, with family, each size's dimensions (mm)"), + CommandSpec::new("parts.fastener", "Insert Fastener", insert_atomic) + .at("SOLID", "INSERT") + .icon("fastener") + .params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?"), CommandSpec::new("parts.insert", "Insert Part", insert_atomic).icon("fastener").params( - "family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \ - length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ + "family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \ + hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \ + \"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \ placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name? → component, occurrence, joint?, \ and with at: occurrences, joints (one per hole, in pick order)", ), - CommandSpec::new("parts.resize", "Change Part Size", resize_atomic).icon("scale") + CommandSpec::new("parts.resize", "Change Part Size", resize_atomic) + .icon("scale") .params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"), ]; @@ -43,6 +48,10 @@ const FAMILIES: &[(&str, &str, &str)] = &[ ("set_screw", "Hex socket set screw, flat point", "ISO 4026"), ("dowel_pin", "Parallel pin (dowel)", "ISO 8734"), ("bearing", "Deep groove ball bearing (envelope)", "ISO 15 (6000/6200/600 series)"), + ("hex_cap_screw_inch", "Hex cap screw (inch)", "ASME B18.2.1"), + ("hex_nut_inch", "Hex nut (inch)", "ASME B18.2.2"), + ("washer_sae", "Plain washer, SAE (narrow)", "ANSI B18.22.1 Type A"), + ("washer_uss", "Plain washer, USS (wide)", "ANSI B18.22.1 Type A"), ]; /// ISO metric coarse: (size, d, pitch). @@ -190,6 +199,127 @@ const BEARINGS: [(&str, f64, f64, f64); 13] = [ const LENGTHS: [f64; 22] = [4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 80.0, 90.0, 100.0, 120.0]; +/// Millimetres per inch. +const INCH: f64 = 25.4; + +/// ASME B18.2.1 hex cap screw, in inches: (nominal size, width across flats F, head height H). +/// 9/16 is left out until the thread catalogue has its UNC and UNF threads. +const INCH_HEX_CAP: [(&str, f64, f64); 9] = [ + ("1/4", 0.4375, 0.156), + ("5/16", 0.5, 0.203), + ("3/8", 0.5625, 0.234), + ("7/16", 0.625, 0.281), + ("1/2", 0.75, 0.312), + ("5/8", 0.9375, 0.391), + ("3/4", 1.125, 0.469), + ("7/8", 1.3125, 0.547), + ("1", 1.5, 0.609), +]; + +/// ASME B18.2.2 hex nut, in inches: (nominal size, width across flats F, thickness H). +const INCH_HEX_NUT: [(&str, f64, f64); 9] = [ + ("1/4", 0.4375, 0.219), + ("5/16", 0.5, 0.266), + ("3/8", 0.5625, 0.328), + ("7/16", 0.6875, 0.375), + ("1/2", 0.75, 0.438), + ("5/8", 0.9375, 0.547), + ("3/4", 1.125, 0.641), + ("7/8", 1.3125, 0.75), + ("1", 1.5, 0.859), +]; + +/// ANSI B18.22.1 Type A plain washers, in inches: (nominal size, inside diameter, outside +/// diameter, thickness). SAE is the standard's narrow (N) series, USS its wide (W) one. +const WASHER_SAE: [(&str, f64, f64, f64); 9] = [ + ("1/4", 0.281, 0.625, 0.065), + ("5/16", 0.344, 0.688, 0.065), + ("3/8", 0.406, 0.812, 0.065), + ("7/16", 0.469, 0.922, 0.065), + ("1/2", 0.531, 1.062, 0.095), + ("5/8", 0.656, 1.312, 0.095), + ("3/4", 0.812, 1.469, 0.134), + ("7/8", 0.938, 1.75, 0.134), + ("1", 1.062, 2.0, 0.134), +]; +const WASHER_USS: [(&str, f64, f64, f64); 9] = [ + ("1/4", 0.312, 0.734, 0.065), + ("5/16", 0.375, 0.875, 0.083), + ("3/8", 0.438, 1.0, 0.083), + ("7/16", 0.5, 1.25, 0.083), + ("1/2", 0.562, 1.375, 0.109), + ("5/8", 0.688, 1.75, 0.134), + ("3/4", 0.812, 2.0, 0.148), + ("7/8", 0.938, 2.25, 0.165), + ("1", 1.062, 2.5, 0.165), +]; + +/// Standard lengths for inch cap screws (inches). +const INCH_LENGTHS: [f64; 19] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 7.0, 8.0]; + +fn inch_lengths() -> Vec { + INCH_LENGTHS.iter().map(|l| l * INCH).collect() +} + +/// The first standard length at or above 2.5 d. Both sides are inches turned into mm, so the +/// comparison allows for rounding: a 1/2 screw's 2.5 d is exactly 1-1/4. +fn default_inch_length(d: f64) -> f64 { + inch_lengths().into_iter().find(|l| *l >= 2.5 * d - 1e-9).unwrap_or(8.0 * INCH) +} + +fn is_inch(family: &str) -> bool { + matches!(family, "hex_cap_screw_inch" | "hex_nut_inch" | "washer_sae" | "washer_uss") +} + +/// A unified inch thread from the catalogue (`1/2-13 UNC`): its nominal size (`1/2`), and its +/// major diameter and pitch (mm). +fn unified(size: &str) -> Option<(String, f64, f64)> { + use solidcraft_doc::TappedThreadFamily::{Unc, Unf}; + let t = solidcraft_doc::parse_tapped_thread(size).filter(|t| matches!(t.family, Unc | Unf))?; + let nominal = t.designation.split('-').next()?.to_string(); + Some((nominal, t.major_diameter, t.pitch)) +} + +/// The catalogue's threads for a nominal inch size, UNC before UNF. +fn unified_sizes(nominal: &str) -> Vec { + use solidcraft_doc::TappedThreadFamily::{Unc, Unf}; + [Unc, Unf].into_iter().flat_map(solidcraft_doc::tapped_thread_sizes).filter(|t| t.designation.split('-').next() == Some(nominal)).collect() +} + +/// A length (mm) in fractional inches to the nearest 1/64 (`1-1/2`), or none off that grid. +fn inch_fraction(mm: f64) -> Option { + let n = mm / INCH * 64.0; + let r = n.round(); + if !r.is_finite() || r <= 0.0 || r > 1e6 || (n - r).abs() > 1e-6 { + return None; + } + let r = r as u64; + let (whole, mut num, mut den) = (r / 64, r % 64, 64); + while num > 0 && num % 2 == 0 { + num /= 2; + den /= 2; + } + Some(match (whole, num) { + (w, 0) => w.to_string(), + (0, n) => format!("{n}/{den}"), + (w, n) => format!("{w}-{n}/{den}"), + }) +} + +/// How a part's length (mm) is shown where its family has a unit of its own: `1-1/2 in` for an +/// inch family, none for a metric one (shown in the design's units). +pub fn part_length_label(family: &str, mm: f64) -> Option { + is_inch(family).then(|| inch_fraction(mm)).flatten().map(|f| format!("{f} in")) +} + +/// A part's size with its length, as names and part numbers write it: `M6x20`, `1/2-13 UNC x 2`. +fn size_with_length(family: &str, size: &str, len: f64) -> String { + match is_inch(family).then(|| inch_fraction(len)).flatten() { + Some(l) => format!("{size} x {l}"), + None => format!("{size}x{len}"), + } +} + fn metric(size: &str) -> Option<(f64, f64)> { METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p)) } @@ -202,9 +332,11 @@ fn default_length(d: f64) -> f64 { #[derive(Clone, Debug, PartialEq)] enum Shape { /// A head (round, domed or hex), a shank down −z, an optional hex socket in the head's top. - Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> }, + /// `pitch` is that of a metric cosmetic thread; inch parts have none, as `solid.thread` takes + /// only metric designations (#9571). + Screw { d: f64, pitch: Option, len: f64, head: Head, socket: Option<(f64, f64)> }, /// A hex prism with a through hole (nut), z 0…m. - Nut { d: f64, pitch: f64, s: f64, m: f64 }, + Nut { d: f64, pitch: Option, s: f64, m: f64 }, /// A ring (washer, bearing envelope), z 0…h. Ring { d_in: f64, d_out: f64, h: f64 }, /// A cylinder down −z with a socket in its top (set screw), or plain (pin). @@ -237,22 +369,43 @@ fn shape(family: &str, size: &str, length: Option) -> std::result::Result { let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?; - Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) } + Shape::Screw { d, pitch: Some(pitch), len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) } } "button_head_cap_screw" => { let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; let (_, dk, k, s, t) = BHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7380-1"))?; - Shape::Screw { d, pitch, len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) } + Shape::Screw { d, pitch: Some(pitch), len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) } } "hex_bolt" => { let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?; - Shape::Screw { d, pitch, len: len(d), head: Head::Hex { s, k }, socket: None } + Shape::Screw { d, pitch: Some(pitch), len: len(d), head: Head::Hex { s, k }, socket: None } } "hex_nut" => { let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; let (_, s, m) = HEX_NUT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4032"))?; - Shape::Nut { d, pitch, s, m } + Shape::Nut { d, pitch: Some(pitch), s, m } + } + "hex_cap_screw_inch" => { + let (nominal, d, _) = unified(size).ok_or_else(|| need("UNC/UNF"))?; + let (_, f, h) = INCH_HEX_CAP.iter().find(|r| r.0 == nominal).copied().ok_or_else(|| need("ASME B18.2.1"))?; + Shape::Screw { + d, + pitch: None, + len: length.unwrap_or_else(|| default_inch_length(d)), + head: Head::Hex { s: f * INCH, k: h * INCH }, + socket: None, + } + } + "hex_nut_inch" => { + let (nominal, d, _) = unified(size).ok_or_else(|| need("UNC/UNF"))?; + let (_, f, h) = INCH_HEX_NUT.iter().find(|r| r.0 == nominal).copied().ok_or_else(|| need("ASME B18.2.2"))?; + Shape::Nut { d, pitch: None, s: f * INCH, m: h * INCH } + } + "washer_sae" | "washer_uss" => { + let table = if family == "washer_sae" { &WASHER_SAE } else { &WASHER_USS }; + let (_, d1, d2, h) = table.iter().find(|r| r.0 == size.trim()).copied().ok_or_else(|| need("ANSI B18.22.1"))?; + Shape::Ring { d_in: d1 * INCH, d_out: d2 * INCH, h: h * INCH } } "washer" => { let (_, d1, d2, h) = WASHER.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7089"))?; @@ -390,12 +543,16 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> { if let Some((af, t)) = socket { hex(s, af, k, k - t, "cut")?; } - thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, -len / 2.0)); + if let Some(p) = pitch { + thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0)); + } } Shape::Nut { d, pitch, s: af, m } => { hex(s, af, 0.0, m, "new")?; cyl(s, d, -1.0, m + 1.0, "cut")?; - thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, m / 2.0)); + if let Some(p) = pitch { + thread(s, d, p, Vec3::new(d / 2.0, 0.0, m / 2.0)); + } } Shape::Ring { d_in, d_out, h } => { revolve(s, &[(d_in / 2.0, 0.0), (d_out / 2.0, 0.0), (d_out / 2.0, h), (d_in / 2.0, h)], "new")?; @@ -424,11 +581,26 @@ fn library(_s: &mut Session, p: &Value) -> Result { "set_screw" => SET_SCREW.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "socket": r.1, "socket_depth": r.2})).collect(), "dowel_pin" => DOWEL.iter().map(|d| json!({"size": format!("{d}"), "d": d})).collect(), "bearing" => BEARINGS.iter().map(|r| json!({"size": r.0, "bore": r.1, "outer": r.2, "width": r.3})).collect(), + "hex_cap_screw_inch" => INCH_HEX_CAP + .iter() + .flat_map(|r| unified_sizes(r.0).into_iter().map(move |t| json!({"size": t.designation, "d": t.major_diameter, "pitch": t.pitch, "across_flats": r.1 * INCH, "head_height": r.2 * INCH}))) + .collect(), + "hex_nut_inch" => INCH_HEX_NUT + .iter() + .flat_map(|r| unified_sizes(r.0).into_iter().map(move |t| json!({"size": t.designation, "d": t.major_diameter, "pitch": t.pitch, "across_flats": r.1 * INCH, "height": r.2 * INCH}))) + .collect(), + "washer_sae" | "washer_uss" => { + let table = if id == "washer_sae" { &WASHER_SAE } else { &WASHER_USS }; + table.iter().map(|r| json!({"size": r.0, "inner": r.1 * INCH, "outer": r.2 * INCH, "thickness": r.3 * INCH})).collect() + } _ => Vec::new(), }; let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", "")); - let lengths = - matches!(id, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec()); + let lengths = match id { + "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin" => Some(LENGTHS.to_vec()), + "hex_cap_screw_inch" => Some(inch_lengths()), + _ => None, + }; if lengths.is_some() { for row in &mut sizes { let Some(size) = row.get("size").and_then(Value::as_str).map(str::to_string) else { continue }; @@ -438,7 +610,8 @@ fn library(_s: &mut Session, p: &Value) -> Result { } } } - json!({"family": id, "name": name, "standard": standard, "sizes": sizes, "lengths": lengths}) + let system = if is_inch(id) { "inch" } else { "metric" }; + json!({"family": id, "name": name, "standard": standard, "system": system, "sizes": sizes, "lengths": lengths}) }; match str_(p, "family") { Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)), @@ -449,16 +622,17 @@ fn library(_s: &mut Session, p: &Value) -> Result { fn part_name(family: &str, size: &str, sh: &Shape) -> String { match sh { - Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {size}x{len}", label(family)), + Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {}", label(family), size_with_length(family, size, *len)), _ => format!("{} {size}", label(family)), } } -/// A standard part's part number and description: its standard and size (`ISO 4762 M6x20`), or -/// a bearing's designation, and the family's name with the size. +/// A standard part's part number and description: its standard and size (`ISO 4762 M6x20`, +/// `ASME B18.2.1 1/2-13 UNC x 2`, an inch washer with its series: `ANSI B18.22.1 1/2 N`), or a +/// bearing's designation, and the family's name with the size. fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) { let size = match sh { - Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{size}x{len}"), + Shape::Screw { len, .. } | Shape::Rod { len, .. } => size_with_length(family, size, *len), _ => size.to_string(), }; let standard = match family { @@ -469,10 +643,18 @@ fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) { "washer" => "ISO 7089", "set_screw" => "ISO 4026", "dowel_pin" => "ISO 8734", + "hex_cap_screw_inch" => "ASME B18.2.1", + "hex_nut_inch" => "ASME B18.2.2", + "washer_sae" | "washer_uss" => "ANSI B18.22.1", + _ => "", + }; + let series = match family { + "washer_sae" => " N", + "washer_uss" => " W", _ => "", }; let name = FAMILIES.iter().find(|f| f.0 == family).map_or("Part", |f| f.1); - let part_number = if standard.is_empty() { size.clone() } else { format!("{standard} {size}") }; + let part_number = if standard.is_empty() { size.clone() } else { format!("{standard} {size}{series}") }; (part_number, format!("{name} {size}")) } @@ -503,6 +685,10 @@ fn label(family: &str) -> &'static str { "washer" => "Washer", "set_screw" => "Set Screw", "dowel_pin" => "Dowel Pin", + "hex_cap_screw_inch" => "Hex Cap Screw", + "hex_nut_inch" => "Hex Nut", + "washer_sae" => "SAE Washer", + "washer_uss" => "USS Washer", _ => "Bearing", } } @@ -777,6 +963,93 @@ mod tests { } } + /// #10316: ASME/ANSI inch hex cap screws, hex nuts and SAE/USS washers, tabled in inches and + /// built in millimetres, with sizes from the UNC/UNF thread catalogue and fractional-inch names. + #[test] + fn inch_parts_follow_the_asme_and_ansi_tables() { + const IN: f64 = 25.4; + let across_corners = |af: f64| af / (std::f64::consts::PI / 6.0).cos(); + let part_number = |s: &Session, out: &Value| { + let comp = out["component"].as_u64().unwrap_or(0); + s.doc.components.iter().find(|c| c.id == comp).and_then(|c| c.metadata.text(solidcraft_doc::metadata::PART_NUMBER)).map(str::to_string) + }; + // Every nominal size 1/4 to 1 but 9/16, coarse and fine. + let l = run(&mut Session::default(), "parts.library", json!({"family": "hex_cap_screw_inch"})); + let sizes: Vec<&str> = l["sizes"].as_array().map(|a| a.iter().filter_map(|x| x["size"].as_str()).collect()).unwrap_or_default(); + assert_eq!(sizes.len(), 18, "{sizes:?}"); + assert_eq!(sizes[..2], ["1/4-20 UNC", "1/4-28 UNF"]); + assert_eq!(sizes[16..], ["1-8 UNC", "1-12 UNF"]); + assert_eq!((l["standard"].as_str(), l["system"].as_str()), (Some("ASME B18.2.1"), Some("inch"))); + let all = run(&mut Session::default(), "parts.library", json!({})); + let inch: Vec<&str> = all["families"] + .as_array() + .map(|a| a.iter().filter(|f| f["system"] == "inch").filter_map(|f| f["family"].as_str()).collect()) + .unwrap_or_default(); + assert_eq!(inch, ["hex_cap_screw_inch", "hex_nut_inch", "washer_sae", "washer_uss"]); + let lengths: Vec = l["lengths"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + assert_eq!((lengths.first().copied(), lengths.last().copied(), lengths.len()), (Some(0.5 * IN), Some(8.0 * IN), 19)); + // A 1/2-13 x 2 cap screw: 3/4 across flats, head 0.312 high, shank 2 in long, no thread mark. + let mut s = Session::default(); + let out = run(&mut s, "parts.insert", json!({"family": "hex_cap_screw_inch", "size": "1/2-13 UNC", "length": 2.0 * IN})); + assert_eq!(out["name"], "Hex Cap Screw 1/2-13 UNC x 2"); + assert_eq!(part_number(&s, &out).as_deref(), Some("ASME B18.2.1 1/2-13 UNC x 2")); + let (lo, hi) = bbox(&s, "Hex Cap Screw 1/2-13 UNC x 2"); + let w = (hi.x - lo.x).max(hi.y - lo.y); + assert!((w - across_corners(0.75 * IN)).abs() < 1e-6 && (lo.z + 2.0 * IN).abs() < 1e-6 && (hi.z - 0.312 * IN).abs() < 1e-6, "{lo:?} {hi:?}"); + assert!(s.model.state().threads.is_empty(), "solid.thread takes only metric designations (#9571)"); + // Its default length is the first standard length at or above 2.5 d: 1-1/4 for a 1/2. + let mut d = Session::default(); + assert_eq!( + run(&mut d, "parts.insert", json!({"family": "hex_cap_screw_inch", "size": "1/2-20 UNF"}))["name"], + "Hex Cap Screw 1/2-20 UNF x 1-1/4" + ); + // A 1/2-13 nut: 3/4 across flats, 0.438 thick, with the 1/2 in hole through it. + let mut n = Session::default(); + let out = run(&mut n, "parts.insert", json!({"family": "hex_nut_inch", "size": "1/2-13 UNC"})); + assert_eq!(part_number(&n, &out).as_deref(), Some("ASME B18.2.2 1/2-13 UNC")); + let (lo, hi) = bbox(&n, "Hex Nut 1/2-13 UNC"); + let (w, h) = ((hi.x - lo.x).max(hi.y - lo.y), hi.z - lo.z); + assert!((w - across_corners(0.75 * IN)).abs() < 1e-6 && (h - 0.438 * IN).abs() < 1e-6, "{w} {h}"); + // 1/2 washers: SAE 1.062 x 0.095, USS 1.375 x 0.109, the series in the part number. + for (family, label, od, t, number) in [ + ("washer_sae", "SAE Washer 1/2", 1.062, 0.095, "ANSI B18.22.1 1/2 N"), + ("washer_uss", "USS Washer 1/2", 1.375, 0.109, "ANSI B18.22.1 1/2 W"), + ] { + let mut w = Session::default(); + let out = run(&mut w, "parts.insert", json!({"family": family, "size": "1/2"})); + assert_eq!(part_number(&w, &out).as_deref(), Some(number)); + let (lo, hi) = bbox(&w, label); + assert!((hi.x - lo.x - od * IN).abs() < 1e-6 && (hi.z - lo.z - t * IN).abs() < 1e-6, "{family}: {lo:?} {hi:?}"); + } + // Resizing keeps it inch, and sizes the tables don't have are refused. + let r = run(&mut s, "parts.resize", json!({"component": "Hex Cap Screw 1/2-13 UNC x 2", "size": "3/8-16 UNC", "length": 1.0 * IN})); + assert_eq!(r["name"], "Hex Cap Screw 3/8-16 UNC x 1"); + for (family, size) in [ + ("hex_cap_screw_inch", "9/16-12 UNC"), + ("hex_cap_screw_inch", "#10-24 UNC"), + ("hex_cap_screw_inch", "M12"), + ("hex_cap_screw_inch", "1/2-20 UNC"), + ("hex_nut_inch", "1/2"), + ("washer_sae", "9/16"), + ("washer_uss", "1/2-13 UNC"), + ] { + assert!(Session::default().execute("parts.insert", &json!({"family": family, "size": size})).is_err(), "{family} {size}"); + } + } + + #[test] + fn inch_lengths_read_as_fractions() { + assert_eq!(super::part_length_label("hex_cap_screw_inch", 1.5 * 25.4).as_deref(), Some("1-1/2 in")); + assert_eq!(super::part_length_label("hex_cap_screw_inch", 0.75 * 25.4).as_deref(), Some("3/4 in")); + assert_eq!(super::part_length_label("hex_cap_screw_inch", 8.0 * 25.4).as_deref(), Some("8 in")); + // Off the 1/64 grid, or a metric family: shown as millimetres in the design's units. + assert_eq!(super::part_length_label("hex_cap_screw_inch", 20.0), None); + assert_eq!(super::part_length_label("socket_head_cap_screw", 25.4), None); + for bad in [f64::NAN, f64::INFINITY, -25.4, 0.0, 1e300] { + assert_eq!(super::part_length_label("hex_cap_screw_inch", bad), None, "{bad}"); + } + } + /// #7692: a button head cap screw (ISO 7380-1) takes the same parameters as a socket head /// cap screw, with a domed crown in place of the cylindrical head. #[test] @@ -955,6 +1228,10 @@ mod tests { ("set_screw", "M6", "M8"), ("dowel_pin", "6", "8"), ("bearing", "608", "6200"), + ("hex_cap_screw_inch", "1/2-13 UNC", "5/8-11 UNC"), + ("hex_nut_inch", "1/2-13 UNC", "5/8-18 UNF"), + ("washer_sae", "1/2", "5/8"), + ("washer_uss", "1/2", "5/8"), ] { let mut s = Session::default(); let part = run(&mut s, "parts.insert", json!({"family": family, "size": size})); diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index 2ea9aa1284..658f413045 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -240,27 +240,79 @@ fn circular_axis_origin_rejects_invalid_values_atomically() { run(&mut s, "parameters.add", json!({"name":"cy","expression":"25 mm"})); let before = s.doc.to_json(); let undo = s.undo.len(); - for origin in [ - json!(["cx", "cy", 0]), - json!(["50", 25, 0]), - json!([0, 0]), - json!([0, 0, 0, 0]), - json!(null), - json!(false), - json!({"x":0,"y":0}), - json!([1e9, 0, 0]), - json!([0, 1e100, 0]), - ] { - for (cmd, params) in [ - ("solid.pattern.circular", json!({"features":["Hole1"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4})), - ("construct.plane.at_angle", json!({"base":"XY","axis":{"origin":origin,"dir":[1,0,0]},"angle":45})), - ] { - let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); - assert!(error.contains("axis.origin") && error.contains("expressions are not supported"), "{error}"); - assert_eq!(s.doc.to_json(), before); - assert_eq!(s.undo.len(), undo); - } + let invalid = [json!([0, 0]), json!([0, 0, 0, 0]), json!(null), json!(false), json!({"x":0,"y":0}), json!([1e9, 0, 0]), json!([0, 1e100, 0])]; + // A circular pattern's origin takes length expressions (#10852); an angled plane's doesn't. + for origin in invalid.iter().chain(&[json!(["nope", 0, 0]), json!(["2 deg", 0, 0])]) { + let params = json!({"features":["Hole1"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4}); + let error = s.execute("solid.pattern.circular", ¶ms).unwrap_err().to_string(); + assert!(error.contains("axis.origin"), "{origin}: {error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); } + for origin in invalid.iter().chain(&[json!(["cx", "cy", 0]), json!(["50", 25, 0])]) { + let params = json!({"base":"XY","axis":{"origin":origin,"dir":[1,0,0]},"angle":45}); + let error = s.execute("construct.plane.at_angle", ¶ms).unwrap_err().to_string(); + assert!(error.contains("axis.origin") && error.contains("expressions are not supported"), "{error}"); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); + } +} + +/// #10852: a circular pattern about `{origin, dir}` with length expressions in the origin turns +/// about the point they give now, so the copies follow the parameters as the seed does. The +/// expressions are saved, renamed with their parameter, and can be set by `timeline.edit`. +#[test] +fn circular_axis_origin_expressions_follow_their_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name":"cx","expression":"50 mm"})); + run(&mut s, "parameters.add", json!({"name":"cy","expression":"40 mm"})); + // A 2 mm cube 20 mm from (cx, cy), turned about the vertical line there. + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":["cx + 20 mm","cy",0],"body_name":"Cube"})); + run(&mut s, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"origin":["cx","cy",0],"dir":[0,0,1]},"count":4})); + // The x and y extent of the four cubes: 22 mm either side of the axis while it is 20 mm away. + let span = |s: &Session| { + let st = s.model.state(); + assert_eq!(st.bodies.len(), 4); + let mut out = [f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY]; + for b in st.bodies.iter().filter_map(|mb| solidcraft_kernel::measure(&mb.body).ok()).map(|m| m.bbox) { + out = [out[0].min(b.min.x), out[1].max(b.max.x), out[2].min(b.min.y), out[3].max(b.max.y)]; + } + out + }; + let about_at = |s: &Session, x: f64, y: f64, half: f64| { + for (got, want) in span(s).into_iter().zip([x - half, x + half, y - half, y + half]) { + assert!((got - want).abs() < 1e-6, "{:?} is not {half} mm either side of ({x}, {y})", span(s)); + } + }; + let about = |s: &Session, x: f64, y: f64| about_at(s, x, y, 22.0); + // The origin as the pattern saves it. + let saved_origin = |s: &Session| match s.doc.features.last().map(|f| &f.kind) { + Some(solidcraft_doc::FeatureKind::Pattern { pattern, .. }) => serde_json::to_value(pattern).unwrap_or_default()["origin"].clone(), + other => panic!("not a pattern: {other:?}"), + }; + about(&s, 50.0, 40.0); + assert_eq!(saved_origin(&s), json!(["cx", "cy", 0.0]), "the expressions are saved"); + run(&mut s, "parameters.change", json!({"name":"cx","expression":"60 mm"})); + about(&s, 60.0, 40.0); + // Renaming a parameter renames it in the origin, and the pattern still follows it. + run(&mut s, "parameters.rename", json!({"name":"cy","new_name":"py"})); + run(&mut s, "parameters.change", json!({"name":"py","expression":"45 mm"})); + about(&s, 60.0, 45.0); + assert_eq!(saved_origin(&s), json!(["cx", "py", 0.0])); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap_or_else(|e| panic!("{e}"))); + about(&reopened, 60.0, 45.0); + // timeline.edit sets the origin as expressions too: 10 mm back, so the cube is 30 mm away. + let pattern = s.doc.features.last().map(|f| f.id).unwrap_or_default(); + run(&mut s, "timeline.edit", json!({"feature":pattern,"set":{"pattern.origin":["cx - 10 mm","py","0 mm"]}})); + about_at(&s, 50.0, 45.0, 32.0); + run(&mut s, "edit.undo", json!({})); + about(&s, 60.0, 45.0); + // Plain numbers are saved as numbers, as before. + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[70,40,0],"body_name":"Cube"})); + run(&mut s, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"origin":[50,40,0],"dir":[0,0,1]},"count":4})); + about(&s, 50.0, 40.0); + assert_eq!(saved_origin(&s), json!([50.0, 40.0, 0.0])); } #[test] @@ -1103,3 +1155,51 @@ fn editing_a_rolled_back_pattern_says_it_is_not_built() { assert_eq!(reply["rolled_back"], json!(true), "{reply}"); assert_eq!(reply["warning"], "1 of 1 cut copies remove no material", "{reply}"); } + +/// A 100 x 60 x 30 box shelled 5 mm (floor included) with a 20 x 11 boss joined to its +Y wall: +/// 67,500 mm³ hollow, 73,500 mm³ with the boss, which adds 6,000 mm³ (#6452). +fn hollow_box_with_join_boss() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -30], "p1": [50, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Base", "distance": 30, "operation": "new", "name": "Block", "body_name": "Box"})); + run(&mut s, "solid.shell", json!({"faces": [[0, 0, 30]], "thickness": 5, "name": "Shell"})); + assert!(rel(volume(&mut s), 67500.0) < 1e-6, "hollow box"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"})); + run(&mut s, "sketch.line", json!({"closed": true, "points": [[20, 29], [40, 29], [40, 40], [20, 40]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": 30, "operation": "join", "targets": ["Box"], "name": "Join Boss"})); + assert!(rel(volume(&mut s), 73500.0) < 1e-6, "joined boss"); + s +} + +/// The copy of a join is built where the source stands, and the source's own material used to +/// read as the target already filling the copy's bottom band: the mirrored boss lost its +/// 20 x 10 x 5 mm floor-level slice with no warning (#6452). +#[test] +fn mirror_of_a_join_on_a_hollow_part_copies_the_whole_boss() { + let mut s = hollow_box_with_join_boss(); + run(&mut s, "solid.mirror", json!({"features": ["Join Boss"], "plane": "XZ", "name": "Mirror Boss"})); + assert!(rel(volume(&mut s), 79500.0) < 1e-6, "mirrored copy lost material"); +} + +#[test] +fn mirror_of_a_join_on_a_hollow_part_copies_the_whole_boss_across_a_custom_plane() { + let mut s = hollow_box_with_join_boss(); + run(&mut s, "solid.mirror", json!({"features": ["Join Boss"], "plane": {"origin": [0, 0, 0], "normal": [0, 1, 0]}, "name": "Mirror Boss"})); + assert!(rel(volume(&mut s), 79500.0) < 1e-6, "mirrored copy lost material"); +} + +/// A pure translation loses the same slice: the copy must be congruent to the original, +/// whatever filled the space where its tool was rebuilt (#6452). +#[test] +fn rectangular_pattern_of_a_join_on_a_hollow_part_copies_the_whole_boss() { + let mut s = hollow_box_with_join_boss(); + run( + &mut s, + "solid.pattern.rectangular", + json!({"features": ["Join Boss"], "dir1": [0, -1, 0], "count1": 2, "spacing1": 69, "name": "Pattern Boss"}), + ); + assert!(rel(volume(&mut s), 79500.0) < 1e-6, "patterned copy lost material"); +} diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index ebae74fa2d..15fb8d92a2 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -24,7 +24,7 @@ fn section(s: &mut Session, p: &Value) -> Result { if bool_(p, "clear").unwrap_or(false) { s.section = None; s.status_slot(crate::status_items::StatusId::Section, false); - s.revision += 1; + s.refresh_display(); return serde_json::to_value(contract::SectionResult { section: None, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string())); } // A named plane's world axis: which component of a point lies along its normal. @@ -82,7 +82,7 @@ fn section(s: &mut Session, p: &Value) -> Result { s.section_hatch = bool_(p, "hatch").unwrap_or(true); s.status_slot(crate::status_items::StatusId::Section, true); s.status_activated(crate::status_items::StatusId::Section); - s.revision += 1; + s.refresh_display(); let section = contract::Section { origin: [origin.x, origin.y, origin.z], normal: [normal.x, normal.y, normal.z], diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index dcb9862579..ce74fff2c3 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -56,7 +56,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .contract(&PERFORATE_CONTRACT) .output(output_schema_for::) .params( - "diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)", + "diameter, pitch: expr; margin?: expr (clear of edges, cut-outs, bends and the outline; default: the diameter); pattern?: staggered (60°, default) | square; body?; sketch? + profiles? (the outline: closed profiles of a sketch on a face of the sheet; holes only inside them, out of their inner loops) - a perforation field over the whole flat sheet or inside the outline: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)", ), CommandSpec::new("sheet.hem", "Hem", crate::checked!("sheet.hem", hem)) .at("SHEET METAL", "CREATE") @@ -472,7 +472,14 @@ fn perforate(s: &mut Session, p: &Value) -> Result { check_expr(s, m, Kind::Length, cmd, "margin")?; } let pattern = str_(p, "pattern").map(|t| perforation_pattern(t).map(str::to_string).map_err(|why| bad(cmd, why))).transpose()?; - add_feature(s, p, FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body: str_(p, "body").map(BodyName::from) }) + // The outline: only a sketch named here, never the active one. + let sketch = match p.get("sketch") { + Some(_) => Some(SketchId(feature_sketch(s, p, cmd)?)), + None if p.get("profiles").is_some() => return Err(bad(cmd, "`profiles` picks the outline's profiles: it needs `sketch`")), + None => None, + }; + let profiles = p.get("profiles").map(|_| profiles(p, cmd)).transpose()?; + add_feature(s, p, FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body: str_(p, "body").map(BodyName::from), sketch, profiles }) } fn hem(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/sheet_convert_tests.rs b/crates/engine/src/cmd/sheet_convert_tests.rs index 1618b1a24e..9ce377d39b 100644 --- a/crates/engine/src/cmd/sheet_convert_tests.rs +++ b/crates/engine/src/cmd/sheet_convert_tests.rs @@ -1777,3 +1777,80 @@ fn a_wall_on_part_of_an_edge_meets_a_wall_at_a_relieved_corner_and_is_relieved_a run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - v).abs() < 1e-6 * v); } + +/// A tray of this build's own sheet metal: a 120 x 90 base, T 1, R 1, K 0.5, gap 0.6, its four +/// walls 30 high under the rule's corner relief `shape`, made in one edge flange feature or in +/// two (long walls, then short), written to a STEP and inserted into a new design as a plain +/// solid. Its bends are picked at mid-length on their outer cylinders, (R + T) out from their +/// axes 45° round from the base. Returns the new design (with the rule), the picks and the +/// design the tray was made in. +fn tray_round_trip(shape: &str, two: bool) -> (Session, Vec<[f64; 3]>, Session) { + let (t, r) = (1.0, 1.0); + let rule = json!({"name": "T", "thickness": "1 mm", "k_factor": "0.5", "bend_radius": "1 mm", "gap": "0.6 mm", "corner_relief_shape": shape, "corner_relief": "3 mm", "active": true}); + let mut a = Session::default(); + run(&mut a, "sheet.manage_rules", rule.clone()); + run(&mut a, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut a, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})); + run(&mut a, "sketch.finish", json!({})); + run(&mut a, "sheet.flange", json!({"type": "base", "sketch": "Base", "rule": "T"})); + let (long, short) = (json!([[60, 0, 1], [60, 90, 1]]), json!([[120, 45, 1], [0, 45, 1]])); + if two { + run(&mut a, "sheet.flange", json!({"edges": long, "height": 30})); + run(&mut a, "sheet.flange", json!({"edges": short, "height": 30})); + } else { + let mut all = long.as_array().cloned().unwrap_or_default(); + all.extend(short.as_array().cloned().unwrap_or_default()); + run(&mut a, "sheet.flange", json!({"edges": all, "height": 30})); + } + let path = std::env::temp_dir().join(format!("solidcraft-tray-round-trip-{shape}-{two}-{}.step", std::process::id())); + run(&mut a, "file.export", json!({"path": path.to_string_lossy()})); + let mut s = Session::default(); + run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + run(&mut s, "sheet.manage_rules", rule); + // Each bend's axis lies R + T in from the base's edge and R + T above its bottom face. + let (o, d) = (r + t, (r + t) * std::f64::consts::FRAC_1_SQRT_2); + let picks = vec![[60.0, o - d, o - d], [60.0, 90.0 - o + d, o - d], [120.0 - o + d, 45.0, o - d], [o - d, 45.0, o - d]]; + (s, picks, a) +} + +/// The flat's outline area and its bends' lengths, longest first. +fn flat_area_and_bends(s: &mut Session) -> (f64, Vec) { + let f = flat(s); + let ring = |l: &Value| { + let p: Vec<(f64, f64)> = + l.as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN))).collect(); + (0..p.len()).map(|i| p[i].0 * p[(i + 1) % p.len()].1 - p[(i + 1) % p.len()].0 * p[i].1).sum::().abs() / 2.0 + }; + let area = f["outline"].as_array().into_iter().flatten().map(ring).sum(); + let mut bends: Vec = f["bends"].as_array().into_iter().flatten().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect(); + bends.sort_by(|x, y| y.total_cmp(x)); + (area, bends) +} + +/// A tray made with this build's sheet metal converts back to the same tray: the walls that run +/// on past their bends' ends (a notch, a tear) step down to the bend there, and a round relief's +/// arc stays; also with the short walls made in a second feature, where the corners are notched +/// or round (a torn butt corner is torn differently from one feature's corner). +#[test] +fn trays_of_its_own_sheet_metal_convert_back() { + for shape in ["notch", "tear", "round"] { + for two in [false, true].into_iter().filter(|two| !(*two && shape == "tear")) { + let what = format!("{shape}, {}", if two { "two features" } else { "one feature" }); + let (mut s, picks, mut source) = tray_round_trip(shape, two); + let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default(); + let out = s.execute("sheet.convert", &json!({"body": body, "face": [60, 45, 0], "bends": picks, "rule": "T"})); + assert!(out.is_ok(), "{what}: {}", out.err().map(|e| e.to_string()).unwrap_or_default()); + // The same tray: its solid, and its flat with the same bends. + let (v, size, (area, bends)) = (volume(&mut source), flat_size(&mut source), flat_area_and_bends(&mut source)); + assert!((volume(&mut s) - v).abs() < 1e-9 * v, "{what}: {} vs {v}", volume(&mut s)); + assert!(same_size(flat_size(&mut s), size, 1e-6), "{what}: {:?} vs {size:?}", flat_size(&mut s)); + let (got_area, got_bends) = flat_area_and_bends(&mut s); + assert!((got_area - area).abs() < 1e-9 * area, "{what}: {got_area} vs {area}"); + assert!( + got_bends.len() == bends.len() && got_bends.iter().zip(&bends).all(|(x, y)| (x - y).abs() < 1e-6), + "{what}: {got_bends:?} vs {bends:?}" + ); + } + } +} diff --git a/crates/engine/src/cmd/sheet_dxf_tests.rs b/crates/engine/src/cmd/sheet_dxf_tests.rs index 8a99ce6eb1..26a7360542 100644 --- a/crates/engine/src/cmd/sheet_dxf_tests.rs +++ b/crates/engine/src/cmd/sheet_dxf_tests.rs @@ -614,3 +614,59 @@ fn bend_lines_can_be_left_out_and_everything_put_on_layer_0() { assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}"); } } + +/// The DXF of a sheet folded across a curved outer outline has the curve as drawn, not arcs fitted +/// to it, and no seams where the fold cut it: the blank, with its bend line where the bend is. +#[test] +fn a_curved_outline_folded_across_stays_its_curve_in_the_dxf() { + let blanks: [(&str, &str, Value, &str); 3] = [ + ("circle", "sketch.circle.center", json!({"center": [0, 0], "radius": 50}), "CIRCLE"), + ("ellipse", "sketch.ellipse", json!({"center": [0, 0], "major": [80, 0], "minor_radius": 40}), "ELLIPSE"), + ( + "spline", + "sketch.spline.fit_point", + json!({"points": [[70, -5], [40, 40], [0, 40], [-50, 35], [-85, 0], [-40, -50], [0, -35], [35, -25]], "periodic": true}), + "SPLINE", + ), + ]; + for (name, id, p, kind) in blanks { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); + run(&mut s, id, p); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Blank"})); + let flat_loops = loops(&s); + let area: f64 = flat_loops.iter().map(|l| l.signed_area().abs()).sum(); + run(&mut s, "sheet.fold", json!({"points": [[10, -200, 2.5], [10, 200, 2.5]]})); + let dxf = export(&mut s, json!({})); + let es = entities(&dxf); + assert!(count(&es, kind) >= 1, "{name}: no {kind}: {:?}", es.iter().map(|e| e.0.as_str()).collect::>()); + if kind != "CIRCLE" { + assert_eq!(count(&es, "ARC"), 0, "{name}: curves came back as fitted arcs"); + } + // The only straight line is the bend line, across the sheet on the zone's centre line. + let lines: Vec<&Entity> = es.iter().filter(|e| e.0 == "LINE").collect(); + assert_eq!(lines.len(), 1, "{name}: seams left in the outline"); + let (a, b) = (point(lines[0], 10), point(lines[0], 11)); + assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 10.0).abs() < 1e-6, "{name}: bend line {a:?} to {b:?}"); + // The blank is the blank: as much of it as was drawn, exactly. + let after: f64 = loops(&s).iter().map(|l| l.signed_area().abs()).sum(); + assert!((after - area).abs() < 1e-6 * area, "{name}: {after} vs {area}"); + } +} + +/// A fold across the straight part of a curved outline keeps the curve as drawn as well: an arc +/// stays one arc, not the 0.001 mm chords a fold used to leave. +#[test] +fn a_curve_away_from_a_fold_stays_its_curve_in_the_dxf() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); + run(&mut s, "sketch.line", json!({"points": [[0, 50], [-60, 50], [-60, -50], [0, -50]]})); + run(&mut s, "sketch.arc.three_point", json!({"start": [0, -50], "through": [50, 0], "end": [0, 50]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Blank"})); + run(&mut s, "sheet.fold", json!({"points": [[-20, -200, 2.5], [-20, 200, 2.5]]})); + let es = entities(&export(&mut s, json!({}))); + assert_eq!(count(&es, "ARC"), 1, "{:?}", es.iter().map(|e| e.0.as_str()).collect::>()); + assert!(count(&es, "LINE") < 12, "the arc went to chords: {} lines", count(&es, "LINE")); +} diff --git a/crates/engine/src/cmd/sheet_face_names_tests.rs b/crates/engine/src/cmd/sheet_face_names_tests.rs index 6c5d656f3e..87491f601f 100644 --- a/crates/engine/src/cmd/sheet_face_names_tests.rs +++ b/crates/engine/src/cmd/sheet_face_names_tests.rs @@ -112,3 +112,23 @@ fn a_flanges_sheet_names_hold_when_another_flange_goes() { assert!(solidcraft_doc::naming::named_face_point(&s.model.state(), None, &gone).is_err()); assert!(solidcraft_doc::naming::named_face_point(&s.model.state(), Some("Body2"), &right).is_err()); } + +/// A fold at 45° across a strip, with a bend zone wide for the strip: about a third of the bend's +/// faces lies beyond the ends of its start line, along the bend, and they still take its names. +#[test] +fn a_bend_askew_across_a_sheet_keeps_its_names() { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "R10", "thickness": "1 mm", "bend_radius": "10 mm", "active": true})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Strip"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 20]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Strip"})); + run(&mut s, "sheet.fold", json!({"points": [[90, -5, 1], [115, 20, 1]], "fixed": [10, 10, 1]})); + let fold = s.doc.features.last().map_or(0, |f| f.id); + let all = faces(&mut s); + for (side, radius) in [("inner", 10.0), ("outer", 11.0)] { + let name = format!("sheet:Body1:F{fold}.0:bend.{side}"); + let f = named(&all, &name); + assert!(f.len() == 1 && f[0].2.is_some_and(|r| (r - radius).abs() < 1e-6), "{name}: {all:?}"); + } +} diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index adc6e3f109..45fa6a3324 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -3094,6 +3094,397 @@ fn fold_at_a_tab_root_names_the_real_cause() { run(&mut ok, "sheet.fold", json!({"points": [[40, 51, 0.8], [60, 51, 0.8]], "fixed": [50, 25, 0.8]})); } +/// The sheet of the folds across slanted edges below: 2 mm thick, R 1, K 0.4. +fn t2_blank(s: &mut Session, outline: Value) { + run(s, "sheet.manage_rules", json!({"name": "T2", "thickness": "2 mm", "bend_radius": "1 mm", "k_factor": "0.4", "active": true})); + run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); + run(s, "sketch.line", json!({"points": outline, "closed": true})); + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"sketch": "Blank"})); +} + +/// 160 long, 50 wide at x = 0 and 100 wide at x = 160: 12000 mm², and 75 wide at its midline. +fn t2_trapezoid(s: &mut Session) { + t2_blank(s, json!([[0, -25], [160, -50], [160, 50], [0, 25]])); +} + +/// A point on `t2_trapezoid`'s lower slanted side, on the sheet's top. +fn t2_slant(x: f64) -> [f64; 3] { + [x, -25.0 - 25.0 * x / 160.0, 2.0] +} + +/// What the T2 sheet's flat pattern says it weighs. A bend of turn θ takes θ·(R + K·T) of the flat +/// and has θ·T·(R + T/2) of section, so it adds θ·T²·(½ − K) per mm across it; ruled ends keep the +/// zone's mean width at its centre line's length, however askew they are. +fn t2_volume_from_flat(f: &Value) -> f64 { + let area = |loops: &Value| -> f64 { + let ring = |l: &Value| { + let p: Vec<(f64, f64)> = + l.as_array().into_iter().flatten().map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN))).collect(); + (0..p.len()).map(|i| p[i].0 * p[(i + 1) % p.len()].1 - p[(i + 1) % p.len()].0 * p[i].1).sum::().abs() / 2.0 + }; + loops.as_array().into_iter().flatten().map(ring).sum() + }; + let bends: f64 = f["bends"] + .as_array() + .into_iter() + .flatten() + .map(|b| b["angle_deg"].as_f64().unwrap_or(f64::NAN).to_radians() * 0.4 * b["length"].as_f64().unwrap_or(f64::NAN)) + .sum(); + (area(&f["outline"]) - area(&f["cutouts"])) * 2.0 + bends +} + +/// The midline of a trapezoid crosses its slanted sides: the bend zone isn't square across, and +/// the sheet keeps its real outline (#10621). +#[test] +fn a_fold_across_slanted_edges_keeps_the_sheets_outline() { + let zone = FRAC_PI_2 * 1.8; + let flat_volume = 12000.0 * 2.0; + let want = flat_volume + 75.0 * FRAC_PI_2 * 0.4; + // Both legs are as long, R + T + 80 - BA / 2 high when folded. + let (wide, narrow) = ([80.0 + zone / 2.0 - 3.0, 160.0], [0.0, 80.0 - zone / 2.0 + 3.0]); + for (name, fixed, x) in + [("no side named: the larger stays", None, wide), ("the wide end", Some([150, 0, 0]), wide), ("the narrow end", Some([10, 0, 0]), narrow)] + { + let mut s = Session::default(); + t2_trapezoid(&mut s); + let mut fold = json!({"points": [[80, -60, 2], [80, 60, 2]]}); + if let Some(p) = fixed { + fold["fixed"] = json!(p); + } + run(&mut s, "sheet.fold", fold); + let v = volume(&mut s); + assert!(rel(v, want) < 1e-5, "{name}: volume {v} vs {want}"); + let b = bbox(&mut s); + let at = |v: &Value| v.as_f64().unwrap_or(f64::NAN); + assert!((at(&b["min"][0]) - x[0]).abs() < 1e-6 && (at(&b["max"][0]) - x[1]).abs() < 1e-6, "{name}: {b}"); + assert!((at(&b["max"][2]) - (3.0 + 80.0 - zone / 2.0)).abs() < 1e-6, "{name}: {b}"); + let f = flat(&mut s); + assert!((at(&f["flat_size_mm"][0]) - 160.0).abs() < 1e-6 && (at(&f["flat_size_mm"][1]) - 100.0).abs() < 1e-6, "{name}: {f}"); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{name}: the outline is the sheet's, not a trimmed rectangle: {f}"); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(1), "{name}: {f}"); + assert!((at(&f["bends"][0]["length"]) - 75.0).abs() < 1e-6 && (at(&f["bends"][0]["allowance"]) - zone).abs() < 1e-9, "{name}: {f}"); + assert!(rel(t2_volume_from_flat(&f), want) < 1e-9, "{name}: {f}"); + // Flat again it weighs the sheet it was, and it folds back the same. + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + assert!(rel(unfolded, flat_volume) < 1e-9, "{name}: unfolded {unfolded}"); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!(rel(refolded, v) < 1e-9, "{name}: refolded {refolded} vs {v}"); + } +} + +/// A fold across slanted edges leaves panels with their real outlines, so flanges and other folds +/// go on it as on any fold, before or after it. +#[test] +fn flanges_and_folds_go_on_a_sheet_folded_across_slanted_edges() { + let mid = json!({"points": [[80, -60, 2], [80, 60, 2]], "fixed": [150, 0, 0]}); + let wide = json!({"edges": [[160, 0, 2]], "height": 20}); + let slanted = json!({"edges": [t2_slant(120.0)], "height": 20}); + let flange = "sheet.flange"; + let fold = "sheet.fold"; + let cases: [(&str, Vec<(&str, &Value)>); 3] = [ + ("flange on the wide end after", vec![(fold, &mid), (flange, &wide)]), + ("flange on the wide end before", vec![(flange, &wide), (fold, &mid)]), + ("flange on the slanted side after", vec![(fold, &mid), (flange, &slanted)]), + ]; + let mut volumes = Vec::new(); + for (name, steps) in cases { + let mut s = Session::default(); + t2_trapezoid(&mut s); + for (id, p) in steps { + run(&mut s, id, p.clone()); + } + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "{name}: {f}"); + assert!(rel(v, t2_volume_from_flat(&f)) < 2e-5, "{name}: volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); + volumes.push(v); + } + // A flange beside the fold: the order doesn't matter. + assert!(rel(volumes[0], volumes[1]) < 1e-6, "{volumes:?}"); + + // A flange along the whole slanted side has its bend where the fold would cross: refused, + // for that reason, as on a rectangle. + let mut s = Session::default(); + t2_trapezoid(&mut s); + run(&mut s, flange, slanted.clone()); + match s.execute(fold, &mid) { + Ok(v) => panic!("the fold goes through the flange's bend: {v}"), + Err(e) => assert!(e.to_string().contains("a fold through a flange's bend"), "{e}"), + } + + // A second fold, 87.5 wide at x = 120 and square to the first, or askew across the same sides. + let mut s = Session::default(); + t2_trapezoid(&mut s); + run(&mut s, fold, mid.clone()); + run(&mut s, fold, json!({"points": [[120, -60, 2], [120, 60, 2]], "fixed": [150, 0, 0]})); + let want = 24000.0 + (75.0 + 87.5) * FRAC_PI_2 * 0.4; + let v = volume(&mut s); + assert!(rel(v, want) < 1e-5, "volume {v} vs {want}"); + let mut s = Session::default(); + t2_trapezoid(&mut s); + run(&mut s, fold, mid.clone()); + run(&mut s, fold, json!({"points": [[100, 60, 2], [140, -60, 2]], "fixed": [155, 0, 0]})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "{f}"); + assert!(rel(v, t2_volume_from_flat(&f)) < 2e-5, "volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); + + // Askew on the blank itself. + let mut s = Session::default(); + t2_trapezoid(&mut s); + run(&mut s, fold, json!({"points": [[60, 60, 2], [100, -60, 2]], "fixed": [155, 0, 0]})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert!(rel(v, t2_volume_from_flat(&f)) < 2e-5, "volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); +} + +/// Walls leaning at 70° close the trapezoid's corner on a slanted line, so a fold across the wide +/// end's wall crosses a slanted edge of a flange: the wall widens evenly up to its top, and the +/// bend with it (#10621). +#[test] +fn a_fold_across_a_flanges_slanted_end_folds() { + let lean = 70f64.to_radians(); + let walls = json!({"edges": [[160, 0, 2], t2_slant(120.0)], "height": 40, "angle": "70 deg"}); + // The wall's outer face, from where the wall alone reaches: its top's outer corner is the + // farthest out, its inner corner the highest. + let mut one = Session::default(); + t2_trapezoid(&mut one); + run(&mut one, "sheet.flange", json!({"edges": [[160, 0, 2]], "height": 40, "angle": "70 deg"})); + let b = bbox(&mut one); + let (x_out, z_in) = (b["max"][0].as_f64().unwrap_or(f64::NAN), b["max"][2].as_f64().unwrap_or(f64::NAN)); + let z_out = z_in - 2.0 * lean.cos(); + let mut lengths = Vec::new(); + for z in [14.0, 22.0, 30.0] { + let mut s = Session::default(); + t2_trapezoid(&mut s); + run(&mut s, "sheet.flange", walls.clone()); + let x = x_out - (z_out - z) / lean.tan(); + run(&mut s, "sheet.fold", json!({"points": [[x, -90.0, z], [x, 90.0, z]], "angle": "30 deg"})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(3), "z {z}: {f}"); + assert!(rel(v, t2_volume_from_flat(&f)) < 2e-5, "z {z}: volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); + lengths.push(f["bends"][2]["length"].as_f64().unwrap_or(f64::NAN)); + } + let (rise, more) = (lengths[1] - lengths[0], lengths[2] - lengths[1]); + assert!(rise > 1e-3 && (rise - more).abs() < 1e-6, "{lengths:?}"); +} + +/// A blank whose outer outline is curved: `draw` makes it in a sketch, on the T2 sheet. +fn t2_curved_blank(s: &mut Session, draw: &[(&str, Value)], k: &str) { + run(s, "sheet.manage_rules", json!({"name": "T2", "thickness": "2 mm", "bend_radius": "1 mm", "k_factor": k, "active": true})); + run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); + for (id, p) in draw { + run(s, id, p.clone()); + } + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"sketch": "Blank"})); +} + +/// A circle or an ellipse as the sheet's outer outline, folded at `x` off its centre. The bend +/// adds θ·T²·(½ − K) per mm of the zone's mean width, which is the material in the zone over BA: +/// for semi-axes a (across the fold) and b, the chord (b / a)·2√(a² − s²) over the zone. The bend +/// allowance sets how high the part that folds stands, so K shows there: compensation. +#[test] +fn a_fold_across_a_round_outer_outline_is_compensated() { + let circle = ("sketch.circle.center", json!({"center": [0, 0], "radius": 50})); + let ellipse = ("sketch.ellipse", json!({"center": [0, 0], "major": [80, 0], "minor_radius": 40})); + for (name, draw, a, b) in [("circle", circle, 50.0_f64, 50.0_f64), ("ellipse", ellipse, 80.0_f64, 40.0_f64)] { + for (x, k) in [(10.0_f64, 0.4_f64), (-20.0, 0.3), (10.0, 0.5)] { + let zone = FRAC_PI_2 * (1.0 + k * 2.0); + let integral = |s: f64| s * (a * a - s * s).sqrt() + a * a * (s / a).asin(); + let in_zone = (b / a) * (integral(x + zone / 2.0) - integral(x - zone / 2.0)); + let want = PI * a * b * 2.0 + FRAC_PI_2 * 4.0 * (0.5 - k) * in_zone / zone; + let mut s = Session::default(); + t2_curved_blank(&mut s, std::slice::from_ref(&draw), &k.to_string()); + run(&mut s, "sheet.fold", json!({"points": [[x, -200, 2], [x, 200, 2]], "fixed": [-(a - 1.0) * x.signum(), 0, 2]})); + let name = format!("{name} at x = {x}, K {k}"); + // The volume measure works on facets: a flat ellipse reads about 1e-4 low on its own. + let v = volume(&mut s); + assert!(rel(v, want) < 3e-4, "{name}: volume {v} vs {want}"); + // The part past the bend stands R + T + its flat length past the zone. + let leg = a - x.abs() - zone / 2.0; + let high = bbox(&mut s)["max"][2].as_f64().unwrap_or(f64::NAN); + assert!((high - (3.0 + leg)).abs() < 1e-3, "{name}: {high} high vs {}", 3.0 + leg); + // The flat pattern is the blank as drawn, the bend line on the zone's centre line. + let f = flat(&mut s); + let at = |v: &Value| v.as_f64().unwrap_or(f64::NAN); + // (The size is measured on 0.001 mm chords, so up to that short on each side.) + assert!((at(&f["flat_size_mm"][0]) - 2.0 * a).abs() < 2.5e-3 && (at(&f["flat_size_mm"][1]) - 2.0 * b).abs() < 2.5e-3, "{name}: {f}"); + let chord = 2.0 * b * (1.0 - (x / a).powi(2)).sqrt(); + assert!((at(&f["bends"][0]["length"]) - chord).abs() < 1e-3 && (at(&f["bends"][0]["allowance"]) - zone).abs() < 1e-9, "{name}: {f}"); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{name}: {f}"); + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + assert!(rel(unfolded, PI * a * b * 2.0) < 3e-4, "{name}: unfolded {unfolded}"); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!(rel(refolded, v) < 1e-9, "{name}: refolded {refolded} vs {v}"); + } + } +} + +/// Any curve, not only conics: a closed spline, an open spline closed by a line, an arc with lines, +/// each folded where its curves cross the zone, then folded again. +#[test] +fn a_fold_crosses_any_curve_of_the_outer_outline() { + let spline = vec![( + "sketch.spline.fit_point", + json!({"points": [[70, -5], [40, 40], [0, 40], [-50, 35], [-85, 0], [-40, -50], [0, -35], [35, -25]], "periodic": true}), + )]; + let open = vec![ + ("sketch.spline.fit_point", json!({"points": [[-60, 0], [-30, 45], [10, 30], [60, 50]]})), + ("sketch.line", json!({"points": [[60, 50], [60, -30], [-60, -30], [-60, 0]]})), + ]; + let d_shape = vec![ + ("sketch.line", json!({"points": [[0, 50], [-60, 50], [-60, -50], [0, -50]]})), + ("sketch.arc.three_point", json!({"start": [0, -50], "through": [50, 0], "end": [0, 50]})), + ]; + for (name, draw, line_at) in [("closed spline", spline, 5.0), ("open spline and lines", open, 7.0), ("arc and lines", d_shape, 20.0)] { + let mut s = Session::default(); + t2_curved_blank(&mut s, &draw, "0.4"); + let flat_volume = volume(&mut s); + run(&mut s, "sheet.fold", json!({"points": [[line_at, -200, 2], [line_at, 200, 2]]})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(1), "{name}: {f}"); + // The bend adds what it adds to the flat sheet, here on a centre line that varies little. + let gain = v - flat_volume; + let want = f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) * FRAC_PI_2 * 0.4; + assert!((gain - want).abs() < 0.03 * want + 1.5, "{name}: gained {gain} vs {want}"); + // Flat again it weighs what it did, and it folds back the same. + run(&mut s, "sheet.unfold", json!({})); + let unfolded = volume(&mut s); + assert!(rel(unfolded, flat_volume) < 3e-4, "{name}: unfolded {unfolded} vs {flat_volume}"); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!(rel(refolded, v) < 1e-9, "{name}: refolded {refolded} vs {v}"); + // A second fold across the part that stayed, where the curves cross it too. + run(&mut s, "sheet.fold", json!({"points": [[line_at - 30.0, -200, 2], [line_at - 30.0, 200, 2]], "fixed": [line_at - 50.0, 0, 2]})); + let f = flat(&mut s); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "{name}: second fold: {f}"); + } +} + +/// A curved outline with a hole across the zone: the bend adds what the material in the zone +/// holds, between the outline's chord and the hole's. +#[test] +fn a_cut_out_crosses_a_fold_across_a_round_outer_outline() { + let (r, hole, x, k) = (50.0_f64, 15.0_f64, 10.0_f64, 0.4_f64); + let zone = FRAC_PI_2 * (1.0 + k * 2.0); + let integral = |a: f64, s: f64| { + let s = s.clamp(-a, a); + s * (a * a - s * s).sqrt() + a * a * (s / a).asin() + }; + let in_zone = integral(r, x + zone / 2.0) - integral(r, x - zone / 2.0) - (integral(hole, zone / 2.0) - integral(hole, -zone / 2.0)); + let want = PI * (r * r - hole * hole) * 2.0 + FRAC_PI_2 * 4.0 * (0.5 - k) * in_zone / zone; + let mut s = Session::default(); + t2_curved_blank(&mut s, &[("sketch.circle.center", json!({"center": [0, 0], "radius": r}))], "0.4"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [x, 0], "radius": hole})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 20, "direction": "symmetric", "operation": "cut", "targets": ["Body1"]})); + run(&mut s, "sheet.fold", json!({"points": [[x, -200, 2], [x, 200, 2]], "fixed": [-49, 0, 2]})); + let v = volume(&mut s); + assert!(rel(v, want) < 3e-4, "volume {v} vs {want}"); + let f = flat(&mut s); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "the hole parts the bend line: {f}"); +} + +/// Curves away from the zone stay curves too, and a flange still goes on either side of a fold +/// across the straight part of a curved outline (the side with the arc gives way to chords). +#[test] +fn flanges_go_on_a_curved_outline_folded_across_its_straight_part() { + let d_shape = vec![ + ("sketch.line", json!({"points": [[0, 50], [-60, 50], [-60, -50], [0, -50]]})), + ("sketch.arc.three_point", json!({"start": [0, -50], "through": [50, 0], "end": [0, 50]})), + ]; + let fold = json!({"points": [[-20, -200, 2], [-20, 200, 2]], "fixed": [-50, 0, 2]}); + // The side that stays is square; the arc goes up with the side that folds. + let mut s = Session::default(); + t2_curved_blank(&mut s, &d_shape, "0.4"); + run(&mut s, "sheet.fold", fold.clone()); + run(&mut s, "sheet.flange", json!({"edges": [[-60, 0, 2]], "height": 20})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "{f}"); + assert!(rel(v, t2_volume_from_flat(&f)) < 3e-4, "volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); + // The side with the arc stays; the flange goes on the square side that folded. + let mut s = Session::default(); + t2_curved_blank(&mut s, &d_shape, "0.4"); + run(&mut s, "sheet.fold", json!({"points": [[-20, -200, 2], [-20, 200, 2]], "fixed": [40, 0, 2]})); + let high = bbox(&mut s)["max"][2].as_f64().unwrap_or(f64::NAN); + run(&mut s, "sheet.flange", json!({"edges": [[-20.0 + FRAC_PI_2 * 0.9 - 3.0, 0, high]], "height": 20})); + let (v, f) = (volume(&mut s), flat(&mut s)); + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2), "{f}"); + assert!(rel(v, t2_volume_from_flat(&f)) < 3e-4, "volume {v} vs the flat's {}: {f}", t2_volume_from_flat(&f)); +} + +/// A sharp corner in the zone stays refused, by name: the material beside it wouldn't hang from +/// anything. A fold clear of it, on the same outline, folds. Where the lines run smoothly into the +/// arc is no corner. +#[test] +fn a_corner_of_a_curved_outline_in_the_zone_is_named() { + let stepped = vec![ + ("sketch.line", json!({"points": [[0, 50], [-20, 50], [-20, 30], [-60, 30], [-60, -30], [-20, -30], [-20, -50], [0, -50]]})), + ("sketch.arc.three_point", json!({"start": [0, -50], "through": [50, 0], "end": [0, 50]})), + ]; + let mut s = Session::default(); + t2_curved_blank(&mut s, &stepped, "0.4"); + match s.execute("sheet.fold", &json!({"points": [[-20, -200, 2], [-20, 200, 2]], "fixed": [40, 0, 2]})) { + Ok(v) => panic!("the zone holds the corners at x = -20: {v}"), + Err(e) => assert!(e.to_string().contains("corner of the sheet's outline"), "{e}"), + } + run(&mut s, "sheet.fold", json!({"points": [[0, -200, 2], [0, 200, 2]], "fixed": [40, 0, 2]})); +} + +/// A circle or an ellipse cut out across a fold that crosses slanted edges, before the fold or in +/// the flat after it: the outline's edges and the cut-out's both cross the bend. +#[test] +fn round_cut_outs_cross_a_fold_across_slanted_edges() { + let zone = FRAC_PI_2 * 1.8; + let fold = json!({"points": [[80, -60, 2], [80, 60, 2]], "fixed": [150, 0, 0]}); + let cut = |s: &mut Session, draw: &(&str, Value)| { + run(s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(s, draw.0, draw.1.clone()); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": "Hole", "distance": 20, "direction": "symmetric", "operation": "cut", "targets": ["Body1"]})); + }; + let circle = ("sketch.circle.center", json!({"center": [80, 0], "radius": 15})); + let ellipse = ("sketch.ellipse", json!({"center": [80, 0], "major": [100, 0], "minor_radius": 10})); + for (name, draw, a, b) in [("circle", circle, 15.0_f64, 15.0_f64), ("ellipse", ellipse, 20.0_f64, 10.0_f64)] { + // What it takes out of the zone: its chord (b / a)·2√(a² − s²) across s in ±BA / 2. + let h = zone / 2.0; + let in_zone = 2.0 * (b / a) * (h * (a * a - h * h).sqrt() + a * a * (h / a).asin()); + let flat_volume = (12000.0 - PI * a * b) * 2.0; + let want = flat_volume + (75.0 - in_zone / zone) * FRAC_PI_2 * 0.4; + + let mut before = Session::default(); + t2_trapezoid(&mut before); + cut(&mut before, &draw); + run(&mut before, "sheet.fold", fold.clone()); + let v = volume(&mut before); + assert!(rel(v, want) < 1e-5, "{name}: volume {v} vs {want}"); + // One cut-out, and the centre line in two pieces either side of it. + let f = flat(&mut before); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{name}: {f}"); + let bends = f["bends"].as_array().cloned().unwrap_or_default(); + let length: f64 = bends.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).sum(); + assert!(bends.len() == 2 && (length - (75.0 - 2.0 * b)).abs() < 1e-3, "{name}: {f}"); + run(&mut before, "sheet.unfold", json!({})); + // A round hole's volume is measured on its facets: 0.235 mm³ over for this circle, as on + // a plain rectangle. + let unfolded = volume(&mut before); + assert!(rel(unfolded, flat_volume) < 2e-5, "{name}: unfolded {unfolded} vs {flat_volume}"); + + let mut after = Session::default(); + t2_trapezoid(&mut after); + run(&mut after, "sheet.fold", fold.clone()); + run(&mut after, "sheet.unfold", json!({})); + cut(&mut after, &draw); + run(&mut after, "sheet.refold", json!({})); + let refolded = volume(&mut after); + assert!(rel(refolded, want) < 1e-5, "{name}: refolded {refolded} vs {want}"); + } +} + /// #296: two bends meeting at an open corner relief touch only along an edge, so the folded /// material the machining guard checks must not be sewn into one solid. #[test] @@ -5215,6 +5606,102 @@ fn perforate_supports_a_square_pattern_and_refuses_impossible_values() { } } +/// The hole centres `staggered_count` counts, over the box (x0, y0)..(x1, y1). +fn staggered_centres(x0: f64, y0: f64, x1: f64, y1: f64, d: f64, pitch: f64, margin: f64) -> Vec<(f64, f64)> { + let (clear, dy) = (d / 2.0 + margin, pitch * 3f64.sqrt() / 2.0); + let mut out = Vec::new(); + let mut row = 0; + while y0 + clear + row as f64 * dy <= y1 - clear + 1e-9 { + let mut x = x0 + clear + if row % 2 == 1 { pitch / 2.0 } else { 0.0 }; + while x <= x1 - clear + 1e-9 { + out.push((x, y0 + clear + row as f64 * dy)); + x += pitch; + } + row += 1; + } + out +} + +/// A 100 x 60 x 0.8 sheet with a sketch on its face: a 50 x 30 rectangle at (10, 10) with a Ø10 circle at (35, 25). +fn sheet_with_a_field_outline(s: &mut Session) { + outline_blank(s, json!([[0, 0], [100, 0], [100, 60], [0, 60]])); + run(s, "sketch.create", json!({"plane": "XY", "name": "Field"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [60, 40]})); + run(s, "sketch.circle.center", json!({"center": [35, 25], "radius": 5})); + run(s, "sketch.finish", json!({})); +} + +/// #732: a sketch bounds the field. The ring of the rectangle less the circle takes Rv 1.0-1.5 only inside it, margin +/// clear of both its loops, on the grid from the outline's lower-left corner; an edit without a sketch keeps it. +#[test] +fn perforation_outline_bounds_the_field_and_keeps_out_its_inner_loops() { + let mut s = Session::default(); + sheet_with_a_field_outline(&mut s); + let made = + run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1", "sketch": "Field", "profiles": [{"point": [15, 15]}]})); + let expected: Vec<(f64, f64)> = + staggered_centres(10.0, 10.0, 60.0, 40.0, 1.0, 1.5, 1.0).into_iter().filter(|&(x, y)| (x - 35.0).hypot(y - 25.0) >= 6.5).collect(); + assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(expected.len() as u64)); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let holes = perforation_circles(&dxf); + assert_eq!(holes.len(), expected.len()); + assert!((holes[0].0 - 11.5).abs() < 1e-9 && (holes[0].1 - 11.5).abs() < 1e-9, "first hole at the outline's corner: {:?}", holes[0]); + assert!(holes.iter().all(|&(x, y, _)| (11.5 - 1e-9..=58.5 + 1e-9).contains(&x) && (11.5 - 1e-9..=38.5 + 1e-9).contains(&y))); + assert!(holes.iter().all(|&(x, y, _)| (x - 35.0).hypot(y - 25.0) >= 6.5 - 1e-9), "no hole in or near the inner loop"); + let n = holes.len(); + run( + &mut s, + "timeline.redefine", + json!({"feature": made["feature"], "command": "sheet.perforate", "params": {"diameter": "1", "pitch": "1.5", "margin": "1"}}), + ); + assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(n as u64), "the edit keeps the outline"); +} + +/// #732: on a folded sheet the outline goes through the panel its sketch lies on and stops there: an outline that +/// reaches past the bend puts no hole in the flange. A sketch on no face of the sheet is refused. +#[test] +fn perforation_outline_on_a_folded_sheet_stays_in_its_panel() { + let mut s = Session::default(); + outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]])); + run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 0.8]], "height": 20})); + for (name, z) in [("Top", 0.8), ("Above", 10.0)] { + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, z], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": name})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [150, 110]})); + run(&mut s, "sketch.finish", json!({})); + } + run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1", "sketch": "Top"})); + let zone = &flat(&mut s)["bend_zones"][0]; // [min x, max x, min y, max y] of the bend zone, flat coordinates + let top = zone[3].as_f64().unwrap_or(f64::NAN); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let holes = perforation_circles(&dxf); + assert!(!holes.is_empty()); + assert!(holes.iter().all(|&(_, y, r)| y - r - 1.0 >= top - 1e-9), "every hole is in the base, beyond the bend zone's y {top}"); + let refused = s.execute("sheet.perforate", &json!({"diameter": "1", "pitch": "1.5", "sketch": "Above"})); + assert!(refused.is_err_and(|e| e.to_string().contains("must lie on a face of the sheet"))); +} + +/// #732: a mirrored copy's bounded field is the mirror image of its source's. +#[test] +fn a_mirrored_sheet_copy_mirrors_its_perforation_outline() { + let mut s = Session::default(); + sheet_with_a_field_outline(&mut s); + run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1", "sketch": "Field", "profiles": [{"point": [15, 15]}]})); + run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"})); + let holes = |s: &mut Session, body: &str, sx: f64| -> Vec<(i64, i64)> { + let dxf = run(s, "sheet.export_dxf", json!({"body": body}))["dxf"].as_str().unwrap_or_default().to_string(); + let c = perforation_circles(&dxf); + // Relative to the field's own lower-left hole, x scaled by sx, rounded to 1e-6 mm. + let x0 = c.iter().map(|h| h.0 * sx).fold(f64::INFINITY, f64::min); + let y0 = c.iter().map(|h| h.1).fold(f64::INFINITY, f64::min); + let mut v: Vec<(i64, i64)> = c.iter().map(|h| (((h.0 * sx - x0) * 1e6).round() as i64, ((h.1 - y0) * 1e6).round() as i64)).collect(); + v.sort_unstable(); + v + }; + let original = holes(&mut s, "Body1", -1.0); + assert!(!original.is_empty()); + assert_eq!(holes(&mut s, "Body1 (2)", 1.0), original); +} + /// A 100 x 60 tray with one 45° flange 40 high on its y = 0 edge (2.5 mm, R 2.5, K 0.44), and on /// it a return 10 high when asked. Returns the far edge of the last panel in the flat pattern: /// the base starts at the setback s = (t + R)·tan 22.5°, each bend's zone is (π/4)·(R + K·t) long, @@ -5618,3 +6105,50 @@ fn an_unfolded_sheets_flat_face_is_named_after_its_base_at_every_size() { } } } + +/// A base flange 1 thick (R 1, K 0.44) from the closed outline `pts` on XY. +fn sheet_from_outline(s: &mut Session, pts: Value) { + run(s, "sheet.manage_rules", json!({"name": "S1", "thickness": "1", "bend_radius": "1", "k_factor": 0.44, "active": true})); + run(s, "sketch.create", json!({"plane": "XY", "name": "B"})); + run(s, "sketch.line", json!({"points": pts, "closed": true})); + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"sketch": "B"})); +} + +/// The bends of the flat pattern as (length, lower y, upper y), sorted by the lower y. +fn bend_spans(s: &mut Session) -> Vec<(f64, f64, f64)> { + let f = flat(s); + let mut v: Vec<(f64, f64, f64)> = f["bends"] + .as_array() + .unwrap() + .iter() + .map(|b| { + let (y0, y1) = (b["start"][1].as_f64().unwrap(), b["end"][1].as_f64().unwrap()); + (b["length"].as_f64().unwrap(), y0.min(y1), y0.max(y1)) + }) + .collect(); + v.sort_by(|a, b| a.1.total_cmp(&b.1)); + v +} + +const STEPPED: [[f64; 2]; 8] = + [[-106.9, 5.4], [108.9, 5.4], [108.9, 408.9], [-106.9, 408.9], [-106.9, 388.3], [-108.9, 388.3], [-108.9, 10.5], [-106.9, 10.5]]; + +/// A flange on part of a stepped outline spans that part: on the X −108.9 segment (Y 10.5…388.3) +/// of a base whose edge beyond it is already at the bend line (X −106.9), the bend is 377.8 long +/// and no relief is cut. Before, it ran the face's whole length (403.5). +#[test] +fn a_flange_on_part_of_a_stepped_outline_spans_that_part() { + let mut s = Session::default(); + sheet_from_outline(&mut s, json!(STEPPED)); + run(&mut s, "sheet.flange", json!({"edges": [[-108.9, 200, 1]], "height": 20})); + let spans = bend_spans(&mut s); + assert_eq!(spans.len(), 1); + let (len, y0, y1) = spans[0]; + assert!((len - 377.8).abs() < 1e-9 && (y0 - 10.5).abs() < 1e-9 && (y1 - 388.3).abs() < 1e-9, "{spans:?}"); + // The base trimmed back to X −106.9 (215.8 × 403.5), and the bend (allowance (π/2)(R + K·t)) and + // the 18 leg along the 377.8: no relief. + let allowance = PI / 2.0 * (1.0 + 0.44 * 1.0); + let expected = 215.8 * 403.5 + 377.8 * (allowance + 18.0); + assert!(rel(flat_area(&flat(&mut s)), expected) < 1e-9, "{} vs {expected}", flat_area(&flat(&mut s))); +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index df826210ab..8aeee9c4bf 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -7,7 +7,9 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3}; -use crate::{EngineError, Result, Session, SketchEntry}; +use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult}; +use crate::schema::{TypedContract, output_schema_for}; +use crate::{EngineError, Result, Session, SketchEntry, Snapshot}; #[path = "sketch_dimensioned.rs"] mod dimensioned; @@ -33,18 +35,28 @@ macro_rules! model_operand_doc { }; } +const EDIT_CONTRACT: TypedContract = TypedContract::new(); +const EXIT_CONTRACT: TypedContract = TypedContract::new(); + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.create", "Create Sketch", create_sketch) .at("SOLID", "CREATE") .icon("sketch") .params("plane: XY|XZ|YZ (sketch x, y along world +X, +Y on XY; +X, -Z on XZ; +Y, +Z on YZ) | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, activate_part?: bool (default false; on a face of a body of another component than the active one: activate that component at the picked placement first, as component.activate would, and make the sketch there; it stays active afterwards and the reply's `activated` names it), name?"), - CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"), - CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), - CommandSpec::new("sketch.cancel", "Cancel Sketch", cancel_sketch) + CommandSpec::new("sketch.edit", "Edit Sketch", crate::checked!("sketch.edit", edit_sketch)) + .noundo().icon("sketch") + .contract(&EDIT_CONTRACT).output(output_schema_for::) + .params("sketch: id or name; restore_marker?: nonnegative timeline position or null (end). When provided, temporarily roll to this sketch and restore that position on Finish or Cancel; Finish groups the edit into one Undo step. Omit to leave the timeline in place"), + CommandSpec::new("sketch.finish", "Finish Sketch", crate::checked!("sketch.finish", finish_sketch)) + .at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo() + .contract(&EXIT_CONTRACT).output(output_schema_for::) + .params("restore_marker?: bool (default true); false transfers the temporary marker directly to another editor without rebuilding the intervening timeline"), + CommandSpec::new("sketch.cancel", "Cancel Sketch", crate::checked!("sketch.cancel", cancel_sketch)) .at("SKETCH", "FINISH SKETCH") .icon("sketch_cancel") .enabled(in_sketch) - .params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead)"), + .contract(&EXIT_CONTRACT).output(output_schema_for::) + .params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead). restore_marker?: bool (default true); false transfers the temporary marker to another editor"), CommandSpec::new("sketch.line", "Line", draw_line) .at("SKETCH", "CREATE") .icon("line") @@ -69,7 +81,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CREATE") .icon("rect_rounded") .enabled(in_sketch) - .params(concat!("p0, p1: opposite corners [x,y]; radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())), + .params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them, as in `sketch.rectangle.two_point`); radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center) .at("SKETCH", "CREATE") .icon("rect_center") @@ -946,6 +958,16 @@ pub(super) fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneR } fn edit_sketch(s: &mut Session, p: &Value) -> Result { + let restore_marker = p + .get("restore_marker") + .map(|v| { + serde_json::from_value::>(v.clone()) + .map_err(|_| bad("sketch.edit", "`restore_marker` must be a nonnegative timeline position or null")) + }) + .transpose()?; + if restore_marker.flatten().is_some_and(|m| m > s.doc.features.len()) { + return Err(bad("sketch.edit", "`restore_marker` is past the end of the timeline")); + } let key = match p.get("sketch") { Some(Value::Number(n)) => n.to_string(), Some(Value::String(x)) => x.clone(), @@ -967,7 +989,16 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result { s.active_occurrence_path = (!path.is_empty()).then_some(path); } if s.active_sketch != Some(id) || s.sketch_entry.as_ref().is_none_or(|entry| entry.sketch != id) { - s.sketch_entry = Some(SketchEntry { sketch: id, ..entry_point(s) }); + s.sketch_entry = Some(SketchEntry { sketch: id, restore_marker, ..entry_point(s) }); + if restore_marker.is_some() + && let Some(index) = s.doc.feature_index(id) + { + let marker = (index + 1 < s.doc.features.len()).then_some(index + 1); + if s.doc.marker != marker { + s.doc_mut().marker = marker; + } + s.selection.clear(); + } } s.active_sketch = Some(id); s.revision += 1; @@ -976,15 +1007,32 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result { /// The design and undo history as they are now, where a sketch is entered (its id set after). fn entry_point(s: &Session) -> SketchEntry { - SketchEntry { sketch: 0, doc: s.doc.clone(), undo_len: s.undo.len(), undo_top: s.undo.last().map(|snap| snap.doc.clone()) } + SketchEntry { + sketch: 0, + doc: s.doc.clone(), + undo_len: s.undo.len(), + undo_top: s.undo.last().map(|snap| snap.doc.clone()), + restore_marker: None, + pending_moves: s.pending_moves.clone(), + visibility: s.visibility.clone(), + } +} + +fn restore_on_exit(cmd: &str, p: &Value) -> Result { + match p.get("restore_marker") { + None => Ok(true), + Some(Value::Bool(restore)) => Ok(*restore), + _ => Err(bad(cmd, "`restore_marker` must be a boolean")), + } } /// Cancel Sketch (#3838): the design as it was when the open sketch was entered. The entry is /// used only while the undo history still runs through it: the same steps before it, and every /// step since taken in this sketch. Otherwise the steps since can't be told apart from others, /// and Undo is the way back. -fn cancel_sketch(s: &mut Session, _p: &Value) -> Result { +fn cancel_sketch(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.cancel"; + let restore = restore_on_exit(cmd, p)?; let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?; let entry = s.sketch_entry.clone().filter(|entry| entry.sketch == id); let current = entry.filter(|entry| { @@ -1004,23 +1052,84 @@ fn cancel_sketch(s: &mut Session, _p: &Value) -> Result { return Err(bad(cmd, "the undo history no longer leads back to where this sketch was opened: use Undo to go back")); }; let created = entry.doc.feature(id).is_none(); + let marker = if restore { entry.restore_marker.unwrap_or(entry.doc.marker) } else { s.doc.marker }; // As Undo does: the session evaluates the design it is given. + // Undo of Cancel reopens this edit, so retain its return position for a later Finish. + s.sketch_entry = entry.restore_marker.map(|_| entry.clone()); s.doc = entry.doc; + let marker = marker.filter(|m| *m < s.doc.features.len()); + if s.doc.marker != marker { + s.doc_mut().marker = marker; + } s.active_sketch = None; - s.sketch_entry = None; s.selection.clear(); s.revision += 1; Ok(json!({"sketch": id, "removed": created})) } -fn finish_sketch(s: &mut Session, _p: &Value) -> Result { +fn finish_sketch(s: &mut Session, p: &Value) -> Result { + let restore = restore_on_exit("sketch.finish", p)?; let id = s.active_sketch.take(); - s.sketch_entry = None; - s.revision += 1; let profiles = id.and_then(|i| s.model.state().sketch(i).map(|ss| ss.profiles.len())).unwrap_or(0); + if let Some(entry) = s.sketch_entry.take().filter(|entry| Some(entry.sketch) == id) + && let Some(marker) = entry.restore_marker + { + finish_edit_history(s, &entry, marker); + if restore { + let marker = marker.filter(|m| *m < s.doc.features.len()); + if s.doc.marker != marker { + s.doc_mut().marker = marker; + } + } + s.selection.clear(); + } + s.revision += 1; Ok(json!({"sketch": id, "profiles": profiles})) } +/// Fold only the steps still belonging to this edit. Its snapshots describe the complete +/// design, never the temporary marker. The anchor may have moved when the undo limit trimmed +/// older steps; if it is gone, only a stack entirely from this sketch can be folded safely. +fn finish_edit_history(s: &mut Session, entry: &SketchEntry, marker: Option) { + use std::sync::Arc; + let start = match &entry.undo_top { + Some(top) => s.undo.iter().rposition(|snap| Arc::ptr_eq(&snap.doc, top)).map(|i| i + 1), + None => Some(0), + } + .or_else(|| (s.undo.len() == crate::MAX_UNDO && s.undo.iter().all(|snap| snap.active_sketch == Some(entry.sketch))).then_some(0)); + let Some(start) = + start.filter(|&start| s.undo.get(start..).is_some_and(|steps| steps.iter().all(|snap| snap.active_sketch == Some(entry.sketch)))) + else { + return; + }; + let mut before = (*entry.doc).clone(); + before.marker = marker.filter(|m| *m < before.features.len()); + let mut after = (*s.doc).clone(); + after.marker = marker.filter(|m| *m < after.features.len()); + let changed = before != after || entry.pending_moves != s.pending_moves || entry.visibility != s.visibility; + s.undo.truncate(start); + if changed { + s.undo.push(Snapshot { + label: "Edit Sketch".into(), + doc: Arc::new(before), + active_sketch: None, + pending_moves: entry.pending_moves.clone(), + visibility: entry.visibility.clone(), + }); + if s.undo.len() > crate::MAX_UNDO { + s.undo.remove(0); + } + } + // Redo of a step undone while editing must not reopen a finished temporary rollback. + for snap in &mut s.redo { + if snap.active_sketch == Some(entry.sketch) { + let doc = Arc::make_mut(&mut snap.doc); + doc.marker = marker.filter(|m| *m < doc.features.len()); + snap.active_sketch = None; + } + } +} + // --------------------------------------------------------------------------------------------- // Drawing @@ -1303,7 +1412,9 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result { /// fillet run in a scratch session that replaces the design only when both succeed. fn rect_rounded(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.rectangle.rounded"; - let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + // The corners may be typed as expressions: `sketch.rectangle.two_point` below places them, so + // here they are only worked out to check the radius against the sides. + let ((a, _), (b, _)) = (corner_arg(&s.doc, p, "p0", cmd)?, corner_arg(&s.doc, p, "p1", cmd)?); let r_expr = req_expr(cmd, p, "radius")?; let r = s.doc.eval(&r_expr, Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?; if !(r > 1e-9 && r < 1e8) { diff --git a/crates/engine/src/cmd/sketch_infinite_tests.rs b/crates/engine/src/cmd/sketch_infinite_tests.rs index 2ca806a34b..00c5dbf958 100644 --- a/crates/engine/src/cmd/sketch_infinite_tests.rs +++ b/crates/engine/src/cmd/sketch_infinite_tests.rs @@ -29,6 +29,44 @@ fn new_sketch() -> Session { s } +#[test] +fn a_short_horizontal_reference_holds_through_dimension_edits_drag_and_reopen() { + let mut s = new_sketch(); + let id = s.active_sketch.unwrap(); + // A saved infinite line may have very close defining points (#11001). + let sk = s.doc_mut().sketch_mut(id).unwrap(); + let end = sk.add_point(Vec2::new(6.36e-8, 0.0), Some("axis.end")).unwrap(); + let arm_end = sk.add_point(Vec2::new(12.0, 10.0), Some("arm.end")).unwrap(); + let axis = sk.add_line_pts(0, end, Some("axis")).unwrap(); + sk.curves[axis].infinite = true; + sk.curves[axis].construction = true; + sk.add_line_pts(0, arm_end, Some("arm")).unwrap(); + sk.add_constraint(solidcraft_sketch::ConstraintKind::Horizontal { l: axis }, None).unwrap(); + s.refresh(); + + let check = |s: &Session, degrees: f64| { + let state = s.model.state(); + let solved = state.sketch(id).unwrap(); + assert!(solved.report.ok(), "{:?}", solved.report); + let axis = solved.sketch.points[end].pos.normalized().unwrap(); + let arm = solved.sketch.points[arm_end].pos.normalized().unwrap(); + assert!(axis.dist(Vec2::X) < 1e-6, "the horizontal reference rotated: {axis:?}"); + assert!((arm.y.atan2(arm.x) - degrees.to_radians()).abs() < 1e-6, "the free line has the wrong angle: {arm:?}"); + }; + let out = run(&mut s, "sketch.dimension", json!({"entities": ["axis", "arm"], "type": "angle", "value": "30 deg"})); + check(&s, 30.0); + run(&mut s, "parameters.change", json!({"name": out["param"], "expression": "15 deg"})); + check(&s, 15.0); + let at = sketch(&s).points[arm_end].pos; + run(&mut s, "sketch.drag", json!({"entity": "arm.end", "from": [at.x, at.y], "to": [14, 10]})); + run(&mut s, "sketch.drag_end", json!({})); + check(&s, 15.0); + let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + *s.doc_mut() = doc; + s.refresh(); + check(&s, 15.0); +} + fn profiles(s: &Session) -> usize { s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.len() } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index c6109d9db1..29c3f97d6d 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -60,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("trim") .key("T") .enabled(in_sketch) - .params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())), + .params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it). Trimming a line source releases its dependent straight offsets' linked extents (offset_released: curve ids), retaining their driving normal-distance dimensions and parallel relations", created_doc!())), CommandSpec::new("sketch.trim.path", "Trim Along Path", trim_path) .icon("trim") .enabled(in_sketch) @@ -326,6 +326,54 @@ fn release_extent(sk: &mut Sketch, ci: usize) { }); } +/// Tangencies tied to one of the curve's ends, through a shared point or Coincident +/// constraints. Record them before splitting: keeping the curve ID alone would leave an +/// end tangent on the wrong piece and lose the solver's endpoint tangency equation. +fn endpoint_tangencies(sk: &Sketch, ci: usize) -> Vec<(usize, usize)> { + let Some((start, end)) = sk.curves.get(ci).and_then(|c| c.kind.ends()) else { return Vec::new() }; + let groups = coincident_points(sk); + let root = |p: usize| groups.get(p).copied().unwrap_or(p); + sk.constraints + .iter() + .enumerate() + .filter_map(|(i, k)| { + let ConstraintKind::Tangent { a, b } = k.kind else { return None }; + let other = if a == ci { + b + } else if b == ci { + a + } else { + return None; + }; + let (a, b) = sk.curves.get(other)?.kind.ends()?; + let joined = |p| root(p) == root(a) || root(p) == root(b); + match (joined(start), joined(end)) { + (true, false) => Some((i, start)), + (false, true) => Some((i, end)), + _ => None, + } + }) + .collect() +} + +/// Follow the original endpoints after the pieces have exchanged geometry to preserve the +/// original curve ID. A tangent on the removed piece goes with it in `remove_curves`. +fn rebind_endpoint_tangencies(sk: &mut Sketch, ci: usize, pieces: &[usize], tangencies: &[(usize, usize)]) { + for &(constraint, point) in tangencies { + let owner = pieces.iter().copied().find(|i| sk.curves.get(*i).and_then(|c| c.kind.ends()).is_some_and(|(a, b)| a == point || b == point)); + if let Some(owner) = owner + && let Some(k) = sk.constraints.get_mut(constraint) + && let ConstraintKind::Tangent { a, b } = &mut k.kind + { + if *a == ci { + *a = owner; + } else if *b == ci { + *b = owner; + } + } + } +} + /// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves. fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) { let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) }; @@ -409,13 +457,103 @@ fn trim(s: &mut Session, p: &Value) -> Result { let at = req_vec2(cmd, p, "at")?; let (out, info) = edit(s, p, cmd, false, |sk, doc| { let ci = curve_arg(sk, p, cmd)?; - let (out, _) = remove_piece(sk, ci, at, cmd)?; + let released = release_source_line_offsets(sk, doc, ci, cmd)?; + let (mut out, _) = remove_piece(sk, ci, at, cmd)?; + if !released.is_empty() { + out["offset_released"] = json!(released); + } doc.prune_model_params(); Ok(out) })?; Ok(json!({"result": out, "sketch": info})) } +/// A source line's endpoints may change independently of its parallel offsets. Materialize +/// the affected line chains before Trim, retaining their original driving distance parameters +/// as point-line dimensions. Directly trimming an associated output remains a separate command policy. +fn release_source_line_offsets(sk: &mut Sketch, doc: &solidcraft_doc::Document, ci: usize, cmd: &str) -> Result> { + let Some(curve) = sk.curves.get(ci) else { return Ok(Vec::new()) }; + if curve.infinite || sk.is_linked_curve(ci) || !matches!(curve.kind, CurveKind::Line { .. }) { + return Ok(Vec::new()); + } + let id = curve.id.clone(); + let affected: Vec = sk + .offsets + .iter() + .enumerate() + .filter_map(|(i, offset)| { + (offset.topology.is_none() + && offset.sources.iter().any(|(source, _)| source == &id) + && offset.sources.len() == offset.outputs.len() + && offset.sources.iter().zip(&offset.outputs).all(|((source, _), output)| { + [source, output] + .iter() + .all(|id| sk.curve_index(id).and_then(|ci| sk.curves.get(ci)).is_some_and(|c| matches!(c.kind, CurveKind::Line { .. }))) + })) + .then_some(i) + }) + .collect(); + if affected.is_empty() { + return Ok(Vec::new()); + } + let report = solve_current(sk, doc)?; + if !report.keeps_orientation() { + return Err(bad(cmd, report.failure(|_| "cannot trim a source with conflicting offset dimensions".into()))); + } + let mut released = Vec::new(); + for index in affected.into_iter().rev() { + let record = sk.offsets.remove(index); + let dimension = sk + .constraints + .iter() + .find(|c| { + matches!(&c.kind, + ConstraintKind::OffsetDistance { output, .. } if record.outputs.contains(output)) + }) + .cloned() + .ok_or_else(|| bad(cmd, "missing offset distance dimension"))?; + let value = match dimension.kind { + ConstraintKind::OffsetDistance { value, .. } => value, + _ => record.distance.abs(), + }; + sk.constraints.retain(|c| !matches!(&c.kind, ConstraintKind::OffsetDistance { output, .. } if record.outputs.contains(output))); + for output in &record.outputs { + let target = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset line"))?; + let points = sk.curves.get(target).map(|c| c.kind.point_ids()).unwrap_or_default(); + for i in points { + if i != 0 + && sk.curves.iter().all(|c| !c.kind.uses(i) || record.outputs.contains(&c.id)) + && let Some(point) = sk.points.get_mut(i) + { + point.fixed = false; + } + } + } + for (index, ((source, _), output)) in record.sources.iter().zip(&record.outputs).enumerate() { + let source = sk.curve_index(source).ok_or_else(|| bad(cmd, "missing offset source"))?; + let target = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset line"))?; + if !sk.constraints.iter().any(|c| { + matches!(c.kind, + ConstraintKind::Parallel { a, b } if (a == source && b == target) || (a == target && b == source)) + }) { + add_c(sk, ConstraintKind::Parallel { a: source, b: target })?; + } + let p = sk.curves.get(target).and_then(|c| c.kind.ends()).map(|ends| ends.0).ok_or_else(|| bad(cmd, "missing offset line endpoint"))?; + let kind = ConstraintKind::PointLineDistance { p, l: source, value }; + let id = sk.add_constraint(kind.clone(), dimension.param.clone())?; + if let Some(added) = sk.constraints.iter_mut().find(|c| c.id == id) { + let mut kept = dimension.clone(); + kept.id = if index == 0 { dimension.id.clone() } else { id }; + kept.kind = kind; + *added = kept; + } + } + released.extend(record.outputs); + } + released.sort(); + Ok(released) +} + /// Remove the piece of curve `ci` around `at`: between the nearest intersections on either /// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the /// removed piece as a polyline. @@ -443,7 +581,23 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu return Ok((json!({"deleted": id}), removed)); } refuse_offset_ends(sk, ci, "trim", cmd)?; + // For parallel lines, any point on the output measures the same normal distance. + // Move an endpoint witness to the surviving piece before removing its old endpoint. + let witnesses: Vec = sk + .constraints + .iter() + .filter_map(|dimension| { + let ConstraintKind::PointLineDistance { p, l, .. } = dimension.kind else { return None }; + let ends = sk.curves.get(ci).and_then(|c| c.kind.ends())?; + let line = matches!(sk.curves.get(ci)?.kind, CurveKind::Line { .. }); + (line + && (p == ends.0 || p == ends.1) + && sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Parallel { a, b } if (a == ci && b == l) || (a == l && b == ci)))) + .then(|| dimension.id.clone()) + }) + .collect(); release_extent(sk, ci); + let tangencies = endpoint_tangencies(sk, ci); let ts: Vec = cuts.iter().map(|c| c.0).collect(); let (pieces, pts) = split_at(sk, ci, &ts)?; // The piece containing tq. @@ -475,8 +629,18 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu } _ => gone, }; + rebind_endpoint_tangencies(sk, ci, &pieces, &tangencies); let keep: Vec = pieces.iter().copied().filter(|x| *x != gone).collect(); tie(sk, &keep)?; + if let Some((a, _)) = sk.curves.get(ci).and_then(|c| c.kind.ends()) { + for dimension in &mut sk.constraints { + if witnesses.contains(&dimension.id) + && let ConstraintKind::PointLineDistance { p, .. } = &mut dimension.kind + { + *p = a; + } + } + } let removed = sk.polyline(gone); sk.remove_curves(&[gone]); Ok((json!({"trimmed": id}), removed)) @@ -575,7 +739,14 @@ fn trim_path(s: &mut Session, p: &Value) -> Result { after = (si, t); // A piece trim refuses (a closed spline crossed once) stays as it was. let before = sk.clone(); - match remove_piece(sk, ci, q, cmd) { + let cut = release_source_line_offsets(sk, doc, ci, cmd).and_then(|released| { + let (mut out, gone) = remove_piece(sk, ci, q, cmd)?; + if !released.is_empty() { + out["offset_released"] = json!(released); + } + Ok((out, gone)) + }); + match cut { Ok((out, gone)) => { trimmed.push(out); removed.push(json!(gone.iter().map(|v| [v.x, v.y]).collect::>())); @@ -598,6 +769,9 @@ fn trim_path(s: &mut Session, p: &Value) -> Result { Ok(json!({"trimmed": trimmed, "removed": removed, "skipped": skipped, "sketch": info})) } +/// Cuts closer than this to a curve's end or to each other are one place (mm). +const BREAK_GAP: f64 = 1e-6; + fn break_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.break"; let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None }; @@ -633,6 +807,23 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result { if ts.is_empty() || (!sh.is_full() && ts.iter().any(|t| *t <= t0 || *t >= t1)) { return Err(bad(cmd, "nothing to break at there")); } + // A cut on (or a hair from) an end or another cut would make a piece of no length, and + // its ties: where a curve ends, or touches a tangent arc, it is already broken. + let ends = if sh.is_full() { Vec::new() } else { vec![sh.point(t0), sh.point(t1)] }; + let mut kept: Vec<(f64, Option)> = Vec::new(); + for (t, c) in ts.iter().zip(&cutters) { + let q = sh.point(*t); + if !ends.iter().chain(kept.iter().map(|k| sh.point(k.0)).collect::>().iter()).any(|e| e.dist(q) < BREAK_GAP) { + kept.push((*t, *c)); + } + } + if kept.is_empty() { + return Err(bad(cmd, "the curve is already broken there")); + } + let (ts, cutters): (Vec, Vec>) = kept.into_iter().unzip(); + // Its length (and its midpoint, an equal length) measured the whole curve: left on the + // first piece, it would stretch that piece back to the old size and skew the sketch. + release_extent(sk, ci); let (pieces, pts) = split_at(sk, ci, &ts)?; for (pi, c) in pts.iter().zip(&cutters) { if let Some(c) = c { diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 8ad0a94af3..0157bdd4f1 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -196,6 +196,87 @@ fn analytic_trim_and_break_use_freeform_cutters() { assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6); } +/// #10851: an endpoint's tangent must stay with the arc piece that owns that endpoint, +/// including when the endpoints are separate points joined by a Coincident constraint. +#[test] +fn trim_keeps_endpoint_tangency_on_its_arc_piece() { + for path in [false, true] { + for separate in [false, true] { + for at_start in [false, true] { + for reverse in [false, true] { + let mut s = new_sketch(); + run(&mut s, "sketch.arc.center_point", json!({"center": [0,0], "start": [50,0], "end": [-50,0]})); + let endpoint = if at_start { "a1.start" } else { "a1.end" }; + let x = if at_start { 50 } else { -50 }; + let start = if separate { json!([x, 0]) } else { json!(endpoint) }; + run(&mut s, "sketch.line", json!({"points": [start, [x,-30]], "connect": false, "infer": false})); + if separate { + run(&mut s, "sketch.constraint.coincident", json!({"a": "l1.start", "b": endpoint})); + } + let (a, b) = if reverse { ("a1", "l1") } else { ("l1", "a1") }; + let tangent = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}))["constraint"].as_str().unwrap().to_string(); + run(&mut s, "sketch.dimension", json!({"entities": ["a1"], "type": "radius", "value": "50"})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "30"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "a1.start"], "mode": "horizontal"})); + for x in [-10, 10] { + run(&mut s, "sketch.line", json!({"points": [[x,40],[x,60]]})); + } + run(&mut s, "sketch.constraint.fix", json!({"entities": ["l2", "l3"]})); + assert_eq!(run(&mut s, "sketch.solve", json!({}))["dof"], 1); + let before = sketch(&s); + let undo = s.undo.len(); + let result = if path { + run(&mut s, "sketch.trim.path", json!({"points": [[0,45],[0,55]]})) + } else { + run(&mut s, "sketch.trim", json!({"curve": "a1", "at": [0,50]})) + }; + assert_eq!(result["sketch"]["dof"], 1, "path={path}, separate={separate}, at_start={at_start}, reverse={reverse}: {result}"); + assert_eq!(s.undo.len(), undo + 1); + let after = sketch(&s); + let ci = after.curve_index(if at_start { "a1" } else { "a3" }).unwrap(); + let li = after.curve_index("l1").unwrap(); + assert!(after.constraints.iter().any( + |k| k.id == tangent && matches!(k.kind, ConstraintKind::Tangent { a, b } if (a == ci && b == li) || (a == li && b == ci)) + )); + let at = |id: &str| after.point(after.resolve_point(id).unwrap()).unwrap(); + assert!(at(endpoint).dist(at("l1.start")) < 1e-8); + let radius = at("l1.start") - at("a1.center"); + let line = at("l1.end") - at("l1.start"); + assert!(radius.dot(line).abs() < 1e-6, "the line stays tangent to the circle"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(sketch(&s), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(sketch(&s), after); + } + } + } + } +} + +#[test] +fn trim_keeps_or_removes_tangency_with_the_original_end() { + for remove_tangent_end in [false, true] { + let mut s = new_sketch(); + run(&mut s, "sketch.arc.center_point", json!({"center": [0,0], "start": [50,0], "end": [-50,0]})); + run(&mut s, "sketch.line", json!({"points": ["a1.end", [-50,-30]]})); + let tangent = run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "a1"}))["constraint"].as_str().unwrap().to_string(); + run(&mut s, "sketch.line", json!({"points": [[0,40],[0,60]]})); + let before = sketch(&s); + let x = if remove_tangent_end { -40 } else { 40 }; + run(&mut s, "sketch.trim", json!({"curve": "a1", "at": [x,30]})); + let after = sketch(&s); + assert!(after.curve_index("a1").is_some(), "the original curve ID survives either end trim"); + assert_eq!(after.constraints.iter().any(|k| k.id == tangent), !remove_tangent_end); + if !remove_tangent_end { + let arc = after.curve_index("a1").unwrap(); + let line = after.curve_index("l1").unwrap(); + assert!(after.constraints.iter().any(|k| k.id == tangent && matches!(k.kind, ConstraintKind::Tangent { a, b } if a == line && b == arc))); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!(sketch(&s), before); + } +} + #[test] fn trim_extend_break() { let mut s = new_sketch(); @@ -4492,3 +4573,204 @@ fn move_copy_drawn_wire_failures_leave_the_design_and_history_unchanged() { assert_eq!(s.doc.to_json(), original); assert_eq!((s.undo.len(), s.redo.len()), history); } + +/// A construction line and its two offsets each stop at their own crossing. Trimming one +/// must never shorten the others, whatever order the three curves are picked in. +#[test] +fn trim_source_then_two_offsets_independently_in_either_order() { + for order in [[0, 1, 2], [0, 2, 1]] { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0,0],[40,40]], "construction": true, "connect": false, "infer": false, "ids": ["source"]})); + let out = run(&mut s, "sketch.offset", json!({"curves": ["source"], "distance": 2, "symmetric": true, "chain": false})); + let mut curves = vec!["source".to_string()]; + curves.extend(ids(&out["curves"])); + assert_eq!(curves.len(), 3); + run(&mut s, "sketch.line", json!({"points": [[-20,20],[60,20]], "connect": false, "infer": false})); + for picked in order { + let before = sketch(&s); + let geometry = |sk: &solidcraft_sketch::Sketch, id: &str| sk.shape(sk.curve_index(id).unwrap()).unwrap(); + let solidcraft_sketch::Shape::Line { a, b } = geometry(&before, &curves[picked]) else { panic!("line") }; + let r = run(&mut s, "sketch.trim", json!({"curve": curves[picked], "at": [a.x + 1.0, a.y + 1.0]})); + assert_eq!(r["sketch"]["solved"], true, "{order:?}: {r}"); + let after = sketch(&s); + let solidcraft_sketch::Shape::Line { a: start, b: end } = geometry(&after, &curves[picked]) else { panic!("line") }; + assert!((start.y - 20.0).abs() < 1e-6 && (start.x - (20.0 + a.x - a.y)).abs() < 1e-6, "{order:?}: {start:?}"); + assert!(end.dist(b) < 1e-6, "the far end stays put: {order:?}: {end:?} {b:?}"); + for (i, id) in curves.iter().enumerate().filter(|(i, _)| *i != picked) { + let solidcraft_sketch::Shape::Line { a: old_a, b: old_b } = geometry(&before, id) else { panic!("line") }; + let solidcraft_sketch::Shape::Line { a: new_a, b: new_b } = geometry(&after, id) else { panic!("line") }; + assert!(new_a.dist(old_a) < 1e-6 && new_b.dist(old_b) < 1e-6, "unpicked {i} changed in {order:?}: {old_a:?} -> {new_a:?}"); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!( + serde_json::to_value(sketch(&s)).unwrap(), + serde_json::to_value(before).unwrap(), + "one undo restores the complete association" + ); + run(&mut s, "edit.redo", json!({})); + assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), serde_json::to_value(after).unwrap()); + } + } +} + +#[test] +fn trim_path_cuts_source_and_two_offsets_at_their_own_crossings() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0,0],[40,40]], "construction": true, "connect": false, "infer": false, "ids": ["source"]})); + let out = run(&mut s, "sketch.offset", json!({"curves": ["source"], "distance": 2, "symmetric": true, "chain": false})); + let mut curves = vec!["source".to_string()]; + curves.extend(ids(&out["curves"])); + run(&mut s, "sketch.line", json!({"points": [[-20,20],[60,20]], "connect": false, "infer": false})); + let before = s.doc.to_json(); + let path = json!({"points": [[4,5],[15,5],[-10,5]], "dry_run": true}); + let preview = run(&mut s, "sketch.trim.path", path); + assert_eq!(preview["trimmed"].as_array().unwrap().len(), 3, "{preview}"); + assert_eq!(s.doc.to_json(), before, "preview releases no stored associations or parameters"); + let r = run(&mut s, "sketch.trim.path", json!({"points": [[4,5],[15,5],[-10,5]]})); + assert_eq!(r["trimmed"], preview["trimmed"]); + assert!(r["skipped"].as_array().unwrap().is_empty(), "{r}"); + let sk = sketch(&s); + assert!(sk.offsets.is_empty()); + for id in curves { + let solidcraft_sketch::Shape::Line { a, .. } = sk.shape(sk.curve_index(&id).unwrap()).unwrap() else { panic!("line") }; + assert!((a.y - 20.0).abs() < 1e-6, "{id}: {a:?}"); + } + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before, "one undo restores all three lines and their associations"); +} + +#[test] +fn trim_whole_source_keeps_its_offsets_and_unrelated_associations() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0,0],[40,40]], "connect": false, "infer": false, "ids": ["source"]})); + let out = run(&mut s, "sketch.offset", json!({"curves": ["source"], "distance": 2, "symmetric": true, "chain": false})); + let outputs = ids(&out["curves"]); + run(&mut s, "sketch.line", json!({"points": [[100,100],[120,100]], "connect": false, "infer": false, "ids": ["unrelated"]})); + let other = run(&mut s, "sketch.offset", json!({"curves": ["unrelated"], "distance": 3, "chain": false})); + let before = sketch(&s); + let doc_before = s.doc.to_json(); + let r = run(&mut s, "sketch.trim", json!({"curve": "source", "at": [10,10]})); + assert_eq!(r["result"]["deleted"], "source"); + assert_eq!(r["sketch"]["solved"], true); + let sk = sketch(&s); + assert!(sk.curve_index("source").is_none()); + assert_eq!(sk.offsets.len(), 1); + for id in outputs { + assert_eq!(sk.polyline(sk.curve_index(&id).unwrap()), before.polyline(before.curve_index(&id).unwrap()), "{id}"); + } + let other_output = other["curves"][0].as_str().unwrap(); + assert_eq!(sk.offset_distance(other_output), Some(3.0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), doc_before); +} + +#[test] +fn trim_keeps_offset_distances_driving_after_trims_drag_and_reopen() { + let verify = |s: &Session, distance: f64, parameter: &str| { + let state = s.world_state(); + let sk = &state.sketch(1).unwrap().sketch; + let solidcraft_sketch::Shape::Line { a, b } = sk.shape(sk.curve_index("source").unwrap()).unwrap() else { panic!("source line") }; + let dir = (b - a).normalized().unwrap(); + for (id, sign) in [("l1", 1.0), ("l2", -1.0)] { + let solidcraft_sketch::Shape::Line { a: p, b: q } = sk.shape(sk.curve_index(id).unwrap()).unwrap() else { panic!("offset line") }; + assert!(dir.cross((q - p).normalized().unwrap()).abs() < 1e-6, "{id} is parallel"); + for point in [p, q] { + assert!((dir.cross(point - a) - sign * distance).abs() < 1e-6, "{id}: {point:?}, distance {distance}"); + } + } + assert!(s.doc.param(parameter).is_some(), "the original driving offset parameter remains"); + assert_eq!(sk.constraints.iter().filter(|c| c.param.as_deref() == Some(parameter)).count(), 2, "both sides retain their dimensions"); + assert!(state.sketch(1).unwrap().report.ok()); + }; + for reverse_outputs in [true, false] { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,0],[40,40]],"construction":true,"connect":false,"infer":false,"ids":["source"]})); + let out = run(&mut s, "sketch.offset", json!({"curves":["source"],"distance":2,"symmetric":true,"chain":false})); + let parameter = out["param"].as_str().unwrap().to_string(); + let cutter = + run(&mut s, "sketch.line", json!({"points":[[-20,20],[60,20]],"connect":false,"infer":false}))["curves"][0].as_str().unwrap().to_string(); + let order = if reverse_outputs { ["source", "l2", "l1"] } else { ["source", "l1", "l2"] }; + for id in order { + let sk = sketch(&s); + let solidcraft_sketch::Shape::Line { a, .. } = sk.shape(sk.curve_index(id).unwrap()).unwrap() else { panic!("line") }; + run(&mut s, "sketch.trim", json!({"curve":id,"at":[a.x+1.0,a.y+1.0]})); + verify(&s, 2.0, ¶meter); + } + // A second end trim must reattach the existing distance witness to the retained line. + let second_cutter = + run(&mut s, "sketch.line", json!({"points":[[-20,25],[60,25]],"connect":false,"infer":false}))["curves"][0].as_str().unwrap().to_string(); + for id in order { + let sk = sketch(&s); + let solidcraft_sketch::Shape::Line { a, .. } = sk.shape(sk.curve_index(id).unwrap()).unwrap() else { panic!("line") }; + run(&mut s, "sketch.trim", json!({"curve":id,"at":[a.x+1.0,a.y+1.0]})); + verify(&s, 2.0, ¶meter); + } + run(&mut s, "sketch.constraint.fix", json!({"entities":["source",cutter,second_cutter]})); + run(&mut s, "sketch.move_point", json!({"point":"l1.end","to":[55,25]})); + verify(&s, 2.0, ¶meter); + run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"})); + verify(&s, 3.0, ¶meter); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + verify(&reopened, 3.0, ¶meter); + run(&mut reopened, "parameters.change", json!({"name":parameter,"expression":"4 mm"})); + verify(&reopened, 4.0, ¶meter); + run(&mut reopened, "edit.undo", json!({})); + verify(&reopened, 3.0, ¶meter); + } +} + +/// Breaking a piece again where it was already broken adds nothing: no zero-length piece and +/// no new constraints, however often it is clicked. +#[test] +fn breaking_again_where_a_curve_was_broken_adds_nothing() { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone(); + // A circle tangent to A (as the slot's end arc is to its side): a tangent touch is a double + // root, found as two cuts a hair apart. + let x = 20.0 / 3.0 + 0.123_456_789; + run(&mut s, "sketch.circle.center", json!({"center": [x, 3.0], "radius": 3.0})); + run(&mut s, "sketch.dimension", json!({"entities": [a.clone()], "value": 20})); + run(&mut s, "sketch.break", json!({"curve": a, "at": [5, 0]})); + let count = |s: &Session| { + let k = sketch(s); + (k.curves.len(), k.constraints.len()) + }; + let first = count(&s); + let shown = |s: &Session| { + let k = sketch(s); + k.curves + .iter() + .map(|c| match c.kind { + CurveKind::Line { a, b } => format!("line {:?}-{:?}", k.point(a), k.point(b)), + _ => "other".into(), + }) + .collect::>() + }; + eprintln!("after the first break: {:?}", shown(&s)); + for _ in 0..5 { + let k = sketch(&s); + let left = k + .curves + .iter() + .find(|c| { + matches!(c.kind, CurveKind::Line { .. }) + && k.shape(k.curve_index(&c.id).unwrap()).is_some_and(|sh| sh.project(Vec2::new(5.0, 0.0)).dist(Vec2::new(5.0, 0.0)) < 1e-9) + }) + .map(|c| c.id.clone()); + let Some(left) = left else { panic!("no piece through (5, 0): {:?}", shown(&s)) }; + let _ = s.execute("sketch.break", &json!({"curve": left, "at": [5, 0]})); + let _ = s.execute("sketch.break", &json!({"curve": left, "at": [x - 1e-7, 0]})); + } + let k = sketch(&s); + eprintln!("first {first:?} now {:?}; constraints {:?}", count(&s), k.constraints.iter().map(|c| format!("{:?}", c.kind)).collect::>()); + assert_eq!(count(&s), first, "repeated breaks added curves or constraints"); + // The line stays where it was, 20 long: the length became the distance between its old ends. + let k = sketch(&s); + for c in &k.curves { + if let CurveKind::Line { a, b } = c.kind { + let (pa, pb) = (k.point(a).unwrap(), k.point(b).unwrap()); + assert!(pa.y.abs() < 1e-9 && pb.y.abs() < 1e-9 && pa.dist(pb) > 1e-3, "{:?}", shown(&s)); + } + } + assert!(k.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Distance { value, .. } if close(value, 20.0))), "{:?}", k.constraints); +} diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index c76c364e72..6afdc7ecbf 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -500,6 +500,77 @@ fn three_d_sketch_curves() { assert!((c["start_at"][0].as_f64().unwrap().abs() - 6.0).abs() < 1e-3, "{c}"); } +/// #9009: picked faces intersect as the viewport picks them, the whole surface patch, not just the +/// B-rep piece under the pick; a fit-point spline's wall is one face per span joined by hidden seams. +#[test] +fn intersection_curve_of_picked_faces_spans_the_whole_surface() { + use solidcraft_doc::project::resolve; + use solidcraft_geom::Plane; + use solidcraft_sketch::{LinkKind, LinkSource}; + use std::collections::BTreeMap; + + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "wall_h", "expression": "20 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Wave"})); + let wave = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [9.5, 12], [30.5, 7], [52, 15]]}))["curves"])[0].clone(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.extrude", json!({"sketch": "Wave", "curves": [wave], "distance": "wall_h", "body_name": "Wall"})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Roof"})); + // XZ sketch y is world -Z, so the arc peaks at world z = 15. + let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [0, 0], "end": [50, 0], "through": [25, -15]}))["created"]["curves"][0] + .as_str() + .unwrap() + .to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.extrude", json!({"sketch": "Roof", "curves": [arc], "distance": 30, "body_name": "Roof"})); + + let st = s.model.state(); + let wall = st.body("Wall").unwrap(); + let m = wall.mesh(); + assert!(wall.body.face_count() > 1, "premise of #9009: one face per spline span"); + assert_eq!(m.surface_patch(m.tri_face[0] as usize).len(), wall.body.face_count(), "premise: the spans are one surface patch (hidden seams)"); + // A pick on the middle span, away from the joins at x = 9.5 and x = 30.5. + let pick = + *m.positions.iter().min_by(|a, b| ((a.x - 20.).abs() + (a.z - 10.).abs()).total_cmp(&((b.x - 20.).abs() + (b.z - 10.).abs()))).unwrap(); + + let check = |s: &Session, id: u64| { + let st = s.model.state(); + let sk = &st.sketch(id).unwrap().sketch; + assert!(sk.links.iter().all(|l| !l.lost), "no link lost"); + let pts: Vec<_> = sk.wires.iter().flat_map(|w| w.pts.iter().copied()).collect(); + assert!(!pts.is_empty()); + let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.x), h.max(p.x))); + assert!(lo < 1.0 && hi > 49.0, "the whole crossing, not one span: x {lo}..{hi}"); + for p in &pts { + assert!((((p.x - 25.).powi(2) + (p.z + 40. / 3.).powi(2)).sqrt() - 85. / 3.).abs() < 0.1, "{p:?}"); + assert!(p.y > -1e-6 && p.y < 30. + 1e-6, "{p:?}"); + } + }; + + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let id = s.active_sketch.unwrap(); + let r = run( + &mut s, + "sketch.intersection_curve", + json!({"a": {"face": [pick.x, pick.y, pick.z], "body": "Wall"}, "b": {"face": [25, 15, 15], "body": "Roof"}}), + ); + assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}"); + check(&s, id); + + // The same as giving the two bodies. + let st = s.model.state(); + let body = |n: &str| Box::new(LinkSource::Body { body: n.to_string() }); + let whole = resolve(&s.doc, &BTreeMap::new(), &st, &Plane::XY, LinkKind::Include, &LinkSource::Intersection { a: body("Wall"), b: body("Roof") }) + .unwrap(); + let sk = &st.sketch(id).unwrap().sketch; + assert_eq!(sk.wires.len(), whole.wires.len()); + + // Editing the wall re-resolves the stored pick to the same patch. + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "parameters.change", json!({"name": "wall_h", "expression": "25 mm"})); + check(&s, id); +} + #[test] fn isoparametric_curves_of_a_cylinder() { let mut s = Session::default(); @@ -861,9 +932,13 @@ fn projecting_what_the_face_sketch_already_shows_adds_no_second_copy() { let border = shown(&mut s); assert_eq!(border.0, 8, "four sides and four rounded corners"); let corner = 5.0 - 5.0 * std::f64::consts::FRAC_1_SQRT_2; - for refs in [json!([{"face":[20,15,10]}]), json!([{"edge":[20,0,10]}]), json!([{"edge":[corner,corner,10]}])] { + // These references name no body, so the refusal names the kind alone: it must not end in + // "face of " with nothing after the "of" (#4929). + let cases = [(json!([{"face":[20,15,10]}]), "face"), (json!([{"edge":[20,0,10]}]), "edge"), (json!([{"edge":[corner,corner,10]}]), "edge")]; + for (refs, what) in cases { let e = s.execute("sketch.project", &json!({"refs": refs})).unwrap_err().to_string(); assert!(e.contains("already projected"), "{refs}: {e}"); + assert!(e.ends_with(&format!("already projected into this sketch: {what}")), "{refs}: {e}"); assert_eq!(shown(&mut s), border, "{refs}"); } // Something new still projects, and alongside it a reference already shown is skipped. diff --git a/crates/engine/src/cmd/sketch_rounded_rect_tests.rs b/crates/engine/src/cmd/sketch_rounded_rect_tests.rs index 023a6c1362..159230b1d2 100644 --- a/crates/engine/src/cmd/sketch_rounded_rect_tests.rs +++ b/crates/engine/src/cmd/sketch_rounded_rect_tests.rs @@ -146,3 +146,27 @@ fn typed_width_and_height_measure_the_whole_rounded_rectangle() { assert!((area - want).abs() < 1e-6, "area {area}, want {want}: {si}"); assert_eq!(si["dof"], 2, "only the position is free: {si}"); } + +/// The corners take expressions over the design's parameters, as a 2-point rectangle's do: the +/// rounded rectangle follows them, and the radius is checked against the sides they work out to. +#[test] +fn corners_typed_as_expressions_follow_their_parameters() { + let mut s = sketch(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "parameters.add", json!({"name": "H", "expression": "20 mm"})); + let r = run(&mut s, "sketch.rectangle.rounded", json!({"p0": ["-W / 2", "-H / 2"], "p1": ["W / 2", "H / 2"], "radius": 3})); + assert_eq!(ids(&r["arcs"]).len(), 4, "{r}"); + let area = |s: &mut Session| { + let si = run(s, "sketch.inspect", json!({})); + si["profiles"][0]["area_mm2"].as_f64().unwrap_or(f64::NAN) + }; + let corners = (4.0 - std::f64::consts::PI) * 9.0; + let a = area(&mut s); + assert!((a - (40.0 * 20.0 - corners)).abs() < 1e-6, "area {a}"); + run(&mut s, "parameters.change", json!({"name": "W", "expression": "70 mm"})); + let a = area(&mut s); + assert!((a - (70.0 * 20.0 - corners)).abs() < 1e-6, "area after W = 70 mm: {a}"); + // A radius the worked-out sides cannot take is refused, as for numbers. + let too_big = s.execute("sketch.rectangle.rounded", &json!({"p0": [0, 0], "p1": ["W", "H / 4"], "radius": 3})); + assert!(too_big.is_err(), "radius 3 mm on a 5 mm side: {too_big:?}"); +} diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 691dd6a374..6bc3a98bff 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -1800,6 +1800,55 @@ fn budget_limited_cursor_progress_requests_a_frame_with_the_same_target() { // --------------------------------------------------------------------------------------------- // Cancel Sketch (#3838) +fn sketch_below_a_solid() -> (Session, u64) { + let mut s = Session::default(); + let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + (s, id) +} + +#[test] +fn finishing_a_temporary_sketch_edit_at_the_undo_limit_keeps_the_original_design() { + let (mut s, id) = sketch_below_a_solid(); + for i in 0..crate::MAX_UNDO { + run(&mut s, "browser.visibility", json!({"items": ["Body1"], "visible": i % 2 != 0})); + } + assert_eq!(s.undo.len(), crate::MAX_UNDO); + let before = s.doc.to_json(); + run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null})); + for y in [20, 30] { + run(&mut s, "sketch.line", json!({"points": [[0, y], [10, y]]})); + } + run(&mut s, "sketch.finish", json!({})); + assert!(s.undo.len() <= crate::MAX_UNDO); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.active_sketch, None); + assert_eq!(s.model.state().bodies.len(), 1); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.marker, None); + assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 6); +} + +#[test] +fn undoing_cancel_then_finishing_restores_the_temporary_sketch_marker() { + let (mut s, id) = sketch_below_a_solid(); + run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null})); + run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]})); + run(&mut s, "sketch.cancel", json!({})); + assert_eq!(s.doc.marker, None); + assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 4); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.active_sketch, Some(id)); + run(&mut s, "sketch.finish", json!({})); + assert_eq!(s.doc.marker, None); + assert_eq!(s.active_sketch, None); + assert_eq!(s.doc.sketch(id).unwrap().curves.len(), 5); + assert_eq!(s.model.state().bodies.len(), 1); +} + #[test] fn cancel_removes_a_new_sketch_with_its_dimensions_and_undo_brings_it_back() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/solid_move_copy_tests.rs b/crates/engine/src/cmd/solid_move_copy_tests.rs index 7938175409..94590e9ddd 100644 --- a/crates/engine/src/cmd/solid_move_copy_tests.rs +++ b/crates/engine/src/cmd/solid_move_copy_tests.rs @@ -181,3 +181,160 @@ fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() { let moved = state.body("Cube").unwrap(); assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6); } + +/// A component `Part` holding a 20 mm cube at the origin, placed once at the root. +fn part_with_cube(s: &mut Session) -> (u64, u64) { + let made = run(s, "component.create", json!({"name": "Part", "grounded": false})); + cube20(s, "Peg"); + run(s, "component.activate", json!({"component": "root"})); + (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap()) +} + +/// The component an occurrence is an instance of. +fn component_of(s: &Session, occurrence: u64) -> u64 { + s.doc.occurrences.iter().find(|o| o.id == occurrence).expect("the occurrence exists").component +} + +/// The lowest corner of the first body a placement shows, in the world. +fn world_min(s: &Session, path: &[u64]) -> Vec3 { + let names = s.occurrence_bodies(path, true).unwrap(); + s.world_state().body(&names[0]).unwrap().mesh().bounds().min +} + +#[test] +fn copying_a_picked_component_makes_an_independent_component_at_the_moved_place() { + let mut s = Session::default(); + let (part, occurrence) = part_with_cube(&mut s); + let before = s.doc.clone(); + let source = world_min(&s, &[occurrence]); + let reply = run(&mut s, "solid.move", json!({"bodies": [], "occurrences": [[occurrence]], "copy": true, "translate": [50, 0, 0]})); + let copy = reply["occurrences"][0].as_u64().expect("the move reports the copy it made"); + let copied = component_of(&s, copy); + assert_ne!(copied, part, "an independent copy of the component, not another instance of it"); + let name = &s.doc.components.iter().find(|c| c.id == copied).expect("the copy is a component").name; + assert_eq!(name, "Part (2)", "named the way Paste New names its copies"); + assert!(world_min(&s, &[copy]).dist(Vec3::new(source.x + 50.0, source.y, source.z)) < 1e-6, "the copy lands at the moved place"); + assert!(world_min(&s, &[occurrence]).dist(source) < 1e-6, "the original component stays where it was"); + assert_eq!(s.occurrence_bodies(&[occurrence], true).unwrap().len(), 1, "the original keeps its body"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before, "one undo removes the copy"); +} + +#[test] +fn a_copied_component_turns_with_the_move() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + let reply = run( + &mut s, + "solid.move", + json!({"bodies": [], "occurrences": [[occurrence]], "copy": true, "translate": [0, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}), + ); + let copy = reply["occurrences"][0].as_u64().unwrap(); + // A quarter turn about the origin takes the cube's x from 0..20 to -20..0 and y to 0..20. + assert!(world_min(&s, &[copy]).dist(Vec3::new(-20.0, 0.0, 0.0)) < 1e-6, "the copy turns with the move"); + assert!(world_min(&s, &[occurrence]).dist(Vec3::ZERO) < 1e-6, "the original does not turn"); +} + +#[test] +fn bodies_and_a_component_copied_together_are_one_undo_step() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + cube20_at(&mut s, "Top", [0, 0, 50]); + let before = s.doc.clone(); + let count = s.model.state().bodies.len(); + let reply = run(&mut s, "solid.move", json!({"bodies": ["Top"], "occurrences": [[occurrence]], "copy": true, "translate": [50, 0, 0]})); + assert_eq!(reply["occurrences"].as_array().map(Vec::len), Some(1)); + assert!(reply.get("feature").is_some(), "the reply is a solid.move reply: the body feature's, with the copies alongside"); + assert_eq!(s.model.state().bodies.len(), count + 2, "a copy of the body and of the component"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before, "one undo removes both copies"); +} + +/// A copying move of a component and of bodies in differently turned frames (a block in the root +/// and a screw inserted lying down) is one move: the component's copy turns with it, the bodies +/// split into one feature per frame as a move of them alone does (#9660), in one undo step. +#[test] +fn a_copied_component_and_bodies_in_differently_turned_frames_are_one_undo_step() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 10, "center": [0, 0, 5], "body_name": "Block"})); + run( + &mut s, + "parts.insert", + json!({"family": "socket_head_cap_screw", "size": "M3", "length": 12, "point": [0, 0, 10], "direction": [1, 0, 0], "name": "screw"}), + ); + let before = s.doc.clone(); + let reply = run( + &mut s, + "solid.move", + json!({"bodies": ["Block", "Body1"], "occurrences": [[occurrence]], "copy": true, "axis": [0, 0, 1], "angle": "90 deg"}), + ); + assert_eq!(reply["features"].as_array().map(Vec::len), Some(2), "one Move feature per frame"); + let copy = reply["occurrences"][0].as_u64().expect("the move reports the copy it made"); + assert!(world_min(&s, &[copy]).dist(Vec3::new(-20.0, 0.0, 0.0)) < 1e-6, "the copy turns with the move"); + assert!(world_min(&s, &[occurrence]).dist(Vec3::ZERO) < 1e-6, "the original does not turn"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before, "one undo removes the copies of the component and of the bodies"); +} + +#[test] +fn a_component_picked_with_its_parent_is_copied_once_inside_the_parents_copy() { + let mut s = Session::default(); + // `Outer` at the root holds `Inner`, which holds a 20 mm cube. + let outer = run(&mut s, "component.create", json!({"name": "Outer", "grounded": false}))["occurrence"].as_u64().unwrap(); + let inner = run(&mut s, "component.create", json!({"name": "Inner", "parent": "Outer"}))["occurrence"].as_u64().unwrap(); + cube20(&mut s, "Peg"); + run(&mut s, "component.activate", json!({"component": "root"})); + let counts = |s: &Session| (s.doc.components.len(), s.doc.occurrences.len(), s.model.state().bodies.len()); + assert_eq!(counts(&s), (2, 2, 1), "components, occurrences and bodies before the move"); + let before = s.doc.clone(); + let source = world_min(&s, &[outer, inner]); + + // Both are picked, Inner inside Outer: Inner comes with its parent's copy, so one copy is made. + let reply = run(&mut s, "solid.move", json!({"bodies": [], "occurrences": [[outer], [outer, inner]], "copy": true, "translate": [50, 0, 0]})); + assert_eq!(reply["occurrences"].as_array().map(Vec::len), Some(1), "one copy: Inner is copied inside Outer's copy"); + let copy = reply["occurrences"][0].as_u64().unwrap(); + assert_eq!(counts(&s), (4, 4, 2), "the copy of Outer and its own Inner, with the cube copied once"); + assert!( + world_min(&s, &[copy]).dist(Vec3::new(source.x + 50.0, source.y, source.z)) < 1e-6, + "the copied cube sits 50 mm along X from the original" + ); + assert!(world_min(&s, &[outer, inner]).dist(source) < 1e-6, "the original cube stays where it was"); + + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(&s), (2, 2, 1), "one undo puts back the two components, two occurrences and one body"); + assert_eq!(s.doc, before, "one undo removes the whole copy"); +} + +#[test] +fn a_copy_that_fails_part_way_changes_nothing() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + let before = s.doc.clone(); + let undo = s.undo.len(); + // The first component copies; the second names no occurrence, so the whole move is refused. + let error = s.execute("solid.move", &json!({"bodies": [], "occurrences": [[occurrence], [999_999]], "copy": true, "translate": [50, 0, 0]})); + assert!(error.is_err()); + assert_eq!(s.doc, before, "the first copy is rolled back with the rest"); + assert_eq!(s.undo.len(), undo, "a refused move leaves no undo step"); +} + +#[test] +fn components_move_only_as_copies() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + let before = s.doc.clone(); + let error = s.execute("solid.move", &json!({"bodies": [], "occurrences": [[occurrence]], "translate": [50, 0, 0]})).unwrap_err(); + assert!(error.to_string().contains("copy"), "{error}"); + assert_eq!(s.doc, before, "a refused move changes nothing"); +} + +#[test] +fn bodies_that_are_not_a_list_are_refused_with_components() { + let mut s = Session::default(); + let (_, occurrence) = part_with_cube(&mut s); + let before = s.doc.clone(); + let error = s.execute("solid.move", &json!({"bodies": "Peg", "occurrences": [[occurrence]], "copy": true, "translate": [50, 0, 0]})).unwrap_err(); + assert!(error.to_string().contains("bodies"), "{error}"); + assert_eq!(s.doc, before, "a refused move changes nothing"); +} diff --git a/crates/engine/src/cmd/surface_analysis.rs b/crates/engine/src/cmd/surface_analysis.rs index cd1c051c7b..aef24a62af 100644 --- a/crates/engine/src/cmd/surface_analysis.rs +++ b/crates/engine/src/cmd/surface_analysis.rs @@ -91,7 +91,7 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { s.analysis = Some(a); } s.status_analysis_changed((!bool_(p, "clear").unwrap_or(false)).then_some(crate::status_items::StatusId::SurfaceAnalysis)); - s.revision += 1; + s.refresh_display(); let vector = |v: Vec3| [v.x, v.y, v.z]; let analysis = s.analysis.map(|a| match a { SurfaceAnalysis::Zebra { stripes } => contract::Analysis::Zebra { stripes }, @@ -155,7 +155,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result { if bool_(p, "clear").unwrap_or(false) { s.continuity.clear(); s.status_analysis_changed(None); - s.revision += 1; + s.refresh_display(); return serde_json::to_value(contract::ContinuityResult::Cleared { analysis: "continuity".into(), cleared: true, @@ -273,7 +273,7 @@ fn continuity(s: &mut Session, p: &Value) -> Result { s.continuity = seams; } s.status_analysis_changed(Some(crate::status_items::StatusId::Continuity)); - s.revision += 1; + s.refresh_display(); serde_json::to_value(contract::ContinuityResult::Measured { analysis: "continuity".into(), samples, diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index 86e387a051..a068f6a7fc 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -315,7 +315,7 @@ fn render_select(s: &mut Session, p: &Value) -> Result { let result = serde_json::to_value(request).map_err(|e| bad("view.render_select", e.to_string()))?; if !s.selection.is_empty() { s.selection.clear(); - s.revision += 1; + s.refresh_display(); } Ok(result) } diff --git a/crates/engine/src/computed_commands_tests.rs b/crates/engine/src/computed_commands_tests.rs index 5e6d6c0e13..5cfb70a15d 100644 --- a/crates/engine/src/computed_commands_tests.rs +++ b/crates/engine/src/computed_commands_tests.rs @@ -149,6 +149,35 @@ fn editor_visibility_changes_allow_apply_and_preserve_separate_undo_steps() { } } +/// A section, an analysis or the selection changed while an editor is open changes only what is +/// shown: OK still applies the edit and Cancel puts the design back (#10687). +#[test] +fn editor_display_changes_allow_apply_and_cancel() { + let cases: [&[(&str, serde_json::Value)]; 5] = [ + &[("inspect.section", json!({"plane":"XZ","offset":1}))], + &[("inspect.section", json!({"plane":"XZ","offset":1})), ("inspect.section", json!({"clear":true}))], + &[("inspect.zebra", json!({"stripes":14.5}))], + &[("select.set", json!({"items":[{"type":"body","name":"Other"}]}))], + &[("select.clear", json!({}))], + ]; + for display in cases { + let (mut session, source, original, commands) = extrusion_editor(); + for (id, params) in display { + session.execute(id, params).unwrap(); + } + let prepared = + session.scratch().compute_editor_commands(&commands, &source, original).unwrap_or_else(|e| panic!("OK after {display:?}: {e}")); + session.accept_commands(prepared).unwrap(); + assert_editor_volume(&mut session, 12024.0); + let (mut session, source, original, _) = extrusion_editor(); + for (id, params) in display { + session.execute(id, params).unwrap(); + } + session.restore_editor(&source, original).unwrap_or_else(|e| panic!("Cancel after {display:?}: {e}")); + assert_editor_volume(&mut session, 6024.0); + } +} + #[test] fn editor_visibility_changes_allow_cancel_and_keep_undo_and_redo_at_the_original_marker() { for undo_hide in [false, true] { diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs index f8fc8b401e..bb3601296b 100644 --- a/crates/engine/src/construct_tests.rs +++ b/crates/engine/src/construct_tests.rs @@ -392,6 +392,57 @@ fn plane_tangent_to_a_cylinder() { assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}"); } +/// #8822: with a reference plane or planar face, the plane touches where the face turns towards +/// the reference (parallel to it, on the side of the pick), turned by an angle about the axis, +/// and follows the reference when it moves. +#[test] +fn plane_tangent_lined_up_with_a_reference() { + let mut s = part(); + // Picked 60° round the cylinder (axis Z through (60, 0)); the XZ plane faces ±Y. + let pick = json!([62.5, 4.330127, 5]); + run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "name": "T"})); + let t = plane(&s, "T"); + assert!(parallel(t.normal(), Vec3::Y) && on_plane(&t, Vec3::new(60.0, 5.0, 9.0)), "{t:?}"); + // Turned 30° about the axis: still on the pick's side. + run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "angle": "30 deg", "name": "A"})); + let a = plane(&s, "A"); + let d = Vec3::new(-0.5, 3f64.sqrt() / 2.0, 0.0); + assert!(parallel(a.normal(), d) && on_plane(&a, Vec3::new(60.0, 0.0, 2.0) + d * 5.0), "{a:?}"); + // A planar face of the box (x = 40, facing X) as the reference. + run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"face": [40, 15, 10]}, "name": "F"})); + let f = plane(&s, "F"); + assert!(parallel(f.normal(), Vec3::X) && on_plane(&f, Vec3::new(65.0, 1.0, 1.0)), "{f:?}"); + // The torus (axis Z through (0, 60)): on its outer equator. + run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 72, 0]}, "reference": {"plane": "XZ"}, "name": "R"})); + let r = plane(&s, "R"); + assert!(parallel(r.normal(), Vec3::Y) && on_plane(&r, Vec3::new(3.0, 72.0, 1.0)), "{r:?}"); + // The sphere (centre (0, 0, 60)), picked below its middle: under it, parallel to XY. + run(&mut s, "construct.plane.tangent", json!({"face": {"face": [3, 0, 56]}, "reference": {"plane": "XY"}, "name": "U"})); + let u = plane(&s, "U"); + assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(1.0, 1.0, 55.0)), "{u:?}"); + + // A reference built on a parameter: the tangent plane follows it. + run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"})); + run(&mut s, "construct.plane.at_angle", json!({"base": "XZ", "axis": "Z", "angle": "turn", "name": "Ref"})); + run(&mut s, "construct.plane.tangent", json!({"face": {"face": pick}, "reference": {"plane": "Ref"}, "name": "M"})); + assert!(on_plane(&plane(&s, "M"), Vec3::new(60.0, 5.0, 1.0))); + run(&mut s, "parameters.change", json!({"name": "turn", "expression": "-90 deg"})); + let m = plane(&s, "M"); + assert!(parallel(m.normal(), Vec3::X) && on_plane(&m, Vec3::new(65.0, 2.0, 1.0)), "{m:?}"); + + // What has no answer is refused and adds nothing. + let count = construction_planes(&s).len(); + for (p, why) in [ + (json!({"face": {"face": pick}, "reference": {"plane": "XY"}}), "a reference square to the axis"), + (json!({"face": {"face": [3, 0, 56]}, "reference": {"plane": "XY"}, "angle": "10 deg"}), "an angle on a sphere"), + (json!({"face": {"face": pick}, "angle": "10 deg"}), "an angle without a reference"), + (json!({"face": {"face": pick}, "reference": {"plane": "XZ"}, "angle": "2 mm"}), "a length for the angle"), + ] { + assert!(s.execute("construct.plane.tangent", &p).is_err(), "{why}"); + } + assert_eq!(construction_planes(&s).len(), count); +} + #[test] fn plane_along_a_path() { let mut s = part(); @@ -738,3 +789,27 @@ fn hiding_an_occurrence_hides_its_construction_geometry() { assert!(!s.construction_visible(name), "{name} shows in a hidden placement"); } } + +/// #10127: an axis and a point built on a component's cylinder turn with it when Move/Copy +/// turns the cylinder, as its faces do. One also pinned to a fixed point stays where it is. +#[test] +fn construction_on_a_moved_body_moves_with_it() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "body_name": "Pin"})); + run(&mut s, "component.create", json!({"name": "Part", "parent": "root", "activate": false})); + run(&mut s, "component.move_bodies", json!({"bodies": ["Pin"], "component": "Part"})); + run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [5, 0, 10]}, "name": "PinAxis"})); + run(&mut s, "construct.point.center", json!({"of": {"edge": [5, 0, 20]}, "name": "PinTop"})); + run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "PinTop"}, "b": {"point": [0, 50, 20]}, "name": "Tether"})); + let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap(); + // A quarter turn about Y through the origin, then 10 mm along X: the top centre (0, 0, 20) + // goes to (30, 0, 0), and the cylinder's axis lies along X. + run(&mut s, "solid.move", json!({"bodies": [body], "translate": [10, 0, 0], "axis": [0, 1, 0], "angle": "90 deg"})); + let errors: Vec = s.model.results.iter().filter_map(|r| r.error.clone()).collect(); + assert!(errors.is_empty(), "{errors:?}"); + let (o, d) = axis(&s, "PinAxis"); + assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(30.0, 0.0, 0.0)), "{o:?} {d:?}"); + assert!(near(point(&s, "PinTop"), Vec3::new(30.0, 0.0, 0.0)), "{:?}", point(&s, "PinTop")); + let (o, d) = axis(&s, "Tether"); + assert!(on_line(o, d, Vec3::new(0.0, 0.0, 20.0)) && on_line(o, d, Vec3::new(0.0, 50.0, 20.0)), "{o:?} {d:?}"); +} diff --git a/crates/engine/src/control_auth.rs b/crates/engine/src/control_auth.rs index c93541923a..6645a1cfbf 100644 --- a/crates/engine/src/control_auth.rs +++ b/crates/engine/src/control_auth.rs @@ -57,12 +57,17 @@ pub fn bind_with_fallback(first: u16, tries: u16) -> Result/control` (`SOLIDCRAFT_CONTROL_DIR` replaces it). fn control_dir() -> Option { - match solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty()) { + match custom_control_dir() { Some(d) => Some(PathBuf::from(d)), None => Some(crate::recovery::data_dir()?.join("control")), } } +/// The folder `SOLIDCRAFT_CONTROL_DIR` names in place of the default one. +fn custom_control_dir() -> Option { + solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty()) +} + /// The token file of the app listening on `port`: `/control/.token` /// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder). pub fn token_path(port: u16) -> Option { @@ -276,9 +281,19 @@ pub fn tokens_match(a: &str, b: &str) -> bool { /// Write `token` to `path`, readable and writable only by the user: the folder is made `0700` /// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one -/// entry for the user (`icacls`) before the token goes in. A file left by an earlier launch is -/// replaced. The process file beside it ([`pid_path`]) records this process, best effort. +/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file +/// is removed and no token is written. A file left by an earlier launch is replaced. The process +/// file beside it ([`pid_path`]) records this process, best effort. pub fn write_token_file(path: &Path, token: &str) -> Result<()> { + #[cfg(windows)] + let restrict = restrict_to_user; + #[cfg(not(windows))] + let restrict = |_: &Path| Ok(()); + write_token_file_with(path, token, restrict) +} + +/// [`write_token_file`], with `restrict` limiting the new file to the user (Windows). +fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> { let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?; std::fs::create_dir_all(dir)?; let _ = std::fs::remove_file(path); @@ -291,8 +306,11 @@ pub fn write_token_file(path: &Path, token: &str) -> Result<()> { opts.mode(0o600); } let mut f = opts.open(path)?; - #[cfg(windows)] - restrict_to_user(path)?; + if let Err(e) = restrict(path) { + drop(f); + let _ = std::fs::remove_file(path); + return Err(e); + } f.write_all(token.as_bytes())?; f.write_all(b"\n")?; f.sync_all()?; @@ -332,9 +350,32 @@ fn held_by_another(path: &Path, token: &str) -> bool { #[cfg(windows)] const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5); +/// How a run of `icacls` ended. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IcaclsEnd { + Succeeded, + Failed, + /// It didn't finish within [`ICACLS_LIMIT`] and was ended. + TimedOut, +} + +/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is +/// in the folder `SOLIDCRAFT_CONTROL_DIR` names. A timeout is accepted only in the default folder, +/// the user's own local app data, which other users can't read by default; nothing is known about +/// the permissions of any other folder. +#[cfg(any(windows, test))] +fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> { + match end { + IcaclsEnd::Succeeded => Ok(()), + IcaclsEnd::TimedOut if !custom_folder => Ok(()), + IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))), + IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))), + } +} + /// Windows: drop inherited entries and grant only the current user full access. An `icacls` -/// that doesn't finish in time is ended and the file kept as made: it is in the user's own -/// local app data folder, which other users can't read by default. +/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means. #[cfg(windows)] fn restrict_to_user(path: &Path) -> Result<()> { use std::os::windows::process::CommandExt; @@ -348,10 +389,12 @@ fn restrict_to_user(path: &Path) -> Result<()> { .stderr(std::process::Stdio::null()) .creation_flags(0x0800_0000) .spawn()?; - match wait_within(child, ICACLS_LIMIT)? { - Some(status) if !status.success() => Err(Error::other(format!("icacls failed to restrict {}", path.display()))), - _ => Ok(()), - } + let end = match wait_within(child, ICACLS_LIMIT)? { + Some(status) if status.success() => IcaclsEnd::Succeeded, + Some(_) => IcaclsEnd::Failed, + None => IcaclsEnd::TimedOut, + }; + restriction_result(end, custom_control_dir().is_some(), path) } /// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it @@ -400,6 +443,39 @@ mod tests { assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success())); } + /// #6145's freeze fix stays in the default folder, the user's own; a folder picked with + /// `SOLIDCRAFT_CONTROL_DIR` gets no token unless the file was really restricted. + #[test] + fn only_the_default_folder_accepts_an_icacls_that_timed_out() { + let path = Path::new("control/7878.token"); + for custom in [false, true] { + assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok()); + assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err()); + } + assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok()); + let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err(); + assert_eq!(e.kind(), ErrorKind::TimedOut); + assert!(e.to_string().contains("7878.token"), "{e}"); + } + + /// A file that couldn't be restricted is removed, empty and with no process file beside it, and + /// the error goes to the caller. A restricted one gets its token. + #[test] + fn a_token_file_that_could_not_be_restricted_is_removed() { + let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id())); + let path = dir.join("7879.token"); + let e = write_token_file_with(&path, "secret-token", |p| { + assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in"); + restriction_result(IcaclsEnd::TimedOut, true, p) + }) + .unwrap_err(); + assert_eq!(e.kind(), ErrorKind::TimedOut); + assert!(!path.exists() && !pid_path(&path).exists()); + write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap(); + assert_eq!(read_token(&path).as_deref(), Some("secret-token")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn tokens_compare_whole() { assert!(tokens_match("abc123", "abc123")); diff --git a/crates/engine/src/extent_tests.rs b/crates/engine/src/extent_tests.rs index 852a271ef0..8a836ce2d9 100644 --- a/crates/engine/src/extent_tests.rs +++ b/crates/engine/src/extent_tests.rs @@ -181,3 +181,56 @@ fn extrude_to_a_cylinder_beside_another_body() { assert!((end + (2.75f64 * 2.75 - 1.5 * 1.5).sqrt()).abs() < 1e-3, "{b}"); assert!((b["bbox"]["min"][0].as_f64().unwrap_or(0.0) + 98.0).abs() < 1e-6, "{b}"); } + +/// A window cut To Object through the side of a tube (r 20 outside, 18 inside), up to the inside +/// face: it takes the wall under the profile and nothing else. Before, the tool ended in the +/// curved inner face: the tool less the tube kept only the air before the wall and cut nothing, +/// or the boolean failed on the coincident curved faces. Picking the outside face from inside +/// the wall cuts back to it. +#[test] +fn cut_to_the_far_side_of_a_curved_wall() { + let tube = || { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 20, "height": 20, "body_name": "Tube"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Bore"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 18})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Bore", "distance": 20, "operation": "cut"})); + s + }; + let volume = |s: &mut Session| body(s, "Tube")["volume_mm3"].as_f64().unwrap_or(0.0); + let wall = |x: f64, r: f64| (r * r - x * x).sqrt(); + let n = 2000; + let between = |r0: f64, r1: f64| -> f64 { + (0..n).map(|i| -3.0 + 6.0 * (i as f64 + 0.5) / n as f64).map(|x| (wall(x, r1) - wall(x, r0)) * 4.0 * 6.0 / n as f64).sum() + }; + // From outside the tube, through the wall, to its inside face. + let mut s = tube(); + let whole = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -30, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Window"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-3, 8], "p1": [3, 12]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Window", "to": [0, -18, 10], "operation": "cut"})); + let removed = whole - volume(&mut s); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results); + // The same window cut 20 mm, ending in the bore: the tube's own facets set its volume. + let mut plain = tube(); + run(&mut plain, "sketch.create", json!({"plane": {"origin": [0, -30, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Window"})); + run(&mut plain, "sketch.rectangle.two_point", json!({"p0": [-3, 8], "p1": [3, 12]})); + run(&mut plain, "sketch.finish", json!({})); + run(&mut plain, "solid.extrude", json!({"sketch": "Window", "distance": -20, "operation": "cut"})); + let reference = whole - volume(&mut plain); + assert!((removed - reference).abs() < reference * 1e-6, "removed {removed}, a 20 mm cut {reference}"); + let want = between(18.0, 20.0); + assert!((removed - want).abs() < want * 2e-2, "removed {removed}, want {want}"); + // From inside the wall (y = -19) back out to the outside face. + let mut s = tube(); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -19, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Window"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-3, 8], "p1": [3, 12]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Window", "to": [0, -20, 10], "operation": "cut"})); + let removed = whole - volume(&mut s); + let want: f64 = (0..n).map(|i| -3.0 + 6.0 * (i as f64 + 0.5) / n as f64).map(|x| (wall(x, 20.0) - 19.0) * 4.0 * 6.0 / n as f64).sum(); + assert!((removed - want).abs() < want * 2e-2, "removed {removed}, want {want}"); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results); +} diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index afe7de3867..5c60938917 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -341,6 +341,9 @@ thread_local! { static DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; /// The outermost command's coordinates were mapped here (its table lists them). static MAPPED: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// The outermost command is `solid.move`, whose parameters stay in the world when its bodies + /// lie in differently turned frames (`split_move`). + static OUTER_MOVE: std::cell::Cell = const { std::cell::Cell::new(false) }; /// The picks of the running command: the point it sees and the bodies whose faces meet /// there (the first is the body under it; more than one means the pick is ambiguous). static PICKS: std::cell::RefCell)>> = const { std::cell::RefCell::new(Vec::new()) }; @@ -407,6 +410,7 @@ impl Nested { if outer { let inner = if id == "timeline.redefine" { None } else { Some(id) }; MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty() || i == "construct.plane.axis_point"))); + OUTER_MOVE.with(|m| m.set(id == "solid.move")); } (Nested, outer) } @@ -420,18 +424,80 @@ impl Drop for Nested { /// `params` with world picks mapped into component frames (`None`: nothing to change). pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { + // A move of bodies in differently turned frames keeps its world vectors: `solid.move` splits + // it by frame and maps each part itself (`split_move`). + if id == "solid.move" && split_move(s, params).is_some() { + return None; + } + map_params(s, id, params) +} + +/// A `solid.move` of bodies placed in different frames, as one move per frame (#9660). A Move +/// feature moves each of its bodies in the body's own frame, by one vector and one turn: given +/// in the world, they can be mapped into one frame only, so bodies in a frame turned otherwise +/// (a screw inserted lying down, moved with a block of the root) moved along their own axes. +/// Bodies whose frames turn alike share a move (and for a turn, frames that are also in the +/// same place, as its pivot is a point). `None`: the bodies share one frame, or are moved as +/// placements (`placements_moved`), or the move is not the outermost command (run by another, +/// such as Edit Feature, its parameters are already in component frames). `to_local` leaves the +/// parameters of a split move in the world, and `solid.move` asks for the parts. +pub(crate) fn split_move(s: &Session, params: &Value) -> Option> { + let outermost_move = DEPTH.with(std::cell::Cell::get) == 1 && OUTER_MOVE.with(std::cell::Cell::get); + if !outermost_move || !placed(s) || placements_moved(s, params).is_some() { + return None; + } + let names = params.get("bodies")?.as_array()?; + let turns = params.get("axis").is_some() || params.get("angle").is_some(); + let alike = |a: &Mat, b: &Mat| { + let columns = if turns { 4 } else { 3 }; + a.iter().zip(b).take(columns).all(|(x, y)| x.iter().zip(y).take(3).all(|(u, v)| (u - v).abs() <= 1e-9 * (1.0 + u.abs().max(v.abs())))) + }; + let frames = s.body_frames(); + let mut groups: Vec<(Mat, Vec)> = Vec::new(); + for name in names { + // An unknown body is the command's to refuse. + let b = name.as_str()?; + let m = frames.iter().find(|(world, own, _)| world == b || own == b).map(|(.., m)| *m)?; + match groups.iter_mut().find(|(g, _)| alike(g, &m)) { + Some((_, bodies)) => bodies.push(name.clone()), + None => groups.push((m, vec![name.clone()])), + } + } + if groups.len() < 2 { + return None; + } + let given = params.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()); + let moves = groups + .into_iter() + .enumerate() + .map(|(k, (_, bodies))| { + let mut p = params.clone(); + p["bodies"] = Value::Array(bodies); + // Each move is its own feature: a name given is numbered after the first. + if let Some(name) = given.filter(|_| k > 0) { + p["name"] = Value::String(format!("{name} ({})", k + 1)); + } + map_params(s, "solid.move", &p).unwrap_or(p) + }) + .collect(); + Some(moves) +} + +/// [`to_local`] for one command. +fn map_params(s: &Session, id: &str, params: &Value) -> Option { if id == "library.apply" { // Library targets are the same world selections as appearance.assign, // nested one level down. Convert at the outer boundary exactly once; // run_nested subsequently receives the component-local picks and names. - let targets = to_local(s, "appearance.assign", params.get("targets")?)?; + let targets = map_params(s, "appearance.assign", params.get("targets")?)?; let mut out = params.clone(); out["targets"] = targets; return Some(out); } // Several placements of one part moved together are moved as placements, in the world - // (`placements_moved`): their names and vectors stay as given. - if id == "solid.move" && placements_moved(s, params).is_some() { + // (`placements_moved`), and components copied (`occurrences`) likewise: their names and vectors + // stay as given. Bodies listed with them are mapped by `copy_components`. + if id == "solid.move" && (params.get("occurrences").is_some() || placements_moved(s, params).is_some()) { return None; } if id == "timeline.redefine" { @@ -445,7 +511,7 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option { if let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &feature.kind { context.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone()); } - let mut inner = to_local(&context, cmd, params.get("params")?)?; + let mut inner = map_params(&context, cmd, params.get("params")?)?; // An untouched explicit axis is displayed in world coordinates by Edit // Feature. Keep its exact saved values instead of an affine round trip. if cmd == "solid.revolve" diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index b614f8e4a5..245179a4dd 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -263,6 +263,65 @@ fn move_expression_distances_keep_parameters_through_rebuild_and_reopen() { } } +/// One `solid.move` of bodies placed in differently turned frames (a block in the root and a +/// screw inserted lying down) moves and turns each of them in the world, whichever is listed +/// first (#9660). +#[test] +fn one_move_of_bodies_in_differently_turned_frames_moves_each_in_the_world() { + let corners = |(lo, hi): (Vec3, Vec3)| { + let mut out = Vec::new(); + for x in [lo.x, hi.x] { + for y in [lo.y, hi.y] { + for z in [lo.z, hi.z] { + out.push(Vec3::new(x, y, z)); + } + } + } + out + }; + let bounds = |points: Vec| { + let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); + let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in points { + lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)); + hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z)); + } + (lo, hi) + }; + let close = |a: (Vec3, Vec3), b: (Vec3, Vec3)| a.0.dist(b.0) < 1e-4 && a.1.dist(b.1) < 1e-4; + // The quarter turn is about the world z axis, which passes through the screw's own origin + // (where it was inserted): a Move feature turns about that origin. + let moves: [(Value, &dyn Fn(Vec3) -> Vec3); 3] = [ + (json!({"translate": [0, 0, 3]}), &|p| p + Vec3::new(0.0, 0.0, 3.0)), + (json!({"translate": [0, "2 mm", 0]}), &|p| p + Vec3::new(0.0, 2.0, 0.0)), + (json!({"axis": [0, 0, 1], "angle": "90 deg"}), &|p| Vec3::new(-p.y, p.x, p.z)), + ]; + for direction in [[1, 0, 0], [0, 0, -1], [0, 1, 0]] { + for order in [["Block", "Body1"], ["Body1", "Block"]] { + for (params, expected) in &moves { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 10, "center": [0, 0, 5], "body_name": "Block"})); + let screw = json!({"family": "socket_head_cap_screw", "size": "M3", "length": 12, "point": [0, 0, 10], "direction": direction, "name": "screw"}); + run(&mut s, "parts.insert", screw); + let before: Vec<_> = order.iter().map(|b| world_bbox(&s, b)).collect(); + let mut p = params.clone(); + p["bodies"] = json!(order); + run(&mut s, "solid.move", p.clone()); + for (body, was) in order.iter().zip(&before) { + let want = bounds(corners(*was).into_iter().map(expected).collect()); + let got = world_bbox(&s, body); + assert!(close(got, want), "{body} after {p} (screw along {direction:?}): {got:?}, want {want:?}"); + } + // One step: a single undo puts both back. + run(&mut s, "edit.undo", json!({})); + for (body, was) in order.iter().zip(&before) { + assert!(close(world_bbox(&s, body), *was), "{body} after undoing {p}"); + } + } + } + } +} + /// Edit Feature passes a primitive's placement through as saved, so changing the radius of /// Leaf B's rod in the Hinge Assembly leaves the leaf where it was (#2926). #[test] diff --git a/crates/engine/src/hole_face_tests.rs b/crates/engine/src/hole_face_tests.rs index 6fe936d374..d1ffbd5ab8 100644 --- a/crates/engine/src/hole_face_tests.rs +++ b/crates/engine/src/hole_face_tests.rs @@ -450,3 +450,47 @@ fn a_hole_may_start_over_the_mouth_of_an_existing_bore() { let e = s.execute("solid.hole", &json!({"position": [-1, 15, 20], "diameter": 8})).unwrap_err().to_string(); assert!(e.contains("is not on a face of a body"), "{e}"); } + +/// A hole moved off the part cuts nothing and says so (#10701), as a pattern's copies do: by a +/// parameter of its position or a sketch point moved away. Back on the part it is silent. +#[test] +fn a_hole_moved_off_the_part_says_it_removes_no_material() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "hx", "expression": "10 mm"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Plate"})); + run(&mut s, "solid.hole", json!({"position": ["hx", "10 mm", "10 mm"], "diameter": 5, "name": "H1"})); + assert_eq!(said(&s, "H1"), ""); + change(&mut s, "hx", "100 mm"); + assert!((volume(&mut s) - 12_000.0).abs() < 1e-6, "nothing is cut"); + assert!(said(&s, "H1").contains("the hole removes no material"), "{}", said(&s, "H1")); + change(&mut s, "hx", "30 mm"); + assert_eq!(said(&s, "H1"), ""); + // Holes at two sketch points, one of them moved off the plate. + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10, "name": "HP"})); + run(&mut s, "sketch.point", json!({"point": [10, 20], "id": "a"})); + run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "b"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.hole", json!({"sketch": "HP", "points": ["a", "b"], "diameter": 4, "name": "H2"})); + assert_eq!(said(&s, "H2"), ""); + run(&mut s, "sketch.edit", json!({"sketch": "HP"})); + run(&mut s, "sketch.move_point", json!({"point": "a", "to": [100, 100]})); + run(&mut s, "sketch.finish", json!({})); + assert!(said(&s, "H2").contains("1 of 2 holes remove no material"), "{}", said(&s, "H2")); +} + +/// A hole wider than the part takes all of it away and says so (#10702): at creation, and when +/// a parameter widens it later. A hole across the part's width keeps the rest, silently. +#[test] +fn a_hole_wider_than_the_part_says_it_removes_all_of_it() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "hd", "expression": "40 mm"})); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Plate"})); + run(&mut s, "solid.hole", json!({"position": [10, 10, 10], "diameter": "hd", "name": "H1"})); + assert_eq!(said(&s, "H1"), ""); + assert_eq!(s.model.state().bodies.len(), 1); + change(&mut s, "hd", "100 mm"); + assert!(s.model.state().bodies.is_empty()); + assert!(said(&s, "H1").contains("the hole removes all of Body1"), "{}", said(&s, "H1")); + change(&mut s, "hd", "5 mm"); + assert_eq!(said(&s, "H1"), ""); +} diff --git a/crates/engine/src/hole_matrix_tests.rs b/crates/engine/src/hole_matrix_tests.rs index 5898647ee4..8c30b0ada3 100644 --- a/crates/engine/src/hole_matrix_tests.rs +++ b/crates/engine/src/hole_matrix_tests.rs @@ -856,3 +856,17 @@ fn recessed_drill_point_through_and_exact_shoulder_keep_the_existing_exit() { } } } + +/// A hole on the side of a cone, given no direction, drills along the cone's normal there, not +/// the nearest mesh facet's, which leaned it up to 2.6° sideways (#10698). +#[test] +fn a_hole_on_a_cones_side_drills_along_its_normal() { + let mut s = Session::default(); + run(&mut s, "solid.cone", json!({"radius": 15, "height": 20, "body_name": "Cone"})).unwrap(); + // The side is r = 15 (1 - z / 20): at (7.5, 0, 10) the outward normal is (0.8, 0, 0.6). + run(&mut s, "solid.hole", json!({"position": [7.5, 0, 10], "diameter": 4, "depth": 4, "name": "H"})).unwrap(); + let f = s.doc.find_feature("H").unwrap(); + let solidcraft_doc::FeatureKind::Hole { direction, .. } = &f.kind else { panic!("not a hole") }; + let d = direction.0; + assert!((d.x + 0.8).abs() < 1e-6 && d.y.abs() < 1e-6 && (d.z + 0.6).abs() < 1e-6, "{d:?}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e99e069738..1b8cbe51cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -297,6 +297,10 @@ pub(crate) struct SketchEntry { pub(crate) doc: Arc, pub(crate) undo_len: usize, pub(crate) undo_top: Option>, + /// A temporary editor rollback, restored by the engine even without a window. + pub(crate) restore_marker: Option>, + pub(crate) pending_moves: std::collections::BTreeMap, + pub(crate) visibility: Visibility, } /// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]). @@ -463,8 +467,9 @@ pub struct Session { pub log: Vec, /// Increments on every document change (views use it to refresh). pub revision: u64, - /// Revision increments caused only by visibility. Comparing both counters proves that - /// every intervening increment left an editor's document and model untouched. + /// Revision increments caused only by what is shown ([`Session::refresh_display`]). + /// Comparing both counters proves that every intervening increment left an editor's + /// document and model untouched. visibility_revision: u64, /// [`Document::browser_stamp`] when the browser's groups and order were last checked. browser_stamp: [u64; 6], @@ -930,6 +935,12 @@ impl Session { } } + /// The marker an active temporary sketch edit returns to. `Some(None)` means the end of + /// the timeline; `None` means this sketch was opened without a temporary rollback. + pub fn sketch_restore_marker(&self) -> Option> { + self.sketch_entry.as_ref().filter(|entry| Some(entry.sketch) == self.active_sketch).and_then(|entry| entry.restore_marker) + } + /// Mutable document (copy-on-write). pub fn doc_mut(&mut self) -> &mut Document { self.forget_document_caches(); @@ -1339,8 +1350,9 @@ impl Session { } } - /// Only what is hidden or shown changed: the model is untouched, so only the view revision - /// moves (the visibility caches are keyed on it) and the viewport redraws. + /// Only what is shown changed (hidden or shown items, a section or analysis, the selection): + /// the design and model are untouched, so only the view revision moves and the viewport + /// redraws. An open feature editor stays valid across it (#10687). pub(crate) fn refresh_display(&mut self) { self.revision += 1; self.visibility_revision += 1; @@ -1997,6 +2009,7 @@ impl Session { let mut copy = self.scratch(); copy.undo.clone_from(&self.undo); copy.redo.clone_from(&self.redo); + copy.sketch_entry.clone_from(&self.sketch_entry); copy.model.keep(Some(Arc::default())); Ahead(copy) } diff --git a/crates/engine/src/schema/construct.rs b/crates/engine/src/schema/construct.rs index 0e138867ca..a670fcf0e9 100644 --- a/crates/engine/src/schema/construct.rs +++ b/crates/engine/src/schema/construct.rs @@ -8,7 +8,7 @@ use schemars::JsonSchema; use serde::Deserialize; -use super::types::{ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present}; +use super::types::{AngleExpr, ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present}; /// Add a construction axis along the axis of a cylinder, cone or torus face, or of a circular /// edge. @@ -214,6 +214,14 @@ pub struct PlaneTangentParams { /// otherwise. #[serde(default)] pub at: Option, + /// A plane or planar face to line the plane up with: it touches where the face turns + /// towards the reference, parallel to it, on the side of `at`. + #[serde(default)] + pub reference: Option, + /// With `reference`, how far the plane is turned from it about the face's axis. Not on a + /// sphere. Default: 0 deg. + #[serde(default)] + pub angle: Option, #[serde(flatten, deserialize_with = "feature_common")] pub common: PlaneCommon, } diff --git a/crates/engine/src/schema/construct_tests.rs b/crates/engine/src/schema/construct_tests.rs index 106b985f47..f2d7a56ba6 100644 --- a/crates/engine/src/schema/construct_tests.rs +++ b/crates/engine/src/schema/construct_tests.rs @@ -27,7 +27,7 @@ fn schemas_and_the_key_check_agree() { assert_keys(input_schema_for::(), &["axis", "name", "participants", "point"]); assert_keys(input_schema_for::(), &["a", "b", "name", "participants"]); assert_keys(input_schema_for::(), &["a", "b", "c", "name", "participants"]); - assert_keys(input_schema_for::(), &["at", "face", "name", "participants"]); + assert_keys(input_schema_for::(), &["angle", "at", "face", "name", "participants", "reference"]); assert_keys(input_schema_for::(), &["name", "participants", "path", "t"]); assert_keys(input_schema_for::(), &["line", "name", "participants", "plane"]); } diff --git a/crates/engine/src/schema/mod.rs b/crates/engine/src/schema/mod.rs index eeb03e5246..c58be27f7d 100644 --- a/crates/engine/src/schema/mod.rs +++ b/crates/engine/src/schema/mod.rs @@ -68,6 +68,7 @@ pub mod render_lights; pub mod render_ui; pub mod section; pub mod sheet; +pub mod sketch; pub mod sketch_constraints; pub mod sketch_create; pub mod sketch_equation; diff --git a/crates/engine/src/schema/sheet.rs b/crates/engine/src/schema/sheet.rs index 8e3cbf5731..da76fcba10 100644 --- a/crates/engine/src/schema/sheet.rs +++ b/crates/engine/src/schema/sheet.rs @@ -417,9 +417,9 @@ pub struct FlangeParams { pub common: FeatureCommon, } -/// Add a field of round perforations over a whole flat sheet: holes in the flat pattern, the DXF -/// (layer PERFORATION) and the measured volume, not cut into the solid. `participants` changes -/// nothing. +/// Add a field of round perforations over a whole flat sheet, or inside a sketch's outline: holes +/// in the flat pattern, the DXF (layer PERFORATION) and the measured volume, not cut into the +/// solid. `participants` changes nothing. #[derive(Debug, Deserialize, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PerforateParams { @@ -427,8 +427,8 @@ pub struct PerforateParams { pub diameter: LengthExpr, /// The distance between hole centres, more than the diameter. pub pitch: LengthExpr, - /// How far the holes stay clear of the sheet's edges, cut-outs and bends, at least 0. - /// Default: the diameter. + /// How far the holes stay clear of the sheet's edges, cut-outs and bends, and of the outline, + /// at least 0. Default: the diameter. #[serde(default)] pub margin: Option, /// How the holes are laid out. Default: staggered. @@ -437,6 +437,18 @@ pub struct PerforateParams { /// The sheet metal body. Default: the newest sheet. #[serde(default)] pub body: Option, + /// The field's outline: a sketch, by id or name, on a face of the sheet (or on the flat sheet + /// while it is unfolded). Holes go only inside its closed profiles, out of their inner loops, + /// on a grid from the outline's lower-left corner; on a folded sheet only in the panels the + /// sketch lies on. Default: the whole sheet. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "IdOrName")] + pub sketch: Option, + /// Which of the outline sketch's profiles bound the field. Needs `sketch`. Default: all of + /// them. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "ProfilesParam")] + pub profiles: Option, #[serde(flatten, deserialize_with = "feature_common")] pub common: FeatureCommon, } diff --git a/crates/engine/src/schema/sheet_tests.rs b/crates/engine/src/schema/sheet_tests.rs index 069904a051..1e1d50d3a8 100644 --- a/crates/engine/src/schema/sheet_tests.rs +++ b/crates/engine/src/schema/sheet_tests.rs @@ -64,7 +64,7 @@ fn schemas_and_the_key_check_agree() { "type", ]), ); - assert_keys(input_schema_for::(), &keys(&["body", "diameter", "margin", "pattern", "pitch"])); + assert_keys(input_schema_for::(), &keys(&["body", "diameter", "margin", "pattern", "pitch", "profiles", "sketch"])); assert_keys(input_schema_for::(), &keys(&["body", "edges", "flip", "gap", "length"])); assert_keys(input_schema_for::(), &keys(&["angle", "body", "curve", "fixed", "flip", "points", "position", "radius", "sketch"])); assert_keys(input_schema_for::(), &keys(&["body"])); diff --git a/crates/engine/src/schema/sketch.rs b/crates/engine/src/schema/sketch.rs new file mode 100644 index 0000000000..c9e8df34c9 --- /dev/null +++ b/crates/engine/src/schema/sketch.rs @@ -0,0 +1,51 @@ +//! Contracts and result types for sketch editing and completion in `cmd/sketch.rs`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::types::{IdOrName, present}; + +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct EditParams { + pub sketch: IdOrName, + /// When present, temporarily roll to this sketch and restore this position when the edit + /// ends. Null means the end of the timeline; omission leaves the timeline in place. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "Option")] + pub restore_marker: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExitParams { + /// Restore the temporary editor marker (default true). False transfers it directly to + /// another editor without rebuilding the intermediate timeline. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "bool")] + pub restore_marker: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct EditResult { + pub sketch: u64, + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct FinishResult { + pub sketch: Option, + pub profiles: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct CancelResult { + pub sketch: u64, + pub removed: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} diff --git a/crates/engine/src/schema/tests.rs b/crates/engine/src/schema/tests.rs index fa2a0d3b5d..f1046b2caa 100644 --- a/crates/engine/src/schema/tests.rs +++ b/crates/engine/src/schema/tests.rs @@ -1130,3 +1130,38 @@ fn plane_names_match_check_plane_name() { } assert!(parse::(json!(5)).is_none()); } + +#[test] +fn sketch_edit_contracts_validate_markers_before_changing_the_session() { + use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult}; + use crate::schema::{ParamCheck, input_schema_for, output_schema_for}; + assert_keys(input_schema_for::(), &["restore_marker", "sketch"]); + assert_keys(input_schema_for::(), &["restore_marker"]); + let mut s = crate::Session::default(); + let run = |s: &mut crate::Session, id: &str, p: serde_json::Value| s.execute(id, &p).unwrap(); + let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + s.param_check = ParamCheck::Strict; + let before = s.doc.to_json(); + for marker in [json!(-1), json!(3), json!(1.5), json!(true), json!([])] { + assert!(s.execute("sketch.edit", &json!({"sketch": id, "restore_marker": marker})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.active_sketch, None); + } + for exit in ["sketch.finish", "sketch.cancel"] { + let r = run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": null})); + let schema = output_schema_for::(); + assert!(conforms(schema.as_value(), schema.as_value(), &r)); + for params in [json!({"restore_marker": "yes"}), json!({"unknown": true})] { + assert!(s.execute(exit, ¶ms).is_err()); + assert_eq!(s.active_sketch, Some(id)); + assert_eq!(s.doc.marker, Some(1)); + } + let r = run(&mut s, exit, json!({})); + let schema = if exit == "sketch.finish" { output_schema_for::() } else { output_schema_for::() }; + assert!(conforms(schema.as_value(), schema.as_value(), &r)); + assert_eq!(s.doc.to_json(), before); + } +} diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index d1852f9b3d..e31f73e555 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -237,7 +237,6 @@ sketch.auto_project sketch.blend_curve sketch.break sketch.break_link -sketch.cancel sketch.centerline sketch.chain sketch.chamfer.distance_angle @@ -273,13 +272,11 @@ sketch.dimension_text sketch.drag sketch.drag_end sketch.draw.dimensioned -sketch.edit sketch.edit_text sketch.ellipse sketch.export_dxf sketch.extend sketch.fillet -sketch.finish sketch.finish_auto_constrain sketch.fit_curves_to_section sketch.glyphs diff --git a/crates/engine/src/status_items.rs b/crates/engine/src/status_items.rs index 5dff8791ce..d6e333edbe 100644 --- a/crates/engine/src/status_items.rs +++ b/crates/engine/src/status_items.rs @@ -144,7 +144,7 @@ impl Session { self.status_slot(id, active); } if self.status_items() != before { - self.revision += 1; + self.refresh_display(); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 788faa656f..930dc6a07c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1731,8 +1731,10 @@ fn section_analysis() { assert_eq!(s.undo.len(), undo); } -/// A section index finds the same cut segments, in the same order, as cutting every triangle: -/// tilted and axis normals, cuts through vertices, at the ends and beyond the part. +/// A section index finds the same cut segments, in the same order and to the bit, as cutting +/// every triangle: one index for every normal (tilting a Section Analysis turns it every frame, +/// #10373), axis and tilted, cuts through vertices, at the ends and beyond the part, and a +/// triangle with a corner that is not a number. #[test] fn section_index_cuts_like_every_triangle() { let mut s = Session::default(); @@ -1740,18 +1742,30 @@ fn section_index_cuts_like_every_triangle() { run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 8, "operation": "join"})); let st = s.model.state(); let m = st.bodies.first().unwrap().mesh(); - assert!(m.triangles.len() > 64, "enough triangles for several slabs: {}", m.triangles.len()); - for n in [Vec3::Z, -Vec3::Y, Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(), Vec3::new(-0.3, 0.1, 0.9).normalized().unwrap()] { - let index = view::SectionIndex::new(&m, n); - let heights: Vec = m.positions.iter().map(|p| n.dot(*p)).collect(); - let (lo, hi) = heights.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| (a.min(*h), b.max(*h))); - let mut cuts: Vec = (0..=40).map(|k| lo - 1.0 + (hi - lo + 2.0) * k as f64 / 40.0).collect(); - cuts.extend(heights.iter().step_by(7).copied()); - cuts.extend([lo, hi]); - for at in cuts { - let o = n * at; - let segments = view::section_segments(&m, o, n); - assert_eq!(index.segments(&m, o), segments, "{n:?} at {at}"); + assert!(m.triangles.len() > 64, "enough triangles for several cells: {}", m.triangles.len()); + let bits = |v: Vec<(Vec3, Vec3)>| v.iter().flat_map(|(a, b)| [a.x, a.y, a.z, b.x, b.y, b.z].map(f64::to_bits)).collect::>(); + let mut hostile = (*m).clone(); + hostile.positions.push(Vec3::new(f64::NAN, 0.0, 0.0)); + let nan = u32::try_from(hostile.positions.len() - 1).unwrap(); + hostile.triangles.extend([[0, 1, nan], [0, 1, u32::MAX]]); + for m in [&*m, &hostile] { + let index = view::SectionIndex::new(m); + let mut normals = vec![Vec3::Z, -Vec3::Y, Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(), Vec3::new(-0.3, 0.1, 0.9).normalized().unwrap()]; + // A tilt drag: the normal turns a little every frame. + normals.extend((0..30).map(|k| { + let a = (5.0 + 3.0 * k as f64).to_radians(); + Vec3::new(0.1, a.sin(), a.cos()).normalized().unwrap() + })); + for n in normals { + let heights: Vec = m.positions.iter().map(|p| n.dot(*p)).filter(|h| h.is_finite()).collect(); + let (lo, hi) = heights.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| (a.min(*h), b.max(*h))); + let mut cuts: Vec = (0..=40).map(|k| lo - 1.0 + (hi - lo + 2.0) * k as f64 / 40.0).collect(); + cuts.extend(heights.iter().step_by(7).copied()); + cuts.extend([lo, hi]); + for at in cuts { + let o = n * at; + assert_eq!(bits(index.segments(m, o, n)), bits(view::section_segments(m, o, n)), "{n:?} at {at}"); + } } } } diff --git a/crates/engine/src/tests/mirror_placed.rs b/crates/engine/src/tests/mirror_placed.rs index 68ee1a398c..d8f2d2b18a 100644 --- a/crates/engine/src/tests/mirror_placed.rs +++ b/crates/engine/src/tests/mirror_placed.rs @@ -232,3 +232,171 @@ fn a_placement_leading_to_another_component_is_reported_lost() { assert!(e.contains("no longer exists"), "{e}"); assert!(s.world_state().body(©).is_none()); } + +/// A carriage-like part (a bored block and a ring) placed once, free to move: its occurrence and +/// body names. +fn curved_part(s: &mut Session) -> (u64, Vec) { + let c = run(s, "component.create", json!({"name": "Carriage", "grounded": false})); + run(s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "corner": [10, 5, 0], "body_name": "Block"})); + run(s, "solid.cylinder", json!({"base": [20, 10, -1], "radius": 3, "height": 12, "operation": "cut", "targets": ["Block"]})); + run(s, "solid.torus", json!({"center": [20, 10, 20], "major": 6, "minor": 2, "operation": "new", "body_name": "Ring"})); + run(s, "component.activate", json!({"component": 0})); + run(s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [0, 7, 3]})); + (c["occurrence"].as_u64().unwrap(), vec!["Block".into(), "Ring".into()]) +} + +/// The shown bodies drawn, as a viewport does, and the tessellation that took. +fn drawn(s: &Session) -> f64 { + let world = s.world_state(); + let (_, work) = solidcraft_kernel::profile::capture(|| { + for b in &world.bodies { + b.mesh_in_place(); + b.display_plan(); + b.mesh(); + } + }); + work.tessellate_ms +} + +/// What the viewport shows is what a fresh evaluation of the design shows: where the triangles +/// are, which way they face and which faces and edges they belong to. +fn assert_as_evaluated_afresh(s: &Session) { + let mut fresh = solidcraft_doc::Model::new(); + fresh.no_cache = true; + fresh.evaluate(&s.doc); + assert!(fresh.results.iter().all(|r| r.error.is_none())); + let (shown, want) = (s.world_state(), solidcraft_doc::world_state(&s.doc, &fresh.state())); + assert_eq!(shown.bodies.len(), want.bodies.len()); + for (a, b) in shown.bodies.iter().zip(&want.bodies) { + assert_eq!(a.name, b.name); + let (x, y) = (a.mesh(), b.mesh()); + assert_eq!(x.triangles, y.triangles, "{}", a.name); + assert_eq!(x.tri_face, y.tri_face, "{}", a.name); + assert_eq!(x.edge_faces, y.edge_faces, "{}", a.name); + assert_eq!(x.seams, y.seams, "{}", a.name); + assert_eq!(x.positions.len(), y.positions.len(), "{}", a.name); + assert!(x.edges.iter().flatten().zip(y.edges.iter().flatten()).all(|(p, q)| p.dist(*q) < 1e-7), "{}: edges", a.name); + assert_same_surface(&x, &y, &a.name); + // A plan's ties fall by the pose it was made at. + let (x, y) = (a.display_plan().unwrap().evaluate(), b.display_plan().unwrap().evaluate()); + assert_eq!(x.triangles.len(), y.triangles.len(), "{}: plan", a.name); + assert_same_surface(&x, &y, &a.name); + } +} + +/// Two meshes of one surface, facing the same way. Their points can differ: a revolved face is +/// turned by where it stands when it is made, which only moves where the surface is sampled. +fn assert_same_surface(x: &solidcraft_geom::Mesh, y: &solidcraft_geom::Mesh, name: &str) { + let enclosed = |m: &solidcraft_geom::Mesh| { + m.triangles.iter().fold((0.0, 0.0), |(v, s), t| { + let [a, b, c] = t.map(|i| m.positions[i as usize]); + (v + a.dot(b.cross(c)) / 6.0, s + (b - a).cross(c - a).len() / 2.0) + }) + }; + let ((v, s), (w, t)) = (enclosed(x), enclosed(y)); + assert!(v > 0.0 && (v - w).abs() < 1e-4 * w && (s - t).abs() < 1e-4 * t, "{name}: encloses {v}, {s} against {w}, {t}"); + let (a, b) = (x.bounds(), y.bounds()); + assert!(a.min.dist(b.min) < 0.05 && a.max.dist(b.max) < 0.05, "{name}: {a:?} against {b:?}"); +} + +/// Dragging a part with a mirrored copy moved the copy too, and every frame meshed the copy from +/// nothing: a design with a few dozen curved mirrored bodies dragged at a frame or two a second. +#[test] +fn dragging_a_mirrored_part_meshes_its_copies_once() { + let mut s = Session::default(); + let (occurrence, bodies) = curved_part(&mut s); + let first = run(&mut s, "solid.mirror", json!({"bodies": bodies, "plane": "XZ"})); + // Mirrored again, as four carriages from one are: the source of these copies moves as well. + run(&mut s, "solid.mirror", json!({"bodies": first["created_bodies"], "plane": "YZ"})); + drawn(&s); + for k in 1..=4 { + let at = f64::from(k) * 3.5; + run(&mut s, "occurrence.drag", json!({"occurrence": occurrence, "at": [0, 0, 0], "point": [1, at, 2], "continuous": true})); + assert!(s.model.last_recomputed > 0, "the copies follow the part"); + assert_eq!(drawn(&s), 0.0, "frame {k}: the copies are drawn from triangles already made"); + } + assert_as_evaluated_afresh(&s); + run(&mut s, "edit.undo", json!({})); + assert_as_evaluated_afresh(&s); +} + +/// What the viewport draws of a copy doesn't reach what the model reads: a later feature finds +/// the copy's own triangles, as it does in a design opened afresh. +#[test] +fn a_mirrored_copys_shared_triangles_are_display_only() { + let mut s = Session::default(); + let (occurrence, bodies) = curved_part(&mut s); + let r = run(&mut s, "solid.mirror", json!({"bodies": bodies, "plane": "XZ"})); + run(&mut s, "occurrence.drag", json!({"occurrence": occurrence, "at": [0, 0, 0], "point": [0, 3, 0], "continuous": true})); + drawn(&s); + let (state, world) = (s.model.state(), s.world_state()); + for copy in r["created_bodies"].as_array().unwrap().iter().map(|n| n.as_str().unwrap()) { + assert!(world.body(copy).is_some_and(|b| b.cached_mesh().is_some()), "{copy} was drawn"); + assert!(state.body(copy).unwrap().cached_mesh().is_none(), "{copy}: the model's own mesh is its own"); + } +} + +/// A source changed is mirrored from what it is now, not from the triangles of what it was. +#[test] +fn a_changed_source_gives_its_copy_new_triangles() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "side", "expression": "10 mm"})); + let c = run(&mut s, "component.create", json!({"name": "Part", "grounded": false})); + run(&mut s, "solid.box", json!({"length": "side", "width": 10, "height": 10, "corner": [10, 0, 0], "body_name": "Block"})); + run(&mut s, "component.activate", json!({"component": 0})); + run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [0, 20, 0]})); + let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["Block"], "plane": "XZ"}))); + assert_box(world_box(&s, ©), (Vec3::new(10.0, -30.0, 0.0), Vec3::new(20.0, -20.0, 10.0))); + run(&mut s, "parameters.change", json!({"name": "side", "expression": "15 mm"})); + assert_box(world_box(&s, ©), (Vec3::new(10.0, -30.0, 0.0), Vec3::new(25.0, -20.0, 10.0))); + assert_as_evaluated_afresh(&s); +} + +/// Copies nothing builds on skip the search that readies their revolved faces for a boolean. +/// A feature added later that does build on them gets them readied, as a design opened with that +/// feature has them, whether or not they were mirrored first. +#[test] +fn a_cut_added_after_a_mirror_finds_its_copies_readied() { + let cut = |s: &mut Session, copy: &str| { + run(s, "solid.cylinder", json!({"base": [20, -17, 15], "radius": 3, "height": 12, "operation": "cut", "targets": [copy]})); + }; + let mut s = Session::default(); + let (_, bodies) = curved_part(&mut s); + let copies = run(&mut s, "solid.mirror", json!({"bodies": bodies, "plane": "XZ"})); + let ring = copies["created_bodies"][1].as_str().unwrap().to_string(); + let evaluated = s.model.recomputed; + cut(&mut s, &ring); + assert!(s.model.results.iter().all(|r| r.error.is_none())); + assert!(s.model.recomputed - evaluated >= 2, "the mirror is evaluated again with the cut"); + let measured = |s: &Session| { + let v = run(&mut s.scratch(), "inspect.measure", json!({"bodies": [ring]})); + v["total"]["volume_mm3"].as_f64().unwrap() + }; + let volume = measured(&s); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(measured(&reopened), volume, "what evaluates here is what opens"); +} + +/// A part inserted after the mirror keeps its meshes while the mirror moves: evaluating the +/// insert again gives the same solids, and meshing them was as slow as meshing the copies. +#[test] +fn an_insert_after_a_mirror_keeps_its_meshes_through_a_drag() { + let dir = std::env::temp_dir().join(format!("solidcraft-mirror-insert-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("insert.step").to_string_lossy().replace('\\', "/"); + let mut part = Session::default(); + run(&mut part, "solid.sphere", json!({"radius": 5})); + run(&mut part, "file.export", json!({"path": file})); + let mut s = Session::default(); + let (occurrence, bodies) = curved_part(&mut s); + run(&mut s, "solid.mirror", json!({"bodies": bodies, "plane": "XZ"})); + run(&mut s, "file.insert_step", json!({"path": file, "name": "After"})); + let _ = std::fs::remove_dir_all(&dir); + run(&mut s, "component.activate", json!({"component": 0})); + drawn(&s); + for k in 1..=3 { + run(&mut s, "occurrence.drag", json!({"occurrence": occurrence, "at": [0, 0, 0], "point": [0, f64::from(k), 0], "continuous": true})); + assert_eq!(drawn(&s), 0.0, "frame {k}"); + } + assert_as_evaluated_afresh(&s); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 31aa716585..efd69313b6 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -935,72 +935,143 @@ fn cut_triangle(m: &solidcraft_geom::Mesh, t: &[u32; 3], o: Vec3, n: Vec3, eps: } } -/// A mesh's triangles sorted into slabs along one normal, so cuts with that normal at any -/// height look only at the triangles of one slab: dragging a Section Analysis keeps the normal -/// and moves the cut, and an imported part can have millions of triangles. The same segments -/// as [`section_segments`]. +/// A mesh's triangles sorted into the cells of a grid by where their boxes sit, each cell +/// keeping the box around its triangles, so a cut with any plane looks only at the triangles of +/// the cells the plane crosses: an imported part can have millions of triangles, and dragging a +/// Section Analysis moves the cut and, when tilted, turns its normal every frame (#10373). The +/// same segments, in the same order, as [`section_segments`]. pub struct SectionIndex { - normal: Vec3, - /// The lowest height (`normal · p`) of the mesh, and the slab thickness. - start: f64, - step: f64, - /// Per slab, the triangles whose heights reach into it. - slabs: Vec>, + /// Per cell holding triangles, the centre and half size of the box around them, and where + /// they are in `tris` (in mesh order). + cells: Vec<(Vec3, Vec3, u32, u32)>, + tris: Vec, + /// Triangles with a corner that is not finite: cut on every plane, as `section_segments` + /// does. + always: Vec, + /// The largest coordinate of the mesh, for the cell test's rounding margin. + reach: f64, eps: f64, } -/// Slabs a [`SectionIndex`] makes, at most. -const SECTION_SLABS: usize = 4096; +/// How many mean triangle widths a [`SectionIndex`] cell is wide, and most cells of its grid. +const SECTION_CELL_WIDTHS: f64 = 4.0; +const SECTION_GRID: usize = 1 << 22; impl SectionIndex { - pub fn new(m: &solidcraft_geom::Mesh, normal: Vec3) -> SectionIndex { - let heights: Vec> = m + pub fn new(m: &solidcraft_geom::Mesh) -> SectionIndex { + let finite_tri = |t: &[u32; 3]| m.tri(t).filter(|p| p.iter().all(|q| q.is_finite())); + let mut always = Vec::new(); + let mut centres = Aabb3::EMPTY; + let (mut widths, mut count) = (0.0, 0usize); + for (i, t) in m.triangles.iter().enumerate() { + match finite_tri(t) { + Some([a, b, c]) => { + let (lo, hi) = (a.min(b).min(c), a.max(b).max(c)); + centres.add((lo + hi) * 0.5); + widths += (hi - lo).len(); + count += 1; + } + None if m.tri(t).is_some() => always.extend(u32::try_from(i)), + None => {} + } + } + // Cells as wide as a few triangles, on the axes the part spans: a part's triangles lie + // on its surface, so cells sized from the part's volume would hold far more. + let size = if centres.is_empty() { Vec3::ZERO } else { centres.size() }; + let ext = [size.x, size.y, size.z]; + let dims_for = |side: f64| ext.map(|e| if e > 0.0 && side > 0.0 { (e / side).ceil().clamp(1.0, SECTION_GRID as f64) as usize } else { 1 }); + let cells_of = |d: [usize; 3]| d.iter().fold(1usize, |p, k| p.saturating_mul(*k)); + let mut side = widths / count.max(1) as f64 * SECTION_CELL_WIDTHS; + while cells_of(dims_for(side)) > SECTION_GRID && side.is_finite() { + side *= 1.25; + } + let dims = dims_for(side); + let cells = cells_of(dims).min(SECTION_GRID); + let at = |v: f64, lo: f64, e: f64, d: usize| { + let k = if e > 0.0 { ((v - lo) / e * d as f64).floor() } else { 0.0 }; + if k.is_finite() { (k.max(0.0) as usize).min(d.saturating_sub(1)) } else { 0 } + }; + let of: Vec> = m .triangles .iter() .map(|t| { - let d = m.tri(t)?.map(|q| normal.dot(q)); - Some((d[0].min(d[1]).min(d[2]), d[0].max(d[1]).max(d[2]))) + let [a, b, c] = finite_tri(t)?; + let p = (a.min(b).min(c) + a.max(b).max(c)) * 0.5; + let [x, y, z] = + [at(p.x, centres.min.x, ext[0], dims[0]), at(p.y, centres.min.y, ext[1], dims[1]), at(p.z, centres.min.z, ext[2], dims[2])]; + Some(((z * dims[1] + y) * dims[0] + x).min(cells.saturating_sub(1))) }) .collect(); - let (lo, hi) = heights.iter().flatten().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (l, h)| (a.min(*l), b.max(*h))); - let count = if lo.is_finite() && hi.is_finite() { (m.triangles.len() / 64).clamp(1, SECTION_SLABS) } else { 0 }; - let step = if count > 0 && hi > lo { (hi - lo) / count as f64 } else { 1.0 }; - let mut index = SectionIndex { normal, start: lo, step, slabs: vec![Vec::new(); count], eps: section_nudge(m) }; - for (i, h) in heights.iter().enumerate() { - let (Some((l, h)), Ok(i)) = (h, u32::try_from(i)) else { continue }; - for k in index.slab(*l)..=index.slab(*h) { - if let Some(slab) = index.slabs.get_mut(k) { - slab.push(i); - } + // Counted per cell, then each cell's first slot (the last entry: the total). + let mut starts = vec![0u32; cells + 1]; + for c in of.iter().flatten() { + if let Some(s) = starts.get_mut(*c) { + *s += 1; } } - index - } - - /// The normal this index was made for. - pub fn normal(&self) -> Vec3 { - self.normal - } - - fn slab(&self, height: f64) -> usize { - let k = ((height - self.start) / self.step).floor(); - if k.is_finite() { (k.max(0.0) as usize).min(self.slabs.len().saturating_sub(1)) } else { 0 } + let mut total = 0u32; + for s in &mut starts { + (*s, total) = (total, total.saturating_add(*s)); + } + let mut fill = starts.clone(); + let mut tris = vec![0u32; total as usize]; + for (i, c) in of.iter().enumerate() { + let (Some(c), Ok(i)) = (c, u32::try_from(i)) else { continue }; + if let Some(at) = fill.get_mut(*c) { + if let Some(slot) = tris.get_mut(*at as usize) { + *slot = i; + } + *at += 1; + } + } + let mut reach: f64 = 0.0; + let cells = starts + .windows(2) + .filter_map(|w| match *w { + [a, e] if e > a => { + let mut b = Aabb3::EMPTY; + for i in tris.get(a as usize..e as usize).unwrap_or(&[]) { + for p in m.triangles.get(*i as usize).and_then(|t| m.tri(t)).into_iter().flatten() { + b.add(p); + } + } + reach = reach.max(b.min.x.abs().max(b.min.y.abs()).max(b.min.z.abs()).max(b.max.x.abs()).max(b.max.y.abs()).max(b.max.z.abs())); + Some((b.center(), b.size() * 0.5, a, e)) + } + _ => None, + }) + .collect(); + SectionIndex { cells, tris, always, reach, eps: section_nudge(m) } } /// [`section_segments`] of `m` (the mesh this index was made from) at the plane through - /// `o` with this index's normal. - pub fn segments(&self, m: &solidcraft_geom::Mesh, o: Vec3) -> Vec<(Vec3, Vec3)> { - let mut out = Vec::new(); - let at = self.normal.dot(o); - if self.slabs.is_empty() || !at.is_finite() || at < self.start { - return out; + /// `o` with normal `n`; nothing for a plane that is not finite. + pub fn segments(&self, m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> { + if !(o.is_finite() && n.is_finite()) { + return Vec::new(); } - for i in self.slabs.get(self.slab(at)).into_iter().flatten() { - if let Some(t) = m.triangles.get(*i as usize) { - cut_triangle(m, t, o, self.normal, self.eps, &mut out); + let an = Vec3::new(n.x.abs(), n.y.abs(), n.z.abs()); + // Rounding in `cut_triangle`'s heights is far below this margin, so a cell left out + // holds no triangle it would cut. + let margin = 1e-9 * (an.x + an.y + an.z) * (self.reach + o.x.abs().max(o.y.abs()).max(o.z.abs()) + 1.0); + let at = n.dot(o); + // The cut triangles' segments, by triangle, then in mesh order. + let mut found: Vec<(u32, (Vec3, Vec3))> = Vec::new(); + let mut out = Vec::with_capacity(1); + let mut cut = |i: u32| { + if let Some(t) = m.triangles.get(i as usize) { + cut_triangle(m, t, o, n, self.eps, &mut out); + found.extend(out.pop().map(|seg| (i, seg))); + } + }; + self.always.iter().for_each(|i| cut(*i)); + for (centre, half, first, end) in &self.cells { + if (n.dot(*centre) - at).abs() <= an.dot(*half) + margin { + self.tris.get(*first as usize..*end as usize).unwrap_or(&[]).iter().for_each(|i| cut(*i)); } } - out + found.sort_unstable_by_key(|(i, _)| *i); + found.into_iter().map(|(_, seg)| seg).collect() } } @@ -1030,12 +1101,12 @@ pub fn sketch_shown(s: &Session, id: u64) -> bool { v.shown_sketches.contains(&id) || !sketch_consumed(s, id) } -/// Is a sketch used by a later feature? A sheet metal base or contour flange uses its sketch as -/// an Extrude does: shown, its lines lie on the sheet's edges and take the clicks meant for -/// them (#6804). So does a Hole at sketch points: shown, its points are drawn in the holes -/// (#9082). A Single hole snapped to one point (#2110) does not: it was placed on a face, not -/// built from the sketch, and hiding the sketch would take its other points from the next -/// Single hole's snap. +/// Is a sketch used by a later feature? A sheet metal base or contour flange, or a perforation's +/// outline, uses its sketch as an Extrude does: shown, its lines lie on the sheet's edges and +/// faces and take the clicks meant for them (#6804). So does a Hole at sketch points: shown, its +/// points are drawn in the holes (#9082). A Single hole snapped to one point (#2110) does not: it +/// was placed on a face, not built from the sketch, and hiding the sketch would take its other +/// points from the next Single hole's snap. pub fn sketch_consumed(s: &Session, id: u64) -> bool { if let Ok(mut cache) = s.consumed_sketches_cache.lock() { if cache.as_ref().is_none_or(|c| !std::ptr::eq(c.doc.as_ptr(), Arc::as_ptr(&s.doc))) { @@ -1065,7 +1136,8 @@ fn consumed_sketches(doc: &solidcraft_doc::Document) -> impl Iterator Some(sketch.0), + | FeatureKind::Hole { points: Some(SketchPoints { sketch, single: false, .. }), .. } + | FeatureKind::SheetPerforate { sketch: Some(sketch), .. } => Some(sketch.0), _ => None, }) } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 9492fe9088..b9020e24a3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -96,6 +96,98 @@ fn the_hlsl_for_fxc_has_no_arrays_fxc_must_unroll_loops_for() { } } +/// Browsers compile WGSL with Tint, which keeps a rule of the WGSL grammar that naga doesn't: a +/// shift, `&`, `|` and `^` don't mix in one expression without parentheses (`a << 3u | b` is +/// refused, `(a << 3u) | b` isn't). A shader that breaks it runs on the desktop and leaves the +/// web app's 3D view black (#11080). Every shader in the repository keeps to it. +#[test] +fn no_shader_mixes_shifts_and_bitwise_operators_without_parentheses() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut shaders = Vec::new(); + let mut dirs = vec![root.join("assets"), root.join("crates"), root.join("vendor")]; + while let Some(dir) = dirs.pop() { + for entry in ok(std::fs::read_dir(&dir)) { + let path = ok(entry).path(); + if path.is_dir() { + if !path.ends_with("target") { + dirs.push(path); + } + } else if path.extension().is_some_and(|e| e == "wgsl") { + shaders.push(path); + } + } + } + assert!(shaders.iter().any(|p| p.ends_with("assets/shaders/viewport.wgsl")), "{shaders:?}"); + let mut found = Vec::new(); + for path in &shaders { + for (n, line) in ok(std::fs::read_to_string(path)).lines().enumerate() { + if let Some(ops) = mixed_bitwise(line) { + found.push(format!("{}:{}: {ops}: {}", path.display(), n + 1, line.trim())); + } + } + } + assert!(found.is_empty(), "{}", found.join("\n")); + // The check finds the line #11080 reported, and takes it parenthesized. + assert_eq!(mixed_bitwise("let v = ((x ^ y) & 1u) << 3u | (y & 1u) << 2u;").as_deref(), Some("shift and |")); + assert_eq!(mixed_bitwise("let v = (((x ^ y) & 1u) << 3u) | ((y & 1u) << 2u);"), None); + assert_eq!(mixed_bitwise("let a = f(&p, x & 1u, y | 2u) && b || c; // x << 1u | y"), None); + assert_eq!(mixed_bitwise("var m: array>; let b = a & 1u ^ c;").as_deref(), Some("& and ^")); +} + +/// The bitwise operators (a shift, `&`, `|`, `^`) that one parenthesized group of a WGSL line +/// mixes, when it mixes more than one: what WGSL needs parentheses for. `&&`, `||`, an +/// address-of `&` and a `>>` closing two generic brackets (`vec2>`) aren't counted. +fn mixed_bitwise(line: &str) -> Option { + let chars: Vec = line.split("//").next().unwrap_or("").chars().collect(); + let at = |i: usize| chars.get(i).copied().unwrap_or(' '); + let mixed = |g: &Vec<&str>| (g.len() > 1).then(|| g.join(" and ")); + let mut groups: Vec> = vec![Vec::new()]; + let (mut i, mut prev) = (0, ' '); + while i < chars.len() { + let (c, next) = (at(i), at(i + 1)); + let mut op = None; + match c { + '(' | '[' => groups.push(Vec::new()), + ')' | ']' | ',' | ';' | '{' | '}' => { + if let Some(m) = groups.last().and_then(mixed) { + return Some(m); + } + if matches!(c, ')' | ']') && groups.len() > 1 { + groups.pop(); + } else if let Some(g) = groups.last_mut() { + g.clear(); + } + } + '<' if next == '<' => { + op = Some("shift"); + i += 1; + } + '>' if next == '>' => { + let after = at(i + 2); + if after == ' ' || after == '_' || after.is_alphanumeric() { + op = Some("shift"); + } + i += 1; + } + '&' | '|' if next == c => i += 1, + '&' if prev == '_' || prev == ')' || prev == ']' || prev.is_alphanumeric() => op = Some("&"), + '|' => op = Some("|"), + '^' => op = Some("^"), + _ => {} + } + if let (Some(op), Some(g)) = (op, groups.last_mut()) + && !g.contains(&op) + { + g.push(op); + } + if !at(i).is_whitespace() { + prev = at(i); + } + i += 1; + } + groups.iter().find_map(mixed) +} + fn ok(r: std::result::Result) -> T { match r { Ok(v) => v, diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index 8536ed6f74..c849837b18 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -3599,7 +3599,7 @@ impl Renderer { }; if phase == Phase::AfterSeeThrough { pass.set_bind_group(1, &res.access_bind, &[]); - pass.set_bind_group(2, &res.ao.bind, &[]); + pass.set_bind_group(2, &res.ao.maps.bind, &[]); Self::draw_over(res, pass, sk, h, pv, m, &tris, &lines); return; } @@ -3608,7 +3608,7 @@ impl Renderer { pass.set_bind_group(0, scene_bind(sk, &res.bind), &[]); lines(pass, &res.overlay, &sk.under); pass.set_bind_group(1, &res.access_bind, &[]); - pass.set_bind_group(2, &res.ao.bind, &[]); + pass.set_bind_group(2, &res.ao.maps.bind, &[]); pass.set_bind_group(0, scene_bind(m, &res.bind), &[]); let planned = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline| { for d in m.planned.iter().filter(|d| d.class == TriClass::Opaque) { diff --git a/crates/gpu/src/view/ao.rs b/crates/gpu/src/view/ao.rs index 850b0fac5b..2b2ce9d932 100644 --- a/crates/gpu/src/view/ao.rs +++ b/crates/gpu/src/view/ao.rs @@ -1,68 +1,134 @@ -//! The optional AO depth prepass. Only opaque model and preview triangles participate: their -//! depth is drawn once into a single-sample depth map the lit fragment samples for occlusion. -//! The map is independent of the window's MSAA, and the depth runs the same way as the -//! viewport's own buffer (see [`super::depth_compare`] and [`super::far_depth`]). +//! The optional AO passes. Only opaque model and preview 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 +//! as the viewport's own buffer (see [`super::depth_compare`] and [`super::far_depth`]). //! Depth testing chooses the nearest surface; an R32Uint colour attachment stores that depth's //! bits for portable integer texel reads. GLSL cannot load from a depth texture, and float //! colour attachments are not available on every GL adapter. use super::{Batches, TriSource, color_layout, depth_compare, far_depth, instance_layout, planned_layout, shared_layout, tri_layout}; -pub(super) struct Resources { - pub layout: wgpu::BindGroupLayout, - pub bind: wgpu::BindGroup, - depth: wgpu::TextureView, - values: wgpu::TextureView, - size: [u32; 2], - pipeline: wgpu::RenderPipeline, - planned_pipeline: wgpu::RenderPipeline, - instanced_pipeline: wgpu::RenderPipeline, - instanced_flat_pipeline: wgpu::RenderPipeline, +const TERM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm; + +/// What the opaque triangles are drawn with in one pass, by where they come from. +struct OpaquePipelines { + built: wgpu::RenderPipeline, + planned: wgpu::RenderPipeline, + instanced: wgpu::RenderPipeline, + instanced_flat: wgpu::RenderPipeline, } -fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::TextureView, wgpu::BindGroup) { - let texture = |label, format, usage| { - device.create_texture(&wgpu::TextureDescriptor { +/// The maps at their current size. Group 2 of the shader is the depth map and a term: `bind` has +/// the blurred one for the lit pass, `blur_bind` the raw one for the blur. +pub(super) struct Maps { + pub bind: wgpu::BindGroup, + blur_bind: wgpu::BindGroup, + depth: wgpu::TextureView, + values: wgpu::TextureView, + raw: wgpu::TextureView, + smooth: wgpu::TextureView, +} + +pub(super) struct Resources { + pub layout: wgpu::BindGroupLayout, + pub maps: Maps, + size: [u32; 2], + prepass: OpaquePipelines, + term: OpaquePipelines, + blur: wgpu::RenderPipeline, +} + +fn maps(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> Maps { + let view = |label, format, usage| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let drawn_and_read = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let depth = view("sc_ao_depth", wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT); + let values = view("sc_ao_values", wgpu::TextureFormat::R32Uint, drawn_and_read); + let raw = view("sc_ao_raw", TERM_FORMAT, drawn_and_read); + let smooth = view("sc_ao_smooth", TERM_FORMAT, drawn_and_read); + let group = |label, term| { + device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(label), - size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage, - view_formats: &[], + layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&values) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(term) }, + ], }) }; - let depth = texture("sc_ao_depth", wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT).create_view(&Default::default()); - let values = texture("sc_ao_values", wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING) - .create_view(&Default::default()); - let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sc_ao_depth"), - layout, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&values) }], - }); - (depth, values, bind) + Maps { bind: group("sc_ao_lit", &smooth), blur_bind: group("sc_ao_blur", &raw), depth, values, raw, smooth } +} + +/// A term map as a pass's target, cleared to no occlusion. +fn term_target(view: &wgpu::TextureView) -> Option> { + Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store }, + }) +} + +/// The opaque triangles of `batches`, as neither pass counts them in the performance overlay. +fn draw_opaque(pass: &mut wgpu::RenderPass<'_>, pipelines: &OpaquePipelines, binds: [&wgpu::BindGroup; 2], batches: [&Batches; 2]) { + for (batch, bind) in batches.into_iter().zip(binds) { + pass.set_bind_group(0, bind, &[]); + pass.set_pipeline(&pipelines.built); + if let Some(tris) = &batch.tris { + for (buffer, count) in &tris.parts { + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..*count, 0..1); + } + } + let uncounted = super::Tally::default(); + // The blocks' opaque triangles, for the same depth. + for key in &batch.block_order { + if let Some(block) = batch.blocks.get(key) { + block.draw(pass, super::TriClass::Opaque, &uncounted); + } + } + pass.set_pipeline(&pipelines.planned); + for draw in batch.planned.iter().filter(|d| d.class == super::TriClass::Opaque) { + pass.set_vertex_buffer(0, draw.mesh.vertices.slice(..)); + pass.set_vertex_buffer(1, draw.colors.slice(..)); + pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1); + } + for draw in batch.instanced.iter().filter(|d| d.look == super::InstanceLook::Opaque) { + draw.draw(pass, &pipelines.instanced, &pipelines.instanced_flat, &uncounted); + } + } } impl Resources { - /// The layout of the depth map's bind group, which the lit pipelines' layout includes. + /// The layout of the maps' bind groups, which the lit pipelines' layout includes. pub fn layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let entry = |binding, sample_type| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { sample_type, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false }, + count: None, + }; device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sc_ao_depth"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Uint, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }], + label: Some("sc_ao_maps"), + entries: &[entry(0, wgpu::TextureSampleType::Uint), entry(1, wgpu::TextureSampleType::Float { filterable: false })], }) } - /// The prepass's pipelines and a 1 x 1 stand-in map, bound with `layout` ([`Resources::layout`]). + /// The passes' pipelines and 1 x 1 stand-in maps, bound with `layout` ([`Resources::layout`]). pub fn new( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, @@ -71,57 +137,82 @@ impl Resources { reversed: bool, ) -> Self { let layout = layout.clone(); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + let prepass_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("sc_ao_prepass"), bind_group_layouts: &[Some(uniform)], immediate_size: 0, }); + let term_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_ao_term"), + bind_group_layouts: &[Some(uniform), None, Some(&layout)], + immediate_size: 0, + }); let built = [Some(tri_layout())]; let evaluated = [Some(planned_layout()), Some(color_layout())]; let placed = [Some(shared_layout()), Some(instance_layout())]; - let pipeline = |source: TriSource| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_ao_prepass"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module, - entry_point: Some(source.entry()), - buffers: match source { - TriSource::Built => &built[..], - TriSource::Planned => &evaluated[..], - TriSource::Instanced | TriSource::InstancedFlat => &placed[..], + let target = |format| [Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::RED })]; + let opaque = |label, layout: &wgpu::PipelineLayout, fragment: &str, format, depth_write_enabled, compare| { + let pipeline = |source: TriSource| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(source.entry()), + buffers: match source { + TriSource::Built => &built[..], + TriSource::Planned => &evaluated[..], + TriSource::Instanced | TriSource::InstancedFlat => &placed[..], + }, + compilation_options: Default::default(), }, - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState::default(), - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, - depth_write_enabled: Some(true), - depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)), - stencil: Default::default(), - bias: Default::default(), - }), - multisample: wgpu::MultisampleState::default(), - fragment: Some(wgpu::FragmentState { - module, - entry_point: Some(&source.fragment("fs_ao_depth")), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::R32Uint, - blend: None, - write_mask: wgpu::ColorWrites::RED, - })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }) + primitive: wgpu::PrimitiveState::default(), + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(depth_write_enabled), + depth_compare: Some(depth_compare(reversed, compare)), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(&source.fragment(fragment)), + targets: &target(format), + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + OpaquePipelines { + built: pipeline(TriSource::Built), + planned: pipeline(TriSource::Planned), + instanced: pipeline(TriSource::Instanced), + instanced_flat: pipeline(TriSource::InstancedFlat), + } }; - let planned_pipeline = pipeline(TriSource::Planned); - let instanced_pipeline = pipeline(TriSource::Instanced); - let instanced_flat_pipeline = pipeline(TriSource::InstancedFlat); - let pipeline = pipeline(TriSource::Built); - let (depth, values, bind) = texture(device, &layout, [1, 1]); - Self { layout, bind, depth, values, size: [1, 1], pipeline, planned_pipeline, instanced_pipeline, instanced_flat_pipeline } + let prepass = opaque("sc_ao_prepass", &prepass_layout, "fs_ao_depth", wgpu::TextureFormat::R32Uint, true, wgpu::CompareFunction::Less); + // The term only where the prepass's depth is, so each pixel's term is worked out once. + let term = opaque("sc_ao_term", &term_layout, "fs_ao_term", TERM_FORMAT, false, wgpu::CompareFunction::LessEqual); + let blur = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_ao_blur"), + layout: Some(&term_layout), + vertex: wgpu::VertexState { module, entry_point: Some("vs_ao_blur"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some("fs_ao_blur"), + targets: &target(TERM_FORMAT), + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let maps = maps(device, &layout, [1, 1]); + Self { layout, maps, size: [1, 1], prepass, term, blur } } pub fn prepare( @@ -138,7 +229,7 @@ impl Resources { // Bound memory at large window sizes; normalized coordinates also handle downsampling. let size = target_size(enabled, size_px, device.limits().max_texture_dimension_2d); if size != self.size { - (self.depth, self.values, self.bind) = texture(device, &self.layout, size); + self.maps = maps(device, &self.layout, size); self.size = size; } if !enabled { @@ -147,7 +238,7 @@ impl Resources { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("sc_ao_prepass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.values, + view: &self.maps.values, depth_slice: None, resolve_target: None, ops: wgpu::Operations { @@ -156,7 +247,7 @@ impl Resources { }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth, + view: &self.maps.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(far_depth(reversed)), store: wgpu::StoreOp::Store }), stencil_ops: None, }), @@ -164,34 +255,35 @@ impl Resources { occlusion_query_set: None, multiview_mask: None, }); - for (batch, bind) in batches.into_iter().zip(binds) { - pass.set_bind_group(0, bind, &[]); - pass.set_pipeline(&self.pipeline); - if let Some(tris) = &batch.tris { - for (buffer, count) in &tris.parts { - pass.set_vertex_buffer(0, buffer.slice(..)); - pass.draw(0..*count, 0..1); - } - } - // The depth prepass is left out of the performance overlay's counts, as before. - let uncounted = super::Tally::default(); - // The blocks' opaque triangles, for the same depth. - for key in &batch.block_order { - if let Some(block) = batch.blocks.get(key) { - block.draw(&mut pass, super::TriClass::Opaque, &uncounted); - } - } - pass.set_pipeline(&self.planned_pipeline); - for draw in batch.planned.iter().filter(|d| d.class == super::TriClass::Opaque) { - pass.set_vertex_buffer(0, draw.mesh.vertices.slice(..)); - pass.set_vertex_buffer(1, draw.colors.slice(..)); - pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1); - } - for draw in batch.instanced.iter().filter(|d| d.look == super::InstanceLook::Opaque) { - draw.draw(&mut pass, &self.instanced_pipeline, &self.instanced_flat_pipeline, &uncounted); - } - } + draw_opaque(&mut pass, &self.prepass, binds, batches); + drop(pass); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_ao_term"), + color_attachments: &[term_target(&self.maps.raw)], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.maps.depth, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Discard }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_bind_group(2, &self.maps.bind, &[]); + draw_opaque(&mut pass, &self.term, binds, batches); + drop(pass); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_ao_blur"), + color_attachments: &[term_target(&self.maps.smooth)], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.blur); + pass.set_bind_group(0, binds[0], &[]); + pass.set_bind_group(2, &self.maps.blur_bind, &[]); + pass.draw(0..3, 0..1); } } diff --git a/crates/io/src/design_lock.rs b/crates/io/src/design_lock.rs index 6a4daf3613..f8376d3087 100644 --- a/crates/io/src/design_lock.rs +++ b/crates/io/src/design_lock.rs @@ -15,6 +15,7 @@ //! The browser build keeps neither: it has no shared folders, and no clock in `std`. use serde::{Deserialize, Serialize}; +use std::io::Read; use std::path::{Path, PathBuf}; /// How often an open design's lock is written again. @@ -109,16 +110,29 @@ fn user_name() -> String { ["USER", "USERNAME", "LOGNAME"].iter().find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty())).unwrap_or_default() } -/// The lock next to the design at `path`, if there is a readable one. +/// The lock next to the design at `path`, if there is a readable one. Anything at the lock's path +/// but a regular file (a symbolic link, a device, a FIFO) is no lock and is not opened, and no +/// more than a lock's size is ever read. pub fn read(path: &Path) -> Option { if cfg!(target_arch = "wasm32") { return None; } let file = lock_path(path); - if std::fs::metadata(&file).ok()?.len() > MAX_LOCK_BYTES { + let meta = std::fs::symlink_metadata(&file).ok().filter(std::fs::Metadata::is_file)?; + if meta.len() > MAX_LOCK_BYTES { return None; } - serde_json::from_slice(&std::fs::read(&file).ok()?).ok() + let opened = std::fs::File::open(&file).ok()?; + // The path may have been swapped between the two calls; the handle decides. + if !opened.metadata().ok()?.is_file() { + return None; + } + let mut bytes = Vec::new(); + opened.take(MAX_LOCK_BYTES + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_LOCK_BYTES { + return None; + } + serde_json::from_slice(&bytes).ok() } /// Another app's fresh lock on the design at `path` at `now`: it is open elsewhere. @@ -126,10 +140,12 @@ pub fn held_elsewhere(path: &Path, now: u64) -> Option { read(path).filter(|l| !l.is_mine() && !l.is_stale(now)) } -/// Write `lock` next to the design at `path` (replacing whatever lock is there). +/// Write `lock` next to the design at `path`, replacing whatever lock is there. A symbolic link +/// at the lock's path is refused and never followed, unlike a save, which follows a link to where +/// the design really lives: the file a link there points to is not this app's to replace. pub fn write(path: &Path, lock: &Lock) -> std::io::Result<()> { let bytes = serde_json::to_vec_pretty(lock).map_err(std::io::Error::other)?; - crate::safe_write::write_atomic(&lock_path(path), &bytes, false) + crate::safe_write::write_atomic_no_follow(&lock_path(path), &bytes) } /// Remove this process's lock on the design at `path`; another app's lock is left alone. diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6e0455f679..2c8a37c7da 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -421,9 +421,14 @@ pub fn export_mesh_at(b: &solidcraft_kernel::Body, quality: MeshQuality) -> Resu Ok(b.tessellate((b.size() * quality.share()).max(1e-3))?) } -/// Distance within which an exported mesh's vertices are welded (STL and 3MF alike). +/// Distance within which an exported mesh's vertices are welded (STL, 3MF and OBJ alike). fn weld_eps(b: &solidcraft_kernel::Body) -> f64 { - (b.size() * 1e-6).max(1e-7) + weld_distance(b.size()) +} + +/// [`weld_eps`] for a body of characteristic size (bounding box diagonal) `size`. +fn weld_distance(size: f64) -> f64 { + (size * 1e-6).max(1e-7) } /// Unit-density [`MassProperties`] of a body from its welded [`export_mesh`], the mesh URDF links @@ -538,49 +543,81 @@ pub fn obj_files(bodies: &[ObjBody<'_>], mtl_name: &str) -> ObjFiles { ObjFiles { obj, mtl } } -/// The mesh of a body: its vertices and normals, each written once per distinct value, and the -/// faces that use them. Returns the text and the bases the next body carries on from. +/// The mesh of a body, welded as STL and 3MF are ([`weld`]: vertices closer than +/// [`weld_distance`] merged, and sides split where another face's vertex lies on them), so +/// neighbouring faces share their edge points and the file opens again as a closed solid +/// (#9074). Each corner keeps the normal it had ([`corner_normals`]), so edges between faces +/// stay sharp. Vertices and normals are each written once per distinct value. Returns the text +/// and the bases the next body carries on from. fn mesh_text(mesh: &Mesh, vbase: usize, nbase: usize) -> (String, usize, usize) { use std::collections::HashMap; - let mut s = String::new(); - // `+ 0.0` turns -0 into 0, so both merge. - let key = |x: f64, y: f64, z: f64| [x + 0.0, y + 0.0, z + 0.0].map(f64::to_bits); - let mut vmap: HashMap<[u64; 3], usize> = HashMap::new(); - let mut nmap: HashMap<[u64; 3], usize> = HashMap::new(); - let vi: Vec = mesh - .positions - .iter() - .map(|p| { - let next = vmap.len(); - *vmap.entry(key(p.x, p.y, p.z)).or_insert_with(|| { - s += &format!("v {} {} {}\n", p.x, p.y, p.z); - next - }) - }) - .collect(); - let ni: Vec = mesh - .normals - .iter() - .map(|n| { - let next = nmap.len(); - *nmap.entry(key(n.x, n.y, n.z)).or_insert_with(|| { - s += &format!("vn {} {} {}\n", n.x, n.y, n.z); - next - }) - }) - .collect(); - for t in &mesh.triangles { - let (Some(a), Some(b), Some(c)) = (vi.get(t[0] as usize), vi.get(t[1] as usize), vi.get(t[2] as usize)) else { continue }; - let (a, b, c) = (a + vbase, b + vbase, c + vbase); - match (ni.get(t[0] as usize), ni.get(t[1] as usize), ni.get(t[2] as usize)) { - (Some(na), Some(nb), Some(nc)) => { - let (na, nb, nc) = (na + nbase, nb + nbase, nc + nbase); - s += &format!("f {a}//{na} {b}//{nb} {c}//{nc}\n"); - } - _ => s += &format!("f {a} {b} {c}\n"), - } + let mut bounds = solidcraft_geom::Aabb3::EMPTY; + for p in &mesh.positions { + bounds.add(*p); } - (s, vbase + vmap.len(), nbase + nmap.len()) + let eps = weld_distance(bounds.diagonal()); + // Each welded triangle is tagged with the triangle it came from, for its normals. + let source: Vec = (0..mesh.triangles.len()).map(|k| u32::try_from(k).unwrap_or(u32::MAX)).collect(); + let (positions, triangles, from) = threemf::weld_tagged(&mesh.positions, &mesh.triangles, &source, eps); + let mut s = String::new(); + for p in &positions { + s += &format!("v {} {} {}\n", p.x, p.y, p.z); + } + // `+ 0.0` turns -0 into 0, so both merge. + let key = |n: Vec3| [n.x + 0.0, n.y + 0.0, n.z + 0.0].map(f64::to_bits); + let mut nmap: HashMap<[u64; 3], usize> = HashMap::new(); + let mut faces = String::new(); + for (t, k) in triangles.iter().zip(&from) { + let (a, b, c) = (t[0] as usize + vbase, t[1] as usize + vbase, t[2] as usize + vbase); + let at = t.map(|i| positions.get(i as usize).copied()); + let normals = match (mesh.triangles.get(*k as usize), at) { + (Some(o), [Some(pa), Some(pb), Some(pc)]) => corner_normals(mesh, *o, [pa, pb, pc], eps), + _ => None, + }; + let Some(normals) = normals else { + faces += &format!("f {a} {b} {c}\n"); + continue; + }; + let [na, nb, nc] = normals.map(|n| { + let next = nmap.len(); + nbase + + *nmap.entry(key(n)).or_insert_with(|| { + s += &format!("vn {} {} {}\n", n.x, n.y, n.z); + next + }) + }); + faces += &format!("f {a}//{na} {b}//{nb} {c}//{nc}\n"); + } + s += &faces; + (s, vbase + positions.len(), nbase + nmap.len()) +} + +/// The normals at the corners `at` of a welded piece of triangle `t` of `mesh` (the whole +/// triangle, or a piece of it split where another face's vertex lies on a side): a corner +/// within `eps` of one of `t`'s own corners takes that corner's normal; a point a side was split +/// at takes the normal between the side's ends. `None` when the mesh has no normals there. +fn corner_normals(mesh: &Mesh, t: [u32; 3], at: [Vec3; 3], eps: f64) -> Option<[Vec3; 3]> { + let p = t.map(|i| mesh.positions.get(i as usize).copied()); + let n = t.map(|i| mesh.normals.get(i as usize).copied()); + let ([Some(p0), Some(p1), Some(p2)], [Some(n0), Some(n1), Some(n2)]) = (p, n) else { return None }; + let (p, n) = ([p0, p1, p2], [n0, n1, n2]); + Some(at.map(|q| p.iter().zip(n).find(|(c, _)| c.dist(q) <= eps).map_or_else(|| normal_at(p, n, q), |(_, n)| n))) +} + +/// The normal at `q` on the triangle with corners `p` and corner normals `n`: the corners' +/// normals weighted by where `q` lies (barycentric), of unit length. The first corner's when +/// the triangle has no area. +fn normal_at(p: [Vec3; 3], n: [Vec3; 3], q: Vec3) -> Vec3 { + let [p0, p1, p2] = p; + let [n0, n1, n2] = n; + let (e1, e2, d) = (p1 - p0, p2 - p0, q - p0); + let (d11, d12, d22, d1, d2) = (e1.dot(e1), e1.dot(e2), e2.dot(e2), d.dot(e1), d.dot(e2)); + let den = d11 * d22 - d12 * d12; + if !(den.abs() > 0.0) { + return n0; + } + let (v, w) = ((d22 * d1 - d12 * d2) / den, (d11 * d2 - d12 * d1) / den); + (n0 * (1.0 - v - w) + n1 * v + n2 * w).normalized().unwrap_or(n0) } /// The material of each body: its name, safe to write in a material library and unique among @@ -1225,9 +1262,8 @@ mod tests { .sum() } - /// Build → export 3MF → import → the same volume as the STL export, closed meshes, names. - #[test] - fn threemf_round_trip() { + /// A filleted box, a cylinder and a torus: bodies whose faces are curved and meshed apart. + fn curved_bodies() -> Model { let mut doc = Document::new("t"); doc.add_feature( FeatureKind::Box { @@ -1276,6 +1312,40 @@ mod tests { .unwrap(); let mut m = Model::new(); m.evaluate(&doc); + m + } + + /// OBJ is welded as STL and 3MF are: a filleted box, a cylinder and a torus written as OBJ + /// open again closed, with their STL export's volume, every face keeping its normals + /// (#9074). Before, each face wrote its edge points again, a few ulps apart, and the file + /// opened as an open mesh with no volume. + #[test] + fn obj_round_trip_of_curved_bodies_is_closed() { + let m = curved_bodies(); + let st = m.state(); + let bytes = export(&st, &[], Format::Obj, "t").unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.lines().filter(|l| l.starts_with("f ")).all(|l| l.matches("//").count() == 3), "every corner has a normal"); + let f = mesh_import_feature(&bytes, "parts.obj").unwrap(); + let mut d2 = Document::new("m"); + let id = d2.add_feature(f.kind, Some(&f.name)).unwrap(); + let mut m2 = Model::new(); + m2.evaluate(&d2); + assert!(m2.result(id).unwrap().error.is_none()); + let st2 = m2.state(); + assert_eq!(st2.bodies.len(), 3); + for (a, b) in st.bodies.iter().zip(&st2.bodies) { + assert!(b.body.is_mesh() && b.body.is_closed_mesh(), "{} is not watertight", a.name); + let v_stl = stl_volume(&export(&st, std::slice::from_ref(&a.name), Format::StlBinary, "t").unwrap()); + let v = solidcraft_kernel::measure(&b.body).unwrap().volume; + assert!((v - v_stl).abs() <= 1e-6 * v_stl.abs(), "{}: {v} vs {v_stl}", a.name); + } + } + + /// Build → export 3MF → import → the same volume as the STL export, closed meshes, names. + #[test] + fn threemf_round_trip() { + let m = curved_bodies(); let st = m.state(); assert_eq!(st.bodies.len(), 3); let bytes = export(&st, &[], Format::ThreeMf, "t").unwrap(); diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs index ebe9184a28..baf309ea0a 100644 --- a/crates/io/src/safe_write.rs +++ b/crates/io/src/safe_write.rs @@ -226,13 +226,17 @@ fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> s return Err(e); } sync_dir(path); - // Temporary files of saves that crashed part way (a minute old or more) go. - for t in stale_temps(path) { - if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { + remove_old_temps(path); + Ok(()) +} + +/// Temporary files of saves that crashed part way (a minute old or more) go. +fn remove_old_temps(path: &Path) { + for t in temps_beside(path) { + if fs::symlink_metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { let _ = fs::remove_file(t); } } - Ok(()) } /// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as @@ -249,6 +253,34 @@ pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result< write_steps(&resolve_links(path)?, bytes, backup, None) } +/// Write `bytes` to the file named `path` itself, atomically: a link at `path` is never followed, +/// and what it points to is never touched. Anything there that isn't a regular file is refused. For +/// the app's own small files beside a design, which never live anywhere else. +pub fn write_atomic_no_follow(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if cfg!(target_arch = "wasm32") { + return crate::vfs::write(&path.to_string_lossy(), bytes); + } + let there = fs::symlink_metadata(path).ok(); + if there.as_ref().is_some_and(|m| !m.is_file()) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{}: not a regular file", path.display()))); + } + let tmp = temp_beside(path, "new"); + if let Err(e) = write_synced(&tmp, bytes) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + keep_permissions(&there.map(|m| m.permissions()), &tmp); + // A rename replaces the name itself, so a link put there since the check above goes, and the + // file it pointed to stays as it was. + if let Err(e) = replace_synced(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + sync_dir(path); + remove_old_temps(path); + Ok(()) +} + /// Publish a complete new file without replacing an existing destination, even if another /// writer creates it after this call starts. Unsupported filesystems return an error. pub fn create_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { @@ -275,8 +307,11 @@ pub fn create_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// Leftover temporary files of saves that never finished (a crash part way), next to `path` /// (next to the file a link at `path` points to, where a save puts them). pub fn stale_temps(path: &Path) -> Vec { - let resolved = resolve_links(path).unwrap_or_else(|_| path.to_path_buf()); - let path = resolved.as_path(); + temps_beside(&resolve_links(path).unwrap_or_else(|_| path.to_path_buf())) +} + +/// The leftover temporary files of the file named `path` itself. +fn temps_beside(path: &Path) -> Vec { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() }; let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let prefix = format!(".{name}."); @@ -407,6 +442,104 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// A link at the lock's path is neither written through nor read through, and closing the + /// design leaves it alone. A save still follows a link at the design's own path. + #[cfg(unix)] + #[test] + fn a_link_at_the_lock_path_is_never_followed() { + use crate::design_lock as lock; + use std::os::unix::fs::symlink; + let d = dir("design-lock-link"); + let design = d.join("Bracket.solidcraft"); + std::fs::write(&design, b"one").unwrap(); + let lock_file = lock::lock_path(&design); + let now = lock::now_secs(); + let mine = lock::Lock::mine(now); + + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"keep me").unwrap(); + symlink(&victim, &lock_file).unwrap(); + assert!(lock::write(&design, &mine).is_err()); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me"); + assert!(std::fs::symlink_metadata(&lock_file).unwrap().file_type().is_symlink(), "the link is refused, not replaced"); + std::fs::remove_file(&lock_file).unwrap(); + + // A link to a place with nothing there doesn't make the file either. + let missing = d.join("made-by-the-link.txt"); + symlink(&missing, &lock_file).unwrap(); + assert!(lock::write(&design, &mine).is_err()); + assert!(!missing.exists()); + std::fs::remove_file(&lock_file).unwrap(); + + // A link to a real lock is no lock: nothing is read through it, and closing leaves it. + let elsewhere = d.join("elsewhere.lock"); + std::fs::write(&elsewhere, serde_json::to_vec(&mine).unwrap()).unwrap(); + symlink(&elsewhere, &lock_file).unwrap(); + assert!(lock::read(&design).is_none()); + lock::remove(&design); + assert!(std::fs::symlink_metadata(&lock_file).is_ok() && elsewhere.exists()); + std::fs::remove_file(&lock_file).unwrap(); + + // A hard link is a regular file: its name is replaced, the other name keeps its content. + let other_name = d.join("other-name.txt"); + std::fs::write(&other_name, b"keep me too").unwrap(); + std::fs::hard_link(&other_name, &lock_file).unwrap(); + lock::write(&design, &mine).unwrap(); + assert_eq!(std::fs::read(&other_name).unwrap(), b"keep me too"); + assert!(lock::read(&design).is_some_and(|l| l.is_mine())); + std::fs::remove_file(&lock_file).unwrap(); + + // Nothing there, and no temporary file left: a plain lock is still written. + lock::write(&design, &mine).unwrap(); + assert_eq!(lock::read(&design), Some(mine)); + assert!(stale_temps(&lock_file).is_empty()); + + // The design's own link is still followed by a save. + let real = d.join("real.solidcraft"); + std::fs::write(&real, b"old").unwrap(); + let linked = d.join("linked.solidcraft"); + symlink(&real, &linked).unwrap(); + write_atomic(&linked, b"new", false).unwrap(); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + assert!(std::fs::symlink_metadata(&linked).unwrap().file_type().is_symlink()); + let _ = std::fs::remove_dir_all(&d); + } + + /// A lock path that isn't a regular file is ignored at once. Read on a thread, so a regression + /// fails here rather than holding up the test run. + #[cfg(unix)] + #[test] + fn a_lock_path_that_is_not_a_regular_file_is_ignored_at_once() { + use crate::design_lock as lock; + use std::os::unix::fs::symlink; + let d = dir("design-lock-special"); + let design = d.join("Bracket.solidcraft"); + let lock_file = lock::lock_path(&design); + let read_soon = |design: &Path| { + let (tx, rx) = std::sync::mpsc::channel(); + let design = design.to_path_buf(); + std::thread::spawn(move || { + let _ = tx.send(lock::read(&design)); + }); + rx.recv_timeout(std::time::Duration::from_secs(10)).expect("read returned") + }; + let cases: [&dyn Fn(&Path); 3] = [&|p| symlink("/dev/zero", p).unwrap(), &|p| std::fs::create_dir(p).unwrap(), &|p| { + assert!(std::process::Command::new("mkfifo").arg(p).status().unwrap().success()) + }]; + for make in cases { + make(&lock_file); + assert!(read_soon(&design).is_none()); + assert!(lock::held_elsewhere(&design, lock::now_secs()).is_none()); + assert!(lock::write(&design, &lock::Lock::mine(lock::now_secs())).is_err()); + assert!(std::fs::symlink_metadata(&lock_file).is_ok(), "left where it is"); + let _ = std::fs::remove_file(&lock_file).or_else(|_| std::fs::remove_dir(&lock_file)); + } + // A regular file too large to be a lock is not read either. + std::fs::write(&lock_file, vec![b' '; 4097]).unwrap(); + assert!(read_soon(&design).is_none()); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn create_does_not_replace_an_existing_destination() { let d = dir("create"); diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 2ad8d52eca..a783853166 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -212,9 +212,13 @@ impl Remote { } } - /// Connect now, if the app is there (`new` does not); the error says why not. + /// See whether the app is there now (`new` does not); the error says why not. The connection + /// doesn't stay open: the app closes one that shows no token within a few seconds, and the + /// first call may be long away. That call connects again. pub fn try_connect(&mut self) -> std::io::Result<()> { - self.reconnect() + self.reconnect()?; + self.drop_conn(); + Ok(()) } /// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f3e3c54077..f416daa295 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -48,25 +48,37 @@ pub fn agent_scope_with(recovery: Option, allowed: impl IntoIterator> = std::sync::OnceLock::new(); + /// This server's own folder for screenshots it reads back and deletes (`screenshot` without a -/// `path`): a new `/solidcraft-mcp--` made on first use (never one that was there -/// already, which could be someone else's), private to the user on Unix. `` is spelled with -/// long names (`vfs::temp_dir`), since the scope refuses short (8.3) ones. +/// `path`): a new `/solidcraft-mcp--` made when the path scope is built, or on the +/// first screenshot without a scope. Never reuse an existing folder, which could be someone +/// else's. It is private to the user on Unix. `` is spelled with long names (`vfs::temp_dir`), +/// since the scope refuses short (8.3) ones. pub fn scratch_dir() -> Option { - static DIR: std::sync::OnceLock> = std::sync::OnceLock::new(); - DIR.get_or_init(|| { - let b = std::fs::DirBuilder::new(); - #[cfg(unix)] - let mut b = b; - #[cfg(unix)] - std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); - let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); - (0..16u32).find_map(|i| { - let d = vfs::temp_dir().join(format!("solidcraft-mcp-{}-{:x}", std::process::id(), seed.wrapping_add(i))); - b.create(&d).ok().map(|()| d) + SCRATCH_DIR + .get_or_init(|| { + let b = std::fs::DirBuilder::new(); + #[cfg(unix)] + let mut b = b; + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + (0..16u32).find_map(|i| { + let d = vfs::temp_dir().join(format!("solidcraft-mcp-{}-{:x}", std::process::id(), seed.wrapping_add(i))); + b.create(&d).ok().map(|()| d) + }) }) - }) - .clone() + .clone() +} + +/// Best-effort cleanup at process shutdown, after all MCP activity has stopped. Only remove +/// this process's empty scratch folder: saved files, other processes' folders and the recovery +/// folder must survive. Looking up the cached path never creates a folder just to clean it up. +pub fn cleanup_scratch_dir() { + if let Some(Some(dir)) = SCRATCH_DIR.get() { + let _ = std::fs::remove_dir(dir); + } } #[cfg(test)] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 4a0e6cc0af..86832a3294 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1754,6 +1754,39 @@ fn a_bridged_request_carries_the_agents_folders() { echo.join().unwrap(); } +/// A server started before its first tool call keeps no connection waiting (the app closes one +/// that shows no token in time), and a call on a connection the app has closed meanwhile goes out +/// on a new one without an error. +#[test] +fn a_bridge_holds_no_idle_connection_and_survives_one_closed_before_its_first_call() { + use std::io::{BufRead, Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let app = std::thread::spawn(move || { + let (mut probe, _) = listener.accept().unwrap(); + probe.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap(); + assert_eq!(probe.read(&mut [0u8; 1]).unwrap(), 0, "the probe closes at once and sends nothing"); + // The connection the second bridge makes first: closed unanswered, as a timed-out one is. + drop(listener.accept().unwrap()); + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let req: Value = serde_json::from_str(&line).unwrap(); + writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": {"method": req["method"]}})).unwrap(); + } + }); + let token = "0123456789abcdef0123456789abcdef"; + let mut early = crate::Remote::new(&addr, Some(token)); + early.try_connect().unwrap(); + let mut idle = crate::Remote::connect_with_token(&addr, Some(token)).unwrap(); + let got = crate::Backend::call(&mut early, "ui.view", json!({})).unwrap(); + assert_eq!(got["method"], "ui.view", "{got}"); + let got = crate::Backend::call(&mut idle, "ui.view", json!({})).unwrap(); + assert_eq!(got["method"], "ui.view", "{got}"); + app.join().unwrap(); +} + /// #7181, bridged: once the agent has saved a design through the app, that file's folder goes /// with every later request as `design_folders`, so the app counts it after the design moves on. /// Not before the save succeeded, not for one the app refused, not for a relative path (the app diff --git a/crates/sketch/src/degenerate_line_tests.rs b/crates/sketch/src/degenerate_line_tests.rs index 4ec991510e..065028290e 100644 --- a/crates/sketch/src/degenerate_line_tests.rs +++ b/crates/sketch/src/degenerate_line_tests.rs @@ -60,3 +60,41 @@ fn dragging_the_midpoint_line_end_keeps_the_sketch_solved() { let off = (at.y.atan2(at.x) - 50f64.to_radians()).abs(); assert!(off < 1e-3, "l1.end went to {at:?} (off its line by {off} rad)"); } + +/// An infinite reference can have very close defining points in a saved sketch. Its +/// horizontal/vertical constraint still fixes its visible direction, not just the tiny +/// distance between the defining points (#11001). +#[test] +fn an_angle_keeps_a_short_infinite_reference_horizontal_or_vertical() { + for (vertical, sign, origin) in [ + (false, 1.0, v(0.0, 0.0)), + (true, 1.0, v(0.0, 0.0)), + (false, -1.0, v(0.0, 0.0)), + (true, -1.0, v(0.0, 0.0)), + (false, 1.0, v(25.0, -17.0)), + (true, 1.0, v(25.0, -17.0)), + ] { + for length in [6.36e-8, 1.0] { + let mut sk = Sketch::new(); + sk.points[0].pos = origin; + let direction = (if vertical { v(0.0, 1.0) } else { v(1.0, 0.0) }) * sign; + let end = sk.add_point(origin + direction * length, None).unwrap(); + let arm_end = sk.add_point(origin + (if vertical { v(-10.0, 12.0) } else { v(12.0, 10.0) }) * sign, None).unwrap(); + let axis = sk.add_line_pts(0, end, Some("axis")).unwrap(); + sk.curves[axis].infinite = true; + sk.curves[axis].construction = true; + let arm = sk.add_line_pts(0, arm_end, Some("arm")).unwrap(); + let constraint = if vertical { ConstraintKind::Vertical { l: axis } } else { ConstraintKind::Horizontal { l: axis } }; + sk.add_constraint(constraint, None).unwrap(); + sk.add_constraint(ConstraintKind::Angle { a: axis, b: arm, value: 30f64.to_radians(), flip: false }, None).unwrap(); + + let report = crate::solve(&mut sk); + assert!(report.ok(), "vertical={vertical}, length={length}: {report:?}"); + let actual = (sk.points[end].pos - origin).normalized().expect("the reference retains a direction"); + assert!(actual.dist(direction) < 1e-6, "vertical={vertical}, length={length}: reference rotated to {actual:?}"); + let arm_direction = (sk.points[arm_end].pos - origin).normalized().unwrap(); + let angle = actual.cross(arm_direction).atan2(actual.dot(arm_direction)); + assert!((angle - 30f64.to_radians()).abs() < 1e-6, "the free line must satisfy the angle: {angle}"); + } + } +} diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 0c3cd518ed..2e2af9017c 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -134,10 +134,10 @@ impl LinkSource { /// Short description for messages and the browser. pub fn describe(&self) -> String { match self { - LinkSource::Edge { body, .. } => format!("edge of {body}"), - LinkSource::Face { body, .. } => format!("face of {body}"), + LinkSource::Edge { body, .. } => of_body("edge", body), + LinkSource::Face { body, .. } => of_body("face", body), LinkSource::Body { body } => body.clone(), - LinkSource::Vertex { body, .. } => format!("vertex of {body}"), + LinkSource::Vertex { body, .. } => of_body("vertex", body), LinkSource::SketchCurve { curve, .. } => format!("sketch curve {curve}"), LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"), LinkSource::Origin => "origin".into(), @@ -146,8 +146,8 @@ impl LinkSource { LinkSource::Plane { name } => format!("{name} plane"), LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"), LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()), - LinkSource::Spun { body, .. } => format!("spun profile of {body}"), - LinkSource::Iso { body, .. } => format!("isoparametric curve of {body}"), + LinkSource::Spun { body, .. } => of_body("spun profile", body), + LinkSource::Iso { body, .. } => of_body("isoparametric curve", body), LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::()), } } @@ -178,6 +178,13 @@ impl LinkSource { } } +/// The description of a source that names a body: "face of Body1", or the kind alone when the +/// reference names no body (a picked face is found by its geometry, and the body it belongs +/// to is not named in the reference). +fn of_body(kind: &str, body: &str) -> String { + if body.is_empty() { kind.to_string() } else { format!("{kind} of {body}") } +} + /// Half length of the line a sketch axis link produces. pub const SKETCH_AXIS_HALF: f64 = 100.0; @@ -1186,11 +1193,31 @@ impl Sketch { let keep = self.links.clone(); self.remove_curves(&curves); let pts = self.link_points(id); + self.free_own_curves(&pts); self.remove_points(&pts); // Removal prunes empty links; this one stays. self.links = keep; } + /// The sketch's own curves that use one of `pts` (a circle drawn on a projected hole's + /// centre) move onto a free point of their own at the same place, so removing `pts` doesn't + /// delete them with the link (#5388). Constraints on `pts` still go with them. + fn free_own_curves(&mut self, pts: &[usize]) { + for &p in pts { + let own: Vec = self.curves.iter().enumerate().filter(|(_, c)| c.link.is_none() && c.kind.uses(p)).map(|(i, _)| i).collect(); + if own.is_empty() { + continue; + } + let Some(pos) = self.points.get(p).map(|q| q.pos) else { continue }; + let Ok(free) = self.add_point(pos, None) else { continue }; + for ci in own { + if let Some(c) = self.curves.get_mut(ci) { + c.kind.map_points(&|q| if q == p { free } else { q }); + } + } + } + } + /// Mark a link as lost (its reference was not found); its geometry stays as it was. pub fn set_link_lost(&mut self, id: &str, lost: bool) { if let Some(l) = self.links.iter_mut().find(|l| l.id == id) { diff --git a/crates/sketch/src/link_identity_tests.rs b/crates/sketch/src/link_identity_tests.rs index bfa5118543..31c91d2292 100644 --- a/crates/sketch/src/link_identity_tests.rs +++ b/crates/sketch/src/link_identity_tests.rs @@ -423,3 +423,38 @@ fn edge_on_face_saved_as_a_line_pair_keeps_the_line_an_offset_follows() { let end = sk.points[sk.link_points(&link)[1]].pos; assert_eq!(end, Vec2::new(17.0, 5.0), "the line moved to the new geometry"); } + +/// #5388: a circle the user drew on a projected hole's centre survives when the face changes +/// shape and its projection is rebuilt (a second hole added to the face). +#[test] +fn rebuilt_face_keeps_a_users_circle_on_its_projected_centre() { + let mut sk = Sketch::new(); + let hole = |c: Vec2| LinkGeom::Circle(c, 2.1); + let face_with = |holes: &[Vec2]| square(15.0).into_iter().chain(holes.iter().map(|c| hole(*c))).collect::>(); + let link = sk.add_link(LinkKind::Project, face(), &face_with(&[Vec2::ZERO])).unwrap(); + // The projected hole's centre point. + let centre = sk + .link_curves(&link) + .into_iter() + .find_map(|i| match sk.curves[i].kind { + CurveKind::Circle { c, .. } => Some(c), + _ => None, + }) + .unwrap(); + let mine = sk.add_curve(CurveKind::Circle { c: centre, r: 5.25 }, Some("c_mine")).unwrap(); + assert!(sk.curves[mine].link.is_none()); + // The face gains two more holes: the projection's shape changes, so it is rebuilt. + assert!(sk.update_link(&link, &face_with(&[Vec2::ZERO, Vec2::new(-10.0, 0.0), Vec2::new(10.0, 0.0)])).unwrap()); + let kept = sk.curves.iter().find(|c| c.id == "c_mine").expect("the user's circle survives the rebuild"); + assert!(kept.link.is_none()); + match kept.kind { + CurveKind::Circle { c, r } => { + assert_eq!(r, 5.25); + let p = &sk.points[c]; + assert!(p.link.is_none(), "it sits on a point of its own, not a projected one"); + assert!(p.pos.len() < 1e-9, "still at the hole's centre: {:?}", p.pos); + } + ref k => panic!("not a circle: {k:?}"), + } + assert_eq!(sk.link_curves(&link).len(), 4 + 3, "the projection is the new outline and holes"); +} diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index cace58562c..6508162b35 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -247,7 +247,7 @@ impl Sketch { let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?); let (a, b) = if *forward { (a, b) } else { (b, a) }; let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance; - (Shape::Line { a: a + n, b: b + n }, a + n, b + n) + (Shape::Line { a: a + n, b: b + n }, a + n, b + n, None) } CurveKind::Arc { c, a, b } => { let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?); @@ -258,7 +258,7 @@ impl Sketch { let a = c + (a - c).normalized().ok_or_else(bad)? * r; let b = c + (b - c).normalized().ok_or_else(bad)? * r; let (a, b) = if *forward { (a, b) } else { (b, a) }; - (Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b) + (Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b, Some(*forward)) } CurveKind::Circle { c, r } => { let c = self.point(c).ok_or_else(bad)?; @@ -282,16 +282,32 @@ impl Sketch { if pieces.is_empty() { continue; } + // Which way a piece runs at `p`: a line along itself, an arc anticlockwise round its + // centre when it follows its source forward. + let along = |piece: &(Shape, Vec2, Vec2, Option), p: Vec2| match (&piece.0, piece.3) { + (Shape::Line { a, b }, _) => *b - *a, + (Shape::Round { c, .. }, Some(true)) => (p - *c).perp(), + (Shape::Round { c, .. }, Some(false)) => (*c - p).perp(), + _ => Vec2::new(0.0, 0.0), + }; let joint = |i: usize, j: usize| -> Result { let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?); if x.2.dist(y.1) < 1e-6 { return Ok(x.2); } let guess = (x.2 + y.1) * 0.5; - intersections(&x.0, &y.0) - .into_iter() - .min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))) - .ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into())) + let mut found = intersections(&x.0, &y.0); + found.sort_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))); + // Where the sources touch, as two tangent arcs do at a dog bone's notch (#10984), + // both crossings are as far from the corner and rounding chose one. The pieces + // overlap there: they meet where both are cut short, not where both run on and + // cross into a loop. + let cut_short = |p: Vec2| (p - x.2).dot(along(x, x.2)) < 0.0 && (p - y.1).dot(along(y, y.1)) > 0.0; + match found.as_slice() { + [p, q, ..] if (q.dist(guess) - p.dist(guess)).abs() <= 1e-9 * (1.0 + p.dist(*q)) && !cut_short(*p) && cut_short(*q) => Ok(*q), + [p, ..] => Ok(*p), + [] => Err(SketchError::Invalid("the offset curves no longer meet".into())), + } }; let n = pieces.len(); let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }]; diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 87213765d4..109960b112 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -502,11 +502,16 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec) { } Horizontal { l } => { let (a, b) = s.line(l); - out.push(b.y - a.y); + let scale = if s.sk.curves.get(l).is_some_and(|c| c.infinite) { (b - a).len().max(1e-12) } else { 1.0 }; + // An infinite line's defining points may be very close together. Its visible + // direction must remain horizontal even when their separation is below the + // distance tolerance, so measure direction rather than endpoint distance. + out.push((b.y - a.y) / scale); } Vertical { l } => { let (a, b) = s.line(l); - out.push(b.x - a.x); + let scale = if s.sk.curves.get(l).is_some_and(|c| c.infinite) { (b - a).len().max(1e-12) } else { 1.0 }; + out.push((b.x - a.x) / scale); } HorizontalPoints { p, q } => out.push(s.p(q).y - s.p(p).y), VerticalPoints { p, q } => out.push(s.p(q).x - s.p(p).x), @@ -1048,7 +1053,7 @@ fn system(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], x: &mut [f6 for &v in vars { let Some(x0) = x.get(v).copied() else { continue }; let opening = distance_opening_slope(&State { sk, lay, x }, b, v); - let h = 1e-7 * x0.abs().max(1.0); + let h = direction_difference_step(&State { sk, lay, x }, b, v, x0); if let Some(xv) = x.get_mut(v) { *xv = x0 + h; } @@ -1100,7 +1105,7 @@ fn local_system( let Some(c) = col.get(v).copied().filter(|c| *c < ncols) else { continue }; let Some(x0) = x.get(v).copied() else { continue }; let opening = distance_opening_slope(&State { sk, lay, x }, b, v); - let h = 1e-7 * x0.abs().max(1.0); + let h = direction_difference_step(&State { sk, lay, x }, b, v, x0); if let Some(xv) = x.get_mut(v) { *xv = x0 + h; } @@ -1124,6 +1129,32 @@ fn local_system( (r, j, owner) } +/// An infinite line's direction derivative must be sampled within its defining span. The +/// usual coordinate-sized step can cross its other end and measure a half-turn instead +/// of the local slope when the saved defining points are very close (#11001). +fn direction_difference_step(s: &State<'_>, block: &Block<'_>, variable: usize, value: f64) -> f64 { + let mut step = 1e-7 * value.abs().max(1.0); + let Block::User(_, kind) = block else { return step }; + let lines = match **kind { + ConstraintKind::Angle { a, b, .. } => [Some(a), Some(b)], + ConstraintKind::LineAngle { l, .. } => [Some(l), None], + ConstraintKind::Horizontal { l } | ConstraintKind::Vertical { l } if s.sk.curves.get(l).is_some_and(|c| c.infinite) => [Some(l), None], + _ => return step, + }; + for l in lines.into_iter().flatten() { + let Some(CurveKind::Line { a, b }) = s.sk.curves.get(l).filter(|c| c.infinite).map(|c| &c.kind) else { continue }; + let depends = [a, b].into_iter().any(|&p| s.lay.pvar.get(p).copied().flatten().is_some_and(|v| variable == v || variable == v + 1)); + if depends { + let length = (s.p(*b) - s.p(*a)).len(); + if length > 0.0 { + step = step.min(length * 1e-3); + } + } + } + // Keep both samples representable when the sketch is far from its origin. + step.max(value.abs() * f64::EPSILON * 8.0) +} + fn max_abs(v: &[f64]) -> f64 { v.iter().fold(0.0_f64, |m, x| if x.is_finite() { m.max(x.abs()) } else { f64::INFINITY }) } @@ -1565,7 +1596,7 @@ fn accumulate(sk: &Sketch, lay: &Layout, b: &Block, unit: &[usize], x: &mut [f64 let (mut plus, mut minus) = (Vec::new(), Vec::new()); for (&v, dk) in unit.iter().zip(&mut d) { let Some(x0) = x.get(v).copied() else { continue }; - let h = 1e-7 * x0.abs().max(1.0); + let h = direction_difference_step(&State { sk, lay, x }, b, v, x0); for (at, out) in [(x0 + h, &mut plus), (x0 - h, &mut minus)] { if let Some(xv) = x.get_mut(v) { *xv = at; diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 89bb53cbdb..196e6336b0 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -949,6 +949,25 @@ fn links_update_in_place_rebuild_and_break() { assert_eq!(back.link(&j3).unwrap().kind, LinkKind::Intersect); } +/// A reference that names no body describes itself by its kind alone: the message must not +/// end in "face of " with nothing after the "of" (#4929). +#[test] +fn a_source_without_a_body_name_names_its_kind_alone() { + use solidcraft_geom::Vec3; + let at = Vec3::ZERO; + for (source, expected) in [ + (LinkSource::Edge { body: String::new(), at }, "edge"), + (LinkSource::Face { body: String::new(), at }, "face"), + (LinkSource::Vertex { body: String::new(), at }, "vertex"), + (LinkSource::Iso { body: String::new(), at, dir: "u".into() }, "isoparametric curve"), + (LinkSource::Spun { body: String::new(), origin: at, dir: Vec3::X }, "spun profile"), + ] { + assert_eq!(source.describe(), expected, "{source:?}"); + } + // A named body keeps its name. + assert_eq!(LinkSource::Face { body: "Body1".into(), at }.describe(), "face of Body1"); +} + /// Many independent constrained rectangles solve quickly (per-component work). #[test] fn large_sketch_solves_fast() { diff --git a/crates/ui-egui/examples/acp_test_agent.rs b/crates/ui-egui/examples/acp_test_agent.rs index 95c625e7fd..58b48bd0d4 100644 --- a/crates/ui-egui/examples/acp_test_agent.rs +++ b/crates/ui-egui/examples/acp_test_agent.rs @@ -177,7 +177,7 @@ fn main() { send(&json!({"jsonrpc": "2.0", "id": 900, "method": "session/request_permission", "params": { "sessionId": session, // Named as the Claude Code adapter names an MCP tool. - "toolCall": {"toolCallId": "t1", "title": "mcp__solidcraft__batch"}, + "toolCall": {"toolCallId": "t1", "title": "mcp__solidcraft-app__batch", "kind": "other"}, "options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}, {"optionId": "reject", "name": "Reject", "kind": "reject_once"}], }})); // Wait for the answer (other lines are not expected meanwhile). diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs index 2f5e4d49fa..efffaf3314 100644 --- a/crates/ui-egui/src/agent_chat/acp.rs +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -160,6 +160,19 @@ pub fn initialize_params(version: &str) -> Value { /// keeps its own entry on a clash) can't shadow the bridge with a headless session. pub const BRIDGE_NAME: &str = "solidcraft-app"; +/// Whether a permission question is about a tool of the chat's own MCP server, whose calls +/// SolidCraft's gate checks. The agent writes both values and ACP has nothing better to go on, so +/// the test is strict: the whole title is the tool's name as the Claude adapter forms it +/// (`mcp__solidcraft-app__batch`), one word with no room for a command line, a path or other free +/// text, and the kind is `other` (the kind an MCP tool has) or not given. Any other title goes +/// through the user's mode and remembered answers: agents that word their MCP tools differently, +/// and a user's own MCP server, which the gate doesn't see. +pub fn is_bridge_tool(title: &str, kind: Option<&str>) -> bool { + let tool = title.strip_prefix("mcp__").and_then(|t| t.strip_prefix(BRIDGE_NAME)).and_then(|t| t.strip_prefix("__")); + let named = tool.is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')); + named && matches!(kind, None | Some("other")) +} + /// The MCP server the agent starts to reach the design: `solidcraft-cli mcp --connect` bridged to /// this app's control port. The server finds the port's token in the token file the app wrote /// (docs/control-protocol.md), so the token never travels through the agent, and it keeps its @@ -473,6 +486,8 @@ pub enum Incoming { Permission { rpc: Value, title: String, + /// The tool call's kind (`execute`, `edit`, `other`, …), as the agent gives it. + kind: Option, options: Vec, }, /// The agent says whether it is logged in (an extension both adapters send). @@ -546,6 +561,7 @@ pub fn parse(line: &str) -> Option { (Some("session/request_permission"), Some(id)) => { let call = params.get("toolCall"); let title = call.and_then(|c| c.get("title")).and_then(Value::as_str).unwrap_or("").to_string(); + let kind = call.and_then(|c| c.get("kind")).and_then(Value::as_str).map(str::to_string); let options = params .get("options") .and_then(Value::as_array) @@ -562,7 +578,7 @@ pub fn parse(line: &str) -> Option { .collect() }) .unwrap_or_default(); - Some(Incoming::Permission { rpc: id.clone(), title, options }) + Some(Incoming::Permission { rpc: id.clone(), title, kind, options }) } (Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }), (Some("session/update"), None) => Some(update(params.get("update")?, params.get("sessionId").and_then(Value::as_str).unwrap_or(""))), @@ -700,6 +716,48 @@ mod tests { // The panel still recognises the bridge's tools as SolidCraft's (agents name them after // their server, e.g. `mcp__solidcraft-app__batch`). assert!(BRIDGE_NAME.contains("solidcraft")); + assert!(is_bridge_tool(&format!("mcp__{BRIDGE_NAME}__batch"), Some("other"))); + } + + /// Only the whole name of a tool of the chat's own server counts, whatever else a title holds. + #[test] + fn only_the_bridges_exact_tool_names_are_its_tools() { + for tool in ["batch", "inspect_design", "set_parameter"] { + assert!(is_bridge_tool(&format!("mcp__solidcraft-app__{tool}"), None), "{tool}"); + assert!(is_bridge_tool(&format!("mcp__solidcraft-app__{tool}"), Some("other")), "{tool}"); + } + for title in [ + "", + "solidcraft", + "SolidCraft", + "run solidcraft-cli; ls", + "solidcraft-cli --version", + "Edit ~/.local/share/solidcraft/agent-chat/work/notes.txt", + // A user's own MCP server (docs/mcp.md), which SolidCraft's gate doesn't see. + "mcp__solidcraft__batch", + "mcp__solidcraft-live__batch", + "mcp__other__solidcraft-app", + "mcp__solidcraft-app__", + "mcp__solidcraft-app", + "solidcraft-app__batch", + "solidcraft-app: batch", + "Tool: solidcraft-app/batch", + "mcp.solidcraft-app.batch", + " mcp__solidcraft-app__batch", + "mcp__solidcraft-app__batch ", + "mcp__solidcraft-app__batch; ls", + "mcp__solidcraft-app__batch && ls", + "mcp__solidcraft-app__batch\nls", + "mcp__solidcraft-app__../../tool", + "mcp__SOLIDCRAFT-APP__batch", + "MCP__solidcraft-app__batch", + "mcp__solidcraft-app__Batch", + ] { + assert!(!is_bridge_tool(title, None), "{title:?}"); + } + for kind in ["execute", "edit", "delete", "move", "read", "search", "fetch", "think", ""] { + assert!(!is_bridge_tool("mcp__solidcraft-app__batch", Some(kind)), "{kind:?}"); + } } #[test] @@ -813,8 +871,9 @@ mod tests { }) ); let perm = r#"{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"toolCallId":"t1","title":"batch"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}"#; - let Some(Incoming::Permission { rpc, title, options }) = parse(perm) else { panic!() }; + let Some(Incoming::Permission { rpc, title, kind, options }) = parse(perm) else { panic!() }; assert_eq!((title.as_str(), options.len(), options[1].kind.as_str()), ("batch", 2, "reject_once")); + assert_eq!(kind, None); assert_eq!(permission_reply(rpc.clone(), Some("allow"))["result"]["outcome"], json!({"outcome": "selected", "optionId": "allow"})); assert_eq!(permission_reply(rpc, None)["result"]["outcome"]["outcome"], "cancelled"); let fs = r#"{"jsonrpc":"2.0","id":8,"method":"fs/read_text_file","params":{}}"#; diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs index 6b453964c8..7255dab253 100644 --- a/crates/ui-egui/src/agent_chat/acp_client.rs +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -3,6 +3,7 @@ //! sign-in). The panel sees only [`Event`]s. use std::collections::HashMap; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -21,6 +22,9 @@ enum Pending { SetConfig, } +/// Give a cancelled prompt time to finish before disconnecting an unresponsive agent. +const CANCEL_GRACE: Duration = Duration::from_secs(5); + /// Variables the agent doesn't inherit: a control token set for the app's own `--control` port /// would otherwise reach the agent and outrank the chat endpoint's own token file. const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"]; @@ -41,6 +45,7 @@ pub struct AcpBackend { /// Open permission questions: our key → the agent's request id. permissions: HashMap, next_key: u64, + cancelling: Option<(u64, Instant)>, exited: bool, config_pending: Option<(u64, String)>, modern_config: bool, @@ -68,6 +73,7 @@ impl AcpBackend { auth: Vec::new(), permissions: HashMap::new(), next_key: 0, + cancelling: None, exited: false, config_pending: None, modern_config: false, @@ -93,6 +99,9 @@ impl AcpBackend { match msg { Incoming::Reply { id, result } => { let Some(what) = self.pending.remove(&id) else { return }; + if self.cancelling.is_some_and(|(request, _)| request == id) { + self.cancelling = None; + } match (what, result) { (Pending::Initialize, Ok(v)) => { self.agent = acp::agent_info(&v); @@ -104,6 +113,12 @@ impl AcpBackend { } (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { Some(s) => { + // New chat has opened successfully: retire the old turn before Ready. + // Its late reply and cancellation deadline must not end the new turn. + if let Some((request, _)) = self.cancelling.take() { + self.pending.remove(&request); + out.push(Event::TurnEnded { reason: "cancelled".into() }); + } self.session = Some(s.to_string()); match acp::config_options(&v) { acp::ConfigParse::Snapshot(options) => { @@ -180,10 +195,10 @@ impl AcpBackend { Incoming::Text { thought, text } => out.push(Event::Text { thought, text }), Incoming::Tool { id, title, kind, status, input, output } => out.push(Event::Tool { id, title, kind, status, input, output }), Incoming::Plan(p) => out.push(Event::Plan(p)), - Incoming::Permission { rpc, title, options } => { + Incoming::Permission { rpc, title, kind, options } => { self.next_key += 1; self.permissions.insert(self.next_key, rpc); - out.push(Event::Permission { key: self.next_key, title, options }); + out.push(Event::Permission { key: self.next_key, title, kind, options }); } Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)), Incoming::ModeChanged(m) if !self.modern_config => out.push(Event::Modes { current: m, available: Vec::new() }), @@ -198,10 +213,17 @@ impl AcpBackend { Incoming::Ignored => {} } } -} -impl ChatBackend for AcpBackend { - fn poll(&mut self) -> Vec { + fn finish_exit(&mut self, tail: Vec) -> Event { + self.exited = true; + self.session = None; + self.pending.clear(); + self.cancelling = None; + self.config_pending = None; + Event::Exited { tail } + } + + fn poll_at(&mut self, now: Instant) -> Vec { let mut out = Vec::new(); if self.exited { return out; @@ -214,18 +236,25 @@ impl ChatBackend for AcpBackend { } } Ok(FromAgent::Exited(tail)) => { - self.exited = true; - self.session = None; - self.pending.clear(); - self.config_pending = None; - out.push(Event::Exited { tail }); + out.push(self.finish_exit(tail)); break; } Err(_) => break, } } + // Drain replies first: an acknowledgement already waiting must keep the connection. + if self.cancelling.is_some_and(|(_, started)| now.saturating_duration_since(started) >= CANCEL_GRACE) { + self.proc.shutdown(false); + out.push(self.finish_exit(vec![crate::i18n::tr("ui.agent_chat_cancel_timeout").to_string()])); + } out } +} + +impl ChatBackend for AcpBackend { + fn poll(&mut self) -> Vec { + self.poll_at(Instant::now()) + } fn prompt(&mut self, parts: &[PromptPart]) { if let Some(s) = self.session.clone() { @@ -240,6 +269,11 @@ impl ChatBackend for AcpBackend { } if let Some(s) = &self.session { self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s}))); + // Repeated Stop presses must not postpone the fallback. An idle session needs none. + if self.cancelling.is_none() { + self.cancelling = + self.pending.iter().find_map(|(&id, request)| (*request == Pending::Prompt).then_some(id)).map(|id| (id, Instant::now())); + } } } @@ -343,6 +377,75 @@ mod tests { use super::*; use crate::agent_chat::backend::StartContext; + fn ready_backend() -> Result { + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut backend = AcpBackend::start(&["sh".into(), "-c".into(), "cat >/dev/null".into()], ctx)?; + let mut events = Vec::new(); + backend.handle(Incoming::Reply { id: 1, result: Ok(json!({"agentInfo":{"name":"fake"}})) }, &mut events); + backend.handle(Incoming::Reply { id: 2, result: Ok(json!({"sessionId":"s1"})) }, &mut events); + Ok(backend) + } + + #[test] + fn an_unresponsive_cancel_disconnects_the_agent() { + let mut backend = ready_backend().unwrap(); + backend.prompt(&[PromptPart::Text("stalled search".into())]); + backend.cancel(); + let started = backend.cancelling.unwrap().1; + assert!(backend.poll_at(started + CANCEL_GRACE - Duration::from_millis(1)).is_empty()); + backend.cancel(); + let events = backend.poll_at(started + CANCEL_GRACE); + assert!(matches!(events.as_slice(), [Event::Exited { .. }]), "Stop must not wait indefinitely: {events:?}"); + assert!(backend.pid().is_none()); + assert!(backend.session.is_none() && backend.pending.is_empty()); + assert!(backend.poll().is_empty(), "the exit is reported only once"); + } + + #[test] + fn a_cancelled_prompt_reply_keeps_the_connection() { + for reply in [Ok(json!({"stopReason":"cancelled"})), Err((-32603, "operation aborted".into()))] { + let mut backend = ready_backend().unwrap(); + backend.prompt(&[PromptPart::Text("search".into())]); + let request = backend.next_id; + backend.cancel(); + let mut events = Vec::new(); + backend.handle(Incoming::Reply { id: request, result: reply }, &mut events); + assert!(events.iter().any(|e| matches!(e, Event::TurnEnded { .. }))); + assert!(backend.poll_at(Instant::now() + CANCEL_GRACE).is_empty()); + assert!(backend.pid().is_some() && !backend.exited); + backend.shutdown(true); + } + } + + #[test] + fn cancelling_an_idle_session_does_not_disconnect_it() { + let mut backend = ready_backend().unwrap(); + backend.cancel(); + assert!(backend.poll_at(Instant::now() + CANCEL_GRACE).is_empty()); + assert!(backend.pid().is_some() && !backend.exited); + backend.shutdown(true); + } + + #[test] + fn a_new_session_retires_the_old_cancelled_prompt() { + let mut backend = ready_backend().unwrap(); + backend.prompt(&[PromptPart::Text("old search".into())]); + let old_prompt = backend.next_id; + backend.cancel(); + ChatBackend::new_session(&mut backend); + let mut events = Vec::new(); + backend.handle(Incoming::Reply { id: backend.next_id, result: Ok(json!({"sessionId":"s2"})) }, &mut events); + assert!(matches!(events.first(), Some(Event::TurnEnded { reason }) if reason == "cancelled")); + assert!(matches!(events.last(), Some(Event::Ready { .. }))); + backend.prompt(&[PromptPart::Text("new search".into())]); + assert!(backend.poll_at(Instant::now() + CANCEL_GRACE).is_empty(), "a new session has no stale cancellation deadline"); + backend.cancel(); + events.clear(); + backend.handle(Incoming::Reply { id: old_prompt, result: Ok(json!({"stopReason":"cancelled"})) }, &mut events); + assert!(events.is_empty(), "an old reply must not end the new turn or clear its cancellation deadline"); + assert!(matches!(backend.poll_at(Instant::now() + CANCEL_GRACE).as_slice(), [Event::Exited { .. }])); + } + fn snapshot(session: &str, value: &str) -> Value { json!({"sessionId":session,"configOptions":[{"id":"provider.model","category":"model","name":"Model","type":"select","currentValue":value,"options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}) } diff --git a/crates/ui-egui/src/agent_chat/agents.rs b/crates/ui-egui/src/agent_chat/agents.rs index 51e49b9739..145b94579a 100644 --- a/crates/ui-egui/src/agent_chat/agents.rs +++ b/crates/ui-egui/src/agent_chat/agents.rs @@ -329,14 +329,32 @@ pub fn key_safe_for(url: &str) -> bool { return true; } let Some(rest) = u.strip_prefix("http://") else { return false }; - let host = rest.split(['/', '?']).next().unwrap_or(""); - let host = host.rsplit_once('@').map_or(host, |(_, h)| h); - let name = if host.starts_with('[') { - host.split(']').next().map(|h| format!("{h}]")).unwrap_or_default() - } else { - host.split(':').next().unwrap_or("").to_string() + // The host part ends where the HTTP client ends it, at the first `/`, `?` or `#`. + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + local_authority(authority) +} + +/// `authority` is exactly this computer's name with at most a port. No user name goes before it +/// and no stray character after it, as URL parsers don't all read those the same way. +fn local_authority(authority: &str) -> bool { + let (host, port) = match authority.strip_prefix('[') { + Some(inner) => { + let Some((ip, after)) = inner.split_once(']') else { return false }; + if ip != "::1" { + return false; + } + ("[::1]", after) + } + None => { + let end = authority.find(':').unwrap_or(authority.len()); + authority.split_at(end) + } }; - matches!(name.as_str(), "localhost" | "127.0.0.1" | "[::1]") + let port_ok = match port.strip_prefix(':') { + Some(digits) => digits.bytes().all(|b| b.is_ascii_digit()) && digits.parse::().is_ok(), + None => port.is_empty(), + }; + port_ok && matches!(host, "localhost" | "127.0.0.1" | "[::1]") } /// A base URL the API backend accepts: `http(s)://host…`, without a trailing slash. @@ -366,6 +384,56 @@ mod tests { assert!(!key_safe_for("ftp://x")); } + /// Plain HTTP only reaches this computer: the check reads the host where the HTTP client does, + /// and takes nothing around the name that URL parsers could read differently. + #[test] + fn a_key_over_http_needs_a_url_that_can_only_mean_this_computer() { + for url in [ + "http://localhost", + "HTTP://LOCALHOST:11434/v1", + " http://localhost:11434/v1/ ", + "http://127.0.0.1:1234/v1?x=1", + "http://[::1]/v1", + "http://[::1]:8080/v1", + "http://localhost:65535/@models", + ] { + assert!(key_safe_for(url), "{url}"); + } + for url in [ + "http://evil.example#@localhost/v1", + "http://evil.example?@localhost/v1", + "http://evil.example/@localhost/v1", + "http://evil.example\\@localhost/v1", + "http://localhost\\@evil.example/v1", + "http://localhost\\.evil.example/v1", + "http://localhost@evil.example/v1", + "http://localhost:1234@evil.example/v1", + "http://user@localhost/v1", + "http://user:secret@localhost:1234/v1", + "http://[::1]@evil.example/v1", + "http://[::1].evil.example/v1", + "http://[::1]evil.example/v1", + "http://[::2]/v1", + "http://[::1/v1", + "http://127.0.0.1.evil.example/v1", + "http://localhost.evil.example:1234/v1", + "http://localhost:80.evil.example/v1", + "http://localhost:abc/v1", + "http://localhost:/v1", + "http://localhost:65536/v1", + "http://local\thost.evil.example/v1", + "http://localhost evil.example/v1", + "http://localhost%2eevil.example/v1", + "http://", + "http:///localhost/v1", + "http:/localhost/v1", + "//localhost/v1", + "localhost:1234/v1", + ] { + assert!(!key_safe_for(url), "{url}"); + } + } + #[test] fn modes_and_ids_round_trip() { for m in Mode::ALL { diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs index ccd404c09e..51d88f0e40 100644 --- a/crates/ui-egui/src/agent_chat/backend.rs +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -78,6 +78,8 @@ pub enum Event { Permission { key: u64, title: String, + /// The tool call's kind, as the agent gives it. + kind: Option, options: Vec, }, /// It needs the user to sign in; `methods` are its own ways to do so. diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index 952fd63ccf..24966a7531 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -49,7 +49,7 @@ use crate::i18n::{format as trf, tr}; use crate::{ControlRequest, SolveApp}; use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus}; -pub(crate) use panel::{left_inset, tool_space}; +pub(crate) use panel::{left_inset, owns_keyboard_input, tool_space}; pub use panel::{show, tip as panel_tip, toggle, toggle_shortcut}; /// Most entries kept in the conversation; older ones are dropped. @@ -375,6 +375,8 @@ pub struct AgentChat { pub confirm_new: bool, /// Where the panel was drawn last (for its shortcuts). pub panel_rect: Option, + /// A pointer press in the transcript owns keys just like a focused text editor. + keyboard_owned: bool, /// Unreserved sheet space, shared by the actual dock and its drag preview. pub(crate) dock_space: Option, /// The last message sent, kept to send again after a sign-in (its files too). @@ -477,6 +479,7 @@ impl AgentChat { } // Hit regions must be measured again for the incoming conversation. self.panel_rect = None; + self.keyboard_owned = false; self.dock_space = None; self.drop_rect = None; } @@ -729,7 +732,7 @@ impl AgentChat { self.push(Item::Plan(entries)); } } - Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), + Event::Permission { key, title, kind, options } => return Some(Outcome::Permission { key, title, kind, options }), Event::Modes { current, available } => { if self.config_options.is_none() && !available.is_empty() { self.modes = available; @@ -834,6 +837,13 @@ impl AgentChat { self.clear_config(); self.state = Some(if matches!(self.state(), State::Starting | State::Failed) { State::Failed } else { State::Exited }); self.steering = false; + for item in &mut self.items { + if let Item::Tool { status, .. } = item + && !matches!(status, Some(ToolStatus::Completed | ToolStatus::Failed)) + { + *status = Some(ToolStatus::Failed); + } + } let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) }; self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true); @@ -850,7 +860,7 @@ enum Outcome { /// A message was answered (or the agent stopped): the turn's bookkeeping ends. TurnEnd, /// The agent asks before running something of its own: answered by the mode, or asked. - Permission { key: u64, title: String, options: Vec }, + Permission { key: u64, title: String, kind: Option, options: Vec }, } /// The AI features are on (the master switch in Settings → AI) and this is the desktop app. @@ -923,12 +933,6 @@ pub(crate) fn assistant_tip(app: &SolveApp, mark: AssistantMark) -> String { format!("{name} — {state}") } -/// Whether a permission question is about one of SolidCraft's own tools (which SolidCraft's own -/// gate checks): the agents name MCP tools after their server. -fn solidcraft_tool(title: &str) -> bool { - title.to_ascii_lowercase().contains("solidcraft") -} - /// `solidcraft-cli` next to this program, else on the search path. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn find_cli() -> Option { @@ -1658,14 +1662,15 @@ pub fn answer_permission(app: &mut SolveApp, index: usize, option: Option<&str>) app.agent_chat.answer(index, option); } -/// The agent asks before running something of its own. SolidCraft's own tools are checked by -/// SolidCraft's gate, so they're allowed here (no double question); otherwise the mode or an -/// earlier "always allow" may answer, and the chat says so; otherwise the user is asked. -fn on_permission(app: &mut SolveApp, key: u64, title: String, options: Vec) { +/// The agent asks before running something of its own. The chat's own MCP tools are checked by +/// SolidCraft's gate, so they're allowed here (no double question, see [`acp::is_bridge_tool`]); +/// otherwise the mode or an earlier "always allow" may answer, and the chat says so; otherwise the +/// user is asked. +fn on_permission(app: &mut SolveApp, key: u64, title: String, kind: Option<&str>, options: Vec) { let agent = app.agent_chat.running_id.clone(); let mode = agents::mode_for(&app.preferences, &agent); let remembered = agents::always_allowed(&app.preferences, &agent, &title); - let why = if solidcraft_tool(&title) { + let why = if acp::is_bridge_tool(&title, kind) { Some("ui.ai_auto_ours") } else if remembered { Some("ui.ai_auto_remembered") @@ -1949,7 +1954,7 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { b.answer_permission(key, None); } } - Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), + Some(Outcome::Permission { key, title, kind, options }) => on_permission(app, key, title, kind.as_deref(), options), None => {} } if let Some(model) = confirmed_model { diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 201b2f8b8f..b7388ca0cf 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -83,6 +83,26 @@ pub fn tip(app: &SolveApp) -> String { } } +/// Selectable transcript labels do not take egui keyboard focus. Keep their keys out of +/// CAD until a pointer press outside the chat, not merely a move of the pointer. Use the +/// panel in this window, never label selections belonging to another native viewport. +/// Called before layout so even a click and a shortcut in the same frame are routed here. +pub(crate) fn owns_keyboard_input(app: &mut SolveApp, ctx: &egui::Context) -> bool { + let rect = app.agent_chat.panel_rect.filter(|_| shown(app) && app.agent_chat.open && !app.preferences.agent_chat_minimized); + let Some(rect) = rect else { + app.agent_chat.keyboard_owned = false; + return false; + }; + ctx.input(|input| { + for event in &input.events { + if let egui::Event::PointerButton { pos, pressed: true, .. } = event { + app.agent_chat.keyboard_owned = rect.contains(*pos); + } + } + }); + app.agent_chat.keyboard_owned +} + /// The panel, when the chat is turned on and open. Call before the central viewport is laid out. pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 7040dd8913..ea0d61b547 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -1,6 +1,126 @@ use super::*; use std::sync::mpsc::channel; +fn transcript_clipboard_harness(layout: crate::shell::LayoutMode) -> crate::scenario::Harness { + let mut h = narrow_chat(); + h.app.ui.layout = layout; + h.app.preferences.agent_chat_width = 500.0; + h.app.agent_chat.items.push(Item::Agent { thought: false, text: "Transcript clipboard sentinel".into() }); + h.app.run("solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"})).unwrap(); + h.app.run("select.set", json!({"items": [{"type": "body", "name": "Cube"}]})).unwrap(); + h.frames(4); + h +} + +fn transcript_text_position(h: &crate::scenario::Harness) -> egui::Pos2 { + let mut at = None; + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |shape| { + if let egui::Shape::Text(text) = shape + && text.galley.text() == "Transcript clipboard sentinel" + { + at = Some(text.pos + egui::vec2(15.0, 7.0)); + } + }); + } + at.expect("the assistant message is visible") +} + +fn transcript_click(h: &mut crate::scenario::Harness, pos: egui::Pos2) { + h.frame_with(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + h.frame_with(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }]); +} + +#[test] +fn transcript_copy_and_cut_do_not_touch_the_cad_clipboard() { + for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] { + let mut h = transcript_clipboard_harness(layout); + let at = transcript_text_position(&h); + transcript_click(&mut h, at); + transcript_click(&mut h, at); + assert!(h.ctx.plugin::().lock().has_selection(), "select real label text"); + assert!(!h.ctx.egui_wants_keyboard_input(), "a selectable label is not a text editor"); + // Moving the mouse away does not transfer the keyboard back to the model. + h.frame_with(vec![egui::Event::PointerMoved(egui::pos2(500.0, 500.0))]); + let undo = h.app.session.undo.len(); + h.frame_with(vec![egui::Event::Copy]); + assert!(h.output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == "Transcript")), "copy selected text"); + assert!(h.app.copied_bodies.is_empty(), "{layout:?}: copying text must not copy a CAD body"); + h.frame_with(vec![egui::Event::Cut]); + assert!(h.app.cut.is_empty(), "{layout:?}: cutting read-only text must not cut a CAD body"); + assert_eq!(h.app.session.undo.len(), undo); + } +} + +#[test] +fn transcript_paste_does_not_insert_a_previously_copied_body() { + for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] { + let mut h = transcript_clipboard_harness(layout); + h.frame_with(vec![egui::Event::Copy]); + assert!(!h.app.copied_bodies.is_empty(), "prime the real CAD clipboard"); + let at = transcript_text_position(&h); + transcript_click(&mut h, at); + transcript_click(&mut h, at); + let undo = h.app.session.undo.len(); + h.frame_with(vec![egui::Event::Paste("text from another app".into())]); + assert_eq!(h.app.session.model.state().bodies.len(), 1, "{layout:?}: text paste must not paste CAD geometry"); + assert_eq!(h.app.session.undo.len(), undo); + // A deliberate click outside the panel returns ownership to CAD. + transcript_click(&mut h, egui::pos2(700.0, 500.0)); + h.frame_with(vec![egui::Event::Paste("SolidCraft bodies".into())]); + assert_eq!(h.app.session.model.state().bodies.len(), 2, "{layout:?}: CAD paste still works after returning to the model"); + } +} + +#[test] +fn transcript_key_events_do_not_run_cad_shortcuts() { + for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] { + let mut h = transcript_clipboard_harness(layout); + let at = transcript_text_position(&h); + transcript_click(&mut h, at); + transcript_click(&mut h, at); + let key = |key| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::CTRL | egui::Modifiers::COMMAND, + }; + h.frame_with(vec![key(egui::Key::C)]); + assert!(h.app.copied_bodies.is_empty(), "{layout:?}: key-event copy also belongs to the chat"); + h.frame_with(vec![key(egui::Key::X)]); + assert!(h.app.cut.is_empty(), "{layout:?}: key-event cut also belongs to the chat"); + assert!(crate::edit_clipboard::copy(&mut h.app)); + h.frame_with(vec![key(egui::Key::V)]); + assert_eq!(h.app.session.model.state().bodies.len(), 1, "{layout:?}: key-event paste stays out of CAD"); + // A quick tap can have no modifiers held by the end of the frame. It must not + // fall through to the plain A (Appearance) shortcut while selecting chat text. + h.frame_with(vec![key(egui::Key::A)]); + assert!(h.app.dialog.is_none(), "{layout:?}: selecting text must not open Appearance"); + } +} + +#[test] +fn transcript_shortcut_ownership_ends_when_the_panel_is_hidden() { + for hidden in ["closed", "minimized", "disabled"] { + let mut h = transcript_clipboard_harness(crate::shell::LayoutMode::Floating); + h.frame_with(vec![egui::Event::Copy]); + let at = transcript_text_position(&h); + transcript_click(&mut h, at); + transcript_click(&mut h, at); + match hidden { + "closed" => h.app.agent_chat.open = false, + "minimized" => h.app.preferences.agent_chat_minimized = true, + _ => h.app.preferences.ai_features = false, + } + h.frame_with(vec![egui::Event::Paste("SolidCraft bodies".into())]); + assert_eq!(h.app.session.model.state().bodies.len(), 2, "{hidden}: the invisible panel must not retain CAD keys"); + } +} + fn chat() -> AgentChat { let mut c = AgentChat::default(); c.state = Some(State::Ready); @@ -532,17 +652,48 @@ fn the_agents_own_questions_are_answered_by_the_mode_or_asked() { PermissionOption { id: "no".into(), name: "Reject".into(), kind: "reject_once".into() }, ] }; - on_permission(&mut app, 1, "mcp__solidcraft__batch".into(), opts()); - assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { text, .. }) if text.contains("mcp__solidcraft__batch"))); - on_permission(&mut app, 2, "Run ls".into(), opts()); + on_permission(&mut app, 1, "mcp__solidcraft-app__batch".into(), Some("other"), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { text, .. }) if text.contains("mcp__solidcraft-app__batch"))); + on_permission(&mut app, 2, "Run ls".into(), None, opts()); assert!(matches!(app.agent_chat.items.last(), Some(Item::Permission { answer: None, .. }))); let n = app.agent_chat.items.len() - 1; answer_permission(&mut app, n, Some("always")); assert!(agents::always_allowed(&app.preferences, "claude", "Run ls")); - on_permission(&mut app, 3, "Run ls".into(), opts()); + on_permission(&mut app, 3, "Run ls".into(), None, opts()); assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered"); } +/// A title that only mentions SolidCraft is no tool of SolidCraft's: in every mode but Allow +/// everything the user is asked, and the mode still answers when it says to. +#[test] +fn a_title_that_mentions_solidcraft_goes_through_the_users_mode() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.running_id = "codex".into(); + let opts = || vec![PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }]; + let titles = [ + ("run solidcraft-cli; ls", Some("execute")), + ("run solidcraft-cli; ls", None), + ("Edit ~/.local/share/solidcraft/agent-chat/work/notes.txt", Some("edit")), + ("mcp__solidcraft__batch", Some("other")), + ("mcp__solidcraft-app__batch", Some("execute")), + ]; + for mode in [agents::Mode::Ask, agents::Mode::Edits] { + app.preferences.agent_permissions.insert("codex".into(), mode); + for (title, kind) in titles { + on_permission(&mut app, 1, title.into(), kind, opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Permission { answer: None, .. })), "{mode:?}: {title}"); + app.agent_chat.items.clear(); + } + // The chat's own tool stays a single question, the gate's. + on_permission(&mut app, 2, "mcp__solidcraft-app__batch".into(), Some("other"), opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "{mode:?}"); + app.agent_chat.items.clear(); + } + app.preferences.agent_permissions.insert("codex".into(), agents::Mode::All); + on_permission(&mut app, 3, titles[0].0.into(), titles[0].1, opts()); + assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "Allow everything answers it"); +} + // ---- Messages typed while the agent works ---- use std::sync::Mutex; @@ -698,7 +849,8 @@ fn a_late_sign_in_retry_or_permission_cannot_restart_a_departed_turn() { crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); script.report(Event::Permission { key: 7, - title: "mcp__solidcraft__batch".into(), + title: "mcp__solidcraft-app__batch".into(), + kind: None, options: vec![PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }], }); ended(&script, "auth"); @@ -1127,6 +1279,51 @@ fn stop_keeps_the_queue() { assert!(app.agent_chat.pending.is_empty()); } +#[test] +fn an_unresponsive_cancel_exit_keeps_the_queue_and_completed_changes() { + let (mut app, script, ctx) = app_with_script(); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + let before = app.session.doc.clone(); + send(&mut app, &ctx, "two boxes".into()); + let replies: Vec<_> = [0.0, 20.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect(); + frame(&mut app); + for reply in replies { + assert_eq!(reply.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true); + } + let completed = app.session.doc.clone(); + let statuses = [Some(ToolStatus::Completed), Some(ToolStatus::Failed), Some(ToolStatus::Pending), Some(ToolStatus::InProgress), None]; + for (id, status) in statuses.into_iter().enumerate() { + script.report(Event::Tool { id: id.to_string(), title: Some("search".into()), kind: None, status, input: None, output: None }); + } + poll(&mut app, &ctx); + send(&mut app, &ctx, "later".into()); + cancel(&mut app); + script.report(Event::Exited { tail: vec![tr("ui.agent_chat_cancel_timeout").to_string()] }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.state(), State::Exited); + assert!(app.agent_chat.backend.is_none() && app.agent_chat.turn.is_none()); + assert_eq!(texts(&app), ["later"]); + assert_eq!(script.prompts().len(), 1, "disconnect must not send the queue"); + assert!(Arc::ptr_eq(&app.session.doc, &completed), "completed changes stay in the design"); + let statuses: Vec<_> = app + .agent_chat + .items + .iter() + .filter_map(|item| match item { + Item::Tool { status, .. } => Some(*status), + _ => None, + }) + .collect(); + assert_eq!( + statuses, + [Some(ToolStatus::Completed), Some(ToolStatus::Failed), Some(ToolStatus::Failed), Some(ToolStatus::Failed), Some(ToolStatus::Failed)] + ); + let changes = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 2, .. })).expect("the completed changes are grouped"); + undo_changes(&mut app, changes); + assert!(Arc::ptr_eq(&app.session.doc, &before), "the completed changes still undo together"); +} + /// Send now: cancel first, the prompt only after the agent has ended the cancelled turn, with a /// note that the previous step was interrupted (shown without it in the conversation). #[test] diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index a4b198e9b0..cb7c09c989 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1701,7 +1701,7 @@ fn occurrence_rows_inner( if r.double && !takes_components && app.session.doc.parts.contains_key(&placement.component) { crate::dialogs_part_size::open(app, placement.component); } - if r.clicked && takes_components { + if r.clicked && (takes_components || crate::dialogs_move::picks_components(app)) { if let Some(mut d) = app.dialog.take() { d.pick(&app.session, Sel::Occurrence { path: path.to_vec() }); app.dialog = Some(d); @@ -2824,7 +2824,9 @@ fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, label_left: f if escape { // Dropping the state cancels the edit. } else if commit { - crate::context_menu::commit_rename(app, &rename); + if crate::context_menu::commit_rename(app, &rename).is_err() { + app.menu.rename = Some(rename); + } } else { app.menu.rename = Some(rename); } diff --git a/crates/ui-egui/src/browser_canvas.rs b/crates/ui-egui/src/browser_canvas.rs index dc1c281146..e8ea1a92f8 100644 --- a/crates/ui-egui/src/browser_canvas.rs +++ b/crates/ui-egui/src/browser_canvas.rs @@ -183,6 +183,7 @@ mod tests { #[test] fn edit_canvas_is_a_card_whose_close_keeps_the_live_edits() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; let id = canvas(&mut h); h.app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(&h.app, id, false)); diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 01cdbf6299..e0465f149e 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -106,6 +106,31 @@ fn f2_on_a_clicked_browser_group_renames_the_group() { } } +/// #10654: a refused inline rename keeps the editor open and says why, instead of closing +/// silently and leaving the old name. +#[test] +fn a_refused_inline_rename_keeps_the_editor_and_shows_the_error() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"A"})).unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"B"})).unwrap(); + h.app.preferences.browser_bodies_folders = crate::prefs::BodiesFolders::Always; + h.settle(); + h.app.menu.rename = Some(crate::context_menu::Rename { + what: crate::context_menu::RenameWhat::Body("B".into()), + text: "A".into(), + at: egui::Pos2::ZERO, + focused: true, + inline: true, + }); + h.frames(2); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(2); + assert!(h.app.menu.rename.is_some(), "the editor stays open after a refused rename"); + let (text, _, error) = h.app.status.clone().expect("the refusal is shown"); + assert!(error && text.contains("another body is called"), "{text}"); + assert!(h.app.session.world_state().body("B").is_some(), "the body keeps its name"); +} + #[test] fn folder_visibility_target_clears_when_the_user_moves_on() { for action in ["body click", "selection command", "empty click", "escape", "design tab", "model edit", "tool"] { diff --git a/crates/ui-egui/src/browser_model_tests.rs b/crates/ui-egui/src/browser_model_tests.rs index 9ebf21d6fa..167255ba5c 100644 --- a/crates/ui-egui/src/browser_model_tests.rs +++ b/crates/ui-egui/src/browser_model_tests.rs @@ -237,6 +237,15 @@ fn body_names(app: &crate::SolveApp) -> Vec { .unwrap_or_default() } +/// The bodies the open Move/Copy would move, a picked component's bodies included. +fn move_bodies(app: &crate::SolveApp) -> Vec { + let Some(d) = app.dialog.as_ref() else { return Vec::new() }; + crate::dialogs_move::params(app, d) + .ok() + .and_then(|p| p["bodies"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())) + .unwrap_or_default() +} + fn two_bodies(h: &mut crate::scenario::Harness) { h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Left"})).unwrap(); h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0], "body_name": "Right"})).unwrap(); @@ -309,7 +318,7 @@ fn browser_move_copy_takes_a_component_clicked_in_the_tree() { open_move(&mut h); h.frames(2); click(&mut h, &format!("row:occurrence:{occurrence}")); - assert_eq!(body_names(&h.app), owned, "the component's bodies are the Move/Copy selection"); + assert_eq!(move_bodies(&h.app), owned, "the component's bodies are the Move/Copy selection"); assert!(h.app.tree.occurrence_move.is_none(), "the open body dialog stays; this click does not switch to component Move"); h.app.dialog = None; @@ -318,7 +327,7 @@ fn browser_move_copy_takes_a_component_clicked_in_the_tree() { click(&mut h, "row:Untitled"); assert_eq!(h.app.tree.picked_components, vec![0]); h.app.start("solid.move"); - let names = body_names(&h.app); + let names = move_bodies(&h.app); assert!(names.iter().any(|n| owned.contains(n)), "Move/Copy starts from the component picked in the tree: {names:?}"); } diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 4c046de448..49af46750f 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -287,6 +287,43 @@ fn groups(t: &Tokens) -> Vec { group("card hints", &[("form_dim", t.form_dim)], &[("dialog_body", t.dialog_body)]), group("card primary", &[("form_primary", t.form_primary)], &[("dialog_body", t.dialog_body)]), group("navigation status", &[("form_primary", t.form_primary), ("warning", t.warning)], &[("dialog_body", t.dialog_body)]), + // NAVIGATOR_AXES draws all three axes on the figure ground; its filled tips have letters. + group( + "Preferences figure axes", + &[("figure_axis_x", t.figure_axis_x), ("figure_axis_y", t.figure_axis_y), ("figure_axis_z", t.figure_axis_z)], + &[("figure_ground", t.figure_ground)], + ), + group( + "Preferences axis letters", + &[("pref_on_accent", t.pref_on_accent)], + &[("figure_axis_x", t.figure_axis_x), ("figure_axis_y", t.figure_axis_y), ("figure_axis_z", t.figure_axis_z)], + ), + // Selection outlines and active geometry are drawn over the model and paper surfaces. + group( + "Preferences figure actions", + &[("figure_accent", t.figure_accent)], + &[ + ("figure_ground", t.figure_ground), + ("figure_card", t.figure_card), + ("figure_face_top", t.figure_face_top), + ("figure_face_left", t.figure_face_left), + ("figure_face_right", t.figure_face_right), + ], + ), + // ASSEMBLY_MOVE and the orbit figures distinguish the active ring from the reference dot. + group("Preferences figure markers", &[("figure_accent", t.figure_accent), ("figure_reference", t.figure_reference)], &[]), + group("Preferences reference dots", &[("figure_reference", t.figure_reference)], &[("figure_ground", t.figure_ground)]), + // SKETCH_STATE_ICONS shows these three states together on top-face pads. + group( + "Preferences sketch states", + &[("figure_ok", t.figure_ok), ("figure_warn", t.figure_warn), ("figure_conflict", t.figure_conflict)], + &[("figure_ground", t.figure_ground), ("figure_face_top", t.figure_face_top)], + ), + // Badge text and selected-card marks use these exact pairs; WCAG ratios are also + // guarded by theme::tests::preferences_foreground_pairs_hold_small_text_contrast. + group("Preferences warning badge", &[("pref_note", t.pref_note)], &[("pref_note_fill", t.pref_note_fill)]), + group("Preferences success badge", &[("pref_ok", t.pref_ok)], &[("pref_ok_fill", t.pref_ok_fill)]), + group("Preferences selected marks", &[("pref_on_accent", t.pref_on_accent)], &[("figure_accent", t.figure_accent)]), // Validation is identified by its words as well as colour. Measure both the // ink and badge surfaces against the corresponding failure states. group( @@ -438,12 +475,12 @@ const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"]; /// Tokens no colour vision mode needs to keep apart, and why. A new token fails /// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here. const NO_MEANING: &[(&str, &str)] = &[ - ("figure_face_left", "neutral cube shading; face geometry identifies the orientation"), - ("figure_face_right", "neutral cube shading; face geometry identifies the orientation"), ("figure_white", "neutral axis label and highlight ink; letters identify the axes"), ("figure_black", "neutral hatching and shadows; pattern and geometry carry the meaning"), ("pref_well", "neutral well behind a labelled control or illustration"), ("pref_disabled", "dimmed control ink; label and disabled interaction identify availability"), + ("figure_highlight", "neutral model lighting; face geometry and outlines identify the model, not the highlight's hue"), + ("figure_shadow", "neutral model shading, never an axis, selection or status category"), ("nav_hover", "neutral hover surface; placement and pointer feedback identify the control"), ("nav_press", "neutral pressed/open surface; interaction and menu presence identify the state"), ("nav_dim", "neutral secondary ink; the Later label identifies the unavailable option"), @@ -518,6 +555,7 @@ const NO_MEANING: &[(&str, &str)] = &[ ("font_compass", "layout dimension"), ("font_subtitle", "layout dimension"), ("font_intro", "layout dimension"), + ("font_page_heading", "layout dimension"), ("font_article_heading", "layout dimension"), ("font_title", "layout dimension"), ("font_display", "layout dimension"), @@ -807,6 +845,12 @@ fn each_mode_changes_only_its_table() { let red_green_both = [ "axis_x", "axis_y", + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "analysis.draft", "analysis.access", "analysis.hatch", @@ -832,6 +876,12 @@ fn each_mode_changes_only_its_table() { false, ColorVision::Tritan, set(&[ + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "surface_accent", "mesh_accent", "form_accent", @@ -849,6 +899,12 @@ fn each_mode_changes_only_its_table() { true, ColorVision::Tritan, set(&[ + "figure_axis_x", + "figure_axis_y", + "figure_axis_z", + "figure_ok", + "figure_warn", + "figure_conflict", "surface_accent", "mesh_accent", "form_accent", diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index b1b10ee429..72d9b2cf12 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -2036,24 +2036,30 @@ pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { app.menu.rename = Some(Rename { what, text, at, focused: false, inline: false }); } -pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { +pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) -> Result<(), String> { let name = r.text.trim().to_string(); if name.is_empty() { - return; + return Ok(()); } - match &r.what { + let result = match &r.what { RenameWhat::Body(old) => { // The command renames the body where it is hidden or locked too. - drop(app.run_heavy("body.rename", json!({ "body": old, "name": name }))); + app.run_heavy("body.rename", json!({ "body": old, "name": name })).map(|_| ()) } - RenameWhat::Feature(f) => drop(app.run("timeline.rename", json!({ "feature": f, "name": name }))), - RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), - RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), - RenameWhat::TimelineGroup(g) => drop(app.run("timeline.rename_group", json!({ "group": g, "name": name }))), - RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))), - RenameWhat::View(v) => drop(app.run("view.rename", json!({ "view": v, "name": name }))), - RenameWhat::Joint(j) => drop(app.run("joint.edit", json!({ "joint": j, "name": name }))), - } + RenameWhat::Feature(f) => app.run("timeline.rename", json!({ "feature": f, "name": name })).map(|_| ()), + RenameWhat::Component(c) => app.run("component.rename", json!({ "component": c, "name": name })).map(|_| ()), + RenameWhat::Group(g) => { + crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })); + Ok(()) + } + RenameWhat::TimelineGroup(g) => app.run("timeline.rename_group", json!({ "group": g, "name": name })).map(|_| ()), + RenameWhat::Canvas(c) => app.run("canvas.edit", json!({ "canvas": c, "name": name })).map(|_| ()), + RenameWhat::View(v) => app.run("view.rename", json!({ "view": v, "name": name })).map(|_| ()), + RenameWhat::Joint(j) => app.run("joint.edit", json!({ "joint": j, "name": name })).map(|_| ()), + }; + // The command's own status line explains a refusal; the caller decides whether to keep the + // editor open on it. + result } // --------------------------------------------------------------------------------------------- @@ -2583,7 +2589,9 @@ fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { done = Some(true); } match done { - Some(true) => commit_rename(app, &r), + Some(true) => { + let _ = commit_rename(app, &r); + } Some(false) => {} None => app.menu.rename = Some(r), } @@ -2596,7 +2604,7 @@ pub fn finish_rename(app: &mut SolveApp, text: Option<&str>, commit: bool) -> bo r.text = s.to_string(); } if commit { - commit_rename(app, &r); + let _ = commit_rename(app, &r); } true } diff --git a/crates/ui-egui/src/crash_ui.rs b/crates/ui-egui/src/crash_ui.rs index 130f058f4b..f09b3d7858 100644 --- a/crates/ui-egui/src/crash_ui.rs +++ b/crates/ui-egui/src/crash_ui.rs @@ -309,7 +309,41 @@ mod tests { fn inventory_actions_details_empty_state_and_keyboard_remain_reachable() { let d = folder("inventory"); let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); + h.app.home.open = false; h.app.crashes.folder = Some(d.clone()); + let assert_general_count = |h: &mut crate::scenario::Harness, count: usize| { + // The fixture folder was assigned after startup; programmatic Preferences uses the cached inventory. + h.app.crashes.refresh(); + for theme in ["light", "dark"] { + h.call("ui.prefs", json!({"open":"General", "theme":theme})); + crate::prefs::reveal_preference(h, "prefs:crash_count"); + assert_eq!(h.app.crashes.saved_count(), count); + assert!(h.widget(&format!("{count} saved")).is_some()); + let badge = crate::scenario::rect_of("prefs:crash_count").unwrap(); + assert!((badge.height() - 18.0).abs() < 0.5); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(badge)); + let t = crate::theme::Tokens::get(); + let fill = if count == 0 { t.dialog_footer } else { t.pref_note_fill }; + assert!( + h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.rect == badge && rect.fill == fill)), + "{theme}: count tone reflects reports waiting" + ); + let buttons: Vec<_> = h + .output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.role() == egui::accesskit::Role::Button && node.label() == Some(h.app.tr("prefs.crash_reports"))) + .collect(); + assert_eq!(buttons.len(), 1); + assert!(!buttons[0].1.is_disabled(), "opening reports stays available even at zero"); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + } + }; + assert_general_count(&mut h, 1); super::open(&mut h.app); h.frames(5); for handle in [ @@ -349,6 +383,7 @@ mod tests { assert!(h.app.crashes.reports.is_empty()); h.step(&json!({"key":"Escape"})).unwrap(); assert!(!h.app.crashes.open); + assert_general_count(&mut h, 0); let _ = std::fs::remove_dir_all(d); } } diff --git a/crates/ui-egui/src/deferred.rs b/crates/ui-egui/src/deferred.rs index 30991ac8b0..38ef51c328 100644 --- a/crates/ui-egui/src/deferred.rs +++ b/crates/ui-egui/src/deferred.rs @@ -133,6 +133,19 @@ fn replaced_by_a_later_one(id: &str) -> bool { } impl SolveApp { + /// A direct exit or editor switch supersedes any queued sketch exit. A late worker must + /// never finish a different sketch that was opened while it computed. + pub(crate) fn take_sketch_exit(&mut self) -> Option { + let is_exit = |id: &str| matches!(id, "sketch.finish" | "sketch.cancel"); + let pending = self.deferred.as_ref().filter(|job| is_exit(&job.command.0)).map(|job| job.command.0.clone()); + if pending.is_some() { + self.deferred = None; + } + let queued = self.deferred_queue.iter().find(|(id, _)| is_exit(id)).map(|(id, _)| id.clone()); + self.deferred_queue.retain(|(id, _)| !is_exit(id)); + pending.or(queued) + } + /// Run a command that may recompute much of the history, off the window thread when it takes /// a while (see the module notes). `Ok(Null)` when it is left computing or waiting its turn: /// its outcome then shows as any command's does (an error in the status line). @@ -140,6 +153,9 @@ impl SolveApp { if id == "timeline.play" { return self.run(id, params); } + if matches!(id, "edit.undo" | "edit.redo" | "timeline.roll_to") { + self.close_editor_for_history()?; + } // A manual history action takes over from playback. Playback takes its state out // while submitting its own next step, then puts it back to wait for that step. crate::timeline::stop_playback(self); diff --git a/crates/ui-egui/src/design_properties.rs b/crates/ui-egui/src/design_properties.rs index c20fe7108e..1cdd5d3ca2 100644 --- a/crates/ui-egui/src/design_properties.rs +++ b/crates/ui-egui/src/design_properties.rs @@ -546,6 +546,7 @@ mod tests { assert_ne!(english.text(k.label), k.label, "`{}`'s label is in the English catalog", k.name); } let mut h = Harness::new(); + h.app.preferences.language = "en".into(); // A key a newer SolidCraft might read: kept as another tool's data, never labelled as one of ours. h.app.session.doc_mut().metadata.0.insert("future_key".into(), json!("kept")); h.step(&json!({"start": "document.metadata"})).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a69fde8ada..537d6c4a5f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -125,6 +125,11 @@ crate::dialog_descriptor::dialog_descriptors! { FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { angle, .. } }) { angle: String = "45 deg".into() => shown_expr(angle, ValueKind::Angle), } => json!({"angle": angle}); + // With a reference plane, the plane touches where the face turns towards it (#8822). + TangentPlane("construct.plane.tangent", "TANGENT PLANE", [("Face", FACES, false), ("Reference plane", PLANES | PLANAR_FACES, false)], + FeatureKind::ConstructionPlane { plane: PlaneRef::Tangent { angle, .. } }) { + angle: String = "0 deg".into() => angle.as_ref().map_or_else(|| "0 deg".to_string(), ToString::to_string), + } => json!({"angle": angle}); Scale("solid.scale", "SCALE", [("Bodies", BODIES, true)], FeatureKind::Scale { factor, .. }) { factor: String = "2".into() => factor.to_string(), @@ -1120,6 +1125,19 @@ impl Dialog { continue; } } + // Move/Copy keeps a selected component as the component, so Create Copy can still be + // ticked after it (the bodies are what a move without a copy moves). + if matches!(d.kind, Kind::Move { .. }) + && let Sel::Occurrence { path } = sel + && !path.is_empty() + && let Some(inp) = d.inputs.first_mut() + && inp.accept == BODIES + { + if !inp.items.contains(sel) { + inp.items.push(sel.clone()); + } + continue; + } let as_body = match sel { Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.to_string() }), _ => None, @@ -1316,6 +1334,7 @@ impl Dialog { | Kind::Loft { .. } | Kind::Sweep { .. } | Kind::AnglePlane { .. } + | Kind::TangentPlane { .. } | Kind::PathPlane { .. } | Kind::Midplane | Kind::Construct { .. } @@ -4783,6 +4802,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S }; ("construct.plane.along_path", json!({"path": path, "t": t})) } + Kind::TangentPlane { .. } => { + need(0, "a curved face")?; + let face = construct_ref(app, sels(d, 0).first().ok_or("pick a curved face")?)?; + match sels(d, 1).first() { + Some(reference) => ("construct.plane.tangent", json!({"face": face, "reference": plane_arg(app, Some(reference))?})), + // A null `angle` keeps the descriptor's angle out: it turns the plane from a reference. + None => ("construct.plane.tangent", json!({"face": face, "angle": null})), + } + } Kind::AnglePlane { .. } => { need(0, "a plane")?; let base = plane_arg(app, sels(d, 0).first())?; @@ -5647,7 +5675,6 @@ fn construct_picks(id: &str) -> Option<(&'static str, &'static str, &'static [(& "PLANE THROUGH THREE POINTS", &[("First point", POINT, "a"), ("Second point", POINT, "b"), ("Third point", POINT, "c")], ), - "construct.plane.tangent" => ("construct.plane.tangent", "TANGENT PLANE", &[("Face", FACES, "face")]), "construct.plane.perpendicular" => { ("construct.plane.perpendicular", "PERPENDICULAR PLANE", &[("Plane", PLANE, "plane"), ("Line", LINE, "line")]) } @@ -6058,6 +6085,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option { + // Evaluation projects the stored point onto the face along the drill + // direction when the body moves; seed the Face pick there too (#10689). + // Keep `position_exprs.point` as the stored value so expressions stay only + // when the pick is still that point (a moved face drops them for numbers). + let on_face = solidcraft_engine::doc::hole_on_surface(&s.world_state(), position.0, direction.0, &[]); if let Some(inp) = d.inputs.first_mut() { - inp.items = vec![face_sel(s, position.0).unwrap_or(Sel::Face { body: String::new(), index: 0, point: position.0 })]; + // Editing a Single hole places it at exactly one point: a pick replaces. + inp.multi = false; + inp.items = vec![face_sel(s, on_face).unwrap_or(Sel::Face { body: String::new(), index: 0, point: on_face })]; } d.extra.insert("direction".into(), pt3(direction.0)); // A position given as expressions (#1678) is sent again while the pick // stays where the hole is; a new pick places it by the point picked. - if let Some(at) = at { - let exprs: Vec<&str> = at.iter().map(|e| e.as_str()).collect(); + if let Some(exprs) = at { + let exprs: Vec<&str> = exprs.iter().map(|e| e.as_str()).collect(); d.extra.insert("position_exprs".into(), json!({"at": exprs, "point": pt3(position.0)})); } crate::hole_refs::restore(s, &mut d, references, &f.edge_names, app.preferences.length_decimals); @@ -6347,11 +6383,15 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option().is_ok_and(|v| v.abs() < 1e-9)); + let origin: Vec = + origin.iter().map(|e| e.trim().parse::().map_or_else(|_| json!(e.to_string()), |v| json!(v))).collect(); let pick = match (axis_ref, axis_feature) { (Some(g), _) => model_axis_sel(s, g), (None, Some(name)) => Some(Sel::Axis { name: name.to_string() }), - (None, None) => world_axis(axis.0).filter(|_| origin.len() < 1e-9).map(|name| Sel::Axis { name }), + (None, None) => world_axis(axis.0).filter(|_| at_zero).map(|name| Sel::Axis { name }), }; match (pick, axis_ref) { (Some(pick), _) => { @@ -6364,7 +6404,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { - d.extra.insert("axis".into(), json!({"origin": pt3(origin.0), "dir": pt3(axis.0)})); + d.extra.insert("axis".into(), json!({"origin": origin, "dir": pt3(axis.0)})); } } d @@ -6504,6 +6544,17 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + let mut d = start("construct.plane.tangent")?; + d.kind = Kind::simple_restore(&f.kind)?; + if let Some(inp) = d.inputs.first_mut() { + inp.items = geo_sel(s, face).into_iter().collect(); + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = reference.as_deref().and_then(|r| geo_sel(s, r)).into_iter().collect(); + } + d + } FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a, b } } => { let mut d = start("construct.plane.midplane")?; if let Some(inp) = d.inputs.first_mut() { @@ -7080,6 +7131,42 @@ mod refused_ok_tests { let bore = h.app.session.execute("timeline.inspect", &json!({"feature": "Bore"})).unwrap(); assert!(bore.to_string().contains("\"depth\":\"5\""), "{bore}"); } + + /// After a parameter moves the drilled face, Edit Hole seeds the face where the hole is + /// now — not the stored raw position above or inside the body (#10689). + #[test] + fn editing_a_hole_after_its_face_moves_seeds_the_face() { + let mut h = Harness::new(); + steps( + &mut h, + &[ + json!({"call": "engine.execute", "params": {"command": "parameters.add", "params": {"name": "t", "expression": "10 mm"}}}), + json!({"call": "engine.execute", "params": {"command": "solid.box", "params": {"name": "Base", "body_name": "Base", "length": 40, "width": 30, "height": "t"}}}), + json!({"call": "engine.execute", "params": {"command": "solid.hole", "params": {"name": "Hole1", "position": [30, 15, 10], "diameter": 5, "depth": 4}}}), + json!({"call": "engine.execute", "params": {"command": "parameters.change", "params": {"name": "t", "expression": "8 mm"}}}), + json!({"call": "ui.editFeature", "params": {"feature": "Hole1"}}), + ], + ); + assert!(h.app.dialog.as_ref().is_some_and(|d| d.editing.is_some())); + let face = h.app.dialog.as_ref().and_then(|d| d.inputs.first()).and_then(|i| i.items.first()); + let Some(Sel::Face { point, body, .. }) = face else { + panic!("Edit Hole seeds a face pick, got {face:?}"); + }; + assert!(!body.is_empty(), "the face has a body name"); + assert!((point.z - 8.0).abs() < 1e-3, "seeded on the face at z=8, got {point:?}"); + h.frames(2); + assert_eq!(shown(&mut h), None, "no off-face refusal"); + if let Some(Kind::Hole { diameter, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *diameter = "6 mm".into(); + } + h.frames(2); + assert_eq!(shown(&mut h), None); + steps(&mut h, &[json!({"key": "Enter"})]); + assert!(h.app.dialog.is_none()); + let vol = h.app.session.execute("inspect.measure", &json!({})).unwrap(); + let volume = vol["total"]["volume_mm3"].as_f64().expect("volume"); + assert!((volume - 9486.90).abs() < 0.1, "Ø6 hole on the moved face: {volume}"); + } } /// Whether two picks are the same face of the same body (wherever on it they were made). @@ -7360,6 +7447,34 @@ mod construct_ask_tests { assert!(status_error(&h.app).is_none(), "{:?}", status_error(&h.app)); assert!(!solidcraft_engine::view::construction_planes(&h.app.session).is_empty(), "the tangent plane is created"); } + + /// #8822: a reference plane and an angle place the tangent plane, and editing it brings both back. + #[test] + fn tangent_plane_lines_up_with_a_reference_plane() { + let mut h = Harness::new(); + cylinder(&mut h); + h.step(&json!({"click": {"world": [10, 0, 10]}})).unwrap(); // the outer cylindrical face + h.step(&json!({"start": "construct.plane.tangent"})).unwrap(); + h.frames(2); + let d = h.app.dialog.as_mut().expect("the dialog opens"); + assert_eq!(d.inputs.iter().map(|i| i.label).collect::>(), ["Face", "Reference plane"]); + d.inputs[1].items = vec![Sel::Plane { name: "XZ".into() }]; + if let Kind::TangentPlane { angle } = &mut d.kind { + *angle = "30 deg".into(); + } + h.step(&json!({"key": "Enter"})).unwrap(); + h.frames(2); + assert!(h.app.dialog.is_none(), "Create closes the dialog: {:?}", status_error(&h.app)); + let planes = solidcraft_engine::view::construction_planes(&h.app.session); + let (id, _, pl) = planes.last().expect("the tangent plane is created"); + // XZ faces ±Y; turned 30° about the axis (Z), on the side of the pick (+X). + let n = Vec3::new(0.5, -(3f64.sqrt()) / 2.0, 0.0); + assert!(pl.normal().cross(n).len() < 1e-6 && (pl.origin - n * 10.0).dot(n).abs() < 1e-6, "{pl:?}"); + let reopened = for_feature(&h.app, *id, None).expect("the plane opens for editing"); + assert!(matches!(&reopened.kind, Kind::TangentPlane { angle } if angle == "30 deg"), "{:?}", reopened.kind); + assert_eq!(reopened.inputs[1].items, vec![Sel::Plane { name: "XZ".into() }]); + assert_eq!(reopened.inputs[0].items.len(), 1, "the face is picked again"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs_all_tests.rs b/crates/ui-egui/src/dialogs_all_tests.rs index 4cd548cc5b..8a9cc93167 100644 --- a/crates/ui-egui/src/dialogs_all_tests.rs +++ b/crates/ui-egui/src/dialogs_all_tests.rs @@ -1251,6 +1251,30 @@ fn ok_on_a_rolled_back_pattern_editor_says_it_is_not_built() { assert!(text.starts_with("Pattern changed, but it is after the history marker"), "{text}"); } +/// #10852: the editor of a circular pattern whose axis origin is length expressions sends them +/// back as they are, so applying it unchanged keeps the pattern following its parameters. +#[test] +fn editing_a_circular_pattern_keeps_its_origin_expressions() { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, params) in [ + ("parameters.add", json!({"name":"cx","expression":"20 mm"})), + ("solid.box", json!({"length":40,"width":30,"height":10,"corner":[0,0,0],"body_name":"Plate"})), + ("solid.cylinder", json!({"radius":2,"height":20,"base":["cx - 10 mm",15,-5],"operation":"cut","targets":["Plate"],"name":"Cut"})), + ("solid.pattern.circular", json!({"features":["Cut"],"axis":{"origin":["cx",15,0],"dir":[0,0,1]},"count":2,"name":"Pattern"})), + ] { + h.app.run(id, params).unwrap(); + } + let pattern = h.app.session.doc.features.last().unwrap().id; + let origin = |h: &Harness| serde_json::to_value(&h.app.session.doc.feature(pattern).unwrap().kind).unwrap()["pattern"]["origin"].clone(); + assert_eq!(origin(&h), json!(["cx", 15.0, 0.0])); + let d = crate::dialogs::for_feature(&h.app, pattern, None).unwrap(); + for (command, params) in crate::dialogs::apply_commands(&h.app, &d).unwrap() { + h.app.run(&command, params).unwrap(); + } + assert_eq!(origin(&h), json!(["cx", 15.0, 0.0]), "the expression is kept, not its value now"); +} + /// The Circular Pattern dialog must show only one field active at a time (#10066): the pick /// field's accent ring yields while a text field holds the keyboard, and clicking a pick field /// takes the keyboard back. diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 22e52c9ec7..5110404de2 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -90,6 +90,7 @@ const MOVES: &[&str] = &[ #[derive(Clone, Debug)] pub enum Asm { + FromBodies, NewComponent { name: String, activate: bool, @@ -196,6 +197,7 @@ impl JointForm { impl Asm { pub fn title(&self) -> &'static str { match self { + Asm::FromBodies => "cmd.component_from_bodies", Asm::NewComponent { .. } => "NEW COMPONENT", Asm::Joint(j) if j.editing.is_some() => "EDIT JOINT", Asm::Joint(j) if j.as_built => "AS-BUILT JOINT", @@ -224,6 +226,7 @@ impl Asm { pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let s = &app.session; let (asm, inputs) = match id { + "component.from_bodies" => (Asm::FromBodies, vec![SelInput::new("Bodies", BODIES, true)]), "component.create" => (Asm::NewComponent { name: String::new(), activate: true }, vec![SelInput::new("Bodies (optional)", BODIES, true)]), "joint.create" => (Asm::Joint(Box::new(JointForm::new(false, &app.session.doc))), side_inputs()), "joint.as_built" => ( @@ -669,6 +672,16 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se let mut enter = false; let problem = joint_problem(app, k, inputs); match k { + Asm::FromBodies => { + ui.add(egui::Label::new(app.tr("ui.components_from_bodies_hint")).wrap()); + if crate::ui_kit::button(ui, app.tr("ui.select_all"), crate::ui_kit::Button::Normal).clicked() + && let Some(input) = inputs.first_mut() + { + // Transfers take definition names, rather than the names of repeated placements. + input.items = app.session.model.state().bodies.iter().map(|body| Sel::Body { name: body.name.clone() }).collect(); + *active = 0; + } + } Asm::NewComponent { name, activate } => { enter |= text_row(ui, "Name", name, crate::i18n::tr_label("Component1")).0; crate::ui_kit::options(ui, "component_options", &mut [("Activate", activate)]); @@ -1073,6 +1086,13 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result { + let bodies = sel_items(inputs, 0).body_names(); + if bodies.is_empty() { + return Err(crate::i18n::tr("ui.components_from_bodies_required").into()); + } + cmd("component.from_bodies", json!({ "bodies": bodies })) + } Asm::NewComponent { name, activate } => { let bodies: Vec = sel_items(inputs, 0).body_names(); if bodies.is_empty() { diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 691ebb3449..04510a3cf6 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -1179,3 +1179,55 @@ fn inch_joint_offsets_limits_and_drive_values_keep_their_units() { let Kind::Assembly(Asm::Joint(f)) = edit.kind else { panic!("no edit") }; assert_eq!((f.limits[0].1.as_str(), f.limits[0].2.as_str()), ("0 in", "0.5 in")); } + +/// The Assemble ribbon must open a body picker, without executing the JSON command yet. +#[test] +fn components_from_bodies_dialog_select_all_cancel_and_ok() { + let mut h = crate::scenario::Harness::new(); + for x in [0, 30] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap(); + } + let before = h.app.session.doc.clone(); + let names: Vec<_> = h.app.session.model.state().bodies.iter().map(|body| body.name.to_string()).collect(); + h.step(&json!({"start":"component.from_bodies"})).unwrap(); + let d = h.app.dialog.as_ref().expect("From Bodies opens a dialog"); + assert!(matches!(d.kind, Kind::Assembly(Asm::FromBodies))); + assert!(apply_commands(&h.app, d).is_err(), "OK requires at least one body"); + assert_eq!(h.app.session.doc, before); + h.step(&json!({"widget":crate::i18n::tr("ui.select_all")})).unwrap(); + let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(commands, vec![("component.from_bodies".into(), json!({"bodies":names}))]); + if let Ok(path) = std::env::var("SOLIDCRAFT_COMPONENTS_DIALOG_EVIDENCE") { + let ctx = crate::fonts::test_context(); + let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut textures = Default::default(); + for frame in 0..6 { + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), ..Default::default() }, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + }); + crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta); + output.textures_delta.clear(); + if frame == 5 { + crate::ui_kit::test_raster(&ctx, output.shapes, output.pixels_per_point, bounds, &textures).save(&path).unwrap(); + } + } + } + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.doc, before, "Cancel makes no components"); + // Existing body picks seed the same standard multi-selection input. + h.app.session.selection = vec![Sel::Body { name: names[0].clone() }]; + h.step(&json!({"start":"component.from_bodies"})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items.body_names(), vec![names[0].clone()]); + h.step(&json!({"widget":crate::i18n::tr("ui.select_all")})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none()); + for name in &names { + let component = h.app.session.doc.find_component(name).expect("one component per body"); + assert_eq!(h.app.session.doc.model_body_component(h.app.session.model.state().body(name).unwrap()), component); + } + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc, before, "the dialog applies one undoable command"); +} diff --git a/crates/ui-egui/src/dialogs_dense.rs b/crates/ui-egui/src/dialogs_dense.rs index da8f7b5722..72198eb7cc 100644 --- a/crates/ui-egui/src/dialogs_dense.rs +++ b/crates/ui-egui/src/dialogs_dense.rs @@ -86,6 +86,7 @@ pub(super) fn command(kind: &Kind) -> Option<&'static str> { _ => k.simple_metadata().map(|(command, _)| command), }, Kind::Assembly(k) => match k { + crate::dialogs_assembly::Asm::FromBodies => Some("component.from_bodies"), crate::dialogs_assembly::Asm::NewComponent { .. } => Some("component.create"), crate::dialogs_assembly::Asm::Joint(f) => Some(if f.as_built { "joint.as_built" } else { "joint.create" }), crate::dialogs_assembly::Asm::JointOrigin { .. } => Some("joint.origin"), @@ -750,6 +751,12 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo Kind::AnglePlane { angle } => { enter |= expr_row(ui, "Angle", angle); } + // The angle turns the plane from its reference, so it shows once one is picked. + Kind::TangentPlane { angle } => { + if d.inputs.get(1).is_some_and(|inp| !inp.items.is_empty()) { + enter |= expr_row(ui, "Angle", angle); + } + } Kind::PathPlane { t, .. } => { enter |= expr_row(ui, "Position (0 to 1)", t); } diff --git a/crates/ui-egui/src/dialogs_move.rs b/crates/ui-egui/src/dialogs_move.rs index 87a3904e2f..5dabbab22e 100644 --- a/crates/ui-egui/src/dialogs_move.rs +++ b/crates/ui-egui/src/dialogs_move.rs @@ -98,7 +98,7 @@ pub(crate) fn chained(app: &SolveApp) -> Option<(Vec, Mat)> { (Some(s), None) => s, (None, c) => c?, }; - (!is_identity(&m)).then(|| (items(d, 0).body_names(), m)) + (!is_identity(&m)).then(|| (bodies_of(app, d), m)) } /// Whether the triad is being dragged in a Move dialog (`drag` keeps the values it began from). @@ -117,7 +117,7 @@ fn made(app: &SolveApp, d: &Dialog) -> Option { /// Where a point a pick keeps on moved body `body` (see [`from_shown`]) shows: moved on by the /// current type's own move. pub(crate) fn shown_point(app: &SolveApp, body: &str, p: Vec3) -> Vec3 { - let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Move { .. }) && items(d, 0).body_names().iter().any(|n| n == body)) else { + let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Move { .. }) && bodies_of(app, d).iter().any(|n| n == body)) else { return p; }; made(app, d).map_or(p, |m| apply_point(&m, p)) @@ -128,7 +128,7 @@ pub(crate) fn shown_point(app: &SolveApp, body: &str, p: Vec3) -> Vec3 { /// own move. Kept there, a pivot or From point picked mid-move doesn't move with the move it /// sets. pub(crate) fn from_shown(app: &SolveApp, sel: Sel) -> Sel { - let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Move { .. }) && on_moved(d, &sel)) else { return sel }; + let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Move { .. }) && on_moved(&bodies_of(app, d), &sel)) else { return sel }; let Some(back) = made(app, d).and_then(|m| mat_inverse(&m)) else { return sel }; match sel { Sel::Edge { body, index, point } => Sel::Edge { body, index, point: apply_point(&back, point) }, @@ -138,14 +138,86 @@ pub(crate) fn from_shown(app: &SolveApp, sel: Sel) -> Sel { } } -/// Whether a pick is on one of the bodies being moved. -fn on_moved(d: &Dialog, s: &Sel) -> bool { +/// Whether a pick is on one of the bodies being moved (`moved`, see [`bodies_of`]). +fn on_moved(moved: &[String], s: &Sel) -> bool { match s { - Sel::Edge { body, .. } | Sel::Face { body, .. } | Sel::Vertex { body, .. } => items(d, 0).body_names().contains(body), + Sel::Edge { body, .. } | Sel::Face { body, .. } | Sel::Vertex { body, .. } => moved.contains(body), _ => false, } } +/// The bodies the picks stand for: the bodies picked, and the bodies of each picked component +/// (the bodies its placement shows, as a Browser row lists them). +fn bodies_of(app: &SolveApp, d: &Dialog) -> Vec { + let mut out: Vec = Vec::new(); + for sel in items(d, 0) { + let names = match sel { + Sel::Body { name } => vec![name.clone()], + Sel::Occurrence { path } => app.session.occurrence_bodies(path, true).unwrap_or_default(), + _ => Vec::new(), + }; + for name in names { + if !out.contains(&name) { + out.push(name); + } + } + } + out +} + +/// Whether Create Copy is ticked. +fn copies(d: &Dialog) -> bool { + d.extra.get("copy").and_then(Value::as_bool).unwrap_or(false) +} + +/// Whether a Move dialog takes the components picked (not their bodies) into its bodies input: a +/// click on a component in the component pick mode, or a Browser row. Not when it edits a move +/// that is already a feature (its picks stay bodies). Without Create Copy the components still +/// move their bodies (`targets`). +pub(crate) fn picks_components(app: &SolveApp) -> bool { + app.dialog.as_ref().is_some_and(|d| matches!(d.kind, Kind::Move { .. }) && d.active == 0 && d.editing.is_none()) +} + +/// What a click picks in a Move dialog: with the component pick mode on, the component a body +/// belongs to (copied as a component with Create Copy, see `targets`), otherwise the pick as it was. +pub(crate) fn component_pick(app: &SolveApp, hit: &crate::viewport::Hit, sel: Sel) -> Sel { + let (crate::viewport::Hit::Face { body, .. }, Sel::Body { .. }) = (hit, &sel) else { return sel }; + if app.ui.pick_components && picks_components(app) { + let occurrence = crate::viewport::component_selection(app, body); + if matches!(occurrence, Sel::Occurrence { .. }) { + return occurrence; + } + } + sel +} + +/// What the move moves and copies, as solid.move takes them: the bodies, and with Create Copy the +/// components picked (copied as components, see `occurrences`). +fn targets(app: &SolveApp, d: &Dialog) -> (Vec, Vec>) { + if !copies(d) { + return (bodies_of(app, d), Vec::new()); + } + let components = items(d, 0) + .iter() + .filter_map(|s| match s { + Sel::Occurrence { path } => Some(path.clone()), + _ => None, + }) + .collect(); + (items(d, 0).body_names(), components) +} + +/// The solid.move parameters that name what the move moves (`bodies`, and `occurrences` when +/// there are components to copy). +fn targets_json(app: &SolveApp, d: &Dialog) -> Value { + let (bodies, components) = targets(app, d); + let mut p = json!({ "bodies": bodies }); + if !components.is_empty() { + p["occurrences"] = json!(components); + } + p +} + /// A point of the model before this move where the earlier types' move puts it: on a moved body /// (`moved`) or, for the bodies' centre, always. fn carried(d: &Dialog, moved: bool, p: Vec3) -> Vec3 { @@ -193,7 +265,7 @@ fn sel_point(app: &SolveApp, d: &Dialog, s: Option<&Sel>) -> Option { None => app.session.world_state(), }; // The centre is found where the body was modelled; the picked point is where it stands. - Some(crate::dialogs::edge_circle_in(&st, body, *index).map_or(*point, |(c, ..)| carried(d, on_moved(d, s), c))) + Some(crate::dialogs::edge_circle_in(&st, body, *index).map_or(*point, |(c, ..)| carried(d, on_moved(&bodies_of(app, d), s), c))) } Sel::Vertex { point, .. } | Sel::Face { point, .. } => Some(*point), _ => None, @@ -211,7 +283,7 @@ fn bodies_center(app: &SolveApp, d: &Dialog) -> Option { None => app.session.model.state(), }; let mut bb: Option<(Vec3, Vec3)> = None; - for n in items(d, 0).body_names() { + for n in bodies_of(app, d) { let Some(b) = st.body(&n) else { continue }; let x = b.mesh().bounds(); bb = Some(match bb { @@ -271,7 +343,7 @@ pub fn params(app: &SolveApp, d: &Dialog) -> Result { let made = transform(app, &p)?; with_transform(p, &mat_mul(&made, &c)) } - (Step::Needs(_), Some(c)) => with_transform(json!({ "bodies": items(d, 0).body_names() }), &c), + (Step::Needs(_), Some(c)) => with_transform(targets_json(app, d), &c), }; if d.extra.get("copy").and_then(Value::as_bool).unwrap_or(false) { p["copy"] = json!(true); @@ -323,13 +395,13 @@ fn with_transform(mut p: Value, m: &Mat) -> Value { /// The current type's own move, from where the earlier types leave the bodies. fn step(app: &SolveApp, d: &Dialog) -> Result { let Kind::Move { mode, x, y, z, rx, ry, rz, angle } = &d.kind else { return Err("not a move".into()) }; - let bodies = items(d, 0).body_names(); - if bodies.is_empty() { + let (bodies, components) = targets(app, d); + if bodies.is_empty() && components.is_empty() { return Err("select bodies first".into()); } let need_point = |i: usize, what: &str| sel_point(app, d, items(d, i).first()).ok_or(format!("select {what} first")); let num = |t: Vec3| json!([mm(t.x), mm(t.y), mm(t.z)]); - let mut p = json!({ "bodies": bodies }); + let mut p = targets_json(app, d); match *mode { TRANSLATE => p["translate"] = json!([x, y, z]), FREE => { @@ -398,7 +470,7 @@ fn step(app: &SolveApp, d: &Dialog) -> Result { fn axis_line(app: &SolveApp, d: &Dialog, s: &Sel) -> Option<(Vec3, Vec3)> { let (o, a) = modelled_axis(app, s)?; match chain(d) { - Some(c) if on_moved(d, s) => Some((apply_point(&c, o), apply_vector(&c, a))), + Some(c) if on_moved(&bodies_of(app, d), s) => Some((apply_point(&c, o), apply_vector(&c, a))), _ => Some((o, a)), } } diff --git a/crates/ui-egui/src/dialogs_move_tests.rs b/crates/ui-egui/src/dialogs_move_tests.rs index 1657d64c25..eecd6b944f 100644 --- a/crates/ui-egui/src/dialogs_move_tests.rs +++ b/crates/ui-egui/src/dialogs_move_tests.rs @@ -643,3 +643,109 @@ fn editing_a_move_about_a_world_axis_keeps_the_angle_expression() { let Kind::Move { rx, ry, rz, .. } = &d.kind else { panic!("not a move") }; assert_eq!((rx.as_str(), ry.as_str(), rz.as_str()), ("0 deg", "0 deg", "45 deg")); } + +/// A design with a component `Part` holding a 20 mm cube at the origin, seen from above, with no +/// dialog open. Returns the harness and the part's placement. +fn part_harness() -> (crate::scenario::Harness, u64) { + let mut h = crate::scenario::Harness::new(); + let made = h.app.run("component.create", json!({"name": "Part", "grounded": false})).unwrap(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "Peg"})).unwrap(); + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.ui.show_browser = false; + h.app.ui.show_timeline = false; + h.app.set_origin_shown(false); + h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap(); + (h, made["occurrence"].as_u64().unwrap()) +} + +/// Opens Move/Copy with the viewport in component (`components`) or body pick mode, Create Copy +/// ticked or not, then clicks the top of the cube. +fn click_cube_in_move(components: bool, copy: bool) -> (crate::scenario::Harness, u64) { + let (mut h, occurrence) = part_harness(); + h.app.start("solid.move"); + h.settle(); + h.app.ui.pick_components = components; + h.app.ui.pick_bodies = !components; + if copy { + h.app.dialog.as_mut().unwrap().extra.insert("copy".into(), json!(true)); + } + h.step(&json!({"click": {"world": [10, 10, 20]}})).unwrap(); + (h, occurrence) +} + +#[test] +fn a_component_picked_with_create_copy_is_moved_as_a_component_copy() { + let (h, occurrence) = click_cube_in_move(true, true); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items, vec![Sel::Occurrence { path: vec![occurrence] }]); + let p = crate::dialogs_move::params(&h.app, d).unwrap(); + assert_eq!(p["occurrences"], json!([[occurrence]])); + assert_eq!(p["bodies"], json!([])); + assert_eq!(p["copy"], json!(true)); +} + +#[test] +fn a_body_picked_with_create_copy_is_still_a_body_copy() { + let (h, _) = click_cube_in_move(false, true); + let d = h.app.dialog.as_ref().unwrap(); + assert!(matches!(d.inputs[0].items.as_slice(), [Sel::Body { .. }]), "{:?}", d.inputs[0].items); + let p = crate::dialogs_move::params(&h.app, d).unwrap(); + assert_eq!(p["bodies"].as_array().map(Vec::len), Some(1)); + assert!(p.get("occurrences").is_none()); + assert_eq!(p["copy"], json!(true)); +} + +#[test] +fn a_component_picked_without_create_copy_moves_its_bodies_as_before() { + let (h, occurrence) = click_cube_in_move(true, false); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items, vec![Sel::Occurrence { path: vec![occurrence] }]); + let p = crate::dialogs_move::params(&h.app, d).unwrap(); + assert_eq!(p["bodies"].as_array().map(Vec::len), Some(1), "the component's body moves"); + assert!(p.get("occurrences").is_none()); + assert!(p.get("copy").is_none()); +} + +#[test] +fn a_component_picked_before_create_copy_is_ticked_is_copied_as_a_component() { + let (mut h, occurrence) = click_cube_in_move(true, false); + h.app.dialog.as_mut().unwrap().extra.insert("copy".into(), json!(true)); + let p = crate::dialogs_move::params(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(p["occurrences"], json!([[occurrence]])); + let placements = h.app.session.doc.occurrences.len(); + h.app.session.execute("solid.move", &p).unwrap(); + assert_eq!(h.app.session.doc.occurrences.len(), placements + 1, "OK copies the component"); +} + +#[test] +fn editing_a_move_never_takes_a_component_pick() { + // An existing move of another body is edited, and the component is clicked in component mode. + let (mut h, _) = part_harness(); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0], "body_name": "Plate"})).unwrap(); + let moved = h.app.run("solid.move", json!({"bodies": ["Plate"], "translate": [0, 0, 5]})).unwrap()["feature"].as_u64().unwrap(); + h.app.dialog = Some(crate::dialogs::for_feature(&h.app, moved, None).unwrap()); + h.settle(); + h.app.ui.pick_components = true; + h.app.ui.pick_bodies = false; + h.step(&json!({"click": {"world": [10, 10, 20]}})).unwrap(); + let items = h.app.dialog.as_ref().unwrap().inputs[0].items.clone(); + assert!(items.iter().any(|s| matches!(s, Sel::Body { name } if name == "Peg")), "the click picks the body: {items:?}"); + assert!(!items.iter().any(|s| matches!(s, Sel::Occurrence { .. })), "an edited move's picks stay bodies: {items:?}"); +} + +#[test] +fn selected_components_are_copied_when_create_copy_is_ticked_after_them() { + // One selected placement starts the Browser's own component move, so two are selected here. + let (mut h, first) = part_harness(); + let second = h.app.run("occurrence.copy", json!({"occurrence": first, "translate": [50, 0, 0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.session.selection = vec![Sel::Occurrence { path: vec![first] }, Sel::Occurrence { path: vec![second] }]; + h.app.start("solid.move"); + let picked = vec![Sel::Occurrence { path: vec![first] }, Sel::Occurrence { path: vec![second] }]; + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, picked); + let p = crate::dialogs_move::params(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(p["bodies"].as_array().map(Vec::len), Some(2), "without a copy the components' bodies move"); + assert!(p.get("occurrences").is_none()); + h.app.dialog.as_mut().unwrap().extra.insert("copy".into(), json!(true)); + let p = crate::dialogs_move::params(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(p["occurrences"], json!([[first], [second]])); +} diff --git a/crates/ui-egui/src/dialogs_part_size.rs b/crates/ui-egui/src/dialogs_part_size.rs index 16d5c9ecb6..2678b76434 100644 --- a/crates/ui-egui/src/dialogs_part_size.rs +++ b/crates/ui-egui/src/dialogs_part_size.rs @@ -18,12 +18,16 @@ pub(crate) fn configuration(app: &SolveApp, component: u64) -> Option { if size.is_empty() { return None; } - Some(configuration_name(size, part.length)) + Some(configuration_name(&part.family, size, part.length)) } -fn configuration_name(size: &str, length: Option) -> String { +/// An inch family's length is in fractional inches (`1/2-13 UNC×2 in`), a metric one's in mm. +fn configuration_name(family: &str, size: &str, length: Option) -> String { match length.filter(|length| length.is_finite() && *length > 0.0) { - Some(length) => format!("{size}×{length}"), + Some(length) => match solidcraft_engine::cmd::part_length_label(family, length) { + Some(label) => format!("{size}×{label}"), + None => format!("{size}×{length}"), + }, None => size.to_owned(), } } @@ -183,14 +187,14 @@ pub fn rows(app: &SolveApp, ui: &mut egui::Ui, k: &mut Resize) -> bool { choice_row(ui, "Size", "part_resize_size", &mut k.size, &sizes); if let Some(lengths) = k.lengths() { let mut index = k.length.and_then(|l| lengths.iter().position(|x| *x == l)).unwrap_or(0); - let labels: Vec = lengths.iter().map(|l| crate::prefs::show_mm(app, *l, 1)).collect(); + let labels: Vec = lengths.iter().map(|l| crate::dialogs_parts::length_label(app, &k.family.id, *l)).collect(); let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); choice_row(ui, "Length", "part_resize_length", &mut index, &labels); k.length = lengths.get(index).copied(); } if let Some((size, _)) = k.family.sizes.get(k.size) { row(ui, "Configuration", |ui| { - ui.label(configuration_name(size, k.length)); + ui.label(configuration_name(&k.family.id, size, k.length)); }); } crate::ui_kit::text(ui, crate::i18n::tr("ui.part_size_shared"), crate::ui_kit::Text::Hint("part_size_shared")); @@ -479,6 +483,10 @@ mod tests { ("set_screw", "M5", Some(10.0)), ("dowel_pin", "6", Some(20.0)), ("bearing", "608", None), + ("hex_cap_screw_inch", "1/2-13 UNC", Some(2.0 * 25.4)), + ("hex_nut_inch", "1/2-13 UNC", None), + ("washer_sae", "1/2", None), + ("washer_uss", "1/2", None), ] { let (app, id) = part(family, size, length); let k = Resize::new(&app, id).unwrap(); @@ -541,6 +549,23 @@ mod tests { assert_eq!(commands[0].1["length"], 20.5); } + /// #10316: an inch part's lengths are fractional inches in a millimetre design too, and the + /// command still takes millimetres. + #[test] + fn part_size_shows_an_inch_part_in_fractional_inches() { + let (app, id) = part("hex_cap_screw_inch", "1/2-13 UNC", Some(1.5 * 25.4)); + assert_eq!(configuration(&app, id).as_deref(), Some("1/2-13 UNC×1-1/2 in")); + let mut h = crate::scenario::Harness::new(); + h.app = app; + h.app.home.open = false; + menu(&mut h.app, Target::Component { id }); + h.frames(3); + assert!(h.widget_near("1-1/2 in", "Length").is_some()); + change(&mut h.app, "5/8-11 UNC", Some(2.0 * 25.4)); + let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap(); + assert_eq!((commands[0].1["size"].as_str(), commands[0].1["length"].as_f64()), (Some("5/8-11 UNC"), Some(50.8))); + } + #[test] fn part_size_real_widgets_change_size_length_and_confirm_once() { let (app, id) = part("socket_head_cap_screw", "M5", Some(20.5)); diff --git a/crates/ui-egui/src/dialogs_parts.rs b/crates/ui-egui/src/dialogs_parts.rs index d6f0c4b593..36e6fd0d1a 100644 --- a/crates/ui-egui/src/dialogs_parts.rs +++ b/crates/ui-egui/src/dialogs_parts.rs @@ -20,6 +20,8 @@ pub struct Family { pub id: String, pub name: String, pub standard: String, + /// `metric` or `inch`: the Unit system list shows the families of one at a time. + pub system: String, /// Size names and their table rows. pub sizes: Vec<(String, Value)>, pub lengths: Option>, @@ -43,6 +45,7 @@ pub fn families(v: &Value) -> Vec { Some(Family { name: f.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(), standard: f.get("standard").and_then(Value::as_str).unwrap_or("").to_string(), + system: f.get("system").and_then(Value::as_str).unwrap_or("metric").to_string(), sizes, lengths: f.get("lengths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()), id, @@ -61,6 +64,9 @@ pub(crate) fn family_name(f: &Family) -> String { } } +/// The Unit system list: each system's id in the library and its label. +const SYSTEMS: [(&str, &str); 2] = [("metric", "Metric"), ("inch", "Inch")]; + #[derive(Clone, Debug)] pub struct Pt { pub family: usize, @@ -120,6 +126,12 @@ pub fn insert_params(lib: &[Family], k: &Pt, holes: &[Sel]) -> Result String { + solidcraft_engine::cmd::part_length_label(family, mm).unwrap_or_else(|| crate::prefs::show_mm(app, mm, 1)) +} + /// Where the Length list opens for a size: the index of the length an insert of it gets when /// none is asked for, which `parts.library` reports beside the size's table values. The list /// carries no item of its own for it, so a size whose default is missing opens on the first. @@ -162,12 +174,34 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu }); return false; } - let names: Vec = - lib.iter().map(|f| if f.standard.is_empty() { family_name(f) } else { format!("{} ({})", family_name(f), f.standard) }).collect(); + // The Unit system list picks metric or inch; Type lists that system's families. The system + // shown is the chosen family's, so a new system moves the choice to its first family. + let current = lib.get(k.family).map_or("metric", |f| f.system.as_str()); + let mut system = SYSTEMS.iter().position(|s| s.0 == current).unwrap_or(0); + let was_system = system; + choice_row(ui, "Unit system", "pt_system", &mut system, &SYSTEMS.map(|s| s.1)); + let system_id = SYSTEMS.get(system).map_or("metric", |s| s.0); + if system != was_system + && let Some(first) = lib.iter().position(|f| f.system == system_id) + { + k.family = first; + k.size = 0; + k.length = None; + } + let shown: Vec = (0..lib.len()).filter(|i| lib.get(*i).is_some_and(|f| f.system == system_id)).collect(); + let names: Vec = shown + .iter() + .filter_map(|i| lib.get(*i)) + .map(|f| if f.standard.is_empty() { family_name(f) } else { format!("{} ({})", family_name(f), f.standard) }) + .collect(); let refs: Vec<&str> = names.iter().map(String::as_str).collect(); - let before = k.family; - choice_row(ui, "Type", "pt_family", &mut k.family, &refs); - if k.family != before { + let mut pick = shown.iter().position(|i| *i == k.family).unwrap_or(0); + let before = pick; + choice_row(ui, "Type", "pt_family", &mut pick, &refs); + if pick != before + && let Some(i) = shown.get(pick) + { + k.family = *i; k.size = 0; k.length = None; } @@ -180,7 +214,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInpu k.length = None; } if let Some(ls) = &f.lengths { - let labels: Vec = ls.iter().map(|l| crate::prefs::show_mm(app, *l, 1)).collect(); + let labels: Vec = ls.iter().map(|l| length_label(app, &f.id, *l)).collect(); let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); let mut i = k.length.unwrap_or_else(|| default_length(f, k.size, ls)); form_row(ui, "Length", |ui| { @@ -302,7 +336,8 @@ mod tests { for f in &l { assert_ne!(family_name(f), f.name, "{}", f.id); } - let unknown = Family { id: "spring".into(), name: "Spring".into(), standard: String::new(), sizes: vec![], lengths: None }; + let unknown = + Family { id: "spring".into(), name: "Spring".into(), standard: String::new(), system: "metric".into(), sizes: vec![], lengths: None }; assert_eq!(family_name(&unknown), "Spring"); } @@ -339,6 +374,38 @@ mod tests { assert!(insert_params(&[], &k, &[]).is_err()); } + /// #10316: the Unit system list above Type picks metric or inch, and Type then lists that + /// system's families only. + #[test] + fn the_system_list_picks_the_families_type_shows() { + let chosen = |h: &crate::scenario::Harness| { + let Kind::Part(k) = &h.app.dialog.as_ref().unwrap().kind else { panic!("not Insert Part") }; + commands(&h.app, k, &[]).unwrap()[0].1.clone() + }; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.start("parts.insert"); + h.frames(3); + assert!(h.widget_near("Metric", "Unit system").is_some()); + assert_eq!(chosen(&h)["family"], "socket_head_cap_screw"); + h.step(&json!({"click":{"handle":"dialog:Unit system"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Unit system:Inch"}})).unwrap(); + h.frames(2); + assert_eq!((chosen(&h)["family"].as_str(), chosen(&h)["size"].as_str()), (Some("hex_cap_screw_inch"), Some("1/4-20 UNC"))); + // Type now lists the inch families and no metric one. + h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap(); + assert!(crate::scenario::handle_at("dialog:Type:Plain washer, USS (wide) (ANSI B18.22.1 Type A)").is_some()); + assert!(crate::scenario::handle_at("dialog:Type:Hex nut (ISO 4032)").is_none()); + h.step(&json!({"click":{"handle":"dialog:Type:Hex nut (inch) (ASME B18.2.2)"}})).unwrap(); + h.frames(2); + assert_eq!(chosen(&h)["family"], "hex_nut_inch"); + // Back to metric: the first metric family again. + h.step(&json!({"click":{"handle":"dialog:Unit system"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Unit system:Metric"}})).unwrap(); + h.frames(2); + assert_eq!(chosen(&h)["family"], "socket_head_cap_screw"); + } + /// Every dimension the library gives for a size is labelled in words, in each family. #[test] fn every_library_dimension_has_a_label_in_words() { diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 7494c1380f..a417ea143d 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -946,6 +946,31 @@ pub fn pick_reference(ctx: &egui::Context, pos: Pos2, double: bool) -> bool { }) } +/// The gap the value box keeps from the point it belongs to (#11057): a click at the pointer +/// reaches the sketch (to place the text or pick a second entity) instead of the box. +const EDIT_GAP: f32 = 30.0; +/// The box's height as its placement works it out: a name line, the value and a possible error. +const EDIT_HEIGHT: f32 = 80.0; + +/// Where the value box opens: beside the text, or beside the pointer while a value is typed +/// first. Near the viewport's edge it opens on the point's other side rather than slide back +/// over it. +fn edit_anchor(at: Pos2, live: bool, drawable: Option, popup_width: f32) -> Pos2 { + let mut anchor = if live { at + vec2(EDIT_GAP, EDIT_GAP) } else { at - vec2(45.0, 11.0) }; + let Some(rect) = drawable else { return anchor }; + if live { + if anchor.x + popup_width > rect.right() - 6.0 { + anchor.x = at.x - popup_width - EDIT_GAP; + } + if anchor.y + EDIT_HEIGHT > rect.bottom() - 6.0 { + anchor.y = at.y - EDIT_HEIGHT - EDIT_GAP; + } + } + anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left())); + anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - EDIT_HEIGHT).max(rect.top())); + anchor +} + fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut e) = EDIT.with(|x| x.borrow_mut().take()) else { return }; if e.at.is_none() { @@ -962,19 +987,11 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { let id = egui::Id::new("sc_dim_edit"); let mut done: Option = None; let mut escaped = false; - // A type-first value sits beside its text, clear of the pointer, so a click still reaches - // the sketch (to place the text or pick a second entity). - let mut anchor = if e.live.is_some() { at + vec2(18.0, 10.0) } else { at - vec2(45.0, 11.0) }; - let mut popup_width = 220.0; - if let Some(layout) = &app.viewport.layout - && layout.drawable.is_finite() - && layout.drawable.is_positive() - { - let rect = layout.drawable; - popup_width = (rect.width() - 8.0).clamp(24.0, 220.0); - anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left())); - anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - 80.0).max(rect.top())); - } + // A type-first value sits beside its text, clear of the pointer and of the target a click + // picks next, and it opens on the pointer's other side at the viewport's edge (#11057). + let drawable = app.viewport.layout.as_ref().map(|l| l.drawable).filter(|r| r.is_finite() && r.is_positive()); + let popup_width = drawable.map_or(220.0, |rect| (rect.width() - 8.0).clamp(24.0, 220.0)); + let anchor = edit_anchor(at, e.live.is_some(), drawable, popup_width); // Background: under Preferences and Report a bug (#9016), like canvas value boxes. Container::new(ctx, Surface::Canvas).area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Background).show(ctx, |ui| { Container::popup_frame(ui.style()).fill(Tokens::get().dialog_body).inner_margin(Tokens::get().space_4).show(ui, |ui| { @@ -1113,6 +1130,21 @@ pub fn drawn() -> Vec<(String, Pos2)> { mod tests { use super::*; + /// #11057: a value typed before its target keeps a 30 px gap from the pointer, and near the + /// viewport's edge it opens on the pointer's other side instead of sliding back over it. + #[test] + fn the_value_box_keeps_its_gap_from_the_pointer() { + use egui::pos2; + let view = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)); + let a = edit_anchor(pos2(300.0, 200.0), true, Some(view), 220.0); + assert_eq!(a, pos2(330.0, 230.0), "30 px below and right of the pointer"); + // Its right and bottom edges stop 30 px short of the pointer. + let a = edit_anchor(pos2(960.0, 560.0), true, Some(view), 220.0); + assert_eq!((a.x + 220.0, a.y + 80.0), (930.0, 530.0), "left of and above the pointer"); + // Editing an existing dimension still opens where its text is. + assert_eq!(edit_anchor(pos2(300.0, 200.0), false, Some(view), 220.0), pos2(255.0, 189.0)); + } + #[test] fn plain_values_are_numbers_with_an_optional_unit() { for t in ["10", " 10 mm ", "12.5", "2,5", "-3", "+4", "45 deg", "45°", "1in"] { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index af6cc8be74..f78629a6d4 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -41,7 +41,6 @@ pub struct DocSlot { pub(crate) autosave: Option, pub(crate) big_operation: bool, cam: Camera, - pre_sketch_marker: Option>, pub(crate) pending_feature_edit: Option, pre_sketch_cam: Option, pre_sketch_component: Option, @@ -135,7 +134,6 @@ impl DocSlot { autosave: None, big_operation: false, cam: Camera::default(), - pre_sketch_marker: None, pending_feature_edit: None, pre_sketch_cam: None, pre_sketch_component: None, @@ -241,7 +239,6 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot { autosave: app.autosave.as_mut().and_then(|a| std::mem::replace(&mut a.active, slot.autosave)), big_operation: std::mem::replace(&mut app.big_operation, slot.big_operation), cam: std::mem::replace(&mut app.cam, slot.cam), - pre_sketch_marker: std::mem::replace(&mut app.pre_sketch_marker, slot.pre_sketch_marker), pending_feature_edit: std::mem::replace(&mut app.pending_feature_edit, slot.pending_feature_edit), pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam), pre_sketch_component: std::mem::replace(&mut app.pre_sketch_component, slot.pre_sketch_component), diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index b2a4c4bfbd..1caecbd2b0 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -55,19 +55,49 @@ fn undoing_the_edited_feature_closes_its_editor_and_preserves_redo() { assert!(h.app.dialog.is_none(), "the restored feature can be edited normally"); } +// An editor kept open over an Undo refused its OK and Cancel afterwards, and Redo then lost the +// edited feature's place (#10688): the Undo cancels it first, as starting another tool does. #[test] -fn undo_that_keeps_the_edited_feature_keeps_its_editor() { +fn undo_that_keeps_the_edited_feature_cancels_its_editor_first() { let mut h = make(json!({})); let feature = h.app.session.doc.find_feature("Depth").unwrap().id; h.app.run("solid.box", json!({"corner":[30,0,0],"length":2,"width":2,"height":2})).unwrap(); + let full = h.app.session.doc.clone(); open(&mut h); h.app.start("edit.undo"); h.frames(8); assert!(h.app.session.doc.feature(feature).is_some()); - assert_eq!(h.app.dialog.as_ref().unwrap().editing.map(|(id, _)| id), Some(feature)); - assert!(h.app.editing_state.is_some()); - assert!(h.app.session.feature_edit_sketch.is_some()); + assert!(h.app.dialog.is_none() && h.app.editing_state.is_none()); + assert!(h.app.session.feature_edit_sketch.is_none()); + assert_eq!(h.app.session.doc.features.len(), 2); + assert_eq!(h.app.session.doc.marker, None, "the editor's rollback is put back before the Undo"); assert_eq!(h.app.session.redo.len(), 1); + + h.app.start("edit.redo"); + h.frames(8); + assert_eq!(*h.app.session.doc, *full, "Undo then Redo leaves the design as it was"); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "the feature can be edited again"); + assert!(h.app.status.as_ref().is_none_or(|s| !s.0.contains("changed while the feature editor was open")), "{:?}", h.app.status); +} + +// A History step while an editor is open moved from the editor's rollback and left OK and +// Cancel refusing the design (#10688): the step cancels the editor, then moves from the marker. +#[test] +fn a_history_step_cancels_the_editor_and_steps_from_the_design_marker() { + let mut h = make(json!({})); + h.app.run("solid.box", json!({"corner":[30,0,0],"length":2,"width":2,"height":2})).unwrap(); + open(&mut h); + assert_eq!(h.app.session.doc.marker, Some(1), "Edit Feature rolls back to the extrude"); + crate::timeline::run_transport(&mut h.app, crate::ui_kit::HistoryTransport::Back); + h.frames(8); + assert!(h.app.dialog.is_none() && h.app.editing_state.is_none()); + assert_eq!(h.app.session.doc.marker, Some(2), "one step back from the end, not from the editor's rollback"); + crate::timeline::run_transport(&mut h.app, crate::ui_kit::HistoryTransport::Last); + h.frames(8); + assert_eq!(h.app.session.doc.marker, None); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 10.0); } #[test] diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs index 60e7897fed..00ee8812ba 100644 --- a/crates/ui-egui/src/history_off_thread_tests.rs +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -316,6 +316,47 @@ fn switching_sketches_keeps_the_original_marker_with_background_rebuilds() { } } +#[test] +fn finishing_a_sketch_in_the_background_restores_the_design_and_its_undo_snapshot() { + let (mut h, sketches) = sketches_with_later_bodies(); + let before = h.app.session.doc.to_json(); + h.app.edit_sketch(sketches[0]); + h.app.run("sketch.line", json!({"points": [[0, 2], [10, 2]]})).unwrap(); + h.app.finish_sketch(); + assert!(h.app.deferred.is_some()); + until_settled(&mut h); + assert_eq!(marker(&h), None); + assert_eq!(h.app.session.active_sketch, None); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.active_sketch, None); +} + +#[test] +fn a_pending_sketch_exit_cannot_close_the_next_editor() { + for cancel in [false, true] { + let (mut h, sketches) = sketches_with_later_bodies(); + let curves = h.app.session.doc.sketch(sketches[0]).unwrap().curves.len(); + h.app.edit_sketch(sketches[0]); + h.app.run("sketch.line", json!({"points": [[0, 2], [10, 2]]})).unwrap(); + if cancel { + h.app.cancel_sketch(); + } else { + h.app.finish_sketch(); + } + assert!(h.app.deferred.is_some()); + h.app.edit_sketch(sketches[1]); + until_settled(&mut h); + assert_eq!(h.app.session.active_sketch, Some(sketches[1])); + assert_eq!(marker(&h), Some(3)); + assert_eq!(h.app.session.doc.sketch(sketches[0]).unwrap().curves.len(), curves + usize::from(!cancel)); + h.app.finish_sketch(); + until_settled(&mut h); + assert_eq!(h.app.session.active_sketch, None); + assert_eq!(marker(&h), None); + } +} + #[test] fn switching_from_a_sketch_to_a_feature_keeps_the_original_marker_with_background_rebuilds() { let (mut h, sketches) = sketches_with_later_bodies(); @@ -1050,7 +1091,7 @@ fn a_body_renamed_in_the_browser_keeps_the_pattern_after_it() { let before = mesh(&h); // What Enter does, without the frames after it that would see the rename in. let rename = h.app.menu.rename.take().unwrap(); - crate::context_menu::commit_rename(&mut h.app, &rename); + let _ = crate::context_menu::commit_rename(&mut h.app, &rename); assert!(h.app.computing_since().is_some(), "computing"); assert_eq!(names(&h), ["Body1"], "the design is as it was while it computes"); until_settled(&mut h); diff --git a/crates/ui-egui/src/hole_place_ui_tests.rs b/crates/ui-egui/src/hole_place_ui_tests.rs index 9f3acacb54..dc0a859ca6 100644 --- a/crates/ui-egui/src/hole_place_ui_tests.rs +++ b/crates/ui-egui/src/hole_place_ui_tests.rs @@ -360,3 +360,28 @@ fn hole_design_preferences_keep_both_controls_and_cancel_after_page_split() { h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.hole_snaps && !h.app.preferences.hole_moves); } + +#[test] +fn editing_a_hole_replaces_its_face_instead_of_adding_one() { + // #10692: a Single hole has one place; a second face pick while editing replaces it. + let mut h = Harness::new(); + h.app.set_origin_shown(false); + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Base"})).unwrap(); + h.app.run("solid.box", json!({"corner": [0, 0, 10], "length": 20, "width": 30, "height": 10, "operation": "new", "body_name": "Top"})).unwrap(); + let hole = h.app.run("solid.hole", json!({"position": [30, 15, 10], "diameter": 6, "name": "Hole1"})).unwrap()["feature"].as_u64().unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap(); + h.settle(); + h.step(&json!({"call": "ui.editFeature", "params": {"feature": hole}})).unwrap(); + h.frames(3); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1, "one place on opening"); + let on_top = Vec3::new(10.0, 15.0, 20.0); + let at = screen(&mut h, on_top); + h.step(&json!({"click": at})).unwrap(); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1, "the pick replaces the place, not '2 selected'"); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(on_top) < 1e-3), "{:?}", places(&h)); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let (again, position) = last_hole(&h); + assert_eq!(again, hole, "the same feature"); + assert!(position.dist(on_top) < 1e-3, "OK uses the new face: {position:?}"); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index db623ddeb5..c8260b139d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -136,10 +136,14 @@ pub const SYSTEM: &str = "system"; /// Match the system's preferred locale tags against the catalogs: the exact tag first /// (`zh-TW`, `pt-BR`, `en-GB`), then the tag's base language (`zh-CN` → `zh`, `de-AT` → `de`), -/// then English. Tags come in the system's order of preference; the first one that matches wins. +/// then English. A Chinese tag goes by its script or region instead ([`chinese_catalog`]). +/// Tags come in the system's order of preference; the first one that matches wins. pub fn match_system_tags(tags: &[String]) -> &'static str { for tag in tags { let tag = tag.replace('_', "-"); + if let Some(catalog) = chinese_catalog(&tag) { + return catalog; + } if let Some(catalog) = LOCALES.iter().find(|catalog| catalog.id.eq_ignore_ascii_case(&tag)) { return catalog.id; } @@ -151,11 +155,39 @@ pub fn match_system_tags(tags: &[String]) -> &'static str { ENGLISH.id } +/// The catalog for a Chinese tag (#10306): by its script when it has one (`Hant` → `zh-TW`, +/// `Hans` → `zh`), else by its region (`TW`, `HK` and `MO` use Traditional characters, so +/// `zh-TW`; any other region, or none, `zh`). macOS lists its languages with a script +/// (`zh-Hant-TW`), which no catalog matches exactly, and the base `zh` is Simplified. +fn chinese_catalog(tag: &str) -> Option<&'static str> { + let mut parts = tag.split('-'); + if !parts.next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) { + return None; + } + let rest: Vec<&str> = parts.collect(); + let traditional = match rest.iter().find(|part| part.len() == 4) { + Some(script) => script.eq_ignore_ascii_case("Hant"), + None => rest.iter().any(|part| ["TW", "HK", "MO"].iter().any(|region| part.eq_ignore_ascii_case(region))), + }; + let id = if traditional { "zh-TW" } else { "zh" }; + LOCALES.iter().find(|catalog| catalog.id == id).map(|catalog| catalog.id) +} + /// The catalog id the system asks for, read once per process: the OS locale on desktop /// (Windows, macOS, Linux/BSD) and `navigator.languages` in the browser. pub fn system_language() -> &'static str { - static CACHED: OnceLock<&'static str> = OnceLock::new(); - CACHED.get_or_init(|| match_system_tags(&sys_locale::get_locales().collect::>())) + SYSTEM_LANGUAGE.get_or_init(|| { + // Tests read the same words on every PC, whatever its language. + if cfg!(test) { ENGLISH.id } else { match_system_tags(&sys_locale::get_locales().collect::>()) } + }) +} + +static SYSTEM_LANGUAGE: OnceLock<&'static str> = OnceLock::new(); + +/// `cfg!(test)` for the tests outside this crate, which link it without that: they all start from +/// the test harness, which calls this first. +pub(crate) fn pin_system_language() { + let _ = SYSTEM_LANGUAGE.set(ENGLISH.id); } /// The catalog id a `language` preference resolves to: the saved choice, or the system's for @@ -407,6 +439,33 @@ mod tests { assert_eq!(super::match_system_tags(&tags(&["xx-YY"])), "en", "unknown: English"); assert_eq!(super::match_system_tags(&tags(&[])), "en", "nothing: English"); assert_eq!(super::match_system_tags(&tags(&["xx-YY", "de-DE"])), "de", "the first tag that matches wins"); + // #10306: a Chinese tag goes by its script, else by its region. + for (tag, want) in [ + ("zh-Hant-TW", "zh-TW"), + ("zh-Hant-HK", "zh-TW"), + ("zh-Hant", "zh-TW"), + ("zh_Hant_MO", "zh-TW"), + ("ZH-HANT-tw", "zh-TW"), + ("zh-Hant-CN", "zh-TW"), + ("zh-HK", "zh-TW"), + ("zh-MO", "zh-TW"), + ("zh-Hans-CN", "zh"), + ("zh-Hans", "zh"), + ("zh-Hans-TW", "zh"), + ("zh-SG", "zh"), + ("zh", "zh"), + ] { + assert_eq!(super::match_system_tags(&tags(&[tag])), want, "{tag}"); + } + // Other languages keep the exact tag, then the base language. + assert_eq!(super::match_system_tags(&tags(&["sr-Latn-RS"])), "sr"); + assert_eq!(super::match_system_tags(&tags(&["zhx-TW", "de"])), "de", "only zh itself is Chinese"); + } + + /// Tests name English words, so the PC's own language never reaches them (#10297). + #[test] + fn tests_take_english_as_the_system_language() { + assert_eq!(super::system_language(), "en"); } /// A saved language is never overridden: only `system` resolves through the OS (#10138). diff --git a/crates/ui-egui/src/joint_handles.rs b/crates/ui-egui/src/joint_handles.rs index 8954a0b913..1672810897 100644 --- a/crates/ui-egui/src/joint_handles.rs +++ b/crates/ui-egui/src/joint_handles.rs @@ -1023,8 +1023,9 @@ mod tests { let invalid = crate::prefs::lenient(&json!({"joint_rotation_coarse":0,"joint_rotation_fine":-3})); assert_eq!((invalid.joint_rotation_coarse, invalid.joint_rotation_fine), (10., 0.1)); } - /// Two boards held by two planar joints whose planes cross (#10331): the second joint can't - /// close its loop, so Move names it instead of saying the part has no movable axis. + /// Two boards held by two planar joints whose faces can never meet (the yellow front against + /// the red top, #10331): the second joint can't close its loop, so Move names it instead of + /// saying the part has no movable axis. #[test] fn blocked_move_names_a_conflicting_joint_of_the_part() { let mut h = Harness::new(); @@ -1038,7 +1039,7 @@ mod tests { h.app.run("occurrence.ground", json!({"occurrence":"Yellow:1","grounded":true})).unwrap(); let planar = |name: &str, a: [i32; 3], b: [i32; 3]| json!({"type":"planar","a":{"occurrence":"Yellow","face":a},"b":{"occurrence":"Red","face":b},"name":name}); h.app.run("joint.create", planar("Top", [200, 50, 20], [200, 350, 0])).unwrap(); - h.app.run("joint.create", planar("Front", [200, 0, 10], [200, 0, 30])).unwrap(); + h.app.run("joint.create", planar("Front", [200, 0, 10], [200, 50, 40])).unwrap(); let solved = joints::solve(&h.app.session.doc, &h.app.session.model.state()); let conflicting: Vec<_> = solved.conflicts.iter().map(|(_, name, _)| name.as_str()).collect(); assert!(!conflicting.is_empty(), "the scenario must conflict: {:?}", solved.conflicts); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b8b5e5816..19ad180eb2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -521,15 +521,15 @@ pub struct SolveApp { pub cam_anim: Option, /// Seconds since start, as of this frame. pub now: f64, - /// The history marker before Edit Sketch rolled the timeline to the sketch; Finish Sketch - /// restores it. - pub pre_sketch_marker: Option>, /// The views around opening the open sketch. Leaving it goes back to the earlier one as the /// "When a sketch ends" preference says (#7038). pub pre_sketch_cam: Option, /// The view from before Extrude turned it to isometric (#9176); the view goes back to it when /// the dialog closes, as the "and back when it closes" preference says. pub pre_extrude_cam: Option, + /// The view a command that ends an open sketch was clicked in (#10571), taken before the + /// sketch's own end turns the view: that turn must not become the view Extrude goes back to. + pre_command_cam: Option, /// The component active before Edit Sketch activated the one the sketch is in (#8127); /// leaving the sketch makes it active again. pub pre_sketch_component: Option, @@ -790,9 +790,9 @@ impl SolveApp { cam: Camera::default(), cam_anim: None, now: 0.0, - pre_sketch_marker: None, pre_sketch_cam: None, pre_extrude_cam: None, + pre_command_cam: None, pre_sketch_component: None, services, file_picker: None, @@ -1158,6 +1158,10 @@ impl SolveApp { if self.file_load.busy() || self.file_picker.is_some() || self.saving.is_some() { return Err("a file operation is in progress".into()); } + // The editor's own marker moves come through here, so only Undo, Redo and Play close it. + if matches!(id, "edit.undo" | "edit.redo" | "timeline.play") { + self.close_editor_for_history()?; + } // Commands may start only detected slicers and the programs the user added under Custom…. if matches!(id, "file.save_mesh" | "file.send_to_slicer") { solidcraft_engine::slicers::set_chosen_programs(&self.preferences.custom_slicers); @@ -1196,14 +1200,12 @@ impl SolveApp { // Render focus already applies its semantic selection after clearing object picks. // A tab switch brings in the other design's own Render selection. if done.is_ok() && !matches!(id, "view.render_select" | "view.next_tab" | "view.previous_tab") && selection_changed { - if self.session.selection.is_empty() && render_workspace::active(self) { - render_workspace::selection_editor(self, &render_workspace::RenderSelection::Nothing); - } - self.render_workspace.view.selection = if self.session.selection.is_empty() { - render_workspace::RenderSelection::Nothing + let selection = render_workspace::object_selection(self); + if render_workspace::active(self) { + render_workspace::selection_editor(self, &selection); } else { - render_workspace::RenderSelection::Object(self.session.selection.clone()) - }; + self.render_workspace.view.selection = selection; + } } if let Ok(v) = &done { if matches!(id, "render.lights.solo" | "render.lights.mute") { @@ -1227,6 +1229,7 @@ impl SolveApp { }; if let Ok(v) = &r { match id { + "sketch.finish" | "sketch.cancel" => self.sketch_closed(v.get("sketch").and_then(Value::as_u64)), "view.fit" | "view.home" => self.frame_view(id == "view.home"), "view.tool" => { self.viewport.nav = match v.get("tool").and_then(Value::as_str) { @@ -1277,23 +1280,16 @@ impl SolveApp { let selected = match v.get("kind").and_then(Value::as_str) { Some("environment") => RenderSelection::Environment, Some("wear") => RenderSelection::Wear, - Some("lighting") => { - render_workspace::select_lighting(self); - RenderSelection::Lighting - } + Some("lighting") => RenderSelection::Lighting, Some("output") => RenderSelection::Output, Some("sampling") => RenderSelection::Sampling, Some("effects") => RenderSelection::Effects, Some("check") => RenderSelection::Check, - Some("light") => { - render_workspace::select_lighting(self); - RenderSelection::Light(v.get("id").and_then(Value::as_str).unwrap_or_default().into()) - } + Some("light") => RenderSelection::Light(v.get("id").and_then(Value::as_str).unwrap_or_default().into()), Some("shot") => RenderSelection::Shot(v.get("id").and_then(Value::as_str).unwrap_or_default().into()), _ => RenderSelection::Nothing, }; render_workspace::selection_editor(self, &selected); - self.render_workspace.view.selection = selected; } if id == "view.layout" { let layout = @@ -1907,6 +1903,9 @@ impl SolveApp { dialogs::cancel(self); } let finished_sketch = self.session.active_sketch.filter(|_| ends_sketch(id) && self.interactive(id)); + // The sketch's end may turn the view (Preferences › Sketch); Extrude goes back to the view + // it was clicked in, so take that one before the turn starts (#10571). + self.pre_command_cam = finished_sketch.is_some().then(|| self.settled_cam()); if finished_sketch.is_some() { self.finish_sketch(); // A feature committed before the marker is back would go in where the sketch was. @@ -2092,6 +2091,22 @@ impl SolveApp { self.open_feature_edit(id, marker, original); } + /// Undo, Redo and the History bar would change the design under an open feature editor (Edit + /// Hole, Edit Box, …), whose OK and Cancel then refuse it (#10688). They cancel the editor + /// first, putting its marker back, as starting another tool does; while it is still opening + /// they wait for it. + pub(crate) fn close_editor_for_history(&mut self) -> Result<(), String> { + if self.pending_feature_edit.is_some() { + let why = i18n::tr("ui.history_waits_for_editor").to_string(); + self.set_status(why.clone(), true); + return Err(why); + } + if self.editing_state.is_some() || self.dialog.as_ref().is_some_and(|d| d.editing.is_some()) { + dialogs::cancel(self); + } + Ok(()) + } + fn open_feature_edit(&mut self, id: u64, marker: Option, original: Session) { self.editing_state = Some(dialogs::EditingState { source: self.session.scratch(), original }); // The id check matters while another feature's reopening is still pending: a later call @@ -2141,22 +2156,19 @@ impl SolveApp { if self.session.active_sketch == Some(id) { return; } - let Some(idx) = self.session.doc.feature_index(id) else { return }; + if self.session.doc.feature_index(id).is_none() { + return; + } let marker = self.finish_sketch_for_edit(); let before = self.settled_cam(); let context = (self.session.active_component, self.session.active_occurrence_path.clone()); self.tool = None; self.dialog = None; activate(self); - let rolled = self.session.doc.marker != Some(idx + 1) && self.run("timeline.roll_to", json!({ "position": idx + 1 })).is_ok(); - if self.run("sketch.edit", json!({ "sketch": id })).is_err() { - if rolled || marker != self.session.doc.marker { - self.restore_marker(marker); - } + if self.run("sketch.edit", json!({ "sketch": id, "restore_marker": marker })).is_err() { self.restore_component(context.0, context.1); return; } - self.pre_sketch_marker = (rolled || marker != self.session.doc.marker).then_some(marker); // `sketch.edit` itself activates the component a face-attached sketch was drawn in. let opened_in = self.session.active_component; let moved = (opened_in, &self.session.active_occurrence_path) != (context.0, &context.1); @@ -2195,7 +2207,7 @@ impl SolveApp { if !self.preferences.extrude_view_isometric { return; } - let before = self.settled_cam(); + let before = self.pre_command_cam.take().unwrap_or_else(|| self.settled_cam()); let mut to = before; to.set_view(StandardView::Iso); self.pre_extrude_cam = self.preferences.extrude_view_return.then_some(before); @@ -2225,9 +2237,11 @@ impl SolveApp { .pending_feature_edit .take() .map(|pending| pending.marker) - .unwrap_or_else(|| self.pre_sketch_marker.take().unwrap_or(self.session.doc.marker)); + .unwrap_or_else(|| self.session.sketch_restore_marker().unwrap_or(self.session.doc.marker)); if self.session.active_sketch.is_some() { - self.finish_sketch(); + let command = self.take_sketch_exit().unwrap_or_else(|| "sketch.finish".into()); + self.prepare_sketch_exit(); + let _ = self.run(&command, json!({"restore_marker": false})); } marker } @@ -2246,19 +2260,30 @@ impl SolveApp { } fn leave_sketch(&mut self, command: &str) { + self.prepare_sketch_exit(); + if self.session.sketch_restore_marker().is_some() { + // Finishing now restores the marker in the engine: evaluate the complete + // command ahead so rebuilding later features stays off the window thread. + let _ = self.run_heavy(command, json!({})); + } else { + let _ = self.run(command, json!({})); + } + } + + fn prepare_sketch_exit(&mut self) { sketch_text_move::cancel_drag(self); if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { dialogs::cancel(self); } dim_view::cancel(); self.tool = None; - let open = self.session.active_sketch; - if self.run(command, json!({})).is_err() && command != "sketch.finish" { - return; - } - if let Some(marker) = self.pre_sketch_marker.take() { - self.restore_marker(marker); - } + } + + /// Successful UI and programmatic exits share the same window cleanup. + fn sketch_closed(&mut self, open: Option) { + self.take_sketch_exit(); + self.tool = None; + dim_view::cancel(); // Unless another component was activated meanwhile: that one stays. if let Some(pre) = self.pre_sketch_component.take().filter(|pre| Some(pre.sketch) == open && pre.opened_in == self.session.active_component) { self.restore_component(pre.component, pre.path); @@ -2271,12 +2296,6 @@ impl SolveApp { } } - fn restore_marker(&mut self, marker: Option) { - // Rolling forward past the features after the sketch recomputes them (an edited sketch - // under a text cut): off the window thread when it takes a while. - let _ = self.run_heavy("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); - } - /// Visibility in the editor's scene: hidden face-profile sources are temporarily shown /// without changing the session's visibility or either history stack. pub(crate) fn body_visible(&self, name: &str) -> bool { diff --git a/crates/ui-egui/src/manage_dialogs_tests.rs b/crates/ui-egui/src/manage_dialogs_tests.rs index 448c8f9dfe..0aede818e1 100644 --- a/crates/ui-egui/src/manage_dialogs_tests.rs +++ b/crates/ui-egui/src/manage_dialogs_tests.rs @@ -388,6 +388,7 @@ fn appearance_card_keeps_the_approved_width_with_no_preselection() { #[test] fn parameter_inventory_remains_reachable_with_regular_draft_rows() { let mut h = fixture(); + h.app.preferences.language = "en".into(); h.app.start("parameters.change"); h.frames(4); for label in ["Filter", "Import…", "Export CSV…", "Export JSON…", "User Parameters", "Model Parameters"] { diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs index 43e0e406f9..ff6c86f879 100644 --- a/crates/ui-egui/src/measurement_ui_tests.rs +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -158,6 +158,7 @@ fn a_datum_plane_and_a_point_measure_their_distance() { #[test] fn measurement_axis_distances_follow_the_upstream_centre_min_and_max_choices() { let mut h = Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; h.app.set_origin_shown(false); h.app.ui.show_browser = false; diff --git a/crates/ui-egui/src/move_preview.rs b/crates/ui-egui/src/move_preview.rs index 76a8ac2a64..bd0a65f105 100644 --- a/crates/ui-egui/src/move_preview.rs +++ b/crates/ui-egui/src/move_preview.rs @@ -23,11 +23,12 @@ pub(crate) struct MovePreview { cached: Option, } -/// The listed bodies' world meshes, for the design and revision they were taken from. +/// The world meshes of the bodies drawn (the listed bodies, and the bodies of the components +/// copied), for the design and revision they were taken from. struct Cached { document: Arc, revision: u64, - bodies: Vec, + names: Vec, meshes: Vec>, } @@ -71,25 +72,38 @@ impl MovePreview { return None; } let bodies: Vec = params.get("bodies")?.as_array()?.iter().map(|b| b.as_str().map(str::to_string)).collect::>()?; - if bodies.is_empty() { + // The components copied (`occurrences`): their bodies are drawn moved, as copies of them. + let components: Vec> = match params.get("occurrences") { + None => Vec::new(), + Some(Value::Array(list)) => { + list.iter().map(|path| path.as_array()?.iter().map(Value::as_u64).collect::>>()).collect::>()? + } + Some(_) => return None, + }; + if bodies.is_empty() && components.is_empty() { return None; } - if self - .cached - .as_ref() - .is_none_or(|c| c.revision != app.session.revision || !Arc::ptr_eq(&c.document, &app.session.doc) || c.bodies != bodies) + let copy = params.get("copy").and_then(Value::as_bool) == Some(true); + // Only a copy takes components (the engine refuses them otherwise). + if !components.is_empty() && !copy { + return None; + } + let mut names = bodies.clone(); + for path in &components { + names.extend(app.session.occurrence_bodies(path, true).ok()?); + } + if self.cached.as_ref().is_none_or(|c| c.revision != app.session.revision || !Arc::ptr_eq(&c.document, &app.session.doc) || c.names != names) { let state = app.session.world_state(); - let meshes: Vec> = bodies.iter().map(|n| state.body(n).map(|b| b.mesh())).collect::>()?; + let meshes: Vec> = names.iter().map(|n| state.body(n).map(|b| b.mesh())).collect::>()?; if meshes.iter().map(|m| m.triangles.len()).sum::() > MAX_TRIANGLES { return None; } - self.cached = Some(Cached { document: app.session.doc.clone(), revision: app.session.revision, bodies: bodies.clone(), meshes }); + self.cached = Some(Cached { document: app.session.doc.clone(), revision: app.session.revision, names: names.clone(), meshes }); } let cache = self.cached.as_ref()?; // About the world origin, as the Move feature turns bodies (`kernel::transform`). let m = rigid(translate, Vec3::ZERO, axis, angle); - let copy = params.get("copy").and_then(Value::as_bool) == Some(true); let mut scene = GpuScene::default(); let removed = scene.color(colors.removed); let mut bounds = Aabb3::EMPTY; @@ -205,4 +219,31 @@ mod tests { } assert!(pv.compute(&app, &[("solid.extrude".to_string(), json!({}))], c).is_none()); } + + /// A copy of a component is drawn where the copy lands: the component's bodies moved, the + /// component itself left where it was, so the preview is not the scratch design's. + #[test] + fn a_component_copy_is_previewed_where_the_copy_lands() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Plate"})).unwrap(); + let made = s.execute("component.create", &json!({"name": "Part", "grounded": false})).unwrap(); + s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 15, "body_name": "Peg"})).unwrap(); + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + let occurrence = made["occurrence"].as_u64().unwrap(); + let app = SolveApp::new(s, Services::default()); + let c = colors(&app); + let mut pv = MovePreview::default(); + // The component's box is 0..20 along X; the copy lands 50 mm along. + let copy = [("solid.move".to_string(), json!({"bodies": [], "occurrences": [[occurrence]], "copy": true, "translate": [50, 0, 0]}))]; + let built = pv.compute(&app, ©, c).expect("taken here").expect("built"); + assert!(built.replaced.is_empty(), "a copy keeps the original shown"); + assert!(near(Vec3::new(built.bounds.min.x, 0.0, 0.0), Vec3::new(50.0, 0.0, 0.0)), "{:?}", built.bounds); + assert!(near(Vec3::new(built.bounds.max.x, 0.0, 0.0), Vec3::new(70.0, 0.0, 0.0)), "{:?}", built.bounds); + // With bodies listed too, the component's copy is drawn alongside the body's: the + // component's box is 15 mm tall, the Plate 5 mm, so the height shows it is there. + let mixed = [("solid.move".to_string(), json!({"bodies": ["Plate"], "occurrences": [[occurrence]], "copy": true, "translate": [50, 0, 0]}))]; + let built = pv.compute(&app, &mixed, c).expect("taken here").expect("built"); + assert!(near(Vec3::new(built.bounds.min.x, 0.0, 0.0), Vec3::new(50.0, 0.0, 0.0)), "{:?}", built.bounds); + assert!(near(Vec3::new(0.0, 0.0, built.bounds.max.z), Vec3::new(0.0, 0.0, 15.0)), "{:?}", built.bounds); + } } diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 44df7a69fc..1b2439e61a 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -151,7 +151,9 @@ fn rows(app: &mut SolveApp) -> Vec { Row { expression: s(r, "expression"), unit: s(r, "unit"), - value: r.get("value").and_then(Value::as_f64), + // The dialog shows the parameter's own unit, so it reads `display_value` + // (`value` is mm / degrees in every reply, #10747). + value: r.get("display_value").and_then(Value::as_f64).or_else(|| r.get("value").and_then(Value::as_f64)), error: r.get("error").and_then(Value::as_str).map(str::to_string), comment: s(r, "comment"), favorite: r.get("favorite").and_then(Value::as_bool).unwrap_or(false), diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index d64c99ee9a..33963ba8d1 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -456,8 +456,7 @@ fn page_path(index: usize) -> String { fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) { let typed = drive_search(ui, app); - let (field, _) = - ui_kit::search_actions_row(ui, egui::Id::new("prefs_search"), &mut app.prefs_window.filter, crate::i18n::tr("prefs.search"), &[]); + let field = ui_kit::preferences_search_field(ui, egui::Id::new("prefs_search"), &mut app.prefs_window.filter, crate::i18n::tr("prefs.search")); if typed { place_search_cursor(ui.ctx(), egui::Id::new("prefs_search"), app.prefs_window.filter.chars().count()); } @@ -468,7 +467,6 @@ fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) { settle_search(app); crate::prefs_search::set_reveal(ui.ctx(), app.prefs_window.reveal.clone()); crate::prefs_search::set_force_scroll(ui.ctx(), !app.prefs_window.filter.trim().is_empty()); - ui.add_space(Tokens::get().space_8); } /// Keys for the search field. Shortcut capture keeps the key for itself. @@ -2543,6 +2541,7 @@ fn unfold(ctx: &egui::Context) { } pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + crate::settings_profiles::rename_popup(app, ctx); // File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page. if app.keymap.open { app.keymap.open = false; @@ -2581,11 +2580,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let window = ui_kit::Window::new(egui::Id::new("sc_prefs"), "settings", app.tr("ui.preferences")) .cards(10.0 / 3.0) .page_rows(24.0) + .page_header_in_titlebar() .page_padding(egui::Margin { - left: (Tokens::get().space_16 + Tokens::get().space_4) as i8, - right: (Tokens::get().space_16 + Tokens::get().space_4) as i8, - top: Tokens::get().space_6 as i8, - bottom: (Tokens::get().space_16 + Tokens::get().space_4) as i8, + left: (Tokens::get().space_24 - Tokens::get().space_2) as i8, + right: (Tokens::get().space_24 - Tokens::get().space_2) as i8, + top: (Tokens::get().space_16 + Tokens::get().space_2) as i8, + bottom: Tokens::get().space_10 as i8, }) .fields() .actions("OK", "Cancel", Some("Apply")) @@ -2599,12 +2599,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app, search_header, clear_search, - Some((Tokens::get().control_height * 3.0, crate::settings_profiles::sidebar)), + Some((Tokens::get().space_24 * 2.0, crate::settings_profiles::sidebar)), |ui| { - if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal) - .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")) - .clicked() - { + let defaults = ui_kit::button(ui, crate::i18n::tr("prefs.restore_page_defaults"), ui_kit::Button::Ghost) + .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")); + crate::scenario::publish_target("prefs:defaults", defaults.rect); + if defaults.clicked() { button = Some("defaults"); } }, @@ -4229,7 +4229,18 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str) h.call("ui.scroll", json!({"x":page.center().x,"y":page.center().y,"delta":-100})); h.frames(20); } - panic!("preference {handle} was not revealed"); + panic!( + "preference {handle} was not revealed: theme={} language={} section={} filter={:?} screen={:?} window={:?} body={:?} target_rect={:?} target_point={:?}", + h.app.preferences.theme, + h.app.preferences.language, + h.app.prefs_window.section, + h.app.prefs_window.filter, + h.ctx.content_rect(), + crate::scenario::rect_of("prefs:window"), + crate::scenario::rect_of("dialog:page_body"), + crate::scenario::rect_of(handle), + crate::scenario::handle_at(handle) + ); } #[cfg(test)] @@ -4428,10 +4439,15 @@ mod tests { fn navigation_bar_appearance_segments_stay_inside_the_page_at_narrow_widths() { for width in [1600, 880, 640, 600, 480] { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; h.call("ui.resize", json!({"width":width,"height":1100})); h.call("ui.prefs", json!({"open":"Interface/Navigation bar", "navigation_bar":{"slots":["fit","visual_style","display","spin"]}})); h.frames(20); + let header = crate::scenario::rect_of("dialog:header").unwrap(); + let search = crate::scenario::rect_of("prefs:search:frame").unwrap(); + assert!(header.contains_rect(search), "titlebar search stays inside the window at {width}"); + assert!(search.width() <= 230.0 && search.width() >= 100.0, "usable bounded search at {width}"); for (key, label, labels) in [ ("position", "Position", &["Left", "Center", "Right"][..]), ("size", "Size", &["Compact", "Regular"][..]), @@ -4593,7 +4609,7 @@ mod tests { let body = crate::scenario::rect_of("prefs:page_body").unwrap(); let window = crate::scenario::rect_of("prefs:window").unwrap(); let t = crate::theme::Tokens::get(); - let inset = t.space_16 + t.space_4 + crate::ui_kit::navigation_popup_frame().stroke.width; + let inset = t.space_24 - t.space_2 + crate::ui_kit::navigation_popup_frame().stroke.width; assert!((body.right() - (window.right() - inset)).abs() < 0.5, "{width}: {page}"); for key in sections { let section = crate::scenario::rect_of(&format!("prefs:section:{key}")).unwrap(); @@ -5924,6 +5940,7 @@ mod tests { ]; let mut found = vec![Vec::new(); cases.len()]; let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; for page in 0..PAGES.len() { open_at(&mut h.app, page); @@ -7555,6 +7572,216 @@ mod tests { h.call("ui.prefs", json!({"button": "cancel"})); } + #[test] + fn general_files_controls_edit_real_limits_and_hosts_in_both_themes() { + for theme in ["light", "dark"] { + for width in [1468, 480] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":width, "height":1100})); + let configured = h.call( + "ui.prefs", + json!({"language":"en", "theme":theme, "network_locations":true, "network_hosts":["files.example", "nas.example"]}), + ); + assert!(configured["error"].is_null(), "{theme} {width}: setting up Preferences failed: {configured}"); + let opened = h.call("ui.prefs", json!({"open":"General"})); + assert!(opened["error"].is_null(), "{theme} {width}: opening General failed: {opened}"); + let initial_body = crate::scenario::rect_of("prefs:page_body").unwrap(); + for target in [ + "dialog:widget:Profile name", + "prefs:profile:save", + "prefs:profile:import", + "prefs:profiles:held:row", + "prefs:profiles:computer:row", + ] { + let rect = crate::scenario::rect_of(target).unwrap(); + assert!(initial_body.contains_rect(rect), "{theme} {width}: {target} {rect:?} leaves {initial_body:?}"); + } + if width == 480 { + let name = crate::scenario::rect_of("dialog:widget:Profile name").unwrap(); + let save = crate::scenario::rect_of("prefs:profile:save").unwrap(); + let import = crate::scenario::rect_of("prefs:profile:import").unwrap(); + assert!(save.top() >= name.bottom(), "narrow Save wraps after the independent name entry"); + assert!((save.top() - import.top()).abs() < 0.5); + } + let painted = |h: &crate::scenario::Harness, label: &str| { + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + h.shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape + && text.galley.job.text == label + { + let rect = egui::Rect::from_min_size(text.pos, text.galley.size()); + if body.contains_rect(rect) && shape.clip_rect.contains_rect(rect) { + return Some(rect); + } + } + None + }) + .unwrap_or_else(|| panic!("{theme} {width}: {label} is not fully painted in the page")) + }; + // Reveal the live dropdown, not the full-width layout row's allocation rectangle. + super::reveal_preference(&mut h, "dialog:widget:Interface language"); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let language = painted(&h, "Interface language"); + assert!((language.left() - body.left()).abs() < 0.75); + assert!((crate::scenario::rect_of("dialog:control:Interface language").unwrap().left() - body.left() - 164.0).abs() < 0.5); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + let autosave = painted(&h, "Autosave every"); + let autosave_help = crate::scenario::rect_of("dialog:row_help:Autosave every").unwrap(); + assert!((autosave.left() - body.left()).abs() < 0.75, "the painted autosave label shares the language label's left edge"); + assert!((autosave_help.left() - autosave.right() - 4.0).abs() < 1.0, "help follows the actual text, not the whole label column"); + assert!((crate::scenario::rect_of("prefs:autosave_minutes").unwrap().left() - body.left() - 164.0).abs() < 0.5); + super::reveal_preference(&mut h, "prefs:autosave_timeline"); + let timeline = crate::scenario::rect_of("prefs:autosave_timeline").unwrap(); + let autosave_control = crate::scenario::rect_of("prefs:autosave_minutes").unwrap(); + assert!((timeline.left() - autosave_control.left()).abs() < 0.5); + assert!((timeline.width() - 260.0_f32.min(body.right() - autosave_control.left())).abs() < 0.5); + assert!(body.contains_rect(timeline)); + super::reveal_preference(&mut h, "prefs:max_step_file_mb"); + super::reveal_preference(&mut h, "dialog:row:Largest file to open"); + let field = crate::scenario::rect_of("prefs:max_step_file_mb").unwrap(); + let design = crate::scenario::rect_of("prefs:max_design_file_mb").unwrap(); + assert!( + (field.width() - 72.0).abs() < 0.5 && (design.width() - 72.0).abs() < 0.5, + "{theme}: both compound file editors retain their width" + ); + let step_group = crate::scenario::rect_of("dialog:row:Largest file to open: STEP and import").unwrap(); + let design_group = crate::scenario::rect_of("dialog:row:Largest file to open: Design").unwrap(); + let step_caption = painted(&h, "STEP and import"); + let design_caption = painted(&h, "Design"); + let largest = painted(&h, "Largest file to open"); + assert!((step_caption.left() - field.left()).abs() < 0.75 && (design_caption.left() - design.left()).abs() < 0.75); + assert!((largest.left() - body.left()).abs() < 0.75); + assert!((largest.top() - step_caption.top() - 6.0).abs() <= 1.0, "the left label stays in its top-aligned 28 px cell"); + assert!(step_group.width() >= 90.0 && step_group.width() < 130.0, "{theme}: the subgroup follows its field, unit and caption"); + if width == 1468 { + assert!((step_caption.top() - design_caption.top()).abs() < 0.5, "{theme}: the actual painted captions share a top baseline"); + assert!( + (field.top() - design.top()).abs() < 0.5 && field.right() < design.left(), + "{theme}: independent editors share one visual row" + ); + assert!((design_group.left() - step_group.right() - 20.0).abs() < 0.5, "{theme}: compact pair gap"); + } else { + assert!(design_group.top() > step_group.bottom(), "{theme}: the pair wraps at narrow widths"); + assert!((design_group.left() - step_group.left()).abs() < 0.5); + } + let group = crate::scenario::rect_of("dialog:row:Largest file to open").unwrap(); + assert!(group.contains_rect(field) && group.contains_rect(design)); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(group)); + h.step(&json!({"click":[field.center().x, field.center().y], "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"4096"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.preferences.max_step_file_mb, 4096); + super::reveal_preference(&mut h, "prefs:max_design_file_mb"); + let field = crate::scenario::rect_of("prefs:max_design_file_mb").unwrap(); + h.step(&json!({"click":[field.center().x, field.center().y], "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"2048"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.preferences.max_design_file_mb, 2048); + assert_eq!(super::file_limits(&h.app.preferences).step, 4096 * solidcraft_engine::io::limits::MB); + let before = h.app.preferences.clone(); + for bad in [json!({"max_step_file_mb":16385}), json!({"max_design_file_mb":255})] { + assert!(h.call("ui.prefs", bad)["error"].is_string()); + assert_eq!(h.app.preferences, before, "invalid file limits fail atomically"); + } + super::reveal_preference(&mut h, "prefs:network_host:files.example:remove"); + h.step(&json!({"click":{"handle":"prefs:network_host:files.example:remove"}})).unwrap(); + assert_eq!(h.app.preferences.network_hosts, ["nas.example"]); + h.step(&json!({"widget":"Allow network locations in designs"})).unwrap(); + h.frames(3); + assert!(!h.app.preferences.network_locations); + assert_eq!(h.app.preferences.network_hosts, ["nas.example"], "hiding the chips retains the hosts"); + assert!(crate::scenario::rect_of("prefs:network_host:nas.example:remove").is_none()); + super::reveal_preference(&mut h, "prefs:print_face_highlight"); + let highlight = painted(&h, "Print face highlight"); + let highlight_help = crate::scenario::rect_of("dialog:row_help:Print face highlight").unwrap(); + assert!((highlight.left() - body.left()).abs() < 0.75, "the painted highlight label shares the language/autosave left edge"); + assert!((highlight_help.left() - highlight.right() - 4.0).abs() < 1.0); + assert!((crate::scenario::rect_of("prefs:print_face_highlight").unwrap().left() - body.left() - 164.0).abs() < 0.5); + let track = crate::scenario::rect_of("dialog:slider_track:Print face highlight").unwrap(); + // The published rectangle includes the circular handle's endpoint insets. + let input_range = track.x_range().shrink(track.height() / 2.5); + h.step(&json!({"click":[egui::lerp(input_range, 0.75), track.center().y]})).unwrap(); + assert!( + (h.app.preferences.print_face_highlight - 0.75).abs() < 0.06, + "{theme} {width}: highlight={} track={track:?} input_range={input_range:?}", + h.app.preferences.print_face_highlight + ); + super::reveal_preference(&mut h, "prefs:check_now"); + assert!(body.contains_rect(crate::scenario::rect_of("prefs:check_now").unwrap())); + assert!(h.app.prefs_window.open); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!((h.app.preferences.max_step_file_mb, h.app.preferences.max_design_file_mb), (DEFAULT_STEP_MB, DEFAULT_DESIGN_MB)); + assert_eq!(h.app.preferences.network_hosts, ["files.example", "nas.example"]); + assert!(h.app.preferences.network_locations); + assert_eq!(h.app.preferences.print_face_highlight, 0.2); + } + } + } + + #[test] + fn general_autosave_rows_apply_cancel_and_defaults_keep_the_effective_interval() { + use std::time::Duration; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.step(&json!({"autosave":true})).unwrap(); + h.app.autosave_minutes = 7.0; + super::set_autosave(&mut h.app); + h.app.preferences.language = "en".into(); + h.app.preferences.nav = "tinkercad".into(); + h.app.preferences.reopen_designs = false; + h.app.run("settings.profile.save", json!({"name":"Working"})).unwrap(); + let profiles = h.app.settings_profiles.list.clone(); + h.call("ui.prefs", json!({"open":"General"})); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + super::reveal_preference(&mut h, "prefs:autosave_timeline"); + let number = crate::scenario::rect_of("prefs:autosave_minutes").unwrap(); + let timeline = crate::scenario::rect_of("prefs:autosave_timeline").unwrap(); + assert!((timeline.width() - 260.0).abs() < 0.5, "the timeline stays bounded in the control column"); + assert!((timeline.left() - number.left()).abs() < 0.5); + assert!(timeline.top() >= number.bottom()); + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(timeline)); + h.step(&json!({"click":{"handle":"prefs:autosave_minutes"}, "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"2.5"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + assert_eq!(saved_prefs(&h.app)["autosave_minutes"], 7.0, "an unapplied value is not persisted"); + h.step(&json!({"click":{"handle":"prefs:apply"}})).unwrap(); + assert_eq!(saved_prefs(&h.app)["autosave_minutes"], 2.5); + super::reveal_preference(&mut h, "prefs:autosave_minutes"); + h.step(&json!({"click":{"handle":"prefs:autosave_minutes"}, "double":true})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"0"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 0.0); + assert!(h.app.autosave.as_ref().unwrap().interval > Duration::from_secs(365 * 24 * 60 * 60)); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + h.call("ui.prefs", json!({"open":"General"})); + assert!(h.widget(h.app.tr("prefs.restore_page_defaults")).is_some(), "the translated Preferences caption is visible"); + assert!(h.widget(h.app.tr("ui.restore_defaults")).is_none(), "the generic caption is absent from Preferences"); + h.step(&json!({"widget":"Restore page defaults"})).unwrap(); + assert_eq!(h.app.autosave_minutes, 5.0); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(300)); + assert_eq!(h.app.preferences.language, crate::i18n::SYSTEM); + assert!(h.app.preferences.reopen_designs); + assert_eq!(h.app.preferences.nav, "tinkercad", "General's reset leaves other pages alone"); + assert_eq!(h.app.settings_profiles.list, profiles); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Working")); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); + assert_eq!(h.app.preferences.language, "en"); + assert!(!h.app.preferences.reopen_designs); + assert_eq!(h.app.autosave_minutes, 2.5); + assert_eq!(h.app.autosave.as_ref().unwrap().interval, Duration::from_secs(150)); + } + #[test] fn graphics_card_picker_selects_named_cards_and_restores_automatic() { let dir = std::env::temp_dir().join(format!("solidcraft-graphics-picker-{}", std::process::id())); @@ -8917,9 +9144,8 @@ mod tests { #[test] fn language_preference_switches_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); - // A first start follows the system (#10138); pin English so the words below are fixed. + // A first start follows the system (#10138), which is English in a test. assert_eq!(app.preferences.language, crate::i18n::SYSTEM); - app.preferences.language = "en".into(); assert_eq!(app.tr("ui.preferences"), "Preferences"); assert_eq!(app.tr("ui.extrude"), "Extrude"); diff --git a/crates/ui-egui/src/prefs/general.rs b/crates/ui-egui/src/prefs/general.rs index 934a664bc5..89eda1fb1c 100644 --- a/crates/ui-egui/src/prefs/general.rs +++ b/crates/ui-egui/src/prefs/general.rs @@ -2,111 +2,147 @@ use super::*; pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { - let t = Tokens::get(); - let labels = t.space_24 * 6.0 + t.space_6; - ui_kit::settings_heading_with_tag(ui, app.tr("prefs.general"), app.tr("prefs.general_note"), Some(crate::settings_profiles::tag)); - crate::settings_profiles::section(app, ui, labels); - ui_kit::settings_section(ui, "language", app.tr("ui.language"), |ui| { - ui_kit::settings_form(ui, labels, |ui| { - ui_kit::row(ui, app.tr("prefs.interface_language"), |ui| { - ui_kit::settings_control_width(ui, t.form_card_width - t.space_4, |ui| { - ui_kit::dropdown_menu( - ui, - "pref_language", - &if app.preferences.language == crate::i18n::SYSTEM { - app.tr("prefs.language_system").to_owned() - } else { - crate::i18n::Locale::for_language(&app.preferences.language).display_name().into_owned() - }, - |ui| { - if ui_kit::list_picker( - ui, - crate::i18n::SYSTEM, - app.tr("prefs.language_system"), - app.preferences.language == crate::i18n::SYSTEM, - ) - .clicked() - { - app.preferences.language = crate::i18n::SYSTEM.into(); - } - for locale in crate::i18n::LOCALES { - if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { - app.preferences.language = locale.id.into(); + ui_kit::settings_page(ui, |ui| { + let t = Tokens::get(); + let labels = t.space_24 * 6.0 + t.space_6; + ui_kit::settings_heading_with_tag(ui, app.tr("prefs.general"), app.tr("prefs.general_note"), Some(crate::settings_profiles::tag)); + crate::settings_profiles::section(app, ui); + ui_kit::settings_section(ui, "language", app.tr("ui.language"), |ui| { + ui_kit::settings_form(ui, labels, |ui| { + ui_kit::row(ui, app.tr("prefs.interface_language"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 11.0 + t.space_16, |ui| { + ui_kit::dropdown_menu( + ui, + "pref_language", + &if app.preferences.language == crate::i18n::SYSTEM { + app.tr("prefs.language_system").to_owned() + } else { + crate::i18n::Locale::for_language(&app.preferences.language).display_name().into_owned() + }, + |ui| { + if ui_kit::list_picker( + ui, + crate::i18n::SYSTEM, + app.tr("prefs.language_system"), + app.preferences.language == crate::i18n::SYSTEM, + ) + .clicked() + { + app.preferences.language = crate::i18n::SYSTEM.into(); } - } - }, - ); + for locale in crate::i18n::LOCALES { + if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { + app.preferences.language = locale.id.into(); + } + } + }, + ); + }); }); }); }); - }); - ui_kit::settings_section(ui, "saving", app.tr("prefs.saving"), |ui| { - ui_kit::settings_block_hint(ui, app.tr("ui.autosave_every"), crate::i18n::tr_label("0 turns it off"), labels, |ui| { - ui.horizontal(|ui| { - if ui_kit::settings_number(ui, &mut app.autosave_minutes, 0.0..=120.0, 0.5).changed() { - set_autosave(app); + ui_kit::settings_section(ui, "saving", app.tr("prefs.saving"), |ui| { + ui_kit::checkbox(ui, app.tr("Reopen the designs that were open when SolidCraft was last closed"), &mut app.preferences.reopen_designs); + ui_kit::settings_form(ui, labels, |ui| { + let shown = ui_kit::settings_row_help(ui, app.tr("ui.autosave_every"), crate::i18n::tr_label("0 turns it off"), |ui| { + let number = ui_kit::settings_number(ui, &mut app.autosave_minutes, 0.0..=120.0, 0.5); + number.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), app.autosave_minutes); + info.label = Some(app.tr("prefs.general.minutes").to_owned()); + info + }); + crate::scenario::publish_target("prefs:autosave_minutes", number.rect); + if number.changed() { + set_autosave(app); + } + ui_kit::settings_inline_note(ui, app.tr("prefs.minutes")); + ui.is_visible() + }); + if shown { + ui_kit::below_row_control(ui, app.tr("ui.autosave_every"), t.space_24 * 10.0 + t.space_16 + t.space_4, |ui| { + ui_kit::settings_autosave_timeline(ui, app.autosave_minutes) + }); } - ui_kit::settings_inline_note(ui, app.tr("prefs.minutes")); }); - ui_kit::settings_autosave_timeline(ui, app.autosave_minutes); }); - ui_kit::settings_block(ui, app.tr_label("Reopen designs"), labels, |ui| { - ui_kit::checkbox( - ui, - crate::i18n::tr_label("Reopen the designs that were open when SolidCraft was last closed"), - &mut app.preferences.reopen_designs, - ); + ui_kit::settings_section(ui, "files", app.tr("FILES"), |ui| { + file_size_rows(app, ui, labels); + ui.horizontal(|ui| { + let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + setting::network_locations(app, ui); + }); + ui_kit::help_icon(ui, app.tr("prefs.network_locations_note")); + }); + if app.preferences.network_locations { + let mut remove = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(t.space_6, t.space_6); + for (i, host) in app.preferences.network_hosts.iter().enumerate() { + let label = format!("\\\\{host}"); + let button = ui_kit::settings_chip( + ui, + &label, + ui_kit::SettingsChip { + key: Some(host), + monospace: true, + remove: Some(app.tr("prefs.network_host_remove")), + ..Default::default() + }, + ); + crate::scenario::publish_target(&format!("prefs:network_host:{host}:remove"), button.rect); + if button.clicked() { + remove = Some(i); + } + } + }); + if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { + app.preferences.network_hosts.remove(i); + } + } + ui.horizontal(|ui| { + let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); + let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(&tip); + }); + ui_kit::help_icon(ui, &tip); + }); + ui_kit::settings_form(ui, labels, |ui| { + ui_kit::settings_row_help(ui, app.tr("prefs.print_face_highlight"), app.tr("prefs.print_face_highlight_tip"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 10.0, |ui| { + ui.spacing_mut().interact_size.x = t.space_24 + t.space_16 + t.space_4; + ui.spacing_mut().button_padding.x = t.space_4; + let response = ui_kit::slider( + ui, + &mut app.preferences.print_face_highlight, + 0.0..=1.0, + ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ); + crate::scenario::publish_target("prefs:print_face_highlight", response.rect); + }); + }); + }); }); - ui_kit::settings_block(ui, app.tr_label("Crash reports"), labels, |ui| { + ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| { + crate::update::prefs_rows(app, ui); + }); + ui_kit::settings_section(ui, "crash_reports", app.tr("prefs.general.crash_reports"), |ui| { ui.horizontal(|ui| { let count = app.crashes.saved_count(); let label = crate::i18n::format("prefs.reports_saved", &[("n", &count.to_string())]); - ui_kit::settings_badge(ui, &label, count > 0); - if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal).clicked() { + let tone = if count == 0 { ui_kit::SettingsBadgeTone::Neutral } else { ui_kit::SettingsBadgeTone::Warning }; + let badge = ui_kit::settings_state_badge(ui, &label, tone); + crate::scenario::publish_rect("prefs:crash_count", badge.rect); + if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal) + .on_hover_text(app.tr("prefs.crash_reports_note")) + .clicked() + { crate::crash_ui::open(app); } }); - ui_kit::text(ui, app.tr("prefs.crash_reports_note"), ui_kit::Text::Hint("crash_reports")); }); }); - ui_kit::settings_section(ui, "file_size", app.tr("prefs.max_file_size"), |ui| file_size_rows(app, ui, labels)); - // Where the files a design names may be: never a network location the user hasn't allowed. - ui_kit::settings_section(ui, "network_locations", app.tr("prefs.network_locations_section"), |ui| { - setting::network_locations(app, ui); - ui_kit::text(ui, app.tr("prefs.network_locations_note"), ui_kit::Text::Hint("network_locations")); - let mut remove = None; - for (i, host) in app.preferences.network_hosts.iter().enumerate() { - ui.horizontal(|ui| { - ui_kit::text(ui, &format!("\\\\{host}"), ui_kit::Text::Hint("network_host")); - if ui_kit::button(ui, app.tr("prefs.network_host_remove"), ui_kit::Button::Small).clicked() { - remove = Some(i); - } - }); - } - if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { - app.preferences.network_hosts.remove(i); - } - }); - ui_kit::settings_section(ui, "slicer", app.tr("ui.slicer"), |ui| { - ui_kit::settings_block(ui, app.tr("ui.slicer"), labels, |ui| { - let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); - ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(tip); - }); - ui_kit::settings_block(ui, app.tr("prefs.print_face_highlight"), labels, |ui| { - let tip = app.tr("prefs.print_face_highlight_tip").to_string(); - let response = ui_kit::slider( - ui, - &mut app.preferences.print_face_highlight, - 0.0..=1.0, - ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, - ); - crate::scenario::publish_target("prefs:print_face_highlight", response.rect); - response.on_hover_text(tip); - }); - }); - ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| { - ui_kit::settings_block(ui, app.tr("update.prefs_label"), labels, |ui| crate::update::prefs_rows(app, ui)); - }); } /// Preferences › General › Largest file to open (#4086): the STEP/import and design file limits, @@ -114,33 +150,57 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { fn file_size_rows(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { use solidcraft_engine::io::limits; if !limits::ADJUSTABLE { - ui_kit::text(ui, app.tr("prefs.max_file_size_web"), ui_kit::Text::Hint("max_file_size")); + ui.horizontal(|ui| { + ui_kit::text(ui, app.tr("prefs.max_file_size"), ui_kit::Text::Inline); + ui_kit::help_icon(ui, app.tr("prefs.max_file_size_web")); + }); return; } let top = limits::ceiling() / limits::MB; let (step, design) = (app.tr("prefs.max_file_size_step"), app.tr("prefs.max_file_size_design")); - let rows = [ - (step, "prefs:max_step_file_mb", DEFAULT_STEP_MB, &mut app.preferences.max_step_file_mb), - (design, "prefs:max_design_file_mb", DEFAULT_DESIGN_MB, &mut app.preferences.max_design_file_mb), - ]; - for (label, handle, default, value) in rows { - ui_kit::settings_block(ui, label, labels, |ui| { - ui.horizontal(|ui| { - let field = ui_kit::settings_number(ui, value, default..=top, 1.0); - crate::scenario::publish_rect(handle, field.rect); - let size = limits::describe(value.saturating_mul(limits::MB)); - let note = if *value >= 1024 { - format!("{} ({size})", crate::i18n::tr("prefs.megabytes")) - } else { - crate::i18n::tr("prefs.megabytes").to_string() - }; - ui_kit::settings_inline_note(ui, ¬e); - }); - }); - } + let (step_caption, design_caption, group) = (app.tr("prefs.general.step_import"), app.tr("ui.design"), app.tr("prefs.max_file_size")); + let mut help = app.tr("prefs.max_file_size_note").to_string(); if let Some(bytes) = limits::override_bytes() { - let note = crate::i18n::format("prefs.max_file_size_flag", &[("size", &limits::describe(bytes))]); - ui_kit::text(ui, ¬e, ui_kit::Text::Hint("max_file_size_flag")); + help.push('\n'); + help.push_str(&crate::i18n::format("prefs.max_file_size_flag", &[("size", &limits::describe(bytes))])); } - ui_kit::text(ui, app.tr("prefs.max_file_size_note"), ui_kit::Text::Hint("max_file_size")); + let t = Tokens::get(); + let rows = [ + (step, step_caption, "prefs:max_step_file_mb", DEFAULT_STEP_MB, &mut app.preferences.max_step_file_mb), + (design, design_caption, "prefs:max_design_file_mb", DEFAULT_DESIGN_MB, &mut app.preferences.max_design_file_mb), + ]; + ui_kit::settings_block(ui, group, labels, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| { + ui.spacing_mut().item_spacing.x = t.space_16 + t.space_4; + for (label, caption, handle, default, value) in rows { + let size = limits::describe(value.saturating_mul(limits::MB)); + let unit = crate::i18n::tr("prefs.megabytes"); + let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() }; + let number_width = t.space_24 * 3.0; + let width = (number_width + t.space_4 + ui_kit::ContentWidth::label(ui, ¬e)) + .max(ui_kit::ContentWidth::label(ui, caption)) + .min(ui.max_rect().width()); + ui.allocate_ui_with_layout( + egui::vec2(width, t.control_height + t.space_16 + t.space_4), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.spacing_mut().item_spacing.x = t.space_4; + ui_kit::settings_compound_field(ui, label, caption, |ui| { + let field = ui + .scope(|ui| { + ui.spacing_mut().interact_size.x = number_width; + ui_kit::number_field(ui, value, default..=top, 1.0, "") + }) + .inner + .on_hover_text(&help); + crate::scenario::publish_rect(handle, field.rect); + let size = limits::describe(value.saturating_mul(limits::MB)); + let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() }; + ui_kit::settings_inline_note(ui, ¬e); + }); + }, + ); + } + }); + }); } diff --git a/crates/ui-egui/src/prefs_search.rs b/crates/ui-egui/src/prefs_search.rs index 94266666bc..5e7c0b63ed 100644 --- a/crates/ui-egui/src/prefs_search.rs +++ b/crates/ui-egui/src/prefs_search.rs @@ -782,6 +782,108 @@ mod tests { } } + #[test] + fn general_redesigned_controls_remain_searchable_without_duplicate_settings() { + use egui::accesskit::Role; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); + for (query, label) in [ + ("reopen the designs", "Reopen the designs that were open last time"), + ("autosave every", "Autosave every"), + ("largest file to open: step", "Largest file to open: STEP and import"), + ("largest file to open: design", "Largest file to open: Design"), + ("allow network locations", "Allow network locations in designs"), + ("slicer that is already running", "Open files in a slicer that is already running"), + ("print face highlight", "Print face highlight"), + ("interface language", "Interface language"), + ] { + h.app.prefs_window.filter = query.into(); + h.frames(4); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let at = h.widget(label).unwrap_or_else(|| panic!("{label} must match {query}")); + assert!(body.contains(egui::pos2(at[0] as f32, at[1] as f32)), "{label} is drawn in the results"); + assert!(h.widget(h.app.tr("prefs.search_empty")).is_none(), "{query} has a real result instead of the empty-results message"); + let (_, _, hits) = nav(&h.ctx); + let matching: Vec<_> = hits.iter().filter(|hit| hit.label == label).collect(); + assert_eq!(matching.len(), 1, "{label} must have one real search result"); + assert_eq!(matching[0].page, crate::prefs::PAGE_GENERAL); + let control = match label { + "Autosave every" => Some(("prefs:autosave_minutes", Role::SpinButton, "Minutes", h.app.autosave_minutes)), + "Largest file to open: STEP and import" => { + Some(("prefs:max_step_file_mb", Role::SpinButton, label, h.app.preferences.max_step_file_mb as f64)) + } + "Largest file to open: Design" => { + Some(("prefs:max_design_file_mb", Role::SpinButton, label, h.app.preferences.max_design_file_mb as f64)) + } + "Print face highlight" => { + Some(("prefs:print_face_highlight", Role::Slider, label, f64::from(h.app.preferences.print_face_highlight))) + } + _ => None, + }; + if let Some((identity, role, accessible_label, value)) = control { + let field = crate::scenario::rect_of(identity).expect("the matching field retains its stable identity"); + assert!(body.contains_rect(field), "{identity} is drawn in the results"); + let tree = h.output.accesskit_update.as_ref().unwrap(); + // Search also measures invisible, disabled probe UIs. Count the live control. + let controls: Vec<_> = tree + .nodes + .iter() + .filter(|(_, node)| !node.is_disabled() && node.role() == role && node.label() == Some(accessible_label)) + .collect(); + assert_eq!(controls.len(), 1, "{identity} must have one real searchable control"); + let node = &controls[0].1; + assert_eq!(node.numeric_value(), Some(value), "{identity} exposes its actual setting value"); + let bounds = node.bounds().unwrap(); + assert!(field.contains(egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32))); + } + } + // An unavailable control is still a real result. Invisible search probes are also + // disabled, so require the live navigation hit and painted label as well as metadata. + h.app.updater.kind = Some(crate::update::InstallKind::Dev); + assert!(!crate::update::available(&mut h.app)); + let label = "Check at start and once a day"; + h.app.prefs_window.filter = label.into(); + for checked in [false, true] { + h.app.preferences.check_updates = checked; + h.frames(4); + let (_, _, hits) = nav(&h.ctx); + let matching: Vec<_> = hits.iter().filter(|hit| hit.label == label).collect(); + assert_eq!(matching.len(), 1, "the disabled setting has one live result"); + assert_eq!(matching[0].page, crate::prefs::PAGE_GENERAL); + assert!(h.widget(h.app.tr("prefs.search_empty")).is_none()); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let field = crate::scenario::rect_of("prefs:check_updates").expect("the real scheduling control"); + assert!(body.contains_rect(field)); + let tree = h.output.accesskit_update.as_ref().unwrap(); + let (_, node) = tree + .nodes + .iter() + .rev() + .find(|(_, node)| { + node.role() == Role::CheckBox + && node.label() == Some(label) + && node.bounds().is_some_and(|bounds| { + field.contains(egui::pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32)) + }) + }) + .expect("the disabled checkbox exposes its real state at the rendered control"); + assert!(node.is_disabled()); + assert_eq!(node.toggled(), Some(if checked { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False })); + let painted = h + .shapes + .iter() + .filter(|shape| { + matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == label && body.contains(text.pos) && shape.clip_rect.contains(text.pos)) + }) + .count(); + assert_eq!(painted, 1, "one visible disabled control is painted, beyond probe metadata"); + h.step(&serde_json::json!({"click":{"handle":"prefs:check_updates"}})).unwrap(); + assert_eq!(h.app.preferences.check_updates, checked, "the search result cannot edit an unavailable setting"); + } + } + /// Typing focuses the field, Enter opens that setting, and Escape returns to the page /// that was up when the search started. Recording a shortcut keeps the key. #[test] @@ -836,9 +938,13 @@ mod tests { h.app.home.open = false; crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); h.frames(2); + crate::prefs::reveal_preference(&mut h, "prefs:max_step_file_mb"); let field = crate::scenario::rect_of("prefs:max_step_file_mb").expect("STEP limit field"); + assert!(crate::scenario::rect_of("prefs:page_body").expect("Preferences body").contains_rect(field)); h.step(&serde_json::json!({"click": [field.center().x, field.center().y]})).expect("click the field"); h.frames(2); + let focused = h.ctx.memory(|memory| memory.focused()).expect("the number editor has focus"); + assert!(field.contains_rect(h.ctx.read_response(focused).expect("focused editor response").rect)); h.step(&serde_json::json!({"text": "7"})).expect("type"); h.frames(2); assert!(h.app.prefs_window.filter.is_empty(), "typed `{}` into the search", h.app.prefs_window.filter); diff --git a/crates/ui-egui/src/render_ui/appearance.rs b/crates/ui-egui/src/render_ui/appearance.rs index 36265c03b2..ef0df59379 100644 --- a/crates/ui-egui/src/render_ui/appearance.rs +++ b/crates/ui-egui/src/render_ui/appearance.rs @@ -221,7 +221,7 @@ fn ended(response: &egui::Response, ui: &egui::Ui) -> bool { || response.changed() && !response.has_focus() && !ui.input(|i| i.pointer.any_down()) && !ui.ctx().egui_wants_keyboard_input() } -pub(super) fn scope_label(app: &SolveApp) -> &str { +pub(in crate::render_workspace) fn scope_label(app: &SolveApp) -> &str { app.tr(match app.session.selection.last() { Some(Sel::Occurrence { .. }) => "render.pick_component", Some(Sel::Face { .. }) => "render.pick_face", @@ -254,7 +254,7 @@ pub(super) fn reset(app: &mut SolveApp) { } } -pub(super) fn remove(app: &mut SolveApp) { +pub(in crate::render_workspace) fn remove(app: &mut SolveApp) { let view = &app.render_workspace.view.inspector.selection_appearance; let selected = targets(app, view.inspected.unwrap_or(mode_step(view.mode))); if let Some(look) = view.draft.clone() { @@ -265,7 +265,7 @@ pub(super) fn remove(app: &mut SolveApp) { } } -pub(super) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) { +pub(in crate::render_workspace) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { let enabled = !app.session.selection.is_empty(); if ui_kit::icon_label_action(ui, app.tr("render.inspector_save_library"), Some("plus"), enabled, false) @@ -283,7 +283,7 @@ pub(super) fn footer(app: &mut SolveApp, ui: &mut egui::Ui) { } /// Returns false only when the explicit-target fallback should be shown. -pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) -> bool { +pub(in crate::render_workspace) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) -> bool { if app.session.selection.is_empty() { app.render_workspace.view.inspector.selection_appearance = View::default(); return false; diff --git a/crates/ui-egui/src/render_ui/assign.rs b/crates/ui-egui/src/render_ui/assign.rs index ae80e0694b..79472aeb09 100644 --- a/crates/ui-egui/src/render_ui/assign.rs +++ b/crates/ui-egui/src/render_ui/assign.rs @@ -563,61 +563,6 @@ pub(crate) fn selection_bar(app: &mut SolveApp, ui: &mut egui::Ui, free: egui::R } /// Existing appearance commands exposed alongside the shared inheritance widget. -pub(crate) fn selection_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - let Some(sel) = app.session.selection.last().cloned() else { return }; - let Ok(target) = chain_target(app, &sel) else { return }; - if let Ok(chain) = app.session.execute("appearance.chain", &json!({"target":target})) { - let steps = chain["steps"].as_array().map(Vec::as_slice).unwrap_or_default(); - let looks = - std::array::from_fn(|index| steps.get(index).and_then(|s| s.get("look")).filter(|l| !l.is_null()).map(super::library::tile_sphere)); - let active = match chain["active"].as_str() { - Some("face") => ui_kit::LookStep::Face, - Some("body") => ui_kit::LookStep::Body, - Some("component") => ui_kit::LookStep::Component, - _ => ui_kit::LookStep::Material, - }; - if ui_kit::look_chain( - ui, - "render:look_chain", - [app.tr("render.pick_face"), app.tr("render.pick_body"), app.tr("render.pick_component"), app.tr("render.look_material")], - looks, - active, - ) - .is_some() - { - app.start("appearance.assign"); - } - } - ui_kit::dropdown_menu(ui, "render_selection_actions", app.tr("cmd.appearance_assign"), |ui| { - for (id, label) in - [("appearance.assign", "cmd.appearance_assign"), ("appearance.replace", "render.replace_all"), ("appearance.copy", "cmd.appearance_copy")] - { - if let Some(command) = solidcraft_engine::find_command(id) { - let info = command.info(&app.session); - if ui_kit::command_menu_button( - ui, - id, - app.tr(label), - command.icon, - &crate::keymap::display_for(app, id).unwrap_or_default(), - info.enabled, - ) - .clicked() - { - app.start(id); - ui.close(); - } - } - } - if ui_kit::command_menu_button(ui, "render_remove_override", app.tr("render.remove_override"), "delete", "", true).clicked() { - if let Ok(targets) = targets(app, &app.session.selection) { - let _ = app.run("appearance.assign", targets); - } - ui.close(); - } - }); -} - pub(crate) fn replacement_dialog(app: &mut SolveApp, ctx: &egui::Context) { let Some(pending) = app.render_workspace.view.assign.replacement.clone() else { return }; if pending.revision != app.session.revision || !pending.document.ptr_eq(&std::sync::Arc::downgrade(&app.session.doc)) { diff --git a/crates/ui-egui/src/render_ui/inspector.rs b/crates/ui-egui/src/render_ui/inspector.rs new file mode 100644 index 0000000000..52e1932703 --- /dev/null +++ b/crates/ui-egui/src/render_ui/inspector.rs @@ -0,0 +1,190 @@ +//! One floating editor for the selected Render item. +use super::{RenderSelection, environment, inspector, library, light, setups, shot, wear}; +use crate::{SolveApp, ui_kit}; +use inspector::selection_appearance; +use serde_json::json; + +pub(crate) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { + // Reviews belong to the workspace, including when their originating editor closes. + inspector::confirmation(app, ui.ctx()); + library::review::dialog(app, ui.ctx()); + library::review::confirmation(app, ui.ctx()); + if app.render_workspace.view.inspector.hidden || app.render_workspace.view.selection != RenderSelection::Environment { + environment::stage_confirmation(app, ui.ctx()); + } + if app.render_workspace.view.inspector.hidden { + crate::shell::register_panel(app, "render:inspector", None, None); + return; + } + setups::refresh_dirty(app); + setups::sync_revision(app); + let selection = app.render_workspace.view.selection.clone(); + let kind = kind(&selection); + let appearance = matches!(selection, RenderSelection::Object(_)); + let (title, icon) = title(app, &selection); + let scope = selection_appearance::scope_label(app).to_owned(); + let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap()); + let mut side = app.preferences.render_inspector_side.clone(); + let mut width = app.preferences.render_inspector_width; + let action = std::cell::Cell::new(None); + let content = ui_kit::Panel::workspace_with_header( + ui, + ui_kit::WorkspaceOptions { + id: "render:inspector", + title: &title, + icon, + bounds, + fill_height: true, + compact: crate::render_ui::compact(app, bounds.width()), + editor: appearance, + enabled: true, + header_actions: None, + }, + &mut side, + &mut width, + false, + |ui, options| { + if appearance { + action.set(ui_kit::inspector_header(ui, options.icon, options.title, &scope)); + } else { + ui_kit::panel::header(ui, options.icon, options.title, || action.set(Some(ui_kit::InspectorAction::Close))); + } + }, + |ui| { + if appearance { + ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui)); + } else if selection == RenderSelection::Output { + ui_kit::Panel::pinned_footer(ui, "render_output_footer", 1, |ui| { + output_actions(app, ui); + crate::scenario::publish_rect("render:output-footer", ui.min_rect()); + }); + } + egui::ScrollArea::vertical() + .max_width(ui.available_width()) + .id_salt(("render_inspector", kind, app.docs.active)) + .auto_shrink([false, false]) + .show(ui, |ui| { + let before = setups::draft(app).ok(); + let rollback = app.render_workspace.view.clone(); + let response = ui.scope(|ui| match &selection { + RenderSelection::Nothing => inspector::scene_controls(app, ui), + RenderSelection::Object(_) => { + selection_appearance::controls(app, ui); + } + RenderSelection::Wear => wear::controls(app, ui), + RenderSelection::Environment => environment::editor(app, ui), + RenderSelection::Lighting => light::lighting(app, ui), + RenderSelection::Light(id) => light::editor(app, ui, id), + RenderSelection::Shot(name) => shot::editor(app, ui, name), + RenderSelection::Output => inspector::output_editor(app, ui), + RenderSelection::Sampling => inspector::sampling_editor(app, ui), + RenderSelection::Effects => super::effects::output_controls(app, ui), + RenderSelection::Check => inspector::check_editor(app, ui), + }); + if matches!(selection, RenderSelection::Output | RenderSelection::Sampling | RenderSelection::Effects | RenderSelection::Wear) + && let Some(before) = before + && let Err(error) = setups::persist_editor(app, &before) + { + // Refuse the numerical edit without undoing transient editor navigation. + let inspector = std::mem::take(&mut app.render_workspace.view.inspector); + app.render_workspace.view = rollback; + app.render_workspace.view.inspector = inspector; + app.set_status(error, true); + } + crate::scenario::publish_rect(&format!("render:editor:{kind}"), response.response.rect); + }); + }, + ); + match action.get() { + Some(ui_kit::InspectorAction::Close) => { + let _ = app.run("view.render_inspector", json!({"visible":false})); + } + Some(ui_kit::InspectorAction::Remove) => selection_appearance::remove(app), + None => {} + } + app.preferences.render_inspector_width = width; + let requested = content + .as_ref() + .and_then(|c| c.inner.1) + .map(str::to_string) + .or_else(|| (app.preferences.render_inspector_side != side).then(|| side.clone())); + if let Some(side) = requested { + let _ = app.run("view.render_dock", json!({"panel":"inspector","side":side})); + } + let rect = content.map(|content| content.response.rect); + crate::shell::register_panel(app, "render:inspector", rect, Some(if side == "left" { ui_kit::Edge::Left } else { ui_kit::Edge::Right })); + if let Some(rect) = rect { + crate::scenario::publish_rect("render:inspector", rect); + } +} + +pub(super) fn output_actions(app: &mut SolveApp, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for (id, label, target) in [ + ("render.start", app.tr("cmd.render_start").to_owned(), "render:output:render"), + ( + "render.batch", + crate::i18n::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]), + "render:output:all-shots", + ), + ] { + let ready = super::ribbon::action_ready(app, id); + let response = ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), id == "render.start"); + let response = if let Err(reason) = ready { response.on_disabled_hover_text(reason) } else { response }; + crate::scenario::publish_target(target, response.rect); + if response.clicked() { + app.start(id); + } + } + }); +} + +fn kind(selection: &RenderSelection) -> &'static str { + match selection { + RenderSelection::Nothing => "scene", + RenderSelection::Object(_) => "appearance", + RenderSelection::Wear => "wear", + RenderSelection::Environment => "environment", + RenderSelection::Lighting => "lighting", + RenderSelection::Light(_) => "light", + RenderSelection::Shot(_) => "shot", + RenderSelection::Output => "output", + RenderSelection::Sampling => "sampling", + RenderSelection::Effects => "effects", + RenderSelection::Check => "check", + } +} + +fn title(app: &SolveApp, selection: &RenderSelection) -> (String, &'static str) { + let (key, icon) = match selection { + RenderSelection::Nothing => ("render.page_scene", "render_scene"), + RenderSelection::Object(items) => { + let name = items + .last() + .and_then(|item| match item { + solidcraft_engine::Sel::Body { name } | solidcraft_engine::Sel::Face { body: name, .. } => Some(name.clone()), + solidcraft_engine::Sel::Occurrence { path } => app.session.occurrence_placement(path).ok().map(|p| p.name), + _ => None, + }) + .unwrap_or_else(|| app.tr("render.page_appearance").to_owned()); + return (name, "body"); + } + RenderSelection::Shot(name) => return (name.clone(), "camera"), + RenderSelection::Light(id) => { + let name = light::rows(app) + .ok() + .and_then(|rows| rows.into_iter().find(|row| &row.id == id)) + .map(|row| light::name(app, &row)) + .unwrap_or_else(|| app.tr("render.tree_lights").to_owned()); + return (name, "render_scene"); + } + RenderSelection::Wear => ("render.wear_title", "appearance"), + RenderSelection::Environment => ("render.page_environment", "environment_map"), + RenderSelection::Lighting => ("render.page_lighting", "render_scene"), + RenderSelection::Output => ("render.page_output", "export"), + RenderSelection::Sampling => ("render.children.sampling", "settings"), + RenderSelection::Effects => ("render.page_effects", "camera"), + RenderSelection::Check => ("render.children.check", "inspect"), + }; + (app.tr(key).to_owned(), icon) +} diff --git a/crates/ui-egui/src/render_ui/scene.rs b/crates/ui-egui/src/render_ui/scene.rs index 0b293f426c..ee1eb5005d 100644 --- a/crates/ui-egui/src/render_ui/scene.rs +++ b/crates/ui-egui/src/render_ui/scene.rs @@ -105,13 +105,13 @@ fn section(app: &mut SolveApp, ui: &mut egui::Ui, key: &str) -> bool { crate::scenario::publish_target(&id, rect); } if row.actions.iter().any(|r| r.clicked()) { - // The existing Shots manager owns naming and saving. This navigation never saves. + // Shot naming and saving use the existing Save shot prompt. if key == "lights" { if let Err(error) = super::light::edit(app, "render.lights.add", None, Some(json!({"kind":"area"}))) { app.set_status(error, true); } } else { - app.start("render.settings.get"); + app.start("render.settings.set"); } } if row.clicked && key == "lights" { @@ -200,7 +200,6 @@ pub(crate) fn sections(app: &mut SolveApp, ui: &mut egui::Ui) { } if row.clicked { select(app, "environment", None); - app.render_workspace.view.inspector.page = inspector::Page::Environment; } if section(app, ui, "lights") { for (index, light) in super::light::rows(app).unwrap_or_default().iter().enumerate() { @@ -243,7 +242,6 @@ pub(crate) fn sections(app: &mut SolveApp, ui: &mut egui::Ui) { } if row.clicked { select(app, "light", Some(id)); - app.render_workspace.view.inspector.page = inspector::Page::Lighting; } } } diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 7a786a094c..bcb40c1b7f 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -38,8 +38,10 @@ mod hdr; mod effects; #[path = "render_workspace_environment.rs"] mod environment; -#[path = "render_workspace_inspector.rs"] +#[path = "render_workspace_output.rs"] mod inspector; +#[path = "render_ui/inspector.rs"] +mod inspector_panel; #[path = "render_workspace_library.rs"] mod library; @@ -304,14 +306,6 @@ impl Stage { Self::Output => "render.stage_output", } } - fn page(self) -> inspector::Page { - match self { - Self::Look => inspector::Page::Appearance, - Self::Light => inspector::Page::Lighting, - Self::Frame => inspector::Page::Camera, - Self::Output => inspector::Page::Output, - } - } } /// Browser focus is transient per-document view state, never part of a saved design. @@ -332,18 +326,18 @@ pub enum RenderSelection { Check, } -/// Selection-driven homes introduced by E1/E7; remaining editors retain their adapters. +/// Focus a Render editor without changing the shared modelling selection. pub(crate) fn selection_editor(app: &mut SolveApp, selection: &RenderSelection) { - app.render_workspace.view.inspector.page = match selection { - RenderSelection::Nothing => inspector::Page::Scene, - RenderSelection::Shot(_) => inspector::Page::Camera, - RenderSelection::Output => inspector::Page::Output, - _ => return, - }; + app.render_workspace.view.selection = selection.clone(); + app.render_workspace.view.inspector.hidden = false; } -pub(crate) fn select_lighting(app: &mut SolveApp) { - app.render_workspace.view.inspector.page = inspector::Page::Lighting; +pub(crate) fn object_selection(app: &SolveApp) -> RenderSelection { + if app.session.selection.is_empty() { RenderSelection::Nothing } else { RenderSelection::Object(app.session.selection.clone()) } +} +pub(crate) fn current_shot_selection(app: &SolveApp) -> RenderSelection { + let name = &app.render_workspace.view.name; + if app.session.doc.render.setups.contains_key(name) { RenderSelection::Shot(name.clone()) } else { RenderSelection::Nothing } } /// Small per-document editor state; GPU uploads and host capabilities stay outside tabs. @@ -363,6 +357,7 @@ pub struct ViewState { draft: RenderSetup, dirty: bool, clean_setup: Option, + clean_name: String, job: Option, completed: Option, retained: Vec, @@ -431,6 +426,7 @@ impl Default for ViewState { draft: RenderSetup::default(), dirty: false, clean_setup: None, + clean_name: "Studio".into(), job: None, completed: None, retained: Vec::new(), @@ -699,9 +695,7 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> { } } setups::refresh_dirty(app); - if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty { - setups::load(app); - } + setups::sync_revision(app); // Fit once the setup in effect is known: the fit frames that image's aspect. if entering { let scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|e| e.to_string())?; @@ -742,7 +736,6 @@ pub(crate) fn select_stage(app: &mut SolveApp, key: &str) { }; app.render_workspace.view.stage = stage; app.render_workspace.view.inspector.hidden = false; - app.render_workspace.view.inspector.page = stage.page(); let sections: &[&str] = match stage { Stage::Look => &["objects"], Stage::Light => &["lights", "environment"], @@ -815,7 +808,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { if crate::render_ui::focused(app) { return; } - inspector::panel(app, ui); + inspector_panel::panel(app, ui); } /// Interactive stage-panel review. Direct engine JSON calls remain dialog-free. @@ -1149,6 +1142,7 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) { completed_image(app, ui); } +#[cfg(test)] fn job_controls(app: &mut SolveApp, ui: &mut egui::Ui, compact: bool) { let params = image_params(app); if !compact { @@ -2392,16 +2386,5 @@ pub(crate) fn comparison_controls(app: &mut SolveApp, ui: &mut egui::Ui) { compare::controls(app, ui); } pub(crate) fn start_interactive(app: &mut SolveApp, id: &str) -> bool { - let handled = assign::start(app, id) || ribbon::start(app, id); - // Opening Appearance Advanced must still reach its atmosphere controls after leaving Wear. - if handled - && id == "render.aging.describe" - && app.render_workspace.view.inspector.page == inspector::Page::Appearance - && app.render_workspace.view.selection == RenderSelection::Wear - { - let _ = app.run("view.render_select", json!({"kind":"nothing"})); - // Clearing semantic focus routes to Scene; retain the explicit Advanced destination. - app.render_workspace.view.inspector.page = inspector::Page::Appearance; - } - handled + assign::start(app, id) || ribbon::start(app, id) } diff --git a/crates/ui-egui/src/render_workspace_cpu.rs b/crates/ui-egui/src/render_workspace_cpu.rs index 9251f1c1c1..36fa62c94b 100644 --- a/crates/ui-egui/src/render_workspace_cpu.rs +++ b/crates/ui-egui/src/render_workspace_cpu.rs @@ -258,6 +258,25 @@ pub(super) fn revalidate(app: &mut SolveApp, name: &str) -> Result<(), String> { Ok(()) } +pub(super) fn refresh_render_revision(app: &mut SolveApp) { + let mut sources = super::setups::SourceCache::default(); + let eligible: Vec = app + .render_workspace + .view + .cpu + .bindings + .iter() + .filter(|(name, binding)| sources.same_model_source(app, &binding.document) && target(app, name).as_ref() == Some(&binding.target)) + .map(|(name, _)| name.clone()) + .collect(); + for name in eligible { + if let Some(binding) = app.render_workspace.view.cpu.bindings.get_mut(&name) { + binding.revision = app.session.revision; + binding.document = std::sync::Arc::downgrade(&app.session.doc); + } + } +} + /// A removed, hidden or ambiguous body never silently becomes a different target. pub(super) fn set_reflection(app: &mut SolveApp, name: &str, mode: Option) -> Result<(), String> { let selected = if mode.is_some() { Some(target(app, name).ok_or_else(|| app.tr("render.cpu_body_missing").to_string())?) } else { None }; diff --git a/crates/ui-egui/src/render_workspace_effects.rs b/crates/ui-egui/src/render_workspace_effects.rs index 1207ded0c3..06851f0ad9 100644 --- a/crates/ui-egui/src/render_workspace_effects.rs +++ b/crates/ui-egui/src/render_workspace_effects.rs @@ -74,18 +74,6 @@ pub(super) fn layer_actions(app: &SolveApp, ui: &mut egui::Ui, prefix: &str, sel (movement, remove) } -pub(super) fn aging_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - super::wear::controls(app, ui); - if app.render_workspace.view.method != ImageMethod::GpuRaytrace { - return; - } - let title = app.tr("render.aging_airborne").to_string(); - ui_kit::section(ui, "render_aging_atmosphere", &title, false, |ui| { - note(app, ui, "render.aging_airborne_help"); - super::environment::atmosphere(app, ui); - }); -} - pub(super) fn presentation_controls(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::banner(ui, ui_kit::AssistantTone::Neutral, |ui| note(app, ui, "render.children.effects_banner")); let enabled = app.render_workspace.view.method == ImageMethod::GpuRaytrace; diff --git a/crates/ui-egui/src/render_workspace_environment.rs b/crates/ui-egui/src/render_workspace_environment.rs index 7ffd78ef9e..141ae7a976 100644 --- a/crates/ui-egui/src/render_workspace_environment.rs +++ b/crates/ui-egui/src/render_workspace_environment.rs @@ -55,8 +55,7 @@ fn ring_target(app: &SolveApp) -> bool { let view = &app.render_workspace.view; super::active(app) && !view.result - && (view.selection == super::RenderSelection::Environment - || matches!(view.inspector.page, super::inspector::Page::Environment | super::inspector::Page::Background)) + && view.selection == super::RenderSelection::Environment && environment_values(app).is_some_and(|(_, _, _, rotates)| rotates) && !app.session.model.is_stale() } @@ -268,12 +267,10 @@ pub(super) fn editor(app: &mut SolveApp, ui: &mut egui::Ui) { } pub(super) fn open_sources(app: &mut SolveApp) { - let page = app.render_workspace.view.inspector.page; super::library::open_category(app, "hdr"); if let Err(error) = app.run("view.render_stage", json!({"stage":Stage::Light.key()})) { app.set_status(error, true); } - app.render_workspace.view.inspector.page = page; } fn note(app: &SolveApp, ui: &mut egui::Ui, key: &str) { diff --git a/crates/ui-egui/src/render_workspace_hdr.rs b/crates/ui-egui/src/render_workspace_hdr.rs index 30caf28f1e..651894a6d4 100644 --- a/crates/ui-egui/src/render_workspace_hdr.rs +++ b/crates/ui-egui/src/render_workspace_hdr.rs @@ -122,9 +122,7 @@ pub(super) fn pick(app: &mut SolveApp, target: Target) { } if let Err(error) = inspect(app, target, path) { app.set_status(error, true); - } else if app.render_workspace.view.selection == super::RenderSelection::Environment - || matches!(app.render_workspace.view.inspector.page, super::inspector::Page::Environment | super::inspector::Page::Background) - { + } else if app.render_workspace.view.selection == super::RenderSelection::Environment { super::environment::persist(app); } }); diff --git a/crates/ui-egui/src/render_workspace_inspector_tests.rs b/crates/ui-egui/src/render_workspace_inspector_tests.rs index b848b9f638..6cc3d98333 100644 --- a/crates/ui-egui/src/render_workspace_inspector_tests.rs +++ b/crates/ui-egui/src/render_workspace_inspector_tests.rs @@ -6,6 +6,7 @@ fn fixture() -> Harness { h.app.run("solid.box", json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap(); super::super::enter(&mut h.app, true).unwrap(); h.app.run("view.render_stage", json!({"stage":"output"})).unwrap(); + h.app.run("view.render_select", json!({"kind":"output"})).unwrap(); h.frames(3); h } @@ -135,7 +136,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_ let before = h.app.session.doc.to_json(); let undo = h.app.session.undo.len(); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene); + assert_eq!(selected_editor(&h.app), Editor::Scene); ordered_sections(&["render.scene_stages", "render.scene_ready", "render.scene_reminders"], &h.app); let mut bottom = f32::NEG_INFINITY; for id in ["look", "light", "frame", "output"] { @@ -149,7 +150,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_ assert!(crate::scenario::rect_of("dialog:note:render.scene_batch_reason").is_some()); h.step(&json!({"click":{"handle":"render:scene:frame"}})).unwrap(); assert!(matches!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot(_))); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Camera); + assert_eq!(selected_editor(&h.app), Editor::Camera); h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap(); h.frames(2); h.step(&json!({"click":{"handle":"render:scene:output"}})).unwrap(); @@ -157,7 +158,7 @@ fn render_workspace_scene_overview_selection_order_links_and_readiness_are_read_ h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap(); h.frames(2); h.step(&json!({"click":{"handle":"render:scene:open-check"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Diagnostics); + assert_eq!(selected_editor(&h.app), Editor::Check); assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Check); assert!(crate::scenario::rect_of("render:check").is_some()); assert_eq!(h.app.session.doc.to_json(), before); @@ -174,7 +175,7 @@ fn render_workspace_shot_e7_order_disabled_reasons_output_link_and_confirm_remov h.app.run("view.render_select", json!({"kind":"shot","id":name})).unwrap(); let before = h.app.session.doc.to_json(); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Camera); + assert_eq!(selected_editor(&h.app), Editor::Camera); assert!(crate::scenario::rect_of("render:job-footer").is_none(), "Shot owns its actions"); assert!(crate::scenario::rect_of("dialog:editor_footer").is_none(), "Appearance footer stays in Appearance"); let inspector = crate::scenario::rect_of("render:inspector").unwrap(); @@ -198,7 +199,7 @@ fn render_workspace_shot_e7_order_disabled_reasons_output_link_and_confirm_remov assert_eq!(h.app.session.doc.to_json(), before, "cancelling removal keeps the Shot"); h.step(&json!({"click":{"handle":"render:shot:output"}})).unwrap(); assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Output); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output); + assert_eq!(selected_editor(&h.app), Editor::Output); } #[test] @@ -253,55 +254,71 @@ fn render_workspace_shot_lens_saves_only_lens_and_orthographic_keeps_faded_contr assert_eq!(h.app.session.doc.render.setups[name], before, "opening must not change saved intent or method"); } #[test] -fn native_inspector_keeps_stage_actions_on_screen_and_output_has_no_pinned_footer() { +fn inspector_dispatch_has_one_editor_per_selection_independent_of_every_stage() { + use super::super::{RenderSelection, Stage}; for dark in [false, true] { for layout in [crate::shell::LayoutMode::Docked, crate::shell::LayoutMode::Floating] { let mut h = fixture(); h.app.preferences.theme = if dark { "dark" } else { "light" }.into(); h.app.ui.layout = layout; - h.call("ui.resize", json!({"width":1280,"height":900})); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output); + h.call("ui.resize", json!({"width":1440,"height":1000})); + super::super::select_image_method(&mut h.app, ImageMethod::CpuRaytrace); + super::super::light::edit(&mut h.app, "render.lights.add", None, Some(json!({"kind":"point"}))).unwrap(); + let light = super::super::light::rows(&h.app).unwrap().last().unwrap().id.clone(); + let shot = h.app.render_workspace.view.name.clone(); let before = h.app.session.doc.to_json(); - let revision = h.app.session.revision; - let history = (h.app.session.undo.len(), h.app.session.redo.len()); - for page in Page::ALL { - h.app.render_workspace.view.inspector.page = page; - h.frames(3); - let inspector = crate::scenario::rect_of("render:inspector").unwrap(); - assert_eq!(h.app.session.doc.to_json(), before); - assert_eq!(h.app.session.revision, revision); - assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); - assert!(h.app.render_workspace.view.job.is_none()); - assert!(h.app.render_workspace.view.completed.is_none()); - if page == Page::Appearance { - let footer = crate::scenario::rect_of("dialog:editor_footer").unwrap(); - assert!(inspector.contains_rect(footer), "Appearance footer outside inspector: {inspector:?} {footer:?}"); - assert!(crate::scenario::rect_of("render:job-footer").is_none()); - continue; - } - if matches!(page, Page::Output | Page::Effects | Page::Diagnostics | Page::Sampling) { - assert!(crate::scenario::rect_of("render:job-footer").is_none()); - let bar = crate::scenario::rect_of("render:stage_bar").unwrap(); - for name in ["render:stage:render.start", "render:stage:render.cancel"] { - let action = crate::scenario::rect_of(name).unwrap(); - assert!(bar.contains_rect(action), "{name} outside stage bar: {bar:?} {action:?}"); - } - } else if page == Page::Scene { - let stages = crate::scenario::rect_of(&format!("dialog:section:{}", h.app.tr("render.scene_stages"))).unwrap(); - assert!(inspector.contains_rect(stages), "Scene stages outside inspector: {inspector:?} {stages:?}"); - assert!(crate::scenario::rect_of("render:job-footer").is_none()); + let undo = (h.app.session.undo.len(), h.app.session.redo.len()); + for (selection, kind) in [ + (RenderSelection::Nothing, "scene"), + (RenderSelection::Object(vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]), "appearance"), + (RenderSelection::Environment, "environment"), + (RenderSelection::Lighting, "lighting"), + (RenderSelection::Light(light.clone()), "light"), + (RenderSelection::Shot(shot.clone()), "shot"), + (RenderSelection::Output, "output"), + (RenderSelection::Sampling, "sampling"), + (RenderSelection::Effects, "effects"), + (RenderSelection::Check, "check"), + (RenderSelection::Wear, "wear"), + ] { + if let RenderSelection::Object(_) = &selection { + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Subject".into() }]})).unwrap(); } else { - let footer = crate::scenario::rect_of("render:job-footer").unwrap(); - assert!(inspector.contains_rect(footer), "footer outside inspector at {page:?}: {inspector:?} {footer:?}"); - for name in ["render:check", "render:start", "render:cancel", "render:export"] { - let target = crate::scenario::rect_of(name).unwrap(); - assert!(footer.contains_rect(target), "{name} clipped at {page:?}"); + let mut params = json!({"kind":if kind == "scene" { "nothing" } else { kind }}); + if let RenderSelection::Light(id) | RenderSelection::Shot(id) = &selection { + params["id"] = json!(id); } + h.app.run("view.render_select", params).unwrap(); + } + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), undo); + let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); + for stage in Stage::ALL { + h.app.run("view.render_stage", json!({"stage":stage.key()})).unwrap(); + h.frames(3); + assert_eq!(h.app.render_workspace.view.selection, selection); + let editors = crate::scenario::rects("render:editor:"); + assert_eq!(editors.len(), 1, "{kind}/{stage:?}: {editors:?}"); + assert!(crate::scenario::rect_of(&format!("render:editor:{kind}")).is_some()); + assert!(crate::scenario::rect_of("render:job-footer").is_none()); + assert!(crate::scenario::rect_of("render:setups").is_none()); + assert!(crate::scenario::rect_of("render:setup-name").is_none()); + assert_eq!(crate::scenario::rect_of("render:output-footer").is_some(), kind == "output"); + assert_eq!(crate::scenario::rect_of("dialog:editor_footer").is_some(), kind == "appearance"); + assert!(h.widget(h.app.tr("render.studio_title")).is_none()); + assert_eq!(h.app.render_workspace.view.method, ImageMethod::CpuRaytrace); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history); + assert!(h.app.render_workspace.view.job.is_none()); + assert!(h.app.render_workspace.view.completed.is_none()); + } + if !dark && layout == crate::shell::LayoutMode::Floating { + appearance_inspector_capture(&mut h, &format!("dispatch-{kind}")); } } } } } + #[test] fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts() { let mut h = fixture(); @@ -310,7 +327,7 @@ fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts() h.app.render_workspace.view.draft.width = 777; for stage in super::super::Stage::ALL { h.step(&json!({"click":{"handle":format!("render:stage:{}",stage.key())}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, stage.page()); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Output); assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster); assert_eq!(h.app.render_workspace.view.samples, 37); assert_eq!(h.app.render_workspace.view.draft.width, 777); @@ -319,32 +336,83 @@ fn stage_navigation_is_local_and_never_switches_executor_or_overwrites_drafts() } #[test] -fn dirty_shot_switch_keep_discard_and_save_are_explicit_and_lossless() { +fn numerical_editor_saves_before_switch_and_keeps_navigation_camera_explicit() { let mut h = fixture(); h.app.render_workspace.view.name = "A".into(); setups::save(&mut h.app).unwrap(); h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap(); + let before = setups::draft(&h.app).unwrap(); + let camera = before.camera; h.app.render_workspace.view.draft.width = 123; - setups::refresh_dirty(&mut h.app); + h.app.cam.yaw += 0.25; + setups::persist_editor(&mut h.app, &before).unwrap(); + assert_eq!(h.app.session.doc.render.setups["A"].width, 123); + assert_eq!(h.app.session.doc.render.setups["A"].camera, camera); request_action(&mut h.app, PendingAction::Switch("B".into())); - h.frames(3); - h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap(); + assert!(confirmation_open(&h.app), "persisting width must not silently discard the camera draft"); assert_eq!(h.app.render_workspace.view.name, "A"); - assert_eq!(h.app.render_workspace.view.draft.width, 123); - request_action(&mut h.app, PendingAction::Switch("B".into())); h.frames(3); h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); assert_eq!(h.app.render_workspace.view.name, "B"); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("B".into())); assert_eq!(h.app.render_workspace.view.draft.width, 321); - assert_ne!(h.app.session.doc.render.setups["A"].width, 123); - h.app.render_workspace.view.draft.width = 456; - setups::refresh_dirty(&mut h.app); - request_action(&mut h.app, PendingAction::Switch("A".into())); - h.frames(3); - h.step(&json!({"widget":h.app.tr("render.save_continue")})).unwrap(); - assert_eq!(h.app.session.doc.render.setups["B"].width, 456); - assert_eq!(h.app.render_workspace.view.name, "A"); + assert_eq!(h.app.session.doc.render.setups["A"].width, 123); } + +#[test] +fn shot_switch_confirms_transient_inputs_and_current_shot_focus_preserves_them() { + use solidcraft_engine::render::job::ReflectionMode; + for kind in ["glass", "maps", "cpu", "camera"] { + let mut h = fixture(); + h.app.render_workspace.view.name = "A".into(); + super::super::select_image_method(&mut h.app, if kind == "cpu" { ImageMethod::CpuRaytrace } else { ImageMethod::GpuRaytrace }); + setups::save(&mut h.app).unwrap(); + h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap(); + setups::load(&mut h.app); + if kind == "cpu" { + super::super::cpu::set_reflection(&mut h.app, "Subject", Some(ReflectionMode::Mirror)).unwrap(); + } else if kind == "camera" { + h.app.cam.yaw += 0.25; + } else { + let target = materials::read_targets(&mut h.app).unwrap().remove(0); + let mut binding = materials::Binding::new(target, &h.app); + binding.glass_enabled = kind == "glass"; + binding.maps_enabled = kind == "maps"; + h.app.render_workspace.view.materials.enabled = true; + h.app.render_workspace.view.materials.bindings.push(binding); + } + let camera = h.app.cam; + let runtime = format!("{:?}", h.app.render_workspace.view.materials); + let reflections = h.app.render_workspace.view.raytrace.reflections.clone(); + let doc = h.app.session.doc.clone(); + let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); + switch_shot(&mut h.app, "A".into()); + assert!(!confirmation_open(&h.app), "focusing the current shot must not reload {kind}"); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("A".into())); + assert_eq!(format!("{:?}", h.app.render_workspace.view.materials), runtime); + assert_eq!(h.app.render_workspace.view.raytrace.reflections, reflections); + assert_eq!(h.app.cam, camera); + switch_shot(&mut h.app, "B".into()); + h.frames(3); + assert!(confirmation_open(&h.app), "unsaved {kind} must be reviewed"); + assert_eq!(h.app.render_workspace.view.name, "A"); + h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap(); + assert!(!confirmation_open(&h.app)); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history); + assert_eq!(format!("{:?}", h.app.render_workspace.view.materials), runtime); + assert_eq!(h.app.render_workspace.view.raytrace.reflections, reflections); + assert_eq!(h.app.cam, camera); + switch_shot(&mut h.app, "B".into()); + h.frames(3); + h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); + assert_eq!(h.app.render_workspace.view.name, "B"); + assert_eq!(h.app.render_workspace.view.draft.width, 321); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), history); + } +} + #[test] fn expired_confirmation_cannot_remove_a_changed_shot() { let mut h = fixture(); @@ -711,7 +779,7 @@ fn surface_target_dropdown_keeps_the_chosen_body_through_review() { fn unsupported_output_is_visible_and_never_fakes_export_or_clamps_intent() { let mut h = fixture(); super::super::select_image_method(&mut h.app, ImageMethod::GpuRaytrace); - h.app.render_workspace.view.inspector.page = Page::Output; + select_editor(&mut h.app, Editor::Output); h.app.render_workspace.view.draft.width = 7000; h.app.render_workspace.view.draft.height = 6000; h.app.render_workspace.view.samples = 37; @@ -770,7 +838,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt h.app.render_workspace.view.draft.width = 1920; h.app.render_workspace.view.draft.height = 1080; h.app.render_workspace.view.samples = 37; - h.app.render_workspace.view.inspector.page = Page::Output; + select_editor(&mut h.app, Editor::Output); setups::save(&mut h.app).unwrap(); let saved = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); @@ -791,7 +859,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt expected.height = 1920; assert_eq!(h.app.session.doc.render.setups[&h.app.render_workspace.view.name], expected); assert_eq!(h.app.session.undo.len(), undo + 1); - assert!(h.app.render_workspace.view.dirty); + assert!(!h.app.render_workspace.view.dirty, "the numeric edit is already saved"); setups::load(&mut h.app); h.frames(3); assert_eq!(h.app.render_workspace.view.inspector.resolution.anchor, Some([1080, 1920])); @@ -800,7 +868,7 @@ fn actual_output_ratio_is_local_and_orientation_saves_once_preserving_other_capt #[test] fn contextual_scope_notes_wrap_with_native_tokens_instead_of_clipping() { let mut h = fixture(); - h.app.render_workspace.view.inspector.page = Page::Scene; + select_editor(&mut h.app, Editor::Scene); h.frames(3); let rect = crate::scenario::rect_of("dialog:note:render.scene_preserves_design").unwrap(); let inspector = crate::scenario::rect_of("render:inspector").unwrap(); @@ -909,39 +977,40 @@ fn ribbon_navigation_reuses_inspector_and_preserves_exact_unsaved_intent() { let doc = h.app.session.doc.to_json(); let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); for (id, page) in [ - ("render.preset.resolve", Page::Scene), - ("render.settings.get", Page::Scene), - ("render.library.appearance.apply", Page::Appearance), - ("render.library.surface.bind", Page::Appearance), - ("render.library.studio.prepare", Page::Lighting), - ("render.library.studio.apply", Page::Lighting), - ("render.library.environment.apply", Page::Environment), - ("render.stage.backdrop", Page::Background), - ("render.quality.prepare", Page::Output), - ("render.status", Page::Diagnostics), - ("render.camera.get", Page::Camera), - ("render.camera.set", Page::Camera), - ("render.preset.apply", Page::Scene), - ("render.shot.duplicate", Page::Scene), - ("render.shot.rename", Page::Scene), - ("render.shot.remove", Page::Scene), - ("render.optics.bind", Page::Appearance), - ("render.optics.unbind", Page::Appearance), - ("render.library.surface.prepare", Page::Appearance), - ("render.library.surface.unbind", Page::Appearance), - ("render.library.physical.prepare", Page::Appearance), - ("render.stage.ground", Page::Background), - ("render.stage.clear", Page::Background), - ("render.hdr.inspect", Page::Environment), - ("render.environment.set", Page::Environment), - ("render.settings.capture_hdr", Page::Environment), - ("render.aging.describe", Page::Appearance), - ("render.effects.describe", Page::Effects), - ("render.backends", Page::Diagnostics), + ("render.preset.resolve", Editor::Scene), + ("render.settings.get", Editor::Camera), + ("render.library.appearance.apply", Editor::Appearance), + ("render.library.surface.bind", Editor::Appearance), + ("render.library.studio.prepare", Editor::Lighting), + ("render.library.studio.apply", Editor::Lighting), + ("render.library.environment.apply", Editor::Environment), + ("render.stage.backdrop", Editor::Environment), + ("render.quality.prepare", Editor::Sampling), + ("render.status", Editor::Check), + ("render.camera.get", Editor::Camera), + ("render.camera.set", Editor::Camera), + ("render.preset.apply", Editor::Scene), + ("render.shot.duplicate", Editor::Camera), + ("render.shot.rename", Editor::Camera), + ("render.shot.remove", Editor::Camera), + ("render.optics.bind", Editor::Appearance), + ("render.optics.unbind", Editor::Appearance), + ("render.library.surface.prepare", Editor::Appearance), + ("render.library.surface.unbind", Editor::Appearance), + ("render.library.physical.prepare", Editor::Appearance), + ("render.stage.ground", Editor::Environment), + ("render.stage.clear", Editor::Environment), + ("render.hdr.inspect", Editor::Environment), + ("render.environment.set", Editor::Environment), + ("render.settings.capture_hdr", Editor::Environment), + ("render.aging.describe", Editor::Wear), + ("render.effects.describe", Editor::Effects), + ("render.backends", Editor::Check), ] { h.app.start(id); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, page, "{id}"); + let expected = if matches!(page, Editor::Appearance | Editor::Camera) { Editor::Scene } else { page }; + assert_eq!(selected_editor(&h.app), expected, "{id}"); assert_eq!(h.app.render_workspace.view.samples, 37); assert_eq!(h.app.render_workspace.view.draft.width, 777); assert_eq!(h.app.session.doc.to_json(), doc, "navigation may not execute a parameterless apply"); @@ -1146,7 +1215,7 @@ fn aging_tab_adds_reorders_disables_and_persists_layers_without_engineering_edit let before = h.app.session.doc.to_json(); h.app.start("render.aging.describe"); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Wear); assert!(!crate::icon_svg::resolve(solidcraft_engine::find_command("render.aging.describe").unwrap().icon).placeholder); for code in [1, 3] { h.step(&json!({"click":{"handle":"render:aging-add"}})).unwrap(); @@ -1162,7 +1231,8 @@ fn aging_tab_adds_reorders_disables_and_persists_layers_without_engineering_edit h.step(&json!({"click":{"handle":"render:aging-enabled"}})).unwrap(); h.frames(2); assert!(!h.app.render_workspace.view.studio.aging.get(0).unwrap().enabled); - assert_eq!(h.app.session.doc.to_json(), before, "editing layers only edits the render draft"); + assert_ne!(h.app.session.doc.to_json(), before, "Wear saves its stack through render.settings.set"); + assert_eq!(setups::snapshot(&h.app).unwrap().job, h.app.session.doc.render.setups[&h.app.render_workspace.view.name].job); let targets = super::super::materials::read_targets(&mut h.app).unwrap(); let identity = targets.iter().find(|target| target.name == "Subject").unwrap().stable_target.clone().unwrap(); h.app.render_workspace.view.studio.aging.get_mut(0).unwrap().target = Some(identity.clone()); @@ -1192,7 +1262,7 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() { let context = format!( "layout={:?}, page={:?}, title={title:?}, header={header:?}, pixels_per_point={}", h.app.ui.layout, - h.app.render_workspace.view.inspector.page, + selected_editor(&h.app), h.ctx.pixels_per_point(), ); // egui stores Role::Label text in value(), not label(). Each pass supplies the @@ -1223,36 +1293,31 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() { let mut h = fixture(); h.app.ui.layout = layout; h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } let before = h.app.session.doc.to_json(); let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); h.app.run("view.render_select", json!({"kind":"wear"})).unwrap(); assert!(h.app.session.selection.is_empty(), "Render focus clears object picks"); let wear_history = (history.0 + 1, history.1, history.2); assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), wear_history); - // Exercise stale object picks alongside Wear; normal focus commands clear them. - h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]; - for page in [Page::Output, Page::Appearance] { - h.app.render_workspace.view.inspector.page = page; + for stage in super::super::Stage::ALL { + h.app.run("view.render_stage", json!({"stage":stage.key()})).unwrap(); h.frames(3); assert_header(&h, h.app.tr("render.wear_title")); assert!(crate::scenario::rect_of("render:wear:controls").is_some()); assert!(crate::scenario::rect_of("render:appearance:close").is_none()); assert!(crate::scenario::rect_of("dialog:editor_footer").is_none()); - assert!(crate::scenario::rect_of("render:page:aging").is_none()); - let inspector = crate::scenario::rect_of("render:inspector").unwrap(); - let footer = crate::scenario::rect_of("render:job-footer").unwrap(); - assert!(inspector.contains_rect(footer)); - for name in ["render:check", "render:start", "render:cancel", "render:export"] { - assert!(footer.contains_rect(crate::scenario::rect_of(name).unwrap()), "{name} outside Wear footer"); - } + assert!(crate::scenario::rect_of("render:job-footer").is_none()); assert_eq!(h.app.session.doc.to_json(), before); assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), wear_history); } h.app.run("view.render_select", json!({"kind":"output"})).unwrap(); assert!(h.app.session.selection.is_empty()); - let output_history = (history.0 + 2, history.1, history.2); + let output_history = wear_history; assert_eq!((h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()), output_history); - h.app.render_workspace.view.inspector.page = Page::Appearance; h.frames(3); assert_header(&h, h.app.tr("render.page_output")); assert!(crate::scenario::rect_of("render:wear:controls").is_none()); @@ -1260,9 +1325,12 @@ fn wear_selection_precedes_output_page_and_appearance_selection_chrome() { assert!(crate::scenario::rect_of("render:appearance:close").is_none()); h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap(); // Clearing semantic focus opens Scene; choose Appearance to test its header recovery. - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene); - h.app.render_workspace.view.inspector.page = Page::Appearance; + assert_eq!(selected_editor(&h.app), Editor::Scene); h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.frames(3); assert_header(&h, "Subject"); let inspector = crate::scenario::rect_of("render:inspector").unwrap(); @@ -1317,10 +1385,9 @@ fn wear_selection_shows_disabled_controls_with_reason_and_never_changes_method() assert!(!h.app.render_workspace.view.studio.aging.get(0).unwrap().enabled); h.app.start("render.aging.describe"); h.frames(3); - assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Nothing); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); - assert!(crate::scenario::rect_of("render:wear:controls").is_some(), "Appearance Advanced mounts the same editor"); - assert!(h.shapes.iter().any(|s| text_color(&s.shape, h.app.tr("render.aging_airborne")).is_some())); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Wear); + assert!(crate::scenario::rect_of("render:wear:controls").is_some()); + assert!(h.shapes.iter().all(|s| text_color(&s.shape, h.app.tr("render.aging_airborne")).is_none())); } #[test] @@ -1348,8 +1415,14 @@ fn wear_editor_bounds_stale_selection_and_hostile_layer_values_never_panic() { h.frames(3); assert_eq!(h.app.render_workspace.view.inspector.effects.selected_aging, 0); } + let model = h.app.session.doc.features.clone(); + let undo = h.app.session.undo.len(); h.step(&json!({"click":{"handle":"render:aging-remove"}})).unwrap(); assert!(h.app.render_workspace.view.studio.aging.is_empty()); + let saved = &h.app.session.doc.render.setups[&h.app.render_workspace.view.name]; + assert!(matches!(&saved.job, Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) if options.aging.is_empty())); + assert_eq!(h.app.session.doc.features, model); + assert_eq!(h.app.session.undo.len(), undo + 1, "removing an invalid layer saves one corrective command"); h.frames(3); assert!(crate::scenario::rect_of("render:aging-enabled").is_none()); } @@ -1362,7 +1435,7 @@ fn output_effects_retains_ordered_looks_and_photographic_intent_across_save_and_ let before = h.app.session.doc.to_json(); h.app.start("render.effects.describe"); h.frames(24); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Effects); + assert_eq!(selected_editor(&h.app), Editor::Effects); assert_eq!(h.app.session.doc.to_json(), before, "opening Effects is read-only"); let undo = h.app.session.undo.len(); @@ -1404,7 +1477,7 @@ fn backgrounds_ribbon_exposes_camera_and_physical_options_without_mutating_draft let revision = h.app.session.revision; h.app.start("render.stage.backdrop"); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Background); + assert_eq!(selected_editor(&h.app), Editor::Environment); assert_eq!(setups::snapshot(&h.app).unwrap(), draft); assert_eq!(h.app.session.doc.to_json(), doc); assert_eq!(h.app.session.revision, revision); @@ -1442,7 +1515,7 @@ fn clearing_physical_stage_preserves_unsaved_camera_background_and_output_intent let materials = h.app.session.doc.materials.clone(); h.app.start("render.stage.clear"); h.frames(4); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Background); + assert_eq!(selected_editor(&h.app), Editor::Environment); h.step(&json!({"click":{"handle":"render:stage-clear"}})).unwrap(); h.frames(3); assert_eq!(h.app.render_workspace.view.draft.stage.len(), 1, "removal needs consent"); @@ -1497,22 +1570,21 @@ fn camera_draft_uses_render_command_and_keeps_saved_design_unchanged() { } #[test] -fn shots_strip_uses_the_existing_dirty_shot_confirmation() { +fn shots_strip_switch_keeps_numeric_edits_saved_and_opens_the_selected_shot() { let mut h = fixture(); h.app.render_workspace.view.name = "A".into(); setups::save(&mut h.app).unwrap(); h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap(); + let before = setups::draft(&h.app).unwrap(); h.app.render_workspace.view.draft.width = 123; + setups::persist_editor(&mut h.app, &before).unwrap(); h.frames(3); h.step(&json!({"click":{"handle":"render:shots:setup:B"}})).unwrap(); - assert!(confirmation_open(&h.app)); - assert_eq!(h.app.render_workspace.view.name, "A"); - h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap(); - assert_eq!(h.app.render_workspace.view.draft.width, 123); - h.step(&json!({"click":{"handle":"render:shots:setup:B"}})).unwrap(); - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); + assert!(!confirmation_open(&h.app)); assert_eq!(h.app.render_workspace.view.name, "B"); assert_eq!(h.app.render_workspace.view.draft.width, 321); + assert_eq!(h.app.session.doc.render.setups["A"].width, 123); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Shot("B".into())); } #[test] @@ -1745,39 +1817,22 @@ fn shots_strip_render_submits_the_current_job_request() { } #[test] -fn browser_shot_menus_keep_target_and_draft_through_confirmation() { +fn browser_shot_remove_keeps_confirmation_and_rejects_stale_actions() { let mut h = fixture(); h.app.render_workspace.view.name = "A".into(); setups::save(&mut h.app).unwrap(); h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap(); - h.app.render_workspace.view.draft.width = 123; - h.frames(3); - h.step(&json!({"click":{"handle":"render:tree:shot:B"},"button":"right"})).unwrap(); - h.step(&json!({"widget":h.app.tr("render.shot_rename")})).unwrap(); - assert!(confirmation_open(&h.app)); - assert_eq!(h.app.render_workspace.view.name, "A"); - h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap(); - assert_eq!(h.app.render_workspace.view.draft.width, 123); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Output); - shot_row_action(&mut h.app, "B".into(), "render.shot.rename"); - h.frames(3); - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); - assert_eq!(h.app.render_workspace.view.name, "B"); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Scene); - assert_eq!(h.app.render_workspace.view.draft.width, 321); - h.app.render_workspace.view.draft.width = 456; - shot_row_action(&mut h.app, "A".into(), "render.shot.remove"); + let before = h.app.session.doc.to_json(); + shot_row_action(&mut h.app, "B".into(), "render.shot.remove"); h.frames(3); assert!(confirmation_open(&h.app)); + h.step(&json!({"click":{"handle":"dialog:cancel"}})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + shot_row_action(&mut h.app, "B".into(), "render.shot.remove"); + h.frames(3); h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); - assert!( - !h.app.session.doc.render.setups.contains_key("A"), - "pending={:?}, status={:?}", - h.app.render_workspace.view.inspector.pending, - h.app.status - ); - assert_eq!(h.app.render_workspace.view.name, "B"); - assert_eq!(h.app.render_workspace.view.draft.width, 456, "removing another shot preserves this draft"); + assert!(!h.app.session.doc.render.setups.contains_key("B")); + assert_eq!(h.app.render_workspace.view.name, "A"); } #[test] @@ -1800,6 +1855,10 @@ fn appearance_inspector_targets_and_chain_follow_body_face_and_component() { .as_u64() .unwrap() as usize; h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } let before = h.app.session.doc.to_json(); for (step, present) in [ (ui_kit::LookStep::Face, [true, true, true, true]), @@ -1828,6 +1887,10 @@ fn appearance_inspector_all_users_replaces_exact_look_once_without_changing_mass h.app.run("material.assign", json!({"bodies":["Subject","Other"],"material":"Steel"})).unwrap(); h.app.run("appearance.assign", json!({"bodies":["Subject","Other"],"appearance":"ABS - Red"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } let target = targets(&h.app, ui_kit::LookStep::Body); let from = chains(&mut h.app, &target).unwrap()[0][1].clone().unwrap(); assert_eq!(users(&h.app, &from), 2); @@ -1844,9 +1907,13 @@ fn appearance_inspector_all_users_replaces_exact_look_once_without_changing_mass h.app.run("edit.undo", json!({})).unwrap(); assert_eq!(users(&h.app, &from), 2); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); let undo = h.app.session.undo.len(); h.step(&json!({"click":{"handle":format!("dialog:{}:{}", h.app.tr("render.inspector_applies_to"), crate::i18n::format("render.inspector_all_users", &[("count", "2")]))}})) .unwrap(); @@ -1878,13 +1945,17 @@ fn appearance_inspector_finish_presets_mixed_set_all_and_clear_use_existing_assi h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap(); h.app.run("appearance.assign", json!({"bodies":["Other"],"appearance":"ABS - Blue"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } let target = targets(&h.app, ui_kit::LookStep::Body); let looks: Vec<_> = chains(&mut h.app, &target).unwrap().into_iter().map(|c| c[1].clone().unwrap()).collect(); assert!(mixed(&looks, |l| l.color)); assert!(!mixed(&looks, |l| l.opacity)); h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); assert!(h.widget(h.app.tr("render.tree_mixed")).is_some()); appearance_inspector_capture(&mut h, "mixed"); let mass = h.app.run("component.properties", json!({})).unwrap(); @@ -1918,9 +1989,13 @@ fn appearance_inspector_real_controls_switch_targets_inspect_chain_and_edit_fini .as_u64() .unwrap() as usize; h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); let before = h.app.session.doc.to_json(); h.step(&json!({"click":{"handle":"render:appearance:chain-menu"}})).unwrap(); h.step(&json!({"click":{"handle":"render:appearance:chain:body"}})).unwrap(); @@ -1948,9 +2023,9 @@ fn appearance_inspector_real_controls_switch_targets_inspect_chain_and_edit_fini h.step(&json!({"click":{"handle":format!("dialog::{}",h.app.tr("render.pick_body"))}})).unwrap(); assert_eq!(h.app.session.undo.len(), edited_undo); appearance_inspector_capture(&mut h, "body"); - h.app.session.selection.clear(); + h.app.run("select.clear", json!({})).unwrap(); h.frames(3); - assert!(h.widget(h.app.tr("render.base_appearance")).is_some()); + assert_eq!(h.app.render_workspace.view.selection, super::super::RenderSelection::Nothing); appearance_inspector_capture(&mut h, "explicit-target"); } @@ -1963,9 +2038,13 @@ fn appearance_inspector_mixed_fields_are_inert_until_edited_and_accept_the_first h.app.run("appearance.assign", json!({"bodies":[name],"color":"#123456","opacity":opacity})).unwrap(); } h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); let before = h.app.session.doc.to_json(); let undo = h.app.session.undo.len(); let field = format!("dialog:scalar:{}", h.app.tr("render.inspector_opacity")); @@ -2002,9 +2081,13 @@ fn appearance_inspector_optical_colour_and_opacity_edits_commit_once_with_shared ) .unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); let mass = h.app.run("component.properties", json!({})).unwrap(); for (key, text) in [("appearance.metallic", "2"), ("appearance.roughness", "0"), ("render.inspector_opacity", "0.25")] { let undo = h.app.session.undo.len(); @@ -2044,6 +2127,10 @@ fn appearance_inspector_hex_edits_validate_and_commit_once() { let mut h = fixture(); h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); let before = h.app.session.doc.to_json(); @@ -2098,6 +2185,10 @@ fn appearance_inspector_shared_picker_preserves_exact_opacity_and_commits_alpha( let mut h = fixture(); h.app.run("appearance.assign", json!({"bodies":["Subject"],"color":"#abcdef","opacity":0.123456,"appearance":"Tint"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); let mass = h.app.run("component.properties", json!({})).unwrap(); @@ -2148,6 +2239,10 @@ fn appearance_inspector_shared_picker_pbr_rejects_nonopaque_alpha() { .run("appearance.assign", json!({"bodies":["Subject"],"color":"#123456", "pbr":{"metallic":0.2,"roughness":0.4,"dielectric_ior":1.6}})) .unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); let before = h.app.session.doc.to_json(); @@ -2179,6 +2274,10 @@ fn appearance_inspector_shared_picker_has_no_outer_hex_editor_and_keeps_mixed_va h.app.run("appearance.assign", json!({"bodies":[body],"color":color,"opacity":0.123456})).unwrap(); } h.app.session.selection = vec![Sel::Body { name: "Subject".into() }, Sel::Body { name: "Other".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); let outer = format!("dialog:color_hex:{}", h.app.tr("render.inspector_colour")); @@ -2222,10 +2321,14 @@ fn appearance_inspector_component_and_face_overrides_remove_to_material_from_rea .as_u64() .unwrap() as usize; h.app.session.selection = vec![Sel::Face { body: "Subject".into(), index: face, point: Vec3::new(10.0, 7.0, 10.0) }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } super::super::enter(&mut h.app, true).unwrap(); h.frames(2); h.step(&json!({"click":{"handle":"render:stage:look"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, Page::Appearance); + assert_eq!(selected_editor(&h.app), Editor::Appearance); let mass = h.app.run("component.properties", json!({})).unwrap(); for (step, name) in [("face", "Copper"), ("body", "ABS - Red"), ("component", "ABS - Blue"), ("material", "Steel")] { let before = h.app.session.doc.to_json(); @@ -2260,15 +2363,22 @@ fn appearance_inspector_capture(h: &mut Harness, name: &str) { h.app.preferences.theme = if dark { "dark" } else { "light" }.into(); h.app.ui.dark = dark; h.app.preferences.render_inspector_width = 305.0; - let ctx = egui::Context::default(); - crate::fonts::setup_bundled_fonts(&ctx); + // Texture handles belong to the Harness context. Keep that context and + // reupload cached artwork so this CPU renderer has the complete images, + // including textures uploaded before evidence recording began. + let ctx = h.ctx.clone(); + crate::icons::clear_texture_cache(&ctx); + super::super::library::clear_preview_texture(&mut h.app); + let thumbnails = std::mem::take(&mut h.app.render_workspace.thumbnails); let mut textures = std::collections::HashMap::new(); + textures.insert(egui::TextureId::default(), ctx.fonts(|fonts| fonts.image())); + let start = ctx.input(|input| input.time); let mut last = None; for frame in 0..10 { let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), - time: Some(frame as f64 / 10.0), + time: Some(start + (frame + 1) as f64 / 10.0), focused: true, ..Default::default() }, @@ -2287,6 +2397,7 @@ fn appearance_inspector_capture(h: &mut Harness, name: &str) { crop.save(std::path::Path::new(&dir).join(format!("{name}-{}-305.png", if dark { "dark" } else { "light" }))).unwrap(); let image = ui_kit::raster_ui(&ctx, output.shapes, output.pixels_per_point, ctx.content_rect(), &textures); image.save(std::path::Path::new(&dir).join(format!("{name}-{}.png", if dark { "dark" } else { "light" }))).unwrap(); + h.app.render_workspace.thumbnails = thumbnails; } } @@ -2299,6 +2410,10 @@ fn appearance_inspector_approved_layout_is_305_wide_and_preserves_stage_bounds() h.app.run("material.assign", json!({"bodies":["Boss","Subject"],"material":"Steel"})).unwrap(); h.app.run("select.filter", json!({"mode":"body"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Boss".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.preferences.render_inspector_width = 305.0; h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); @@ -2335,6 +2450,10 @@ fn appearance_editor_e2_sections_are_ordered_collapsed_and_expand_for_each_metho h.call("ui.resize", json!({"width":1440,"height":4000})); h.app.preferences.render_inspector_width = 308.0; h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); let titles = [ "render.inspector_edit", @@ -2401,6 +2520,10 @@ fn appearance_editor_e2_sections_are_ordered_collapsed_and_expand_for_each_metho [Sel::Face { body: "Subject".into(), index: 0, point: solidcraft_engine::geom::Vec3::ZERO }, Sel::Body { name: "Subject".into() }] { h.app.session.selection = vec![selection]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } if matches!(h.app.session.selection.first(), Some(Sel::Body { .. })) { h.app.ui.pick_components = true; } @@ -2425,13 +2548,13 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() { ( "sampling", RenderSelection::Sampling, - Page::Sampling, + Editor::Sampling, vec!["render.output_quality", "render.children.path", "render.children.adaptive", "render.children.glass", "render.children.cpu"], ), ( "effects", RenderSelection::Effects, - Page::Effects, + Editor::Effects, vec![ "render.children.tone", "render.children.looks", @@ -2443,13 +2566,13 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() { ( "check", RenderSelection::Check, - Page::Diagnostics, + Editor::Check, vec!["render.children.check", "render.children.request", "render.children.renderers", "render.children.under_hood"], ), ( "output", RenderSelection::Output, - Page::Output, + Editor::Output, vec!["render.children.renderer", "render.output_image", "render.output_destination", "render.children.render"], ), ] { @@ -2468,7 +2591,7 @@ fn output_children_select_their_editor_with_all_sections_in_design_order() { h.step(&json!({"click":{"handle":handle}})).unwrap(); h.frames(3); assert_eq!(h.app.render_workspace.view.selection, selection); - assert_eq!(selected_page(&h.app), page); + assert_eq!(selected_editor(&h.app), page); let mut bottom = 0.0; for key in sections { let rect = crate::scenario::rect_of(&format!("dialog:section:{}", h.app.tr(key))) @@ -2501,6 +2624,10 @@ fn appearance_editor_reset_restores_each_picked_saved_look() { let mut h = fixture(); h.app.run("appearance.assign", json!({"bodies":["Subject"],"appearance":"ABS - Red"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); let saved = h.app.session.doc.appearances.bodies["Subject"].clone(); @@ -2530,6 +2657,10 @@ fn appearance_editor_saved_optics_removal_confirms_and_preserves_other_drafts() .unwrap(); setups::load(&mut h.app); h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.appearance_editor.saved"))}})).unwrap(); @@ -2569,6 +2700,10 @@ fn appearance_editor_save_optics_keeps_other_temporary_bindings() { h.app.render_workspace.view.materials.bindings.push(binding); h.app.render_workspace.view.materials.enabled = true; h.app.session.selection = vec![Sel::Body { name: "Subject".into() }]; + { + let selection = super::super::object_selection(&h.app); + super::super::selection_editor(&mut h.app, &selection); + } h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); h.frames(3); h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.appearance_editor.glass"))}})).unwrap(); @@ -2642,6 +2777,7 @@ fn output_children_hostile_drafts_are_readable_without_normalizing_or_saving() { #[test] fn output_children_check_diagnostics_are_collapsed_and_copy_the_current_request() { let mut h = fixture(); + setups::save(&mut h.app).unwrap(); h.call("ui.resize", json!({"width":1440,"height":3000})); h.app.session.set_render_job_host(Some(solidcraft_engine::render::job::CpuHost::cooperative())).unwrap(); h.app.pending_fit = None; @@ -2688,7 +2824,7 @@ fn output_children_check_diagnostics_are_collapsed_and_copy_the_current_request( record_check(&mut h.app, &request, &json!({"error":"Exposure exceeds the CPU limit"})); h.frames(3); h.step(&json!({"click":{"handle":"render:check:go-to:0"}})).unwrap(); - assert_eq!(selected_page(&h.app), Page::Camera); + assert_eq!(selected_editor(&h.app), Editor::Camera); } #[test] @@ -2700,7 +2836,7 @@ fn output_children_focus_survives_clearing_an_object_pick() { ("effects", super::super::RenderSelection::Effects), ("check", super::super::RenderSelection::Check), ] { - h.app.run("select.set", json!({"bodies":["Subject"]})).unwrap(); + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Subject".into() }]})).unwrap(); h.app.run("view.render_select", json!({"kind":kind})).unwrap(); assert_eq!(h.app.render_workspace.view.selection, selection); assert!(h.app.session.selection.is_empty()); diff --git a/crates/ui-egui/src/render_workspace_library.rs b/crates/ui-egui/src/render_workspace_library.rs index 12d6a7a3d5..55c26cd297 100644 --- a/crates/ui-egui/src/render_workspace_library.rs +++ b/crates/ui-egui/src/render_workspace_library.rs @@ -66,6 +66,11 @@ struct SourcePreview { sha256: String, texture: egui::TextureHandle, } + +#[cfg(test)] +pub(super) fn clear_preview_texture(app: &mut SolveApp) { + app.render_workspace.view.library.preview = None; +} impl std::fmt::Debug for SourcePreview { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SourcePreview").field("id", &self.id).field("sha256", &self.sha256).finish() @@ -306,9 +311,8 @@ pub(super) fn open_category(app: &mut SolveApp, kind: &str) { "quality" | "capabilities" => "output", _ => "look", }; - let page = app.render_workspace.view.inspector.page; let _ = app.run("view.render_stage", json!({"stage":stage})); - app.render_workspace.view.inspector.page = page; + switch_kind(&mut app.render_workspace.view.library, kind); let view = &mut app.render_workspace.view.library; view.collection = 0; @@ -343,14 +347,6 @@ pub(super) fn choose(app: &mut SolveApp, view: &mut View, kind: &str, id: &str) view.labels.insert(key, display); view.labels.retain(|key, _| app.preferences.render_library_favorites.contains(key) || app.preferences.render_library_recent.contains(key)); view.statuses.retain(|key, _| app.preferences.render_library_favorites.contains(key) || app.preferences.render_library_recent.contains(key)); - app.render_workspace.view.inspector.page = match kind { - "native_scenes" | "scenes" | "selene_shots" => super::inspector::Page::Scene, - "hdr" | "environments" => super::inspector::Page::Environment, - "rigs" | "lighting" => super::inspector::Page::Lighting, - "quality" => super::inspector::Page::Output, - "capabilities" => super::inspector::Page::Diagnostics, - _ => super::inspector::Page::Appearance, - }; Ok(()) } @@ -1272,12 +1268,6 @@ pub(super) fn inspect_kinds(app: &mut SolveApp, ui: &mut egui::Ui, kinds: &[&str } } -pub(super) fn inspect_appearance(app: &mut SolveApp, ui: &mut egui::Ui) { - if app.render_workspace.view.library.kind == "appearances" { - inspect(app, ui); - } -} - /// Interactive command activation can review the specification selected by S5. pub(super) fn review_selected_quality(app: &mut SolveApp) -> Result<(), String> { let id = app.render_workspace.view.library.selected.clone().filter(|_| app.render_workspace.view.library.kind == "quality"); @@ -1696,19 +1686,17 @@ mod studio_browser_tests { h.step(&json!({"click":{"handle":format!("render:library:{id}")}})).unwrap(); h.frames(3); assert_eq!(h.app.render_workspace.view.library.selected.as_deref(), Some(id.as_str())); - assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance); + assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene); assert_eq!(h.app.session.doc.to_json(), before, "browsing and selection must not apply appearances"); - // The selection-following editor takes precedence; library inspection is - // available under Advanced when no model target is selected. + // Browsing keeps Scene selected until an object is picked. h.app.run("select.clear", json!({})).unwrap(); h.frames(3); // Clearing model selection opens Scene overview; inspect the resource again. h.step(&json!({"click":{"handle":format!("render:library:{id}")}})).unwrap(); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance); - h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.inspector_advanced"))}})).unwrap(); - h.frames(3); - h.step(&json!({"widget":h.app.tr("render.library_favorite")})).unwrap(); + assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene); + let look = h.app.session.doc.appearances.bodies.get("Sample").unwrap().clone(); + h.app.run("appearance.favorite", json!({"look":look,"favorite":true})).unwrap(); assert!(h.app.preferences.render_library_favorites.contains(&("appearances".into(), id.clone()))); h.step(&json!({"click":{"handle":"render:library-list"}})).unwrap(); h.frames(3); @@ -1719,15 +1707,15 @@ mod studio_browser_tests { assert_eq!(h.app.session.doc.to_json(), before); let catalog = h.app.run("appearance.library", json!({})).unwrap(); let blue = catalog["appearances"].as_array().unwrap().iter().find(|entry| entry["name"] == "ABS - Blue").unwrap(); - let mut view = std::mem::take(&mut h.app.render_workspace.view.library); - choose(&mut h.app, &mut view, "appearances", &appearance_id(blue)).unwrap(); - refresh_targets(&mut h.app, &mut view).unwrap(); - view.target = view.targets.first().cloned(); - h.app.render_workspace.view.library = view; + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Sample".into() }]})).unwrap(); + h.app.render_workspace.view.library.collection = 0; + h.app.render_workspace.view.library.loaded = false; h.frames(3); assert_eq!(h.app.session.doc.to_json(), before); - h.step(&json!({"widget":h.app.tr("render.library_apply_appearance")})).unwrap(); + let undo = h.app.session.undo.len(); + h.step(&json!({"click":{"handle":format!("render:library:{}", appearance_id(blue))}})).unwrap(); assert_eq!(h.app.session.doc.appearances.bodies.get("Sample").unwrap().name, "ABS - Blue"); + assert_eq!(h.app.session.undo.len(), undo + 1); if let Some(dir) = std::env::var_os("SOLIDCRAFT_LIBRARY_EVIDENCE") { std::fs::create_dir_all(&dir).unwrap(); h.app.run("appearance.assign", json!({"bodies":["Sample"],"appearance":"Aluminum - Anodized Blue"})).unwrap(); @@ -2033,9 +2021,9 @@ mod studio_browser_tests { assert!(h.app.session.undo.is_empty()); choose(&mut h.app, &mut view, "surfaces", "SURF-048").unwrap(); assert_eq!(h.app.preferences.render_library_recent.len(), 32); - assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Appearance); + assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene); choose(&mut h.app, &mut view, "native_scenes", "NATIVE-STUDIO-002").unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, super::super::inspector::Page::Scene); + assert_eq!(super::super::inspector::selected_editor(&h.app), super::super::inspector::Editor::Scene); assert_eq!(h.app.session.doc.to_json(), before); } } diff --git a/crates/ui-egui/src/render_workspace_materials.rs b/crates/ui-egui/src/render_workspace_materials.rs index 7f2fcd0849..576676ac00 100644 --- a/crates/ui-egui/src/render_workspace_materials.rs +++ b/crates/ui-egui/src/render_workspace_materials.rs @@ -18,7 +18,9 @@ use solidcraft_engine::{ }, }; -use crate::{SolveApp, theme::Tokens, ui_kit}; +#[cfg(test)] +use crate::theme::Tokens; +use crate::{SolveApp, ui_kit}; const MAX_BINDINGS: usize = 128; @@ -164,7 +166,9 @@ pub(super) struct View { pub enabled: bool, pub bindings: Vec, targets: Vec, + #[cfg(test)] target: Option, + #[cfg(test)] target_search: String, selected: Option, target_revision: Option, @@ -208,6 +212,30 @@ pub(super) fn revalidate(app: &mut SolveApp, binding: &mut Binding) -> Result<() Ok(()) } +/// Render settings may change document identity without changing the reviewed source. +/// A changed model, material, placement or target still needs explicit revalidation. +pub(super) fn refresh_render_revision(app: &mut SolveApp) { + if app.render_workspace.view.materials.bindings.is_empty() { + return; + } + let Ok(targets) = read_targets(app) else { return }; + let mut sources = super::setups::SourceCache::default(); + let eligible: Vec = app + .render_workspace + .view + .materials + .bindings + .iter() + .map(|binding| sources.same_model_source(app, &binding.document) && targets.contains(&binding.target)) + .collect(); + for (binding, eligible) in app.render_workspace.view.materials.bindings.iter_mut().zip(eligible) { + if eligible { + binding.revision = app.session.revision; + binding.document = Arc::downgrade(&app.session.doc); + } + } +} + /// Called for every Check and Start through the one shared image request builder. /// No files are read until the engine captures the actual request. pub(super) fn params(app: &SolveApp, params: &mut Value) -> Result<(), String> { @@ -315,6 +343,7 @@ fn vector(app: &SolveApp, ui: &mut egui::Ui, key: &str, values: &mut [f64; 3], r ui_kit::row(ui, app.tr(key), |ui| ui_kit::vector3_field(ui, values, range, suffix)) } +#[cfg(test)] pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui) { let title = app.tr("render.material_title").to_string(); ui_kit::section(ui, "render_materials", &title, false, |ui| { diff --git a/crates/ui-egui/src/render_workspace_inspector.rs b/crates/ui-egui/src/render_workspace_output.rs similarity index 66% rename from crates/ui-egui/src/render_workspace_inspector.rs rename to crates/ui-egui/src/render_workspace_output.rs index 307729afd1..22decf66f5 100644 --- a/crates/ui-egui/src/render_workspace_inspector.rs +++ b/crates/ui-egui/src/render_workspace_output.rs @@ -1,5 +1,5 @@ -//! Contextual native Render UI. One workspace, existing command authority and shared controls. -//! Page navigation is per document; unsupported backend capabilities never emit dummy parameters. +//! Output, sampling, checks and shared Render editor adapters. +//! Unsupported backend capabilities remain visible with a reason. use super::{ImageMethod, cpu, library, materials, setups, studio}; use crate::{SolveApp, ui_kit}; use serde_json::{Value, json}; @@ -7,100 +7,16 @@ use solidcraft_engine::doc::Document; use std::sync::{Arc, Weak}; #[path = "render_ui/appearance.rs"] -mod selection_appearance; +pub(super) mod selection_appearance; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(super) enum Page { - #[default] - Scene, - Appearance, - Effects, - Lighting, - Environment, - Background, - Camera, - Output, - Sampling, - Diagnostics, - #[cfg(test)] - All, -} -impl Page { - #[cfg(test)] - const ALL: [Self; 11] = [ - Self::Scene, - Self::Appearance, - Self::Lighting, - Self::Environment, - Self::Background, - Self::Camera, - Self::Effects, - Self::Output, - Self::Sampling, - Self::Diagnostics, - Self::All, - ]; - pub(super) fn key(self) -> &'static str { - match self { - Self::Scene => "render.page_scene", - Self::Appearance => "render.page_appearance", - Self::Effects => "render.page_effects", - Self::Lighting => "render.page_lighting", - Self::Environment => "render.page_environment", - Self::Background => "render.page_background", - Self::Camera => "render.page_camera", - Self::Output => "render.page_output", - Self::Sampling => "render.children.sampling", - Self::Diagnostics => "render.page_diagnostics", - #[cfg(test)] - Self::All => "render.page_all", - } - } - fn handle(self) -> &'static str { - match self { - Self::Scene => "scene", - Self::Appearance => "appearance", - Self::Effects => "effects", - Self::Lighting => "lighting", - Self::Environment => "environment", - Self::Background => "background", - Self::Camera => "camera", - Self::Output => "output", - Self::Sampling => "sampling", - Self::Diagnostics => "diagnostics", - #[cfg(test)] - Self::All => "all", - } - } - fn icon(self) -> &'static str { - match self { - Self::Scene => "render_scene", - Self::Appearance => "appearance", - Self::Effects => "camera", - Self::Lighting => "settings", - Self::Environment => "environment_map", - Self::Background => "render_scene", - Self::Camera => "camera", - Self::Output => "export", - Self::Sampling => "settings", - Self::Diagnostics => "inspect", - #[cfg(test)] - Self::All => "list", - } - } -} -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub(super) struct View { pub effects: super::effects::View, pub hidden: bool, - pub page: Page, reveal: Option<&'static str>, pending: Option, - #[cfg(test)] - shot_name: String, pub checked: Option, scene_info: Option, - appearance: Appearance, selection_appearance: selection_appearance::View, pub(super) resolution: Resolution, } @@ -166,7 +82,6 @@ struct Pending { enum PendingAction { Switch(String), ManageShot { name: String, command: String }, - Reload, Remove(String), } #[derive(Clone, Debug)] @@ -181,256 +96,11 @@ impl Review { self.revision == app.session.revision && Weak::ptr_eq(&self.document, &Arc::downgrade(&app.session.doc)) } } -impl Default for View { - fn default() -> Self { - Self { - effects: Default::default(), - hidden: false, - page: Page::Scene, - reveal: None, - pending: None, - #[cfg(test)] - shot_name: String::new(), - checked: None, - scene_info: None, - appearance: Appearance::default(), - selection_appearance: Default::default(), - resolution: Resolution::default(), - } - } -} pub(super) fn record_check(app: &mut SolveApp, request: &Value, reply: &Value) { app.render_workspace.view.inspector.checked = Some(Review { request: request.clone(), reply: reply.clone(), revision: app.session.revision, document: Arc::downgrade(&app.session.doc) }); } -pub(super) fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { - if app.render_workspace.view.inspector.hidden { - crate::shell::register_panel(app, "render:inspector", None, None); - return; - } - setups::refresh_dirty(app); - if app.render_workspace.revision != app.session.revision && !app.render_workspace.view.dirty { - setups::load(app); - } - let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap()); - let mut side = app.preferences.render_inspector_side.clone(); - let mut width = app.preferences.render_inspector_width; - let wear = app.render_workspace.view.selection == super::RenderSelection::Wear; - let page = selected_page(app); - let output = !wear && matches!(page, Page::Output | Page::Sampling | Page::Effects | Page::Diagnostics); - let appearance_editor = !wear && page == Page::Appearance; - let standalone = !wear && (page == Page::Scene || (page == Page::Camera && super::shot::selected(app).is_some())); - let selection_appearance = appearance_editor && !app.session.selection.is_empty(); - let title = if wear { - app.tr("render.wear_title").to_string() - } else if selection_appearance { - app.session - .selection - .last() - .and_then(|item| match item { - solidcraft_engine::Sel::Body { name } | solidcraft_engine::Sel::Face { body: name, .. } => Some(name.clone()), - solidcraft_engine::Sel::Occurrence { path } => app.session.occurrence_placement(path).ok().map(|placement| placement.name), - _ => None, - }) - .unwrap_or_else(|| app.tr("render.inspector").to_owned()) - } else if standalone { - super::shot::selected(app).filter(|_| page == Page::Camera).map(str::to_owned).unwrap_or_else(|| app.tr("render.page_scene").to_string()) - } else { - app.tr(if page == Page::Diagnostics { - "render.children.check" - } else if output { - page.key() - } else { - "render.studio_title" - }) - .to_string() - }; - let icon = if wear { - "appearance" - } else if selection_appearance { - "body" - } else { - page.icon() - }; - let scope = selection_appearance::scope_label(app).to_owned(); - let header_action = std::cell::Cell::new(None); - let content = ui_kit::Panel::workspace_with_header( - ui, - ui_kit::WorkspaceOptions { - id: "render:inspector", - title: &title, - icon, - bounds, - fill_height: true, - compact: crate::render_ui::compact(app, bounds.width()), - editor: appearance_editor, - enabled: true, - header_actions: None, - }, - &mut side, - &mut width, - false, - |ui, options| { - if appearance_editor { - header_action.set(ui_kit::inspector_header(ui, options.icon, options.title, &scope)); - } else { - ui_kit::panel::header(ui, options.icon, options.title, || header_action.set(Some(ui_kit::InspectorAction::Close))); - } - }, - |ui| { - if output { - ui_kit::Panel::pinned_footer(ui, "render_output_footer", 1, |ui| { - ui.horizontal_wrapped(|ui| { - for (id, label, target) in [ - ("render.start", app.tr("cmd.render_start").to_owned(), "render:output:render"), - ( - "render.batch", - crate::i18n::format("render.batch_all_shots", &[("count", &app.session.doc.render.setups.len().to_string())]), - "render:output:all-shots", - ), - ] { - let ready = super::ribbon::action_ready(app, id); - let response = ui_kit::icon_label_action(ui, &label, Some("play"), ready.is_ok(), id == "render.start"); - let response = if let Err(reason) = ready { response.on_disabled_hover_text(reason) } else { response }; - crate::scenario::publish_target(target, response.rect); - if response.clicked() { - app.start(id); - } - } - }); - crate::scenario::publish_rect("render:output-footer", ui.min_rect()); - }); - } - if !appearance_editor && !output && !standalone { - header(app, ui); - ui_kit::Panel::pinned_footer(ui, "render_job_footer", 4, |ui| { - ui.separator(); - super::job_controls(app, ui, true); - if ui_kit::button(ui, app.tr("render.return_design"), ui_kit::Button::Normal).clicked() - && let Err(error) = super::enter(app, false) - { - app.set_status(error, true); - } - crate::scenario::publish_rect("render:job-footer", ui.min_rect()); - }); - } - if appearance_editor { - ui_kit::Panel::editor_footer(ui, "render_appearance_footer", |ui| selection_appearance::footer(app, ui)); - } - egui::ScrollArea::vertical() - .max_width(ui.available_width()) - .id_salt(("render_inspector", wear, page.handle(), app.docs.active)) - .auto_shrink([false, false]) - .show(ui, |ui| { - if wear { - super::wear::controls(app, ui); - setups::refresh_dirty(app); - return; - } - if !appearance_editor && !output && !standalone { - ui_kit::text(ui, app.tr(page.key()), ui_kit::Text::Heading(page.key())); - } - match page { - Page::Scene => scene_controls(app, ui), - Page::Appearance => appearance_controls(app, ui), - Page::Effects => super::effects::output_controls(app, ui), - Page::Lighting => lighting_controls(app, ui), - Page::Environment | Page::Background => super::environment::editor(app, ui), - Page::Camera => { - if let Some(shot) = super::shot::selected(app).map(str::to_owned) { - super::shot::editor(app, ui, &shot); - } else { - camera_controls(app, ui); - } - } - Page::Output => output_editor(app, ui), - Page::Sampling => sampling_editor(app, ui), - Page::Diagnostics => check_editor(app, ui), - #[cfg(test)] - Page::All => { - ui_kit::text(ui, app.tr("render.all_controls_help"), ui_kit::Text::Note("render.all_controls_help")); - shot_controls(app, ui); - camera_actions(app, ui); - let title = app.tr("render.page_camera").to_string(); - ui_kit::section(ui, "render_all_camera", &title, false, |ui| camera_controls(app, ui)); - let title = app.tr("render.page_aging").to_string(); - ui_kit::section(ui, "render_all_aging", &title, false, |ui| super::effects::aging_controls(app, ui)); - super::image_edit_controls(app, ui); - } - } - setups::refresh_dirty(app); - }); - }, - ); - match header_action.get() { - Some(ui_kit::InspectorAction::Close) => { - let _ = app.run("view.render_inspector", json!({"visible":false})); - } - Some(ui_kit::InspectorAction::Remove) => selection_appearance::remove(app), - None => {} - } - app.preferences.render_inspector_width = width; - let requested = content - .as_ref() - .and_then(|c| c.inner.1) - .map(str::to_string) - .or_else(|| (app.preferences.render_inspector_side != side).then(|| side.clone())); - if let Some(side) = requested { - let _ = app.run("view.render_dock", serde_json::json!({"panel":"inspector","side":side})); - } - let rect = content.map(|c| c.response.rect); - crate::shell::register_panel(app, "render:inspector", rect, Some(if side == "left" { ui_kit::Edge::Left } else { ui_kit::Edge::Right })); - if let Some(rect) = rect { - crate::scenario::publish_rect("render:inspector", rect); - } - confirmation(app, ui.ctx()); - library::review::dialog(app, ui.ctx()); - library::review::confirmation(app, ui.ctx()); -} -fn header(app: &mut SolveApp, ui: &mut egui::Ui) { - let current = app.render_workspace.view.name.clone(); - let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect(); - let mut chosen = None; - let menu = ui_kit::dropdown_menu(ui, "render_setups", ¤t, |ui| { - for name in names { - let r = ui_kit::list_picker(ui, &name, &name, name == current); - crate::scenario::publish_handle(&format!("render:setup:{name}"), r.rect.center()); - if r.clicked() { - chosen = Some(name); - ui.close(); - } - } - }); - crate::scenario::publish_target("render:setups", menu.rect); - if let Some(name) = chosen { - switch_shot(app, name); - } - let field = ui_kit::bounded_cell_editor(ui, ui.id().with("render_setup_name"), &mut app.render_workspace.view.name, "", false, 128); - crate::scenario::publish_target("render:setup-name", field.rect); - if field.changed() { - app.render_workspace.view.dirty = true; - } - ui.horizontal_wrapped(|ui| { - if ui_kit::button(ui, app.tr("render.save_setup"), ui_kit::Button::Enabled(setups::snapshot(app).is_ok())).clicked() - && let Err(error) = setups::save(app) - { - app.set_status(error, true); - } - if ui_kit::button(ui, app.tr("render.reload_settings"), ui_kit::Button::Normal).clicked() { - request_action(app, PendingAction::Reload); - } - }); - let status = if app.render_workspace.view.dirty { app.tr("render.unsaved") } else { app.tr("render.studio_saved") }; - ui.add_sized([ui.available_width(), ui.text_style_height(&egui::TextStyle::Body)], egui::Label::new(status).truncate()); - { - let labels = [app.tr("render.result_preview").to_string(), app.tr("render.result_title").to_string()]; - let mut value = usize::from(app.render_workspace.view.result); - if ui_kit::segmented(ui, &mut value, &labels.iter().map(String::as_str).collect::>(), Default::default()) { - let _ = app.run("render.result.view", json!({"view":if value == 1 {"image"} else {"model"}})); - } - } -} pub(super) fn switch_shot(app: &mut SolveApp, name: String) { setups::refresh_dirty(app); request_action(app, PendingAction::Switch(name)); @@ -446,7 +116,20 @@ pub(super) fn shot_row_action(app: &mut SolveApp, name: String, command: &str) { } fn request_action(app: &mut SolveApp, action: PendingAction) { - if app.render_workspace.view.dirty || matches!(&action, PendingAction::Remove(_)) { + // Focusing the current shot is navigation, never a request to reload its draft. + if let PendingAction::Switch(name) = &action + && *name == app.render_workspace.view.name + { + super::selection_editor(app, &super::RenderSelection::Shot(name.clone())); + return; + } + setups::refresh_dirty(app); + let replaces_draft = match &action { + PendingAction::Switch(_) => true, + PendingAction::ManageShot { name, .. } => *name != app.render_workspace.view.name, + PendingAction::Remove(_) => false, + }; + if (replaces_draft && app.render_workspace.view.dirty) || matches!(&action, PendingAction::Remove(_)) { app.render_workspace.view.inspector.pending = Some(Pending { action, document: Arc::downgrade(&app.session.doc), revision: app.session.revision }); } else if let Err(error) = perform_action(app, action) { @@ -456,16 +139,18 @@ fn request_action(app: &mut SolveApp, action: PendingAction) { fn perform_action(app: &mut SolveApp, action: PendingAction) -> Result<(), String> { match action { PendingAction::Switch(name) => { - app.render_workspace.view.name = name; + app.render_workspace.view.name = name.clone(); setups::load(app); + super::selection_editor(app, &super::RenderSelection::Shot(name)); } PendingAction::ManageShot { name, command } => { - app.render_workspace.view.name = name; - setups::load(app); + if name != app.render_workspace.view.name { + app.render_workspace.view.name = name; + setups::load(app); + } // The existing manager owns name validation and command execution. app.start(&command); } - PendingAction::Reload => setups::load(app), PendingAction::Remove(name) => { app.run("render.shot.remove", json!({"name":name,"expected_revision":app.session.revision}))?; if name == app.render_workspace.view.name { @@ -479,7 +164,7 @@ fn perform_action(app: &mut SolveApp, action: PendingAction) -> Result<(), Strin } Ok(()) } -fn confirmation(app: &mut SolveApp, ctx: &egui::Context) { +pub(super) fn confirmation(app: &mut SolveApp, ctx: &egui::Context) { super::shots::confirmation(app, ctx); materials::confirmation(app, ctx); super::light::confirmation(app, ctx); @@ -500,7 +185,7 @@ fn confirmation(app: &mut SolveApp, ctx: &egui::Context) { .actions(&primary, &keep, (!removing).then_some(discard.as_str())) .selected(ui_kit::Action::Cancel) .show(ctx, removing || setups::snapshot(app).is_ok(), |ui| { - ui.label(&message); + ui_kit::text(ui, &message, ui_kit::Text::Inline); }); if closed || answer == ui_kit::Action::Cancel { app.render_workspace.view.inspector.pending = None; @@ -526,7 +211,7 @@ fn browse_button(app: &mut SolveApp, ui: &mut egui::Ui, kind: &str, key: &str) { library::open_category(app, kind); } } -fn scene_controls(app: &mut SolveApp, ui: &mut egui::Ui) { +pub(super) fn scene_controls(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::text(ui, app.tr("render.scene_preserves_design"), ui_kit::Text::Note("render.scene_preserves_design")); let label = app.tr("render.children.method").to_string(); ui_kit::row(ui, &label, |ui| super::method_dropdown(app, ui)); @@ -631,75 +316,6 @@ fn scene_stage(app: &mut SolveApp, stage: super::Stage) { super::Stage::Output => json!({"kind":"output"}), }; let _ = app.run("view.render_select", params); - app.render_workspace.view.inspector.page = stage.page(); -} -#[cfg(test)] -fn shot_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - let title = app.tr("render.shot_manager").to_string(); - reveal_section(app, ui, "render_shot_manager"); - ui_kit::section(ui, "render_shot_manager", &title, false, |ui| { - ui_kit::text(ui, app.tr("render.shot_saved_only"), ui_kit::Text::Note("render.shot_saved_only")); - let label = app.tr("render.shot_new_name").to_string(); - ui_kit::bounded_cell_editor(ui, ui.id().with("render_shot_new_name"), &mut app.render_workspace.view.inspector.shot_name, &label, false, 128); - for (command, key) in [("render.shot.duplicate", "render.shot_duplicate"), ("render.shot.rename", "render.shot_rename")] { - let enabled = !app.render_workspace.view.dirty - && app.session.doc.render.setups.contains_key(&app.render_workspace.view.name) - && !app.render_workspace.view.inspector.shot_name.trim().is_empty(); - if ui_kit::button(ui, app.tr(key), ui_kit::Button::Enabled(enabled)).clicked() { - let name = app.render_workspace.view.inspector.shot_name.clone(); - match app.run(command, json!({"name":app.render_workspace.view.name,"new_name":name,"expected_revision":app.session.revision})) { - Ok(_) => { - app.render_workspace.view.name = name; - setups::load(app); - } - Err(e) => app.set_status(e, true), - } - } - } - if ui_kit::button( - ui, - app.tr("render.shot_remove"), - ui_kit::Button::Enabled(app.session.doc.render.setups.contains_key(&app.render_workspace.view.name)), - ) - .clicked() - { - request_action(app, PendingAction::Remove(app.render_workspace.view.name.clone())); - } - }); -} -fn appearance_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - super::assign::selection_controls(app, ui); - if selection_appearance::controls(app, ui) { - return; - } - library::inspect_appearance(app, ui); - ui_kit::text(ui, app.tr("render.appearance_physics_separate"), ui_kit::Text::Note("render.appearance_physics_separate")); - base_appearance(app, ui); - materials::controls(app, ui); - let title = app.tr("render.inspector_advanced").to_owned(); - reveal_section(app, ui, "render:appearance:advanced"); - ui_kit::section(ui, "render:appearance:advanced", &title, false, |ui| advanced_appearance_controls(app, ui)); -} -fn advanced_appearance_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - browse_button(app, ui, "surfaces", "render.browse_surfaces"); - browse_button(app, ui, "physical_profiles", "render.browse_physical"); - library::inspect_kinds(app, ui, &["surfaces", "materials", "selene_appearances", "physical_profiles"]); - super::effects::aging_controls(app, ui); -} -fn lighting_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - if let super::RenderSelection::Light(id) = app.render_workspace.view.selection.clone() { - super::light::editor(app, ui, &id); - return; - } - super::light::lighting(app, ui); -} -fn vector(ui: &mut egui::Ui, label: &str, value: &mut [f64; 3], range: std::ops::RangeInclusive, speed: f64) { - ui.label(label); - ui.horizontal(|ui| { - for component in value { - ui_kit::number_field(ui, component, range.clone(), speed, ""); - } - }); } pub(super) fn apply_camera_draft(app: &mut SolveApp, camera: solidcraft_engine::render::Camera) -> Result<(), String> { app.run("render.camera.set", json!({"camera":camera}))?; @@ -708,93 +324,78 @@ pub(super) fn apply_camera_draft(app: &mut SolveApp, camera: solidcraft_engine:: Ok(()) } -fn camera_controls(app: &mut SolveApp, ui: &mut egui::Ui) { - if super::shots::camera_modified(app, &app.render_workspace.view.name) { - ui_kit::banner(ui, ui_kit::AssistantTone::Warning, |ui| { - ui_kit::text(ui, app.tr("render.shots_camera_changed"), ui_kit::Text::Note("render.shots_camera_changed")); - ui.horizontal_wrapped(|ui| { - let update = ui_kit::primary_button(ui, app.tr("render.shots_update")); - crate::scenario::publish_target("render:shots:update", update.rect); - if update.clicked() - && let Err(error) = super::shots::update_camera(app) - { - app.set_status(error, true); - } - let revert = ui_kit::button(ui, app.tr("render.shots_revert"), ui_kit::Button::Normal); - crate::scenario::publish_target("render:shots:revert", revert.rect); - if revert.clicked() - && let Err(error) = restore_camera(app) - { - app.set_status(error, true); - } - }); - }); - } - ui_kit::text(ui, app.tr("render.camera_exact_help"), ui_kit::Text::Note("render.camera_exact_help")); - let before = app.cam; - let mut camera = before; - let mut perspective = camera.fov > 0.0; - if ui_kit::checkbox(ui, app.tr("render.perspective"), &mut perspective).changed() { - // On at the Field of view preference's angle (#5364); a saved setup keeps its own. - camera.fov = if perspective { crate::prefs::perspective_fov(&app.preferences) } else { 0.0 }; - } - if perspective { - let mut fov = camera.fov.to_degrees(); - let label = app.tr("render.camera_fov").to_string(); - if ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut fov, 1.0..=150.0, 0.1, " deg")).changed() { - camera.fov = fov.to_radians(); +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Editor { + Scene, + Appearance, + Environment, + Wear, + Lighting, + Light, + Camera, + Output, + Sampling, + Effects, + Check, +} + +/// Existing fixtures navigate the same selection model as Browser clicks. +#[cfg(test)] +pub(super) fn select_editor(app: &mut SolveApp, editor: Editor) { + use super::RenderSelection; + let selection = match editor { + Editor::Appearance => { + if app.session.selection.is_empty() + && let Some(group) = super::scene::groups(app).first() + { + app.run( + "select.set", + json!({"items":group.objects.iter().map(|name| solidcraft_engine::Sel::Body { name: name.clone() }).collect::>()}), + ) + .unwrap(); + } + super::object_selection(app) } - } - let label = app.tr("render.camera_distance").to_string(); - ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut camera.distance, 0.000001..=1e9, 1.0, " mm")); - let mut target = [camera.target.x, camera.target.y, camera.target.z]; - vector(ui, app.tr("render.camera_target"), &mut target, -1e9..=1e9, 1.0); - camera.target = solidcraft_engine::geom::Vec3::new(target[0], target[1], target[2]); - for (key, value) in [("render.camera_yaw", &mut camera.yaw), ("render.camera_pitch", &mut camera.pitch), ("render.camera_roll", &mut camera.roll)] - { - let mut degrees = value.to_degrees(); - let label = app.tr(key).to_string(); - if ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut degrees, -360000.0..=360000.0, 0.5, " deg")).changed() { - *value = degrees.to_radians(); - } - } - if camera != before - && camera.is_valid() - && let Err(error) = apply_camera_draft(app, camera) - { - app.set_status(error, true); - } - camera_actions(app, ui); - if app.render_workspace.view.method == ImageMethod::GpuRaytrace { - studio::lens_controls(app, ui); - } - editor_section(app, ui, "render_shot_exposure", "render.image_exposure", |app, ui| { - let enabled = app.render_workspace.view.method != ImageMethod::Raster; - if !enabled { - ui_kit::text(ui, app.tr("render.method_limit_raster"), ui_kit::Text::Note("render.method_limit_raster")); - } - let label = app.tr("render.image_exposure").to_string(); - ui.add_enabled_ui(enabled, |ui| { - ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, &mut app.render_workspace.view.draft.exposure_ev, -32.0..=32.0, 0.1, " EV")); - }); - }); - if app.render_workspace.view.result { - if ui_kit::button(ui, app.tr("render.result_fit"), ui_kit::Button::Normal).clicked() { - super::fit_result(app); - } - if ui_kit::button(ui, crate::i18n::tr("render.result_actual_size"), ui_kit::Button::Enabled(!super::reduced_display(app))).clicked() { - app.render_workspace.view.result_scale = super::ResultScale::Actual; + Editor::Camera => super::current_shot_selection(app), + _ => { + let kind = match editor { + Editor::Environment => "environment", + Editor::Wear => "wear", + Editor::Lighting => "lighting", + Editor::Output => "output", + Editor::Sampling => "sampling", + Editor::Effects => "effects", + Editor::Check => "check", + Editor::Scene => "nothing", + Editor::Light | Editor::Appearance | Editor::Camera => panic!("use the exact object, light or shot selection"), + }; + app.run("view.render_select", json!({"kind":kind})).unwrap(); + return; } + }; + if let RenderSelection::Shot(name) = &selection { + app.run("view.render_select", json!({"kind":"shot","id":name})).unwrap(); + } else { + super::selection_editor(app, &selection); } } -/// The selection owns the editor independently of the active stage. -fn selected_page(app: &SolveApp) -> Page { + +#[cfg(test)] +pub(super) fn selected_editor(app: &SolveApp) -> Editor { + use super::RenderSelection; match app.render_workspace.view.selection { - super::RenderSelection::Output => Page::Output, - super::RenderSelection::Sampling => Page::Sampling, - super::RenderSelection::Effects => Page::Effects, - super::RenderSelection::Check => Page::Diagnostics, - _ => app.render_workspace.view.inspector.page, + RenderSelection::Nothing => Editor::Scene, + RenderSelection::Object(_) => Editor::Appearance, + RenderSelection::Environment => Editor::Environment, + RenderSelection::Wear => Editor::Wear, + RenderSelection::Lighting => Editor::Lighting, + RenderSelection::Light(_) => Editor::Light, + RenderSelection::Shot(_) => Editor::Camera, + RenderSelection::Output => Editor::Output, + RenderSelection::Sampling => Editor::Sampling, + RenderSelection::Effects => Editor::Effects, + RenderSelection::Check => Editor::Check, } } fn editor_section(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, key: &'static str, draw: fn(&mut SolveApp, &mut egui::Ui)) { @@ -802,7 +403,7 @@ fn editor_section(app: &mut SolveApp, ui: &mut egui::Ui, id: &'static str, key: let title = app.tr(key).to_string(); ui_kit::section(ui, id, &title, true, |ui| draw(app, ui)); } -fn output_editor(app: &mut SolveApp, ui: &mut egui::Ui) { +pub(super) fn output_editor(app: &mut SolveApp, ui: &mut egui::Ui) { let summary = check_summary(app); let notes = ui_kit::button(ui, &summary, ui_kit::Button::Normal); crate::scenario::publish_target("render:output:check", notes.rect); @@ -1002,7 +603,7 @@ fn render_controls(app: &mut SolveApp, ui: &mut egui::Ui) { let _ = app.run("render.result.view", json!({"view":"image"})); } } -fn sampling_editor(app: &mut SolveApp, ui: &mut egui::Ui) { +pub(super) fn sampling_editor(app: &mut SolveApp, ui: &mut egui::Ui) { editor_section(app, ui, "render_sampling_quality", "render.output_quality", |app, ui| { quality_choices(app, ui); ui_kit::text(ui, app.tr("render.output_quality_waiting"), ui_kit::Text::Note("render.output_quality_waiting")); @@ -1063,7 +664,7 @@ fn cpu_sampling(app: &mut SolveApp, ui: &mut egui::Ui) { *value = reflections; } } -fn check_editor(app: &mut SolveApp, ui: &mut egui::Ui) { +pub(super) fn check_editor(app: &mut SolveApp, ui: &mut egui::Ui) { editor_section(app, ui, "render_check_check", "render.children.check", check_controls); editor_section(app, ui, "render_check_request", "render.children.request", request_controls); editor_section(app, ui, "render_check_renderers", "render.children.renderers", renderer_status); @@ -1112,12 +713,15 @@ fn check_note_link(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, line: &C let response = ui_kit::button(ui, &label, ui_kit::Button::Normal); crate::scenario::publish_target(&format!("render:check:go-to:{index}"), response.rect); if response.clicked() { - let _ = app.run("view.render_select", json!({"kind":kind})); - if kind == "nothing" { - app.render_workspace.view.inspector.page = if key == "render.page_camera" { Page::Camera } else { Page::Appearance }; - } - if kind == "environment" { - app.render_workspace.view.inspector.page = Page::Environment; + if key == "render.page_camera" { + let selection = super::current_shot_selection(app); + if let super::RenderSelection::Shot(name) = selection { + let _ = app.run("view.render_select", json!({"kind":"shot","id":name})); + } + } else if kind == "nothing" { + super::selection_editor(app, &super::object_selection(app)); + } else { + let _ = app.run("view.render_select", json!({"kind":kind})); } } } @@ -1546,7 +1150,9 @@ fn diagnostics(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(check) = &app.render_workspace.view.inspector.checked { json_details(app, ui, "render.diag_preflight_details", &check.reply); } + cpu::checked_status(app, ui, super::image_params(app).as_ref().ok()); if let Some(job) = job { + cpu::captured_status(app, ui, &job); json_details(app, ui, "render.diag_job_details", &job); } for key in @@ -1571,107 +1177,6 @@ fn json_details(app: &SolveApp, ui: &mut egui::Ui, key: &str, value: &Value) { ui_kit::read_only_text(ui, &text); }); } -#[derive(Clone, Debug)] -struct Appearance { - query: String, - items: Vec, - selected: Option, - document: Option>, - revision: Option, - color: [u8; 3], - metallic: f64, - roughness: f64, - ior: f64, - opaque: bool, -} -impl Default for Appearance { - fn default() -> Self { - Self { - query: String::new(), - items: Vec::new(), - selected: None, - document: None, - revision: None, - color: [160, 170, 180], - metallic: 0.0, - roughness: 0.5, - ior: 1.5, - opaque: false, - } - } -} -fn base_appearance(app: &mut SolveApp, ui: &mut egui::Ui) { - let title = app.tr("render.base_appearance").to_string(); - ui_kit::section(ui, "render_base_appearance", &title, false, |ui| { - let mut view = std::mem::take(&mut app.render_workspace.view.inspector.appearance); - ui_kit::bounded_cell_editor(ui, ui.id().with("render_base_target_search"), &mut view.query, app.tr("render.material_filter"), false, 128); - if ui_kit::button(ui, app.tr("render.material_refresh"), ui_kit::Button::Normal).clicked() { - match app.run("render.library.targets", json!({"query":view.query,"limit":128})) { - Ok(reply) => { - view.items = reply["items"].as_array().cloned().unwrap_or_default(); - view.selected = None; - view.document = Some(Arc::downgrade(&app.session.doc)); - view.revision = Some(app.session.revision); - } - Err(error) => app.set_status(error, true), - } - } - let label = view.selected.clone().unwrap_or_else(|| app.tr("render.material_choose_body").into()); - ui_kit::dropdown_menu(ui, "render_base_target", &label, |ui| { - for item in &view.items { - let Some(name) = item["name"].as_str() else { continue }; - if ui_kit::list_picker(ui, &format!("base:{name}"), name, view.selected.as_deref() == Some(name)).clicked() { - view.selected = Some(name.into()); - view.color = serde_json::from_value(item["color"].clone()).unwrap_or([160, 170, 180]); - view.opaque = item["opacity"].as_f64() == Some(1.0); - view.metallic = item["pbr"]["metallic"].as_f64().unwrap_or(0.0); - view.roughness = item["pbr"]["roughness"].as_f64().unwrap_or(0.5); - view.ior = item["pbr"]["dielectric_ior"].as_f64().unwrap_or(1.5); - ui.close(); - } - } - }); - if view.selected.is_some() { - ui.label(app.tr("render.base_color")); - ui_kit::color_field(ui, &mut view.color); - for (key, value, range) in [ - ("render.base_metallic", &mut view.metallic, 0.0..=1.0), - ("render.material_roughness", &mut view.roughness, 0.045..=1.0), - ("render.material_ior", &mut view.ior, 1.0..=3.0), - ] { - let label = app.tr(key).to_string(); - ui_kit::row(ui, &label, |ui| ui_kit::number_field(ui, value, range, 0.01, "")); - } - let current = view.revision == Some(app.session.revision) - && view.document.as_ref().is_some_and(|d| Weak::ptr_eq(d, &Arc::downgrade(&app.session.doc))) - && !app.session.model.is_stale(); - if !current { - ui_kit::text(ui, app.tr("render.material_refresh_needed"), ui_kit::Text::Note("render.material_refresh_needed")); - } - if !view.opaque { - ui_kit::text(ui, app.tr("render.base_opaque_only"), ui_kit::Text::Note("render.base_opaque_only")); - } - if ui_kit::button(ui, app.tr("render.base_apply"), ui_kit::Button::Enabled(current && view.opaque)).clicked() - && let Some(name) = &view.selected - { - match app.run( - "appearance.assign", - json!({"bodies":[name],"color":view.color,"opacity":1.0, - "pbr":{"metallic":view.metallic,"roughness":view.roughness,"dielectric_ior":view.ior}}), - ) { - Ok(_) => { - view.revision = Some(app.session.revision); - view.document = Some(Arc::downgrade(&app.session.doc)); - } - Err(error) => app.set_status(error, true), - } - } - ui_kit::text(ui, app.tr("render.base_apply_help"), ui_kit::Text::Note("render.base_apply_help")); - } - app.render_workspace.view.inspector.appearance = view; - }); -} - #[cfg(test)] #[path = "render_workspace_inspector_tests.rs"] mod tests; @@ -1692,21 +1197,6 @@ pub(super) fn restore_camera(app: &mut SolveApp) -> Result<(), String> { app.follow_view_request(false); Ok(()) } -fn camera_actions(app: &mut SolveApp, ui: &mut egui::Ui) { - ui.horizontal_wrapped(|ui| { - if ui_kit::button(ui, app.tr("render.fit"), ui_kit::Button::Normal).clicked() - && let Err(error) = fit_subject(app) - { - app.set_status(error, true); - } - if ui_kit::button(ui, app.tr("render.restore_camera"), ui_kit::Button::Normal).clicked() - && let Err(error) = restore_camera(app) - { - app.set_status(error, true); - } - }); -} - pub(super) fn confirmation_open(app: &SolveApp) -> bool { app.render_workspace.view.inspector.pending.is_some() || app.render_workspace.view.save_shot_name.is_some() @@ -1727,17 +1217,14 @@ fn reveal_section(app: &mut SolveApp, ui: &mut egui::Ui, section: &'static str) } fn gpu_status(app: &SolveApp, ui: &mut egui::Ui) { - if app.render_workspace.view.method != ImageMethod::GpuRaytrace { - return; - } match &app.render_workspace.path_host { super::PathHost::Preparing(_, started) => super::setup_progress(ui, app, *started), super::PathHost::Failed(error) => { ui_kit::text(ui, &format!("{}: {error}", app.tr("render.gpu_path_failed")), ui_kit::Text::Error("render.gpu_path_failed")); } - super::PathHost::NotStarted => { + super::PathHost::NotStarted if app.render_workspace.view.method == ImageMethod::GpuRaytrace => { ui_kit::text(ui, app.tr("render.gpu_unavailable"), ui_kit::Text::Note("render.gpu_unavailable")); } - super::PathHost::Ready(_) => {} + super::PathHost::NotStarted | super::PathHost::Ready(_) => {} } } diff --git a/crates/ui-egui/src/render_workspace_ribbon.rs b/crates/ui-egui/src/render_workspace_ribbon.rs index ff8cb4f20f..10a4f76b24 100644 --- a/crates/ui-egui/src/render_workspace_ribbon.rs +++ b/crates/ui-egui/src/render_workspace_ribbon.rs @@ -220,17 +220,31 @@ pub(super) fn presentation(app: &SolveApp) -> solidcraft_engine::render::job::pr } } -fn focus(app: &mut SolveApp, page: inspector::Page, category: Option<&str>) { - app.render_workspace.view.inspector.hidden = false; - let kind = match page { - inspector::Page::Sampling => "sampling", - inspector::Page::Effects => "effects", - inspector::Page::Diagnostics => "check", - inspector::Page::Output => "output", - _ => "nothing", - }; - let _ = app.run("view.render_select", json!({"kind":kind})); - app.render_workspace.view.inspector.page = page; +fn focus(app: &mut SolveApp, selection: super::RenderSelection, category: Option<&str>) { + use super::RenderSelection; + if matches!(selection, RenderSelection::Object(_)) { + // Object shortcuts keep the shared modelling selection. + super::selection_editor(app, &selection); + } else { + let kind = match &selection { + RenderSelection::Nothing => "nothing", + RenderSelection::Environment => "environment", + RenderSelection::Wear => "wear", + RenderSelection::Lighting => "lighting", + RenderSelection::Light(_) => "light", + RenderSelection::Shot(_) => "shot", + RenderSelection::Output => "output", + RenderSelection::Sampling => "sampling", + RenderSelection::Effects => "effects", + RenderSelection::Check => "check", + RenderSelection::Object(_) => return, + }; + let mut params = json!({"kind":kind}); + if let RenderSelection::Shot(id) | RenderSelection::Light(id) = &selection { + params["id"] = json!(id); + } + let _ = app.run("view.render_select", params); + } if let Some(category) = category { library::open_category(app, category); } @@ -248,7 +262,7 @@ pub(super) fn start(app: &mut SolveApp, id: &str) -> bool { return true; } // Entering from Design never starts an expensive render with hidden defaults. - focus(app, inspector::Page::Output, None); + focus(app, super::RenderSelection::Output, None); if is_job_action(id) { return true; } @@ -262,11 +276,12 @@ pub(super) fn start(app: &mut SolveApp, id: &str) -> bool { fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> { action_ready(app, id)?; - use inspector::Page; + use super::RenderSelection; + let object = super::object_selection(app); + let shot = super::current_shot_selection(app); match id { "render.aging.describe" => { - focus(app, Page::Appearance, None); - inspector::reveal(app, "render:appearance:advanced"); + let _ = app.run("view.render_select", json!({"kind":"wear"})); } "render.compare" => super::compare::start_default(app)?, "render.result.close" => { @@ -275,35 +290,34 @@ fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> { "render.result.view" => { app.run(id, json!({"view":"image"}))?; } - "render.effects.describe" => focus(app, Page::Effects, None), - "render.preset.resolve" | "render.preset.apply" => focus(app, Page::Scene, Some("native_scenes")), + "render.effects.describe" => focus(app, RenderSelection::Effects, None), + "render.preset.resolve" | "render.preset.apply" => focus(app, RenderSelection::Nothing, Some("native_scenes")), "render.settings.get" | "render.shot.duplicate" | "render.shot.rename" | "render.shot.remove" => { - focus(app, Page::Scene, None); - inspector::reveal(app, "render_shot_manager"); + focus(app, shot, None); } "render.settings.set" => app.render_workspace.view.save_shot_name = Some(String::new()), - "render.library.appearance.apply" | "render.optics.bind" | "render.optics.unbind" => focus(app, Page::Appearance, Some("surfaces")), + "render.library.appearance.apply" | "render.optics.bind" | "render.optics.unbind" => focus(app, object, Some("surfaces")), "render.library.surface.bind" | "render.library.surface.prepare" | "render.library.surface.unbind" => { - focus(app, Page::Appearance, Some("surfaces")); + focus(app, object, Some("surfaces")); } - "render.library.physical.prepare" => focus(app, Page::Appearance, Some("physical_profiles")), - "render.library.studio.prepare" => focus(app, Page::Lighting, Some("rigs")), - "render.library.studio.apply" => focus(app, Page::Lighting, None), - "render.library.environment.apply" => focus(app, Page::Environment, Some("hdr")), - "render.environment.set" | "render.hdr.inspect" | "render.settings.capture_hdr" => focus(app, Page::Environment, None), - "render.stage.backdrop" => focus(app, Page::Background, None), + "render.library.physical.prepare" => focus(app, object, Some("physical_profiles")), + "render.library.studio.prepare" => focus(app, RenderSelection::Lighting, Some("rigs")), + "render.library.studio.apply" => focus(app, RenderSelection::Lighting, None), + "render.library.environment.apply" => focus(app, RenderSelection::Environment, Some("hdr")), + "render.environment.set" | "render.hdr.inspect" | "render.settings.capture_hdr" => focus(app, RenderSelection::Environment, None), + "render.stage.backdrop" => focus(app, RenderSelection::Environment, None), "render.stage.ground" | "render.stage.clear" => { - focus(app, Page::Background, None); + focus(app, RenderSelection::Environment, None); inspector::reveal(app, "render_ground_details"); } "render.settings.apply" => inspector::restore_camera(app)?, "render.camera.fit" => inspector::fit_subject(app)?, - "render.camera.get" | "render.camera.set" => focus(app, Page::Camera, None), + "render.camera.get" | "render.camera.set" => focus(app, shot, None), "render.quality.prepare" => { - focus(app, Page::Output, Some("quality")); + focus(app, RenderSelection::Sampling, Some("quality")); library::review_selected_quality(app)?; } - "render.status" | "render.backends" => focus(app, Page::Diagnostics, None), + "render.status" | "render.backends" => focus(app, RenderSelection::Check, None), _ => return Err(crate::i18n::tr("render.error_interactive_command").into()), } Ok(()) diff --git a/crates/ui-egui/src/render_workspace_setups.rs b/crates/ui-egui/src/render_workspace_setups.rs index 6c323ef991..1f88a04f6e 100644 --- a/crates/ui-egui/src/render_workspace_setups.rs +++ b/crates/ui-egui/src/render_workspace_setups.rs @@ -50,7 +50,7 @@ pub(super) fn snapshot(app: &SolveApp) -> Result { // Compare editable numerical intent before validation, so an incompatible legacy value // or a lens temporarily paired with an orthographic camera cannot disable dirty tracking. -fn draft(app: &SolveApp) -> Result { +pub(super) fn draft(app: &SolveApp) -> Result { let view = &app.render_workspace.view; let mut setup = RenderSetup { camera: app.cam, ..view.draft.clone() }; setup.job = Some(match view.method { @@ -140,10 +140,66 @@ fn draft(app: &SolveApp) -> Result { Ok(setup) } +/// Save only fields a numerical editor changed. Navigation keeps its explicit +/// Update shot/Revert flow, and temporary optical bindings remain local. +pub(super) fn persist_editor(app: &mut SolveApp, before: &RenderSetup) -> Result<(), String> { + let after = draft(app)?; + // A corrective edit (for example removing an invalid wear layer) must not + // require the hostile previous draft to be serialisable. The new value and + // the saved command are still validated normally. + let old = serde_json::to_value(before).ok(); + let new = serde_json::to_value(&after).map_err(|error| error.to_string())?; + let saved = app.session.doc.render.setups.get(&app.render_workspace.view.name); + let saved = saved.and_then(|setup| serde_json::to_value(setup).ok()); + let mut patch = serde_json::Map::new(); + if let Some(fields) = new.as_object() { + for (key, value) in fields { + if key != "camera" + && old.as_ref().and_then(|old| old.get(key)) != Some(value) + && saved.as_ref().and_then(|saved| saved.get(key)) != Some(value) + { + patch.insert(key.clone(), value.clone()); + } + } + } + if !patch.is_empty() { + after.validate().map_err(|error| error.to_string())?; + let name = app.render_workspace.view.name.clone(); + app.run("render.settings.set", serde_json::json!({"name":name,"set":patch}))?; + app.render_workspace.revision = app.session.revision; + let camera = app.render_workspace.view.clean_setup.as_ref().map_or(after.camera, |clean| clean.camera); + app.render_workspace.view.clean_setup = Some(RenderSetup { camera, ..after.clone() }); + app.render_workspace.view.clean_name = app.render_workspace.view.name.clone(); + materials::refresh_render_revision(app); + cpu::refresh_render_revision(app); + } + if let Some(saved) = app.session.doc.render.setups.get(&app.render_workspace.view.name) { + let mut saved = saved.clone(); + saved.camera = after.camera; + if saved == after { + let camera = app.render_workspace.view.clean_setup.as_ref().map_or(after.camera, |clean| clean.camera); + app.render_workspace.view.clean_setup = Some(RenderSetup { camera, ..after }); + app.render_workspace.view.clean_name = app.render_workspace.view.name.clone(); + app.render_workspace.revision = app.session.revision; + } + } + refresh_dirty(app); + Ok(()) +} + /// A setup switch/reload replaces its numeric editor draft, never a completed image or camera. pub(super) fn load(app: &mut SolveApp) { let setup = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default(); - let view = &mut app.render_workspace.view; + load_values(&mut app.render_workspace.view, setup); + app.render_workspace.view.dirty = false; + app.render_workspace.revision = app.session.revision; + // Keep the explicit Restore Saved Camera action: loading options does not navigate. + app.render_workspace.view.clean_setup = draft(app).ok(); + app.render_workspace.view.clean_name = app.render_workspace.view.name.clone(); + super::light::loaded(app); +} + +fn load_values(view: &mut super::ViewState, setup: RenderSetup) { view.method = ImageMethod::Raster; view.transparent = false; view.raytrace = RaytraceOptions::default(); @@ -230,18 +286,193 @@ pub(super) fn load(app: &mut SolveApp) { None => {} } view.draft = setup; - view.dirty = false; +} + +/// Compare the complete authoring source, excluding only saved Render settings. +/// Stable body labels alone do not prove that geometry or its material is unchanged. +pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak) -> bool { + if app.session.model.is_stale() { + return false; + } + let Some(source) = document.upgrade() else { return false }; + // Exhaustive by design: adding a Document field must update this comparison. + // Match ergonomics bind references, including vectors and point-cloud data. + macro_rules! compare_authoring_fields { + ($($field:ident),* $(,)?) => {{ + let solidcraft_engine::doc::Document { render: _, $($field),* } = source.as_ref(); + true $(&& $field == &app.session.doc.$field)* + }}; + } + compare_authoring_fields!( + format, + format_pending, + name, + units, + metadata, + params, + text_params, + features, + marker, + next_id, + reserved_body_names, + components, + next_component_color_index, + occurrences, + assembly, + sheet, + plastic, + parts, + links, + configs, + favorites, + canvases, + point_clouds, + param_comments, + materials, + print_faces, + user_materials, + appearances, + body_components, + body_offsets, + browser_groups, + timeline_groups, + browser_order, + named_views, + visibility, + ) +} + +/// A single refresh's source comparisons. Targets are still checked per binding. +#[derive(Default)] +pub(super) struct SourceCache(Vec<(std::sync::Weak, bool)>); + +impl SourceCache { + pub(super) fn same_model_source(&mut self, app: &SolveApp, document: &std::sync::Weak) -> bool { + if let Some((_, same)) = self.0.iter().find(|(source, _)| std::sync::Weak::ptr_eq(source, document)) { + return *same && !app.session.model.is_stale() && document.upgrade().is_some(); + } + let same = same_model_source(app, document); + self.0.push((document.clone(), same)); + same + } +} + +/// Synchronize saved edits/Undo without replacing local camera or runtime inputs. +pub(super) fn sync_revision(app: &mut SolveApp) { + if app.render_workspace.revision == app.session.revision { + return; + } + // A new shot name can intentionally carry the unsaved draft of its source. + // Its missing saved entry is not an Undo of that source's numerical fields. + if app.render_workspace.view.clean_name != app.render_workspace.view.name { + if !app.render_workspace.view.dirty { + load(app); + } + return; + } + let Some(clean) = app.render_workspace.view.clean_setup.clone() else { + if !app.render_workspace.view.dirty { + load(app); + } + return; + }; + let saved = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default(); + let mut previous = super::ViewState::default(); + load_values(&mut previous, clean.clone()); + let mut updated = super::ViewState::default(); + load_values(&mut updated, saved); + // Construct the new clean numerical baseline independently of local drafts. + let current = std::mem::replace(&mut app.render_workspace.view, updated); + let mut next_clean = draft(app).ok(); + if let Some(next) = &mut next_clean { + next.camera = clean.camera; + } + let updated = std::mem::replace(&mut app.render_workspace.view, current); + let view = &mut app.render_workspace.view; + macro_rules! sync { + ($($head:ident $(.$tail:ident)*),* $(,)?) => {$( + if previous.$head$(.$tail)* != updated.$head$(.$tail)* && view.$head$(.$tail)* == previous.$head$(.$tail)* { + view.$head$(.$tail)* = updated.$head$(.$tail)*; + } + )*}; + } + // Only persisted fields participate. Reflections, optical bindings, file selectors, + // editor selection, results and host/job state stay in their existing owners. + sync!( + method, + transparent, + samples, + max_depth, + seed, + draft.backend, + draft.mode, + draft.width, + draft.height, + draft.format, + draft.exposure_ev, + draft.environment_rotation, + draft.environment_intensity, + draft.background, + draft.job, + draft.surfaces, + draft.optics, + draft.stage, + raytrace.budget, + raytrace.primary_samples, + raytrace.progressive, + raytrace.preset, + raytrace.lights, + raytrace.punctual_lights, + raytrace.area_lights, + raytrace.area_samples, + raytrace.seed, + raytrace.shadows, + cpu.environment, + cpu.color, + cpu.intensity, + cpu.rotation_rad, + cpu.samples, + studio.aging, + studio.nested_media, + studio.volume, + studio.adaptive, + studio.transmission_samples, + studio.finishing, + studio.backdrop_enabled, + studio.backdrop, + studio.linear_exr, + studio.tone_map, + studio.denoise, + studio.lens_enabled, + studio.lens, + studio.lighting_enabled, + studio.ambient, + studio.sun_enabled, + studio.sun, + studio.area_lights, + studio.environment, + studio.gradient, + studio.rotation_rad, + studio.intensity, + studio.assume_linear_srgb + ); + // File HDR selectors are runtime inputs; embedded selectors follow saved Undo. + if view.cpu.hdr == previous.cpu.hdr { + view.cpu.hdr = updated.cpu.hdr; + } + if view.studio.hdr == previous.studio.hdr { + view.studio.hdr = updated.studio.hdr; + } + view.clean_setup = next_clean; app.render_workspace.revision = app.session.revision; - // Keep the existing explicit Restore Saved Camera action: loading options does not navigate. - app.render_workspace.view.clean_setup = draft(app).ok(); - super::light::loaded(app); + materials::refresh_render_revision(app); + cpu::refresh_render_revision(app); + refresh_dirty(app); } pub(super) fn refresh_dirty(app: &mut SolveApp) { - if let Some(clean) = &app.render_workspace.view.clean_setup - && (has_runtime_inputs(app) || draft(app).as_ref() != Ok(clean)) - { - app.render_workspace.view.dirty = true; + if let Some(clean) = &app.render_workspace.view.clean_setup { + app.render_workspace.view.dirty = has_runtime_inputs(app) || draft(app).as_ref() != Ok(clean); } } @@ -284,6 +515,7 @@ pub(super) fn save(app: &mut SolveApp) -> Result<(), String> { } app.render_workspace.view.draft = setup.clone(); app.render_workspace.view.clean_setup = Some(setup); + app.render_workspace.view.clean_name = app.render_workspace.view.name.clone(); app.render_workspace.view.dirty = false; app.render_workspace.revision = app.session.revision; Ok(()) diff --git a/crates/ui-egui/src/render_workspace_shot.rs b/crates/ui-egui/src/render_workspace_shot.rs index 23a2f57119..4331f50038 100644 --- a/crates/ui-egui/src/render_workspace_shot.rs +++ b/crates/ui-egui/src/render_workspace_shot.rs @@ -266,7 +266,6 @@ pub(super) fn editor(app: &mut SolveApp, ui: &mut egui::Ui, shot: &str) { crate::scenario::publish_target("render:shot:output", response.rect); if response.clicked() { let _ = app.run("view.render_select", json!({"kind":"output"})); - app.render_workspace.view.inspector.page = inspector::Page::Output; } }); }); @@ -451,9 +450,7 @@ pub(super) fn frame_rect(aspect: f64, viewport: Rect) -> Option { } pub(crate) fn overlay_visible(app: &SolveApp) -> bool { - !app.render_workspace.view.result - && (app.render_workspace.view.stage == Stage::Frame - || (app.render_workspace.view.inspector.page == inspector::Page::Camera && selected(app).is_some())) + !app.render_workspace.view.result && (app.render_workspace.view.stage == Stage::Frame || selected(app).is_some()) } pub(crate) fn preview_rect(app: &SolveApp, viewport: Rect) -> Option { diff --git a/crates/ui-egui/src/render_workspace_shots.rs b/crates/ui-egui/src/render_workspace_shots.rs index 48fe8b1cea..e42de977ba 100644 --- a/crates/ui-egui/src/render_workspace_shots.rs +++ b/crates/ui-egui/src/render_workspace_shots.rs @@ -9,13 +9,6 @@ pub(super) fn camera_modified(app: &SolveApp, name: &str) -> bool { app.session.doc.render.setups.get(name).is_some_and(|setup| setup.camera != app.cam) } -/// Update just the saved camera, preserving all the shot's other saved intent. -pub(super) fn update_camera(app: &mut SolveApp) -> Result<(), String> { - let name = app.render_workspace.view.name.clone(); - app.run("render.settings.set", json!({"name":name,"set":{"camera":app.cam}}))?; - Ok(()) -} - pub(crate) fn contents(app: &mut SolveApp, ui: &mut egui::Ui) { super::thumbnails::refresh(app, ui.ctx()); let status = app.render_workspace.view.job.and_then(|job| app.session.render_job_handle(job).ok()).and_then(|handle| handle.status().ok()); diff --git a/crates/ui-egui/src/render_workspace_studio.rs b/crates/ui-egui/src/render_workspace_studio.rs index 47c5a5f255..9f829cd9f1 100644 --- a/crates/ui-egui/src/render_workspace_studio.rs +++ b/crates/ui-egui/src/render_workspace_studio.rs @@ -164,6 +164,7 @@ pub(super) fn tone_controls(app: &mut SolveApp, ui: &mut egui::Ui) { } } +#[cfg(test)] pub(super) fn lens_controls(app: &mut SolveApp, ui: &mut egui::Ui) { let lens = app.tr("render.studio_lens").to_string(); ui_kit::checkbox(ui, &lens, &mut app.render_workspace.view.studio.lens_enabled); diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 42703667a4..c87b662a7f 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -3,6 +3,14 @@ use crate::scenario::Harness; use egui::Color32; use solidcraft_engine::Session; +fn click_render_check(h: &mut Harness) { + h.app.run("view.render_select", json!({"kind":"check"})).unwrap(); + h.frames(3); + h.step(&json!({"click":{"handle":"render:check"}})).unwrap(); + h.app.run("view.render_select", json!({"kind":"output"})).unwrap(); + h.frames(3); +} + fn environment_editor_frame(h: &mut Harness, events: Vec) { let mut output = h.ctx.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 1800.0))), events, ..Default::default() }, @@ -275,7 +283,7 @@ fn environment_ring_frame(h: &mut Harness, mods: egui::Modifiers, events: Vec Harness { let mut h = environment_editor_fixture(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::Environment; + inspector::select_editor(&mut h.app, inspector::Editor::Environment); h.app.preferences.nav = crate::prefs::OPENSCAD_NAV.into(); h.app.cam.fit(&solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds); environment_ring_frame(&mut h, egui::Modifiers::NONE, vec![]); @@ -407,14 +415,14 @@ fn environment_ring_cancel_or_stale_revision_discards_the_drag() { #[test] fn environment_ring_visibility_follows_editor_target_and_live_canvas() { let mut h = environment_ring_fixture(); - h.app.render_workspace.view.inspector.page = inspector::Page::Lighting; + inspector::select_editor(&mut h.app, inspector::Editor::Lighting); h.app.render_workspace.view.selection = RenderSelection::Environment; environment_ring_frame(&mut h, egui::Modifiers::NONE, vec![]); assert!(h.app.render_workspace.view.environment_ring.geometry.is_some()); h.app.render_workspace.view.selection = RenderSelection::Nothing; environment_ring_frame(&mut h, egui::Modifiers::NONE, vec![]); assert!(h.app.render_workspace.view.environment_ring.geometry.is_none()); - h.app.render_workspace.view.inspector.page = inspector::Page::Environment; + inspector::select_editor(&mut h.app, inspector::Editor::Environment); h.app.render_workspace.view.result = true; environment_ring_frame(&mut h, egui::Modifiers::NONE, vec![]); assert!(h.app.render_workspace.view.environment_ring.geometry.is_none()); @@ -425,15 +433,16 @@ fn environment_editor_opening_pages_and_sources_preserves_design_and_history() { let mut h = environment_editor_fixture(); let before = h.app.session.doc.clone(); let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); - for page in [inspector::Page::Environment, inspector::Page::Background] { - h.app.render_workspace.view.inspector.page = page; + { + let page = inspector::Editor::Environment; + inspector::select_editor(&mut h.app, page); environment_editor_frame(&mut h, vec![]); assert!(crate::scenario::rect_of("render:environment:rotation-slider").is_some()); h.app.ui.show_browser = false; environment::open_sources(&mut h.app); assert!(h.app.ui.show_browser); assert_eq!(h.app.render_workspace.view.stage, Stage::Light); - assert_eq!(h.app.render_workspace.view.inspector.page, page); + assert_eq!(inspector::selected_editor(&h.app), page); assert!(Arc::ptr_eq(&before, &h.app.session.doc)); assert_eq!(history, (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len())); } @@ -565,8 +574,10 @@ fn render_workspace_frame_guides_are_transient_and_overlay_visibility_is_context let frame = shot::preview_rect(&h.app, viewport).unwrap(); assert!((frame.width() / frame.height() - 16.0 / 9.0).abs() < 0.001); h.app.run("view.render_stage", json!({"stage":"look"})).unwrap(); + assert!(shot::overlay_visible(&h.app), "a stage change keeps the selected Shot"); + h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap(); assert!(!shot::overlay_visible(&h.app)); - h.app.render_workspace.view.inspector.page = inspector::Page::Camera; + h.app.run("view.render_select", json!({"kind":"shot","id":"Hero"})).unwrap(); assert!(shot::overlay_visible(&h.app)); h.app.render_workspace.view.result = true; assert!(!shot::overlay_visible(&h.app)); @@ -746,7 +757,6 @@ fn render_lights_editor_opening_and_disabled_gpu_types_never_edit_or_start_a_job .unwrap(); setups::load(&mut h.app); h.app.run("view.render_select", json!({"kind":"light","id":"area:0"})).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::Lighting; let document = h.app.session.doc.clone(); let before = (h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision, h.app.session.is_dirty()); h.frames(3); @@ -808,7 +818,7 @@ fn render_lights_list_inspects_unsaved_ui_draft_without_applying_or_opening_anyt let document = h.app.session.doc.clone(); let before = (h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision, h.app.session.is_dirty()); let camera = h.app.cam; - let page = h.app.render_workspace.view.inspector.page; + let page = inspector::selected_editor(&h.app); let snapshot = setups::snapshot(&h.app).unwrap(); let reply = h.app.run("render.lights.list", json!({"settings":snapshot})).unwrap(); assert_eq!(reply["lights"].as_array().unwrap().len(), 1); @@ -819,7 +829,7 @@ fn render_lights_list_inspects_unsaved_ui_draft_without_applying_or_opening_anyt assert_eq!(before, (h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision, h.app.session.is_dirty())); assert_eq!(h.app.cam, camera); assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Environment); - assert_eq!(h.app.render_workspace.view.inspector.page, page); + assert_eq!(inspector::selected_editor(&h.app), page); assert_eq!(setups::snapshot(&h.app).unwrap(), snapshot); assert!(h.app.render_workspace.view.job.is_none()); assert!(h.app.dialog.is_none() && h.app.tool.is_none()); @@ -853,7 +863,7 @@ mod async_path_host { let mut h = Harness::new(); h.call("ui.resize", json!({"width":1600,"height":1600})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); h } @@ -1004,7 +1014,7 @@ mod async_path_host { crate::documents::adopt(&mut h.app, Session::default()); assert!(matches!(h.app.render_workspace.path_host, PathHost::Preparing(..))); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); assert!(h.widget("GPU path tracer: compiling shaders…").is_some()); enter(&mut h.app, false).unwrap(); @@ -1012,7 +1022,7 @@ mod async_path_host { finish_setup(&mut h); assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_))); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); assert!(h.widget("GPU path tracer: compiling shaders…").is_none()); assert!(image_host(&h.app, ImageMethod::GpuRaytrace).is_ok()); @@ -1086,7 +1096,7 @@ mod async_path_host { completed_preview(&mut h, [200, 210, 220]); crate::documents::adopt(&mut h.app, Session::default()); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); assert!(h.widget(message).is_some()); h.app.render_workspace.path_host.start(|| panic!("failed setup retried on viewport install")); @@ -1263,7 +1273,7 @@ fn adopting_into_blank_document_resets_render_draft_and_preserves_hosts() { sync_backend(&mut h.app); h.app.render_workspace.view.name = "Old draft".into(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); let mut incoming = Session::default(); incoming.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); crate::documents::adopt(&mut h.app, incoming); @@ -1280,20 +1290,19 @@ fn clicked_image_controls_complete_cancel_and_export_without_dirtying_the_design let mut h = Harness::new(); h.app.preferences.language = language.into(); h.frames(1); - let check = h.app.tr("cmd.render_preflight").to_string(); - let start = h.app.tr("render.image_start").to_string(); - let export = h.app.tr("render.image_export").to_string(); + + let export = studio::export_label(&h.app).to_string(); h.call("ui.resize", json!({"width":1600,"height":1400})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.render_workspace.view.draft.width = 16; h.app.render_workspace.view.draft.height = 16; h.app.render_workspace.view.dirty = true; h.frames(3); let doc = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); - h.step(&json!({"widget":check})).unwrap(); - h.step(&json!({"widget":start})).unwrap(); + click_render_check(&mut h); + h.step(&json!({"click":{"handle":"render:stage:render.start"}})).unwrap(); for _ in 0..50 { h.frames(1); if h.app.render_workspace.view.completed.is_some() { @@ -1305,7 +1314,7 @@ fn clicked_image_controls_complete_cancel_and_export_without_dirtying_the_design assert!(h.dir.join("render.png").is_file()); h.app.render_workspace.view.draft.width = 1024; h.app.render_workspace.view.draft.height = 768; - h.step(&json!({"widget":start})).unwrap(); + h.step(&json!({"click":{"handle":"render:stage:render.start"}})).unwrap(); assert!(job_ready(&h.app, "render.cancel").is_ok()); h.step(&json!({"click":{"handle":"render:stage:render.cancel"}})).unwrap(); h.frames(5); @@ -1330,13 +1339,19 @@ fn saved_camera_survives_frames_and_product_fit_ignores_selection() { h.frames(3); let design = h.app.cam; enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); - h.step(&json!({"widget":"Restore Saved Camera"})).unwrap(); + h.app.run("view.render_select", json!({"kind":"shot","id":"Studio"})).unwrap(); + h.frames(3); + h.step(&json!({"click":{"handle":"render:shot:revert"}})).unwrap(); h.frames(3); assert_eq!(h.app.cam, camera); assert_eq!(h.app.render_workspace.view.draft.background, [21, 43, 65]); h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Product".into() }]; + { + let selection = object_selection(&h.app); + selection_editor(&mut h.app, &selection); + } h.call("engine.execute", json!({"command":"view.fit","params":{}})); h.frames(3); let selected_fit = h.app.cam; @@ -1516,7 +1531,7 @@ fn progressive_image_controls_route_to_the_registered_host() { h.app.session.mark_saved(); h.call("ui.resize", json!({"width":1600,"height":1600})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.render_workspace.view.draft.width = 16; h.app.render_workspace.view.draft.height = 16; h.app.render_workspace.view.samples = 4; @@ -1528,6 +1543,16 @@ fn progressive_image_controls_route_to_the_registered_host() { h.app.render_workspace.view.studio.lens.aperture_radius_mm = 0.0; h.app.render_workspace.view.studio.lighting_enabled = true; h.app.render_workspace.view.studio.ambient = [0.75; 3]; + h.frames(3); + let engineering = h.app.session.doc.features.clone(); + h.step(&json!({"click":{"handle":"render:method"}})).unwrap(); + h.step(&json!({"click":{"handle":"render:method-gpu"}})).unwrap(); + assert_eq!(h.app.render_workspace.view.method, ImageMethod::GpuRaytrace); + assert_eq!(h.app.session.doc.features, engineering); + assert!(matches!( + h.app.session.doc.render.setups[&h.app.render_workspace.view.name].job, + Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { .. }) + )); // The UI request reaches the real registered host with a coat and captured map. let target: materials::Target = serde_json::from_value(h.app.run("render.scene", json!({})).unwrap()["bodies"][0].clone()).unwrap(); let mut material = materials::Binding::new(target, &h.app); @@ -1538,19 +1563,16 @@ fn progressive_image_controls_route_to_the_registered_host() { material.maps.color = map_path.to_string_lossy().into_owned(); h.app.render_workspace.view.materials.enabled = true; h.app.render_workspace.view.materials.bindings.push(material); - h.frames(3); + h.app.session.mark_saved(); let doc = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); - h.step(&json!({"click":{"handle":"render:method"}})).unwrap(); - h.step(&json!({"click":{"handle":"render:method-gpu"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.method, ImageMethod::GpuRaytrace); let checked = image_params(&h.app).unwrap(); assert_eq!(checked["raytrace"]["clear_coats"][0]["weight"], 1.0); assert_eq!(checked["raytrace"]["textures"][0]["base_color_srgb"], map_path.to_string_lossy().as_ref()); h.app.run("render.preflight", checked.clone()).unwrap(); assert_eq!(image_params(&h.app).unwrap(), checked); - let start = h.app.tr("render.image_start").to_string(); - h.step(&json!({"widget":start})).unwrap(); + + h.step(&json!({"click":{"handle":"render:stage:render.start"}})).unwrap(); let job = h.app.render_workspace.view.job.unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while h.app.render_workspace.view.completed.is_none() { @@ -1568,7 +1590,7 @@ fn progressive_image_controls_route_to_the_registered_host() { assert_eq!(status["options"]["lens"]["aperture_radius_mm"], 0.0); assert_eq!(status["options"]["lighting"]["environment"], json!([0.75, 0.75, 0.75])); assert_eq!(status["textures"]["bodies"], 1); - let export = h.app.tr("render.image_export").to_string(); + let export = studio::export_label(&h.app).to_string(); h.step(&json!({"widget":export})).unwrap(); assert!(h.dir.join("render.png").is_file()); assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); @@ -1594,11 +1616,10 @@ fn progressive_image_controls_route_to_the_registered_host() { } #[test] -fn clicked_render_workspace_saves_one_setup_and_restores_design_view() { +fn clicked_shot_editor_saves_camera_and_restores_design_view() { for language in ["en", "de", "ko", "ru"] { let mut h = Harness::new(); h.app.preferences.language = language.into(); - h.app.preferences.perspective_fov = 50f64.to_radians(); h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap(); h.app.session.mark_saved(); h.frames(3); @@ -1609,24 +1630,27 @@ fn clicked_render_workspace_saves_one_setup_and_restores_design_view() { h.step(&json!({"click":[at.x,at.y]})).unwrap(); h.step(&json!({"click":{"handle":"menu:ui.workspace.render"}})).unwrap(); assert!(active(&h.app)); - let heading = h.app.tr("render.page_scene").to_string(); - let perspective = h.app.tr("render.perspective").to_string(); - let save = h.app.tr("render.save_setup").to_string(); - let back = h.app.tr("render.return_design").to_string(); - assert!(h.widget(&heading).is_some(), "{language}"); - h.step(&json!({"click":{"handle":"render:scene:frame"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Camera); - h.step(&json!({"widget":perspective})).unwrap(); - assert_eq!(h.app.cam.fov, 50f64.to_radians(), "{language}: Perspective takes the Field of view preference's angle (#5364)"); - assert!(!h.app.session.is_dirty(), "preview edits are staged"); - h.step(&json!({"widget":save})).unwrap(); - assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.widget(h.app.tr("render.page_scene")).is_some(), "{language}"); + h.step(&json!({"click":{"handle":"render:shots:save"}})).unwrap(); + h.app.render_workspace.view.save_shot_name = Some("Studio".into()); + h.frames(3); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.app.run("view.render_select", json!({"kind":"shot","id":"Studio"})).unwrap(); + h.frames(3); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Camera); + h.step(&json!({"widget":h.app.tr("render.perspective")})).unwrap(); + assert_eq!(h.app.cam.fov, 35f64.to_radians(), "{language}: keep the current Shot editor's projection behavior"); + assert_eq!(h.app.session.undo.len(), undo + 2); assert_eq!(h.app.session.doc.render.setups["Studio"].camera, h.app.cam); - h.step(&json!({"widget":back})).unwrap(); + let at = crate::scenario::rect_of("workspace_switcher").unwrap().center(); + h.step(&json!({"click":[at.x,at.y]})).unwrap(); + h.step(&json!({"click":{"handle":"menu:ui.workspace.design"}})).unwrap(); assert!(!active(&h.app)); assert_eq!(h.app.cam, camera); assert_eq!(flags, (h.app.ui.show_grid, h.app.origin_shown(), h.app.ui.visual_style, h.app.ui.perspective)); - h.app.run("edit.undo", json!({})).unwrap(); + for _ in 0..2 { + h.app.run("edit.undo", json!({})).unwrap(); + } assert!(!h.app.session.is_dirty()); } } @@ -1641,7 +1665,7 @@ fn render_drafts_and_capabilities_are_isolated_across_document_switches() { h.app.render_workspace.view.draft.background = [20, 40, 60]; h.app.render_workspace.view.dirty = true; enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); crate::documents::open_tab(&mut h.app, Session::default()).unwrap(); assert!(!active(&h.app)); assert_eq!(h.app.render_workspace.view.name, "Studio"); @@ -1991,7 +2015,7 @@ fn result_canvas_paints_large_capture_reports_staleness_and_preserves_export() { h.app.render_workspace.view.completed = Some(job); h.app.render_workspace.view.result = true; enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); let preview = h.app.cam; let doc = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); @@ -2043,7 +2067,7 @@ fn result_navigation_uses_shared_pan_zoom_and_leaves_product_camera_unchanged() h.app.render_workspace.view.completed = Some(job); h.app.render_workspace.view.result = true; enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.ui.perspective = true; h.app.preferences.smooth_wheel_zoom = false; h.app.preferences.zoom_reverse = false; @@ -2123,22 +2147,22 @@ fn clicked_result_controls_switch_canvas_and_frame_without_changing_the_setup() let job = completed_preview(&mut h, [20, 40, 60]); h.app.render_workspace.view.job = Some(job); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); let camera = h.app.cam; let doc = h.app.session.doc.clone(); - let result = h.app.tr("render.result_title").to_string(); + let result = h.app.tr("render.output_view_image").to_string(); h.step(&json!({"widget":result})).unwrap(); h.frames(2); assert!(h.app.render_workspace.view.result); - h.step(&json!({"widget":"100%"})).unwrap(); + h.step(&json!({"widget":h.app.tr("render.result_actual")})).unwrap(); h.frames(2); assert_eq!(h.app.render_workspace.view.result_scale, ResultScale::Actual); - let fit = h.app.tr("render.result_fit").to_string(); + let fit = h.app.tr("render.output_fit").to_string(); h.step(&json!({"widget":fit})).unwrap(); h.frames(2); assert_eq!(h.app.render_workspace.view.result_scale, ResultScale::Fit); - let preview = h.app.tr("render.result_preview").to_string(); + let preview = h.app.tr("render.state_model").to_string(); h.step(&json!({"widget":preview})).unwrap(); assert!(!h.app.render_workspace.view.result); assert_eq!(h.app.cam, camera); @@ -2149,7 +2173,7 @@ fn clicked_result_controls_switch_canvas_and_frame_without_changing_the_setup() fn image_method_requests_never_fall_back_or_mix_cpu_and_gpu_options() { let mut h = Harness::new(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.render_workspace.view.result = true; h.app.render_workspace.view.result_camera.distance = h.app.cam.distance * 3.0; h.app.render_workspace.view.raytrace.exposure_ev = f64::NAN; @@ -2191,10 +2215,10 @@ fn native_cpu_controls_check_and_start_the_same_capture_and_keep_it_exportable() h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap(); h.call("ui.resize", json!({"width":1600,"height":1800})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; - h.app.render_workspace.view.draft.width = 16; - h.app.render_workspace.view.draft.height = 16; - h.app.render_workspace.view.dirty = true; + inspector::select_editor(&mut h.app, inspector::Editor::Output); + // Keep the small capture size in the saved setup across editor autosaves. + let name = h.app.render_workspace.view.name.clone(); + h.app.run("render.settings.set", json!({"name":name,"set":{"width":16,"height":16}})).unwrap(); h.frames(3); let available = image_host(&h.app, ImageMethod::CpuRaytrace).is_ok(); h.step(&json!({"click":{"handle":"render:method"}})).unwrap(); @@ -2205,6 +2229,9 @@ fn native_cpu_controls_check_and_start_the_same_capture_and_keep_it_exportable() return; } assert_eq!(h.app.render_workspace.view.method, ImageMethod::CpuRaytrace); + h.app.run("view.render_select", json!({"kind":"sampling"})).unwrap(); + h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.children.cpu"))}})).unwrap(); h.step(&json!({"click":{"handle":"render:aa"}})).unwrap(); h.step(&json!({"click":{"handle":"render:aa-4"}})).unwrap(); assert_eq!(h.app.render_workspace.view.raytrace.primary_samples, 4); @@ -2213,22 +2240,20 @@ fn native_cpu_controls_check_and_start_the_same_capture_and_keep_it_exportable() let numeric = image_params(&h.app).unwrap(); assert_eq!(numeric["settings"]["job"]["method"], "cpu_raytrace"); assert!(numeric.get("raytrace").is_none()); - let numeric_check = h.app.tr("cmd.render_preflight").to_string(); - h.step(&json!({"widget":numeric_check})).unwrap(); + + click_render_check(&mut h); assert_eq!(h.app.render_workspace.view.cpu.checked.as_ref().unwrap().request, numeric); let reflected = h.app.session.world_state().bodies.first().unwrap().name.clone(); - let reflection_section = format!("dialog:section:{}", h.app.tr("render.cpu_reflections")); - h.step(&json!({"click":{"handle":reflection_section}})).unwrap(); - h.step(&json!({"click":{"handle":"render:cpu-reflection-body"}})).unwrap(); - h.step(&json!({"click":{"handle":format!("dialog:list:{reflected}")}})).unwrap(); - h.step(&json!({"click":{"handle":"render:cpu-reflection-add"}})).unwrap(); - h.step(&json!({"click":{"handle":format!("render:cpu-reflection-mode:{reflected}")}})).unwrap(); - h.step(&json!({"click":{"handle":"dialog:list:render.cpu_reflection_mirror"}})).unwrap(); + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: reflected }]})).unwrap(); + h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.appearance_editor.reflections"))}})).unwrap(); + h.step(&json!({"widget":h.app.tr("render.cpu_reflection_mirror")})).unwrap(); assert_eq!( h.app.render_workspace.view.raytrace.reflections.as_ref().unwrap().bodies[0].mode, solidcraft_engine::render::job::ReflectionMode::Mirror ); - h.step(&json!({"click":{"handle":reflection_section}})).unwrap(); + h.app.run("view.render_select", json!({"kind":"output"})).unwrap(); + h.frames(3); let hdr_path = h.dir.join("cpu-captured.hdr"); let mut hdr = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\nPRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n\n-Y 1 +X 2\n".to_vec(); hdr.extend_from_slice(&[128, 128, 128, 129, 128, 128, 128, 129]); @@ -2245,14 +2270,14 @@ fn native_cpu_controls_check_and_start_the_same_capture_and_keep_it_exportable() let params = image_params(&h.app).unwrap(); let preflight = h.app.session.execute("render.preflight", ¶ms).unwrap(); assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace"); - let check = h.app.tr("cmd.render_preflight").to_string(); - h.step(&json!({"widget":check})).unwrap(); + + click_render_check(&mut h); assert_eq!(image_params(&h.app).unwrap(), params); let checked = h.app.render_workspace.view.cpu.checked.as_ref().unwrap(); assert_eq!(checked.request, params); assert_eq!(checked.environment, preflight["environment"]); - let start = h.app.tr("render.image_start").to_string(); - h.step(&json!({"widget":start})).unwrap(); + + h.step(&json!({"click":{"handle":"render:stage:render.start"}})).unwrap(); let id = h.app.render_workspace.view.job.unwrap(); let handle = h.app.session.render_job_handle(id).unwrap(); // The job owns the source bytes captured by Start, even if the file changes. @@ -2545,7 +2570,7 @@ fn render_workspace_view_keys_double_middle_click_and_navigators_work() { h.app.preferences.look_at_anim = false; h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.frames(3); let mut home = h.app.cam; home.set_view(solidcraft_engine::render::StandardView::Iso); @@ -2736,6 +2761,107 @@ fn gpu_material_recipes_use_exact_linear_absorption_without_changing_saved_appea assert!(raster.get("raytrace").is_none()); } +#[test] +fn numeric_editor_save_refreshes_only_unchanged_runtime_binding_sources() { + use solidcraft_engine::render::job::ReflectionMode; + for (method, change_geometry) in + [(ImageMethod::GpuRaytrace, false), (ImageMethod::GpuRaytrace, true), (ImageMethod::CpuRaytrace, false), (ImageMethod::CpuRaytrace, true)] + { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":3,"body_name":"Target"})).unwrap(); + enter(&mut h.app, true).unwrap(); + select_image_method(&mut h.app, method); + setups::save(&mut h.app).unwrap(); + if method == ImageMethod::GpuRaytrace { + let target = materials::read_targets(&mut h.app).unwrap().remove(0); + let mut binding = materials::Binding::new(target, &h.app); + binding.maps_enabled = true; + binding.maps.color = "unchanged-source.png".into(); + h.app.render_workspace.view.materials.enabled = true; + h.app.render_workspace.view.materials.bindings.push(binding); + } else { + cpu::set_reflection(&mut h.app, "Target", Some(ReflectionMode::Mirror)).unwrap(); + } + let runtime = image_request(&h.app).unwrap(); + let features = h.app.session.doc.features.clone(); + let undo = h.app.session.undo.len(); + let before = setups::draft(&h.app).unwrap(); + if method == ImageMethod::GpuRaytrace { + h.app.render_workspace.view.samples += 1; + } else { + h.app.render_workspace.view.raytrace.primary_samples = 4; + } + setups::persist_editor(&mut h.app, &before).unwrap(); + let after = image_request(&h.app).expect("render-only persistence must retain current bindings"); + assert_eq!(after["raytrace"]["textures"], runtime["raytrace"]["textures"]); + if method == ImageMethod::GpuRaytrace { + assert_eq!(after["raytrace"]["textures"][0]["base_color_srgb"], "unchanged-source.png"); + } else { + assert!(after["raytrace"]["reflections"]["bodies"].as_array().is_some_and(|bodies| bodies.len() == 1)); + } + assert_eq!(after["raytrace"]["reflections"], runtime["raytrace"]["reflections"]); + assert_eq!(h.app.session.doc.features, features); + assert_eq!(h.app.session.undo.len(), undo + 1); + if change_geometry { + h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"body_name":"Later","operation":"new"})).unwrap(); + } else { + h.app.run("appearance.assign", json!({"bodies":["Target"],"color":"#123456"})).unwrap(); + } + assert!(image_request(&h.app).is_err()); + let before = setups::draft(&h.app).unwrap(); + h.app.render_workspace.view.draft.exposure_ev += 0.25; + setups::persist_editor(&mut h.app, &before).unwrap(); + assert!(image_request(&h.app).is_err(), "a render save must never bless a changed material source"); + h.app.run("browser.visibility", json!({"items":["Target"],"visible":false})).unwrap(); + let before = setups::draft(&h.app).unwrap(); + h.app.render_workspace.view.draft.exposure_ev += 0.25; + setups::persist_editor(&mut h.app, &before).unwrap(); + assert!(image_request(&h.app).is_err(), "hidden or moved targets must remain stale"); + } +} + +#[test] +fn undo_numeric_editor_sync_preserves_runtime_inputs_and_camera_draft() { + use solidcraft_engine::render::job::ReflectionMode; + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1600,"height":2600})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":3,"body_name":"Target"})).unwrap(); + enter(&mut h.app, true).unwrap(); + select_image_method(&mut h.app, ImageMethod::CpuRaytrace); + h.app.render_workspace.view.raytrace.primary_samples = 1; + setups::save(&mut h.app).unwrap(); + h.app.run("view.render_select", json!({"kind":"sampling"})).unwrap(); + cpu::set_reflection(&mut h.app, "Target", Some(ReflectionMode::Mirror)).unwrap(); + h.app.cancel_view_animation(); + h.app.cam.yaw += 0.25; + let camera = h.app.cam; + let saved = h.app.session.doc.render.setups["Studio"].clone(); + let request = image_request(&h.app).unwrap(); + let undo = h.app.session.undo.len(); + h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.children.cpu"))}})).unwrap(); + h.step(&json!({"click":{"handle":"render:aa"}})).unwrap(); + h.step(&json!({"click":{"handle":"render:aa-4"}})).unwrap(); + assert_eq!(h.app.render_workspace.view.raytrace.primary_samples, 4); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.frames(3); + h.app.run("edit.undo", json!({})).unwrap(); + h.frames(3); + assert_eq!(h.app.session.doc.render.setups["Studio"], saved); + assert_eq!(h.app.render_workspace.view.raytrace.primary_samples, 1, "Undo must reach the actual Sampling editor"); + assert!(h.widget("1×").is_some(), "the visible Sampling control must show the undone value"); + assert_eq!(h.app.cam, camera); + assert_eq!(image_request(&h.app).unwrap(), request, "Undo must preserve valid temporary reflection bindings"); + assert_eq!(h.app.session.undo.len(), undo); + assert!(h.app.render_workspace.view.dirty, "the camera and reflection drafts remain unsaved"); + h.app.run("edit.redo", json!({})).unwrap(); + h.frames(3); + assert_eq!(h.app.render_workspace.view.raytrace.primary_samples, 4); + assert!(h.widget("4×").is_some()); + assert_eq!(h.app.cam, camera); + assert_eq!(image_request(&h.app).unwrap()["raytrace"]["reflections"], request["raytrace"]["reflections"]); +} + #[test] fn gpu_material_identity_requires_explicit_revalidation_and_never_retargets() { let mut h = Harness::new(); @@ -2780,19 +2906,12 @@ fn clicked_gpu_glass_recipe_stays_temporary_and_blocks_cpu_rendering() { h.app.run("solid.box", json!({"length":10,"width":10,"height":3,"body_name":"Optics"})).unwrap(); h.app.session.mark_saved(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.render_workspace.view.method = ImageMethod::GpuRaytrace; h.frames(3); let doc = h.app.session.doc.clone(); - let title = h.app.tr("render.material_title").to_string(); - h.step(&json!({"click":{"handle":format!("dialog:section:{title}")}})).unwrap(); - let refresh = h.app.tr("render.material_refresh").to_string(); - h.step(&json!({"widget":refresh})).unwrap(); - let choose = h.app.tr("render.material_choose_body").to_string(); - h.step(&json!({"widget":choose})).unwrap(); - h.step(&json!({"click":{"handle":"dialog:list:render:material-body-0"}})).unwrap(); - let add = h.app.tr("render.material_add").to_string(); - h.step(&json!({"widget":add})).unwrap(); + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: "Optics".into() }]})).unwrap(); + h.frames(3); let glass = h.app.tr("render.appearance_editor.glass").to_string(); h.step(&json!({"click":{"handle":format!("dialog:section:{glass}")}})).unwrap(); h.step(&json!({"widget":h.app.tr("render.material_custom")})).unwrap(); @@ -2819,29 +2938,22 @@ fn clicked_gpu_material_at_assignment_limit_edits_existing_but_cannot_add_anothe let doc = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.render_workspace.view.materials.bindings = targets.iter().skip(1).cloned().map(|target| materials::Binding::new(target, &h.app)).collect(); + h.app.render_workspace.view.method = ImageMethod::GpuRaytrace; + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: targets[1].name.clone() }]})).unwrap(); h.frames(3); - let title = h.app.tr("render.material_title").to_string(); - h.step(&json!({"click":{"handle":format!("dialog:section:{title}")}})).unwrap(); - let refresh = h.app.tr("render.material_refresh").to_string(); - h.step(&json!({"widget":refresh})).unwrap(); - let choose = h.app.tr("render.material_choose_body").to_string(); - h.step(&json!({"widget":choose})).unwrap(); - h.step(&json!({"click":{"handle":format!("dialog:list:render:material-body-{}", targets[1].ordinal)}})).unwrap(); - let add = h.app.tr("render.material_add").to_string(); - h.step(&json!({"widget":add})).unwrap(); - let include = h.app.tr("render.material_binding_enable").to_string(); - h.step(&json!({"widget":include})).unwrap(); - assert!(!h.app.render_workspace.view.materials.bindings[0].enabled, "the existing assignment remains editable at the limit"); - assert!(h.app.render_workspace.view.materials.bindings.iter().skip(1).all(|binding| binding.enabled)); + let glass = h.app.tr("render.appearance_editor.glass").to_string(); + h.step(&json!({"click":{"handle":format!("dialog:section:{glass}")}})).unwrap(); + h.step(&json!({"widget":h.app.tr("render.appearance_editor.glass_enable")})).unwrap(); + assert!(h.app.render_workspace.view.materials.bindings[0].glass_enabled, "existing assignments remain editable at the limit"); + assert!(h.app.render_workspace.view.materials.bindings.iter().skip(1).all(|binding| !binding.glass_enabled)); assert_eq!(h.app.render_workspace.view.materials.bindings.len(), 128); - // The first scene body has no assignment: selecting it cannot create a 129th entry. - h.step(&json!({"widget":refresh})).unwrap(); - h.step(&json!({"widget":choose})).unwrap(); - h.step(&json!({"click":{"handle":format!("dialog:list:render:material-body-{}", targets[0].ordinal)}})).unwrap(); - h.step(&json!({"widget":add})).unwrap(); + // The first body has no assignment: editing cannot allocate a 129th entry. + h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name: targets[0].name.clone() }]})).unwrap(); + h.frames(3); + h.step(&json!({"widget":h.app.tr("render.appearance_editor.glass_enable")})).unwrap(); assert_eq!(h.app.render_workspace.view.materials.bindings.len(), 128); assert!(h.app.render_workspace.view.materials.bindings.iter().all(|binding| binding.target != targets[0])); assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); @@ -3109,32 +3221,28 @@ fn clicked_setup_save_reload_and_switch_keep_gpu_numeric_intent_without_a_host() assert!(!h.app.render_workspace.view.dirty); assert!(image_params(&h.app).is_err(), "offline editing must not claim an executable GPU"); assert_eq!(image_request(&h.app).unwrap(), json!({"settings":expected})); - h.step(&json!({"click":{"handle":"render:setup-name"}})).unwrap(); - h.step(&json!({"key":"A","cmd":true})).unwrap(); - h.step(&json!({"text":"GPU copy"})).unwrap(); + h.app.render_workspace.view.name = "GPU copy".into(); + h.app.render_workspace.view.dirty = true; assert_eq!(h.app.render_workspace.view.name, "GPU copy"); assert_eq!(setups::snapshot(&h.app).unwrap(), expected, "typing Save As must not reset the settings"); assert!(h.app.render_workspace.view.dirty); let undo = h.app.session.undo.len(); - h.step(&json!({"widget":h.app.tr("render.save_setup")})).unwrap(); + setups::save(&mut h.app).unwrap(); assert_eq!(h.app.session.undo.len(), undo + 1); assert_eq!(h.app.session.doc.render.setups["GPU copy"], expected); h.app.render_workspace.view.seed = 8; h.app.render_workspace.view.studio.ambient[1] = 0.125; setups::refresh_dirty(&mut h.app); assert!(h.app.render_workspace.view.dirty); - h.step(&json!({"widget":h.app.tr("render.reload_settings")})).unwrap(); - if h.app.render_workspace.view.dirty { - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); - } + setups::load(&mut h.app); + h.frames(3); assert_eq!(setups::snapshot(&h.app).unwrap(), expected); assert!(!h.app.render_workspace.view.dirty); select_image_method(&mut h.app, ImageMethod::Raster); h.app.render_workspace.view.name = "Raster saved".into(); setups::save(&mut h.app).unwrap(); h.frames(3); - h.step(&json!({"click":{"handle":"render:setups"}})).unwrap(); - h.step(&json!({"click":{"handle":"render:setup:GPU copy"}})).unwrap(); + h.step(&json!({"click":{"handle":"render:shots:setup:GPU copy"}})).unwrap(); assert_eq!(setups::snapshot(&h.app).unwrap(), expected); assert_eq!(h.app.render_workspace.view.completed, completed); assert_eq!(h.app.render_workspace.view.result_camera, result_camera); @@ -3151,7 +3259,7 @@ fn cpu_setup_reload_preserves_unedited_numeric_options_and_explicit_empty_values let mut h = Harness::new(); h.call("ui.resize", json!({"width":1600,"height":2400})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); let options: CpuJob = serde_json::from_value(json!({ "primary_samples":4,"shadows":false, "lights":[{"direction":[2.0,-4.0,8.0],"color":[0.2,0.4,0.6],"intensity":3.0}], @@ -3198,13 +3306,15 @@ fn cpu_progressive_controls_resize_only_on_request_and_save_reload_without_a_hos let mut h = Harness::new(); h.call("ui.resize", json!({"width":1600,"height":2600})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Sampling); h.app.session.set_render_job_host(None).unwrap(); select_image_method(&mut h.app, ImageMethod::CpuRaytrace); h.app.render_workspace.view.raytrace.primary_samples = 16; h.app.render_workspace.view.draft.width = 1024; h.app.render_workspace.view.draft.height = 1024; h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.children.cpu"))}})).unwrap(); + h.frames(3); h.step(&json!({"click":{"handle":"render:cpu-sampling"}})).unwrap(); h.step(&json!({"click":{"handle":"render:cpu-progressive"}})).unwrap(); assert_eq!(h.app.render_workspace.view.raytrace.primary_samples, 1); @@ -3221,23 +3331,27 @@ fn cpu_progressive_controls_resize_only_on_request_and_save_reload_without_a_hos assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (1024, 1024)); assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); assert_eq!(h.app.session.undo.len(), undo); - let resize = format!("{} 512 × 512", h.app.tr("render.image_use_largest")); + h.app.run("view.render_select", json!({"kind":"output"})).unwrap(); + h.frames(3); + let resize = crate::i18n::format("render.output_largest", &[("width", "512"), ("height", "512")]); assert!(h.widget(&resize).is_some(), "the proposed dimensions must be visible before accepting them"); h.step(&json!({"widget":resize})).unwrap(); assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (512, 512)); let expected = setups::snapshot(&h.app).unwrap(); assert_eq!(image_request(&h.app).unwrap(), json!({"settings":expected})); assert!(image_request(&h.app).unwrap().get("progressive").is_none(), "CPU sampling must not select the GPU executor"); - h.step(&json!({"widget":h.app.tr("render.save_setup")})).unwrap(); + setups::save(&mut h.app).unwrap(); assert_eq!(h.app.session.doc.render.setups["Progressive CPU"], expected); + h.app.run("view.render_select", json!({"kind":"sampling"})).unwrap(); + h.frames(3); h.step(&json!({"click":{"handle":"render:cpu-sampling"}})).unwrap(); h.step(&json!({"click":{"handle":"render:cpu-fixed"}})).unwrap(); assert!(h.app.render_workspace.view.raytrace.progressive.is_none()); - assert!(h.app.render_workspace.view.dirty); - h.step(&json!({"widget":h.app.tr("render.reload_settings")})).unwrap(); - if h.app.render_workspace.view.dirty { - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); - } + assert!(!h.app.render_workspace.view.dirty, "Sampling saves its numerical edit immediately"); + h.app.run("edit.undo", json!({})).unwrap(); + h.frames(3); + assert_eq!(h.app.session.doc.render.setups["Progressive CPU"], expected); + assert!(h.app.render_workspace.view.raytrace.progressive.is_some()); assert_eq!(setups::snapshot(&h.app).unwrap(), expected); assert!(!h.app.render_workspace.view.dirty); h.app.render_workspace.view.name = "Progressive CPU copy".into(); @@ -3293,7 +3407,7 @@ fn setup_save_refuses_nonportable_state_atomically_and_tracks_invalid_legacy_edi let mut h = Harness::new(); h.app.run("solid.box", json!({"length":10,"width":20,"height":5,"body_name":"Target"})).unwrap(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); setups::load(&mut h.app); select_image_method(&mut h.app, ImageMethod::GpuRaytrace); setups::save(&mut h.app).unwrap(); @@ -3365,13 +3479,12 @@ fn hdr_ui_save_embeds_once_without_a_host_and_reload_never_reopens_the_source_pa h.app.run("solid.box", json!({"body_name":"HDR target","length":10,"width":20,"height":5})).unwrap(); h.call("ui.resize", json!({"width":1600,"height":2200})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); h.app.session.set_render_job_host(None).unwrap(); let path = h.dir.join("original-embedded.hdr"); let bytes = original_hdr_bytes(128, true); std::fs::write(&path, &bytes).unwrap(); let resource = solidcraft_engine::doc::render_resources::HdrId::of(&bytes); - let save = h.app.tr("render.save_setup").to_string(); select_image_method(&mut h.app, ImageMethod::GpuRaytrace); h.app.render_workspace.view.name = "HDR GPU".into(); h.app.render_workspace.view.draft.width = 16; @@ -3387,7 +3500,7 @@ fn hdr_ui_save_embeds_once_without_a_host_and_reload_never_reopens_the_source_pa let unsaved = image_request(&h.app).unwrap(); assert_eq!(unsaved["environment"]["expected_sha256"], json!(resource)); h.frames(3); - h.step(&json!({"widget":save})).unwrap(); + setups::save(&mut h.app).unwrap(); assert_eq!(h.app.session.undo.len(), before.1 + 1); assert_eq!(h.app.render_workspace.view.studio.hdr, hdr::Input::EmbeddedResource { resource }); assert_eq!(h.app.session.doc.render.resources.len(), 1); @@ -3407,7 +3520,7 @@ fn hdr_ui_save_embeds_once_without_a_host_and_reload_never_reopens_the_source_pa hdr::inspect(&mut h.app, hdr::Target::Cpu, path.to_string_lossy().into_owned()).unwrap(); let undo = h.app.session.undo.len(); h.frames(2); - h.step(&json!({"widget":save})).unwrap(); + setups::save(&mut h.app).unwrap(); assert_eq!(h.app.session.undo.len(), undo + 1); assert_eq!(h.app.session.doc.render.resources.len(), 1, "both setups share one source payload"); assert_eq!(h.app.session.doc.render.resources.total_bytes(), bytes.len()); @@ -3429,12 +3542,9 @@ fn hdr_ui_save_embeds_once_without_a_host_and_reload_never_reopens_the_source_pa assert_eq!(setups::snapshot(&h.app).unwrap(), expected); assert_eq!(image_request(&h.app).unwrap(), json!({"settings":expected})); let before = (h.app.session.revision, h.app.session.undo.len()); - let section = if target == hdr::Target::Cpu { "render.cpu_environment" } else { "render.studio_environment" }; - h.frames(2); - if target == hdr::Target::Gpu { - h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr("render.image_advanced"))}})).unwrap(); - } - h.step(&json!({"click":{"handle":format!("dialog:section:{}", h.app.tr(section))}})).unwrap(); + h.app.run("view.render_select", json!({"kind":"environment"})).unwrap(); + h.frames(3); + assert!(crate::scenario::rect_of("render:environment:intensity").is_some()); h.frames(2); assert_eq!(setups::snapshot(&h.app).unwrap(), expected, "displaying degrees must preserve saved radians"); setups::save(&mut h.app).unwrap(); @@ -3447,7 +3557,7 @@ fn hdr_ui_save_embeds_once_without_a_host_and_reload_never_reopens_the_source_pa fn changed_hdr_bytes_reject_save_atomically_and_check_start_keep_the_expected_digest() { let mut h = Harness::new(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); select_image_method(&mut h.app, ImageMethod::CpuRaytrace); h.app.render_workspace.view.name = "Pinned HDR".into(); h.app.render_workspace.view.cpu.environment = cpu::Environment::Hdr; @@ -3518,7 +3628,7 @@ fn embedded_hdr_runtime_requests_preserve_temporary_bindings_without_serializing let mut h = Harness::new(); h.app.run("solid.box", json!({"body_name":"Target","length":10,"width":20,"height":5})).unwrap(); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Output); select_image_method(&mut h.app, if target == hdr::Target::Cpu { ImageMethod::CpuRaytrace } else { ImageMethod::GpuRaytrace }); h.app.render_workspace.view.cpu.environment = cpu::Environment::Hdr; h.app.render_workspace.view.studio.environment = studio::Environment::Hdr; @@ -3563,7 +3673,7 @@ fn cpu_resource_profile_edits_preserve_resolution_samples_and_saved_intent() { let mut h = Harness::new(); h.call("ui.resize", json!({"width":1600,"height":2600})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Sampling); h.app.session.set_render_job_host(None).unwrap(); select_image_method(&mut h.app, ImageMethod::CpuRaytrace); h.app.render_workspace.view.name = "Studio output".into(); @@ -3572,17 +3682,23 @@ fn cpu_resource_profile_edits_preserve_resolution_samples_and_saved_intent() { h.app.render_workspace.view.raytrace.budget = CpuBudget::Studio; h.app.render_workspace.view.raytrace.progressive = Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 82 }); h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.children.cpu"))}})).unwrap(); + h.frames(3); let expected = setups::snapshot(&h.app).unwrap(); setups::save(&mut h.app).unwrap(); setups::load(&mut h.app); assert_eq!(setups::snapshot(&h.app).unwrap(), expected); + let saved = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); h.step(&json!({"click":{"handle":"render.cpu_budget_compact"}})).unwrap(); h.frames(3); assert_eq!((h.app.render_workspace.view.draft.width, h.app.render_workspace.view.draft.height), (3840, 2160)); assert_eq!(h.app.render_workspace.view.raytrace.progressive.unwrap().samples_per_pixel, 128); - assert!(setups::snapshot(&h.app).is_err()); - assert!(h.app.render_workspace.view.dirty); + assert_eq!(h.app.render_workspace.view.raytrace.budget, CpuBudget::Studio, "an invalid saved edit is refused without resizing"); + assert_eq!(h.app.session.doc, saved); + assert_eq!(h.app.session.undo.len(), undo); + assert!(h.app.status.is_some(), "the refused budget needs its reason"); setups::load(&mut h.app); assert_eq!(setups::snapshot(&h.app).unwrap(), expected); } @@ -3619,46 +3735,50 @@ fn large_image_display_uses_captured_profile_and_keeps_export_pixels_unchanged() } #[test] -fn setup_actions_and_return_design_stay_visible_with_long_render_controls() { +fn stage_actions_and_workspace_return_stay_visible_with_long_render_controls() { let mut h = Harness::new(); h.call("ui.resize", json!({"width":1280,"height":900})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Sampling); select_image_method(&mut h.app, ImageMethod::CpuRaytrace); h.frames(3); - for key in ["render.save_setup", "render.reload_settings", "render.return_design"] { - let label = h.app.tr(key).to_string(); - assert!(h.widget(&label).is_some(), "sticky action {key}"); + let bar = crate::scenario::rect_of("render:stage_bar").unwrap(); + for id in ["render:stage:render.start", "render:stage:render.cancel"] { + assert!(bar.contains_rect(crate::scenario::rect_of(id).unwrap())); } - h.step(&json!({"widget":h.app.tr("render.return_design")})).unwrap(); + for key in ["render.save_setup", "render.reload_settings"] { + assert!(h.widget(h.app.tr(key)).is_none(), "removed studio action {key}"); + } + let at = crate::scenario::rect_of("workspace_switcher").unwrap().center(); + h.step(&json!({"click":[at.x,at.y]})).unwrap(); + h.step(&json!({"click":{"handle":"menu:ui.workspace.design"}})).unwrap(); assert!(!active(&h.app)); } #[test] -fn dirty_indication_preserves_reload_and_resource_picker_hit_targets() { +fn camera_draft_preserves_sampling_resource_picker_hit_targets() { let mut h = Harness::new(); h.call("ui.resize", json!({"width":1600,"height":2600})); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::All; + inspector::select_editor(&mut h.app, inspector::Editor::Sampling); select_image_method(&mut h.app, ImageMethod::CpuRaytrace); setups::save(&mut h.app).unwrap(); setups::load(&mut h.app); + let saved = h.app.session.doc.clone(); + let undo = h.app.session.undo.len(); + h.frames(3); + h.step(&json!({"click":{"handle":format!("dialog:section:{}",h.app.tr("render.children.cpu"))}})).unwrap(); h.frames(3); - let reload = h.app.tr("render.reload_settings").to_string(); - let clean_reload = h.widget(&reload).unwrap(); let clean_picker = crate::scenario::rect_of("render:cpu-budget").unwrap(); - h.app.render_workspace.view.draft.exposure_ev = 1.0; + h.app.cam.yaw += 0.25; setups::refresh_dirty(&mut h.app); assert!(h.app.render_workspace.view.dirty); h.frames(3); - assert_eq!(h.widget(&reload).unwrap(), clean_reload, "dirty text must not move Reload"); - assert_eq!(crate::scenario::rect_of("render:cpu-budget").unwrap(), clean_picker, "dirty text must not move the CPU resource picker"); - h.step(&json!({"widget":reload})).unwrap(); - if h.app.render_workspace.view.dirty { - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); - } - assert!(!h.app.render_workspace.view.dirty); - assert_eq!(h.app.render_workspace.view.draft.exposure_ev, 0.0); + assert_eq!(crate::scenario::rect_of("render:cpu-budget").unwrap(), clean_picker); + assert_eq!(h.app.session.doc, saved, "navigation keeps the saved camera until Update shot"); + assert_eq!(h.app.session.undo.len(), undo); + inspector::restore_camera(&mut h.app).unwrap(); + assert_eq!(h.app.cam, saved.render.setups["Studio"].camera); } #[test] @@ -3819,7 +3939,7 @@ fn finishing_backdrop_and_raw_retention_save_reload_without_a_gpu_or_hidden_tran assert_eq!(h.app.render_workspace.view.studio.finishing, Finishing::default()); assert!(h.app.render_workspace.view.studio.linear_exr); assert!(h.app.render_workspace.view.studio.backdrop_enabled); - assert!(h.app.render_workspace.view.dirty); + assert!(!h.app.render_workspace.view.dirty, "Effects already saved the numerical edit"); assert_ne!(h.app.session.doc.to_json(), before, "Effects saves its job patch through the shared editor"); let reset = setups::snapshot(&h.app).unwrap(); setups::load(&mut h.app); @@ -3862,7 +3982,7 @@ fn render_workspace_stage_commands_keep_registered_actions_without_a_ribbon() { assert!(crate::scenario::rect_of("tab:RENDER").is_none()); assert!(crate::scenario::rect_of("render:hud").is_none()); assert_eq!(h.app.render_workspace.view.stage, Stage::Look); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Scene); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Scene); } #[test] @@ -3873,11 +3993,12 @@ fn render_workspace_stage_chips_dispatch_without_mutations_and_remember_each_doc let document = h.app.session.doc.clone(); let history = (h.app.session.revision, h.app.session.undo.len(), h.app.session.redo.len()); for stage in Stage::ALL { + let selection = h.app.render_workspace.view.selection.clone(); h.frames(3); h.step(&json!({"click":{"handle":format!("render:stage:{}",stage.key())}})).unwrap(); h.frames(2); assert_eq!(h.app.render_workspace.view.stage, stage); - assert_eq!(h.app.render_workspace.view.inspector.page, stage.page()); + assert_eq!(h.app.render_workspace.view.selection, selection); assert_eq!(crate::scenario::rect_of("render:pick").is_some(), stage == Stage::Look); assert_eq!(crate::scenario::rect_of("render:preview_note").is_some(), stage == Stage::Look); assert!(Arc::ptr_eq(&h.app.session.doc, &document)); @@ -3888,7 +4009,7 @@ fn render_workspace_stage_chips_dispatch_without_mutations_and_remember_each_doc for command in ["render.status", "render.backends"] { h.app.start(command); h.frames(2); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Diagnostics); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Check); } } h.app.run("view.render_stage", json!({"stage":"frame"})).unwrap(); @@ -4269,6 +4390,10 @@ fn render_workspace_shots_toggle_persists_and_design_roundtrip_preserves_state() h.call("ui.resize", json!({"width":1440,"height":1000})); h.app.run("solid.box", json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap(); h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Subject".into() }]; + { + let selection = object_selection(&h.app); + selection_editor(&mut h.app, &selection); + } h.app.cam.yaw = 0.71; h.app.cam.pitch = 0.23; h.app.cam.roll = 0.17; @@ -4415,7 +4540,7 @@ fn assert_product_fits_canvas(app: &SolveApp) { fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() { for (layout, page) in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] .into_iter() - .flat_map(|layout| [inspector::Page::Scene, inspector::Page::Appearance].map(|page| (layout, page))) + .flat_map(|layout| [inspector::Editor::Scene, inspector::Editor::Appearance].map(|page| (layout, page))) { let mut h = Harness::new(); h.app.ui.layout = layout; @@ -4424,7 +4549,7 @@ fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() { h.frames(4); enter(&mut h.app, true).unwrap(); - h.app.render_workspace.view.inspector.page = page; + inspector::select_editor(&mut h.app, page); h.frames(4); let viewport = h.app.viewport.rect.unwrap(); let history = crate::timeline::floating_rect(viewport); @@ -4465,7 +4590,7 @@ fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() { "render:tree:by-look" } else if key == "render:appearances" { "render:library-search" - } else if page == inspector::Page::Appearance { + } else if page == inspector::Editor::Appearance { "dialog:editor_footer" } else { "render:scene:look" @@ -4923,9 +5048,9 @@ fn render_lights_browser_sections_and_lights_describe_current_job_entries() { assert_eq!(scene::environment(&h.app), ("Sky".into(), "90° · 2.50×".into())); h.frames(3); h.step(&json!({"click":{"handle":"render:tree:environment:current"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Environment); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Environment); h.step(&json!({"click":{"browser":"Key"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Lighting); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Light); } #[test] @@ -5045,24 +5170,23 @@ fn render_workspace_dock_grip_menus_dispatch_the_other_side() { } #[test] -fn render_workspace_browser_shot_switch_keeps_the_existing_dirty_confirmation() { +fn render_workspace_browser_shot_switch_preserves_saved_edits_without_a_draft_prompt() { let mut h = Harness::new(); h.call("ui.resize", json!({"width":1440,"height":1100})); enter(&mut h.app, true).unwrap(); h.app.render_workspace.view.name = "A".into(); setups::save(&mut h.app).unwrap(); h.app.run("render.settings.set", json!({"name":"B","set":{"width":321,"job":{"method":"raster"}}})).unwrap(); + let before = setups::draft(&h.app).unwrap(); h.app.render_workspace.view.draft.width = 123; + setups::persist_editor(&mut h.app, &before).unwrap(); h.frames(3); h.step(&json!({"click":{"handle":"render:tree:shot:B"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.name, "A"); - assert!(inspector::confirmation_open(&h.app)); - h.step(&json!({"widget":h.app.tr("render.keep_editing")})).unwrap(); - assert_eq!(h.app.render_workspace.view.draft.width, 123); - h.step(&json!({"click":{"handle":"render:tree:shot:B"}})).unwrap(); - h.step(&json!({"widget":h.app.tr("render.discard_continue")})).unwrap(); + assert!(!inspector::confirmation_open(&h.app)); assert_eq!(h.app.render_workspace.view.name, "B"); assert_eq!(h.app.render_workspace.view.draft.width, 321); + assert_eq!(h.app.session.doc.render.setups["A"].width, 123); + assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Shot("B".into())); assert!(!h.app.render_workspace.view.dirty); } @@ -5088,7 +5212,7 @@ fn render_lights_browser_focus_covers_each_kind_without_design_edits() { assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Object(vec![Sel::Body { name: "Object".into() }])); h.step(&json!({"click":{"handle":"render:tree:environment"}})).unwrap(); assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Environment); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Environment); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Environment); assert!(crate::scenario::rect_of("dialog:section:Source").is_some()); assert!(h.app.session.selection.is_empty()); let focused_revision = h.app.session.revision; @@ -5115,7 +5239,7 @@ fn render_lights_browser_focus_covers_each_kind_without_design_edits() { assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Output); h.app.run("view.render_select", json!({"kind":"nothing"})).unwrap(); assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Nothing); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Scene); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Scene); assert_eq!(h.app.session.revision, focused_revision, "semantic selection only refreshes the view when clearing object picks"); assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); assert_eq!(before, (h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.is_dirty())); @@ -5136,11 +5260,12 @@ fn render_lights_browser_eye_and_solo_are_view_state_and_editor_opening_is_read_ assert_eq!(h.app.render_workspace.view.lights.visibility.solo.as_deref(), Some("directional:1")); h.step(&json!({"click":{"handle":"render:tree:light:1"}})).unwrap(); h.frames(3); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Lighting); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Light); assert!(crate::scenario::rect_of("render:light:editor").is_some()); assert!(crate::scenario::handle_at("render:tree:shots:add").is_some()); h.step(&json!({"click":{"handle":"render:tree:shots:add"}})).unwrap(); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Scene); + assert!(inspector::confirmation_open(&h.app)); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Light); assert!(Arc::ptr_eq(&document, &h.app.session.doc)); assert_eq!(before, (h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision, h.app.session.is_dirty())); assert!(h.app.render_workspace.view.job.is_none()); @@ -5414,7 +5539,7 @@ fn render_workspace_shots_modified_dot_update_and_revert_follow_only_camera() { enter(&mut h.app, true).unwrap(); h.frames(3); setups::save(&mut h.app).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::Camera; + inspector::select_editor(&mut h.app, inspector::Editor::Camera); h.frames(3); assert!(!shots::camera_modified(&h.app, "Studio")); assert!(crate::scenario::rect_of("dialog:list:shot:Studio:modified").is_none()); @@ -5425,8 +5550,8 @@ fn render_workspace_shots_modified_dot_update_and_revert_follow_only_camera() { h.app.cam.yaw += 0.3; h.frames(3); assert!(crate::scenario::rect_of("dialog:list:shot:Studio:modified").is_some()); - assert!(crate::scenario::handle_at("render:shots:update").is_some()); - h.step(&json!({"click":{"handle":"render:shots:update"}})).unwrap(); + assert!(crate::scenario::handle_at("render:shot:update").is_some()); + h.step(&json!({"click":{"handle":"render:shot:update"}})).unwrap(); h.frames(2); let mut expected = saved.clone(); expected.camera = h.app.cam; @@ -5434,12 +5559,14 @@ fn render_workspace_shots_modified_dot_update_and_revert_follow_only_camera() { assert_eq!(h.app.render_workspace.view.draft.width, 317, "Update must preserve the editor draft"); assert!(!shots::camera_modified(&h.app, "Studio")); assert!(crate::scenario::rect_of("dialog:list:shot:Studio:modified").is_none()); - assert!(crate::scenario::handle_at("render:shots:update").is_none()); + let tree = h.output.accesskit_update.as_ref().unwrap(); + let update = tree.nodes.iter().find(|(_, node)| node.label() == Some(h.app.tr("render.shots_update"))).unwrap(); + assert!(update.1.is_disabled(), "Update stays visible but inactive once the camera is saved"); h.app.cam.roll += 0.2; h.frames(3); let document = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); - h.step(&json!({"click":{"handle":"render:shots:revert"}})).unwrap(); + h.step(&json!({"click":{"handle":"render:shot:revert"}})).unwrap(); h.frames(2); assert_eq!(h.app.cam, expected.camera); assert!(!shots::camera_modified(&h.app, "Studio")); @@ -6160,6 +6287,10 @@ fn render_assign_gestures_selection_sizes_are_atomic_and_undoable() { } h.app.run("select.filter", json!({"mode":"body"})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Plate".into() }, Sel::Body { name: "missing".into() }]; + { + let selection = object_selection(&h.app); + selection_editor(&mut h.app, &selection); + } let before = h.app.session.doc.to_json(); let undo = h.app.session.undo.len(); assert!(assign::tile(&mut h.app, json!({"appearance":"ABS - Red"}), false).is_err()); @@ -6207,6 +6338,10 @@ fn render_assign_gestures_paint_sample_copy_paste_escape_and_failed_pick() { h.app.start("appearance.paint"); h.step(&json!({"key":"Escape"})).unwrap(); h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Plate".into() }]; + { + let selection = object_selection(&h.app); + selection_editor(&mut h.app, &selection); + } h.app.start("appearance.copy"); h.app.run("appearance.assign", json!({"bodies":["Plate"],"appearance":"ABS - Yellow"})).unwrap(); let undo = h.app.session.undo.len(); @@ -6378,7 +6513,7 @@ fn render_lighting_group_selection_opens_ordered_editor_and_plus_still_adds() { h.frames(3); h.step(&json!({"click":{"handle":"render:tree:lights"}})).unwrap(); assert_eq!(h.app.render_workspace.view.selection, RenderSelection::Lighting); - assert_eq!(h.app.render_workspace.view.inspector.page, inspector::Page::Lighting); + assert_eq!(inspector::selected_editor(&h.app), inspector::Editor::Lighting); assert!(crate::scenario::rect_of("render:lighting:editor").is_some()); assert!(Arc::ptr_eq(&before, &h.app.session.doc)); let sections: Vec<_> = ["render.lighting_source_section", "render.lighting_shadows_section", "render.lighting_budget"] @@ -6452,7 +6587,7 @@ fn render_assign_gestures_stage_tools_and_selection_actions_use_commands() { assert!(h.app.render_workspace.view.assign.sampling); h.step(&json!({"key":"Escape"})).unwrap(); h.step(&json!({"click":{"handle":"selection:action:0"}})).unwrap(); - assert!(crate::scenario::rect_of("render:look_chain").is_some()); + assert!(crate::scenario::rect_of("render:appearance:chain-menu").is_some()); h.step(&json!({"click":{"handle":"selection:action:2"}})).unwrap(); assert_eq!(h.app.preferences.render_library_favorites.len(), 1); h.step(&json!({"click":{"handle":"selection:action:2"}})).unwrap(); @@ -6564,7 +6699,6 @@ fn render_lights_each_kind_contains_design_sections_and_real_parameter_fields_in h.app.run("render.settings.set", json!({"name":"Studio","set":{"job":{"method":method,"options":options}}})).unwrap(); setups::load(&mut h.app); light::edit(&mut h.app, "render.lights.add", None, Some(json!({"kind":kind}))).unwrap(); - h.app.render_workspace.view.inspector.page = inspector::Page::Lighting; let before = h.app.session.doc.clone(); h.frames(3); assert!(crate::scenario::rect_of("render:light:editor").is_some()); @@ -6774,7 +6908,6 @@ fn render_lighting_hostile_numeric_edits_refuse_without_panics_or_partial_state( assert!(Arc::ptr_eq(&before, &h.app.session.doc)); assert_eq!(h.app.session.undo.len(), history); } - h.app.render_workspace.view.inspector.page = inspector::Page::Lighting; h.frames(3); assert!(crate::scenario::rect_of("render:light:editor").is_some()); } diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 277466f1ca..36c7b3b098 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -178,6 +178,7 @@ impl Drop for Harness { impl Harness { pub fn new() -> Harness { + crate::i18n::pin_system_language(); let mut h = Harness { app: SolveApp::new(Session::default(), Services::default()), ctx: { diff --git a/crates/ui-egui/src/settings_profiles.rs b/crates/ui-egui/src/settings_profiles.rs index ce3b309931..b68f735295 100644 --- a/crates/ui-egui/src/settings_profiles.rs +++ b/crates/ui-egui/src/settings_profiles.rs @@ -34,7 +34,7 @@ const MAX_THEMES: usize = 4; /// Most characters in a command id or a key name. const MAX_ID: usize = 200; -/// The 93 stored keys from the pre-split Interface, Navigation and Sketch pages, plus +/// The 98 stored keys selected dynamically before the split: Interface, Navigation and Sketch, plus /// language, excluding space_mouse_* and orbit_cursor. Page moves and title tags must never /// change profile contents (including the bridge, origin names and direct interaction). const PROFILE_KEYS: &[&str] = &[ @@ -57,6 +57,8 @@ const PROFILE_KEYS: &[&str] = &[ "history_in_browser", "history_play_button", "history_sketches", + "home_fit_size", + "invert_navigator_arrows", "keep_work_on_tab_switch", "language", "layout", @@ -72,6 +74,7 @@ const PROFILE_KEYS: &[&str] = &[ "nav_bridge_enabled", "nav_bridge_port", "navigation_bar", + "navigator_fit", "navigator_style", "orbit_around", "orbit_reverse", @@ -83,6 +86,7 @@ const PROFILE_KEYS: &[&str] = &[ "project_options_dialog", "quick_measure", "radial_context_menu", + "ribbon_click_ends_tool", "ribbon_icon_grid", "ribbon_labels", "ribbon_menus_skip_pinned", @@ -97,6 +101,7 @@ const PROFILE_KEYS: &[&str] = &[ "sketch_conflict_colour", "sketch_construction_lines", "sketch_exit_view", + "sketch_face_activates_part", "sketch_open_look_at", "sketch_recent_snap", "sketch_state_icons", @@ -145,10 +150,19 @@ pub struct Profiles { pub list: Vec, /// The profile applied or saved last, while it exists. pub active: Option, - /// The name typed in Preferences. + /// The independent name typed in Preferences, empty until the user enters one. draft: String, /// The last action's outcome in Preferences. message: Option, + /// General's row-local rename editor; never changes the active profile just to rename it. + rename: Option, +} + +struct Rename { + name: String, + text: String, + focused: bool, + at: egui::Pos2, } /// What a profile action in Preferences reports. Kept untranslated: applying a profile can @@ -508,84 +522,157 @@ fn act(app: &mut SolveApp, id: &str, params: Value, done: &'static str) { }); } -/// The Settings profiles section of the General page: the active profile (choosing one applies -/// it), a name to save the current settings under, and Rename, Delete, Export… and Import…. -pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { +/// General's saved profiles and row actions. All mutations still use settings.profile.*. +pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); - if app.settings_profiles.draft.is_empty() - && let Some(active) = &app.settings_profiles.active - { - app.settings_profiles.draft = active.clone(); - } - ui_kit::settings_section(ui, "profiles", app.tr("prefs.profiles"), |ui| { - ui_kit::text(ui, app.tr("prefs.profiles_note"), ui_kit::Text::Hint("profiles")); - ui_kit::settings_form(ui, labels, |ui| { - let names = app.settings_profiles.names(); - let current = app.settings_profiles.active.clone().unwrap_or_else(|| crate::i18n::tr_label("None").to_string()); - let mut chosen = None; - ui_kit::row(ui, app.tr("prefs.profile_active"), |ui| { - ui_kit::settings_control_width(ui, t.form_card_width - t.space_4, |ui| { - ui_kit::dropdown_menu(ui, "pref_profile", ¤t, |ui| { - for name in &names { - if ui_kit::list_picker(ui, name, name, app.settings_profiles.active.as_ref() == Some(name)).clicked() { - chosen = Some(name.clone()); + let count = crate::i18n::format("prefs.reports_saved", &[("n", &app.settings_profiles.list.len().to_string())]); + ui_kit::settings_section_count(ui, "profiles", app.tr("ui.profiles"), Some(&count), |ui| { + let names = app.settings_profiles.names(); + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = t.space_8; + for name in &names { + let active = app.settings_profiles.active.as_ref() == Some(name); + let edited = active && app.settings_profiles.get(name).is_ok_and(|doc| *doc != capture(app, name)); + ui.push_id(name, |ui| { + let card = ui_kit::settings_row_card(ui, active, t.control_height + t.space_14, |ui| { + ui.spacing_mut().item_spacing.x = t.space_6; + let state_width = if active { + ui_kit::ContentWidth::label(ui, app.tr("prefs.general.active")) + + t.space_16 + + if edited { ui_kit::ContentWidth::label(ui, app.tr("prefs.general.edited")) + t.space_24 } else { 0.0 } + } else { + ui_kit::ContentWidth::label(ui, app.tr("ui.apply")) + t.space_24 + }; + let name_width = (ui.available_width() - t.space_24 * 3.0 - t.space_6 * 4.0 - state_width).max(0.0); + ui.allocate_ui_with_layout( + egui::vec2(name_width, t.control_height), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let side = t.space_16 + t.space_2 / 2.0; + let (icon, _) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover()); + crate::icons::paint_ink(ui.painter(), icon, "settings_profile", if active { t.selected_ink } else { t.form_dim }); + ui_kit::text(ui, name, if active { ui_kit::Text::SelectedNameCell } else { ui_kit::Text::NameCell(false) }); + }, + ); + if edited { + let badge = ui_kit::settings_state_badge(ui, app.tr("prefs.general.edited"), ui_kit::SettingsBadgeTone::Warning); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:edited"), badge.rect); + } + if active { + let badge = ui_kit::settings_state_badge(ui, app.tr("prefs.general.active"), ui_kit::SettingsBadgeTone::Accent); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:active"), badge.rect); + } else { + let button = ui_kit::button(ui, app.tr("ui.apply"), ui_kit::Button::Small); + crate::scenario::publish_target(&format!("prefs:profile:{name}:apply"), button.rect); + if button.clicked() { + act(app, "settings.profile.apply", json!({ "name": name }), "prefs.profile_applied"); + } + } + for (action, icon, label) in [ + ("export", "export_tray", app.tr("prefs.profile_export")), + ("rename", "edit", app.tr("prefs.profile_rename")), + ("delete", "delete", app.tr_label("Delete")), + ] { + let (target, _) = ui.allocate_exact_size(egui::vec2(t.space_24, t.space_24), egui::Sense::hover()); + let button = ui_kit::icon_button_at(ui, target, icon, label); + crate::scenario::publish_target(&format!("prefs:profile:{name}:{action}"), button.rect); + if button.clicked() { + match action { + "export" => { + let name = name.clone(); + app.pick_save(&format!("{name}.solidcraft-profile.json"), &["json"], move |app, path| { + if let Some(path) = path { + act(app, "settings.profile.export", json!({ "name": name, "path": path }), "prefs.profile_exported"); + } + }); + } + "rename" => { + app.settings_profiles.rename = + Some(Rename { name: name.clone(), text: name.clone(), focused: false, at: button.rect.left_bottom() }); + } + "delete" => { + act(app, "settings.profile.delete", json!({ "name": name }), "prefs.profile_deleted"); + } + _ => {} + } + } + } + }); + crate::scenario::publish_rect(&format!("prefs:profile:{name}:card"), card.response.rect); + }); + } + }); + // Searching for "profiles" must keep this entry on the results page; without a hit here the + // whole section was dropped and the name field could not be reached with a query active. + let entry = [app.tr("prefs.general.profile_name"), app.tr("prefs.general.save_new"), app.tr("prefs.general.import")]; + if crate::prefs_search::visible(ui, &entry) { + ui.horizontal_wrapped(|ui| { + let mut draft = std::mem::take(&mut app.settings_profiles.draft); + let enter = ui_kit::named_control(ui, app.tr("prefs.general.profile_name"), |ui| { + ui_kit::settings_control_width(ui, t.space_24 * 9.0 + t.space_4, |ui| { + ui_kit::text_field(ui, &mut draft, app.tr("prefs.general.name_setup")).0 + }) + }); + app.settings_profiles.draft = draft; + let name = app.settings_profiles.draft.trim().to_string(); + let valid = profile_name(&name).is_some(); + let save = + ui_kit::icon_label_action(ui, app.tr("prefs.general.save_new"), None, valid, true).on_hover_text(app.tr("prefs.profiles_note")); + crate::scenario::publish_target("prefs:profile:save", save.rect); + if valid && (enter || save.clicked()) { + act(app, "settings.profile.save", json!({ "name": name }), "prefs.profile_saved"); + } + let import = ui_kit::icon_label_button(ui, app.tr("prefs.general.import"), "import_tray", true, false); + crate::scenario::publish_target("prefs:profile:import", import.rect); + if import.clicked() { + app.pick_open_for(&["json"], |app, path| { + if let Some(path) = path { + act(app, "settings.profile.import", json!({ "path": path }), "prefs.profile_imported"); + } + }); + } + }); + } + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = t.space_8; + for (label, chips, accent) in [ + ( + app.tr("prefs.general.held"), + &[ + ("prefs.structure.appearance", "settings_palette"), + ("prefs.structure.layout", "settings_monitor"), + ("ui.navigation", "settings_mouse"), + ("prefs.structure.sketch", "select_sketch"), + ("prefs.shortcuts", "settings_keyboard"), + ("ui.language", "settings"), + ][..], + true, + ), + ( + app.tr("prefs.general.this_computer"), + &[("prefs.structure.mouse3d", "lock"), ("ui.graphics", "lock"), ("prefs.assistant", "lock")][..], + false, + ), + ] { + let key = if accent { "held" } else { "computer" }; + let row = ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = t.space_6; + let (rect, _) = ui.allocate_exact_size(egui::vec2(t.space_24 * 3.0 + t.space_12, t.space_24), egui::Sense::hover()); + let mut label_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let caption = ui_kit::text(&mut label_ui, label, ui_kit::Text::Inline); + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:label"), caption.rect); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(t.space_6, t.space_6); + for (index, (chip, icon)) in chips.iter().enumerate() { + let response = + ui_kit::settings_chip(ui, app.tr(chip), ui_kit::SettingsChip { icon: Some(icon), accent, ..Default::default() }); + if index == 0 { + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:first_chip"), response.rect); } } }); }); - }); - if let Some(name) = chosen { - app.settings_profiles.draft = name.clone(); - act(app, "settings.profile.apply", json!({ "name": name }), "prefs.profile_applied"); - } - if let Some(active) = app.settings_profiles.active.clone() - && app.settings_profiles.get(&active).is_ok_and(|doc| *doc != capture(app, &active)) - { - ui_kit::text(ui, app.tr("prefs.profile_changed"), ui_kit::Text::Note("profile_changed")); - } - ui_kit::row(ui, crate::i18n::tr_label("Name"), |ui| { - let mut draft = std::mem::take(&mut app.settings_profiles.draft); - let (enter, _) = ui_kit::text_field(ui, &mut draft, app.tr("prefs.profile_name_hint")); - app.settings_profiles.draft = draft; - if enter { - let name = app.settings_profiles.draft.clone(); - act(app, "settings.profile.save", json!({ "name": name }), "prefs.profile_saved"); - } - }); - }); - let draft = app.settings_profiles.draft.trim().to_string(); - let active = app.settings_profiles.active.clone(); - ui.horizontal_wrapped(|ui| { - if ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Enabled(profile_name(&draft).is_some())).clicked() { - act(app, "settings.profile.save", json!({ "name": draft }), "prefs.profile_saved"); - } - let renamable = active.as_ref().is_some_and(|a| *a != draft) && profile_name(&draft).is_some(); - if ui_kit::button(ui, app.tr("prefs.profile_rename"), ui_kit::Button::Enabled(renamable)).clicked() - && let Some(name) = &active - { - act(app, "settings.profile.rename", json!({ "name": name, "to": draft }), "prefs.profile_renamed"); - } - if ui_kit::button(ui, crate::i18n::tr_label("Delete"), ui_kit::Button::Enabled(active.is_some())).clicked() - && let Some(name) = &active - { - act(app, "settings.profile.delete", json!({ "name": name }), "prefs.profile_deleted"); - app.settings_profiles.draft.clear(); - } - if ui_kit::button(ui, app.tr("prefs.profile_export"), ui_kit::Button::Enabled(active.is_some())).clicked() - && let Some(name) = active.clone() - { - app.pick_save(&format!("{name}.solidcraft-profile.json"), &["json"], move |app, path| { - if let Some(path) = path { - act(app, "settings.profile.export", json!({ "name": name, "path": path }), "prefs.profile_exported"); - } - }); - } - if ui_kit::button(ui, crate::i18n::tr_label("Import…"), ui_kit::Button::Normal).clicked() { - app.pick_open_for(&["json"], |app, path| { - if let Some(path) = path { - act(app, "settings.profile.import", json!({ "path": path }), "prefs.profile_imported"); - } - }); + crate::scenario::publish_rect(&format!("prefs:profiles:{key}:row"), row.response.rect); } }); let locale = crate::i18n::Locale::for_language(&app.preferences.language); @@ -605,49 +692,88 @@ pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { }); } +/// Run before the Preferences window so Escape belongs to this editor, including in a scrolled +/// page. A separate popup container keeps the shared rename menu accessible and unclipped. +pub(crate) fn rename_popup(app: &mut SolveApp, ctx: &egui::Context) { + if !app.prefs_window.open || app.prefs_window.section != prefs::PAGE_GENERAL || !app.prefs_window.filter.is_empty() { + app.settings_profiles.rename = None; + return; + } + let Some(mut rename) = app.settings_profiles.rename.take() else { return }; + crate::prefs_search::note_key_sink(ctx); + let mut done = None; + let popup = ui_kit::Container::new(ctx, ui_kit::Surface::Popup) + .area(egui::Id::new("sc_profile_rename")) + .fixed_pos(rename.at) + .constrain(true) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + done = ui_kit::rename_menu(ui, &mut rename.text, &mut rename.focused); + }); + crate::scenario::publish_rect("prefs:profile:rename_popup", popup.response.rect); + for action in ["ok", "cancel"] { + if let Some(rect) = crate::scenario::rect_of(&format!("dialog:{action}")) { + crate::scenario::publish_target(&format!("prefs:profile:rename:{action}"), rect); + } + } + if ctx.input(|i| i.pointer.any_pressed()) && !popup.response.contains_pointer() { + done = Some(false); + } + ctx.input_mut(|input| { + for key in [egui::Key::Escape, egui::Key::Enter, egui::Key::Tab] { + input.consume_key(egui::Modifiers::NONE, key); + } + }); + match done { + Some(true) if profile_name(&rename.text) != Some(rename.name.as_str()) => { + act(app, "settings.profile.rename", json!({"name": rename.name, "to": rename.text}), "prefs.profile_renamed"); + } + Some(_) => {} + None => app.settings_profiles.rename = Some(rename), + } +} + /// The pinned profile switcher on every Preferences page. Actions use the same commands as /// General > Profiles; saving a new name remains in that existing section. pub(crate) fn sidebar(ui: &mut egui::Ui, app: &mut SolveApp) { - ui_kit::divider(ui, "profile_sidebar"); - ui_kit::text(ui, app.tr("prefs.structure.profile"), ui_kit::Text::Heading("profile_sidebar")); let active = app.settings_profiles.active.clone(); let edited = active.as_ref().is_some_and(|name| app.settings_profiles.get(name).is_ok_and(|doc| *doc != capture(app, name))); let current = active.clone().unwrap_or_else(|| app.tr_label("None").to_string()); let names = app.settings_profiles.names(); let mut apply = None; let mut save = false; - ui.horizontal(|ui| { - let t = crate::theme::Tokens::get(); - let width = ui.available_width() - if edited { t.space_16 + ui.spacing().item_spacing.x } else { 0.0 }; - let response = ui_kit::settings_control_width(ui, width, |ui| { - ui_kit::dropdown_menu(ui, "profile_sidebar", ¤t, |ui| { - for name in &names { - if ui_kit::list_picker(ui, name, name, active.as_ref() == Some(name)).clicked() { - apply = Some(name.clone()); + let label = app.tr("prefs.structure.profile"); + let changed_hint = app.tr("prefs.structure.profile_edited"); + let menu = ui + .push_id("profile_sidebar", |ui| { + ui_kit::Menu::button_with( + ui, + |ui, _| ui_kit::settings_profile_button(ui, label, ¤t, edited, changed_hint), + |ui| { + for name in &names { + if ui_kit::list_picker(ui, name, name, active.as_ref() == Some(name)).clicked() { + apply = Some(name.clone()); + } } - } - if let Some(name) = &active - && ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Normal).clicked() - { - save = true; - apply = None; - app.settings_profiles.draft = name.clone(); - ui.close(); - } - if ui_kit::button(ui, app.tr("prefs.profiles"), ui_kit::Button::Normal).clicked() { - prefs::open_profiles(app); - ui.close(); - } - }) - }); - crate::scenario::publish_target("prefs:profile", response.rect); - if edited { - let dot = ui_kit::status_line(ui, "●", ui_kit::StatusTone::Warning).on_hover_text(app.tr("prefs.structure.profile_edited")); - crate::scenario::publish_target("prefs:profile:edited", dot.rect); - } - }); + if active.is_some() && ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Normal).clicked() { + save = true; + apply = None; + ui.close(); + } + if ui_kit::button(ui, app.tr("prefs.profiles"), ui_kit::Button::Normal).clicked() { + prefs::open_profiles(app); + ui.close(); + } + }, + ) + }) + .inner; + if menu.inner.is_some() && ui.ctx().input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + // The standard menu has handled Escape; the Preferences footer must not also cancel. + menu.response.request_focus(); + } + crate::scenario::publish_target("prefs:profile", menu.response.rect); if let Some(name) = apply { - app.settings_profiles.draft = name.clone(); act(app, "settings.profile.apply", json!({"name":name}), "prefs.profile_applied"); } else if save && let Some(name) = active { act(app, "settings.profile.save", json!({"name":name}), "prefs.profile_saved"); @@ -656,7 +782,11 @@ pub(crate) fn sidebar(ui: &mut egui::Ui, app: &mut SolveApp) { /// Page-title tag. Membership is descriptive; the stable key list above governs storage. pub(crate) fn tag(ui: &mut egui::Ui) { - let response = - ui_kit::workspace_chip(ui, crate::i18n::tr("prefs.structure.in_profile")).on_hover_text(crate::i18n::tr("prefs.structure.profile_hover")); + let response = ui_kit::settings_chip( + ui, + crate::i18n::tr("prefs.structure.in_profile"), + ui_kit::SettingsChip { icon: Some("settings_profile"), compact: true, ..Default::default() }, + ) + .on_hover_text(crate::i18n::tr("prefs.structure.profile_hover")); crate::scenario::publish_target("prefs:in_profile", response.rect); } diff --git a/crates/ui-egui/src/settings_profiles_tests.rs b/crates/ui-egui/src/settings_profiles_tests.rs index c9fc6dead8..b84aea45ef 100644 --- a/crates/ui-egui/src/settings_profiles_tests.rs +++ b/crates/ui-egui/src/settings_profiles_tests.rs @@ -330,6 +330,31 @@ fn starting_a_profile_command_opens_preferences_instead_of_failing() { assert!(h.app.prefs_window.open); } +#[test] +fn typing_a_profile_name_keeps_focus_out_of_preferences_search() { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"open": "General"})); + h.frames(3); + h.step(&json!({"text": "profiles"})).expect("find Settings profiles"); + h.frames(3); + h.step(&json!({"click": {"handle": "dialog:Profile name"}})).expect("focus the profile name"); + h.frames(2); + let name_focus = h.ctx.memory(|memory| memory.focused()).expect("the name field has focus"); + for text in ["Lap", "top"] { + h.step(&json!({"text": text})).expect("type a profile name"); + h.frames(2); + assert_eq!(h.app.prefs_window.filter, "profiles", "profile-name text went to Preferences search"); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(name_focus), "the name field keeps focus"); + } + assert!(h.widget("Laptop").is_some(), "the name field contains the typed name"); + h.step(&json!({"widget": "Save as new"})).expect("save the profile"); + h.frames(2); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert_eq!(h.app.settings_profiles.names(), ["Laptop"]); + assert_eq!(h.app.prefs_window.filter, "profiles"); +} + #[test] fn choosing_a_profile_in_preferences_applies_it() { let mut h = Harness::new(); @@ -340,14 +365,347 @@ fn choosing_a_profile_in_preferences_applies_it() { assert!(!h.app.preferences.zoom_reverse); h.call("ui.prefs", json!({"open": "General"})); h.frames(3); - h.step(&json!({"click": {"handle": "dialog:Active profile"}})).unwrap(); - h.frames(3); - h.step(&json!({"click": {"handle": "dialog:list:Large monitor"}})).unwrap(); + h.step(&json!({"click": {"handle": "prefs:profile:Large monitor:apply"}})).unwrap(); h.frames(3); assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Large monitor")); assert!(h.app.preferences.zoom_reverse); } +#[test] +fn general_profile_rows_save_replace_apply_and_show_real_state_in_both_themes() { + for theme in ["light", "dark"] { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"language":"en", "theme":theme, "open":"General"})); + h.frames(3); + assert!(h.app.settings_profiles.list.is_empty()); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let heading = crate::scenario::rect_of("prefs:page_heading").unwrap(); + let subtitle = crate::scenario::rect_of("dialog:hint:settings_heading").unwrap(); + let profiles = crate::scenario::rect_of("prefs:section:profiles").unwrap(); + assert!(heading.contains_rect(subtitle)); + { + let title = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape + && text.galley.job.text == "PROFILES" + && profiles.contains(text.pos) + { + return Some(egui::Rect::from_min_size(text.pos, text.galley.size())); + } + None + }) + .unwrap(); + let count = crate::scenario::rect_of("dialog:section:Profiles:count").unwrap(); + assert!((count.left() - title.right() - 12.0).abs() < 1.1, "{theme}: count follows the painted title"); + let t = crate::theme::Tokens::get(); + let rule = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::LineSegment { points, stroke } = &shape.shape + && stroke.color == t.dialog_rule + && points.iter().all(|point| (point.y - profiles.center().y).abs() < 0.5) + { + return Some(*points); + } + None + }) + .unwrap(); + assert!((rule[0].x - count.right() - 12.0).abs() < 0.5, "{theme}: the rule begins after the count"); + assert!((rule[1].x - profiles.right()).abs() < 0.5); + for (key, text) in [("held", "Held"), ("computer", "This computer")] { + let label = crate::scenario::rect_of(&format!("prefs:profiles:{key}:label")).unwrap(); + let first = crate::scenario::rect_of(&format!("prefs:profiles:{key}:first_chip")).unwrap(); + let painted = h + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(galley) = &shape.shape + && galley.galley.job.text == text + && label.contains(galley.pos) + { + return Some(egui::Rect::from_min_size(galley.pos, galley.galley.size())); + } + None + }) + .unwrap(); + assert!((painted.left() - body.left()).abs() < 0.75, "{theme}: {text} starts at the body's left edge"); + assert!((first.left() - body.left() - 90.0).abs() < 0.5, "{theme}: {text} reserves the full caption column and gap"); + assert!((first.height() - 24.0).abs() < 0.5); + } + let held = crate::scenario::rect_of("prefs:profiles:held:row").unwrap(); + let computer = crate::scenario::rect_of("prefs:profiles:computer:row").unwrap(); + assert!((computer.top() - held.bottom() - 8.0).abs() < 0.5); + } + assert!((profiles.top() - heading.bottom() - 38.0).abs() < 0.5, "{theme}: the first section has the same breathing room as later sections"); + for (previous, next) in [("profiles", "language"), ("language", "saving")] { + let previous_body = crate::scenario::rect_of(&format!("prefs:section:{previous}:body")).unwrap(); + let next_header = crate::scenario::rect_of(&format!("prefs:section:{next}")).unwrap(); + assert!((next_header.top() - previous_body.bottom() - 38.0).abs() < 0.5, "{theme}: {previous} to {next}"); + assert!((next_header.height() - 30.0).abs() < 0.5); + let next_body = crate::scenario::rect_of(&format!("prefs:section:{next}:body")).unwrap(); + assert!((next_body.top() - next_header.bottom() - 10.0).abs() < 0.5); + } + let name_field = crate::scenario::rect_of("dialog:widget:Profile name").unwrap(); + assert!((name_field.width() - 220.0).abs() < 0.5, "{theme}: bounded name entry"); + assert!((name_field.left() - body.left()).abs() < 0.5, "{theme}: no separate label column before name entry"); + assert!(body.contains_rect(name_field)); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + let status = h.app.status.clone(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.status, status, "empty Enter cannot submit a save"); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"x".repeat(65)})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.status, status, "invalid Enter cannot submit a save"); + assert!(h.app.settings_profiles.list.is_empty()); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":" Laptop "})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(" Laptop "), + "saving keeps exactly the typed text while the command normalizes its name" + ); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:active").is_some()); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:apply").is_none()); + h.app.preferences.zoom_reverse = true; + h.frames(3); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:edited").is_some()); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_some()); + // The new label must keep save's replacement semantics, not create a duplicate. + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.list.len(), 1); + assert_eq!(h.app.settings_profiles.list[0]["preferences"]["zoom_reverse"], true); + assert!(crate::scenario::rect_of("prefs:profile:Laptop:edited").is_none()); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); + h.app.preferences.zoom_reverse = false; + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"Desk"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert!(crate::scenario::rect_of("prefs:profile:Desk:apply").is_none()); + let laptop = crate::scenario::rect_of("prefs:profile:Laptop:card").unwrap(); + let desk = crate::scenario::rect_of("prefs:profile:Desk:card").unwrap(); + assert!((laptop.height() - 42.0).abs() < 0.5 && (desk.height() - 42.0).abs() < 0.5, "{theme}: compact saved cards"); + assert!((desk.top() - laptop.bottom() - 8.0).abs() < 0.5, "{theme}: saved card gap"); + assert_eq!((laptop.left(), laptop.right()), (desk.left(), desk.right())); + for (name, card) in [("Laptop", laptop), ("Desk", desk)] { + assert!(crate::scenario::rect_of("prefs:page_body").unwrap().contains_rect(card)); + for action in ["export", "rename", "delete"] { + let target = crate::scenario::rect_of(&format!("prefs:profile:{name}:{action}")).unwrap(); + assert!(card.contains_rect(target), "{theme}: {name} {action} stays inside its card"); + assert!((target.width() - 24.0).abs() < 0.5 && (target.height() - 24.0).abs() < 0.5); + } + } + let active = crate::scenario::rect_of("prefs:profile:Desk:active").unwrap(); + assert!(desk.contains_rect(active)); + assert!((active.height() - 18.0).abs() < 0.5); + let t = crate::theme::Tokens::get(); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.rect == desk && rect.fill == t.selected_bg && rect.stroke.color == t.form_primary))); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if desk.contains(text.pos) && text.galley.job.text == "Desk" && text.galley.job.sections.iter().all(|section| section.format.color == t.selected_ink && section.format.font_id == crate::fonts::semibold(t.font_label))))); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + assert!(h.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if body.contains(text.pos) && text.galley.job.text == "Appearance" && text.galley.job.sections.iter().all(|section| section.format.color == t.selected_ink && section.format.font_id == crate::fonts::semibold(t.font_caption))))); + assert!(laptop.contains_rect(crate::scenario::rect_of("prefs:profile:Laptop:apply").unwrap())); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:apply"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert!(h.app.preferences.zoom_reverse); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Desk"), + "applying a different row preserves the user-typed draft" + ); + h.app.preferences.zoom_reverse = false; + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"key":"A", "cmd":true})).unwrap(); + h.step(&json!({"text":"Laptop"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:save"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.list.len(), 2, "typing an existing name still replaces it"); + assert_eq!(h.app.settings_profiles.list[0]["preferences"]["zoom_reverse"], false); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Laptop"), + "replacement keeps the typed name too" + ); + assert!(h.app.prefs_window.filter.is_empty(), "name entry must not type into search"); + assert!(h.app.prefs_window.open); + } +} + +#[test] +fn general_inactive_profile_rows_export_rename_import_and_delete_without_applying() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.zoom_reverse = true; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + h.app.preferences.zoom_reverse = false; + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "the name entry starts empty even with an active profile" + ); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:export"}})).unwrap(); + h.frames(6); + let path = h.dir.join("Laptop.solidcraft-profile.json"); + let exported: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(exported["name"], "Laptop"); + assert_eq!(exported["preferences"]["zoom_reverse"], true); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse); + + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + assert!(h.widget("Laptop").is_some(), "the shared editor is prefilled"); + h.step(&json!({"text":"Travel"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Travel", "Desk"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse); + assert!(h.app.prefs_window.open && h.app.prefs_window.filter.is_empty()); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "the row rename editor does not fill the independent name entry" + ); + + h.step(&json!({"choose_file":"Laptop.solidcraft-profile.json"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:import"}})).unwrap(); + h.frames(6); + assert_eq!(h.app.settings_profiles.names(), ["Travel", "Desk", "Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(!h.app.preferences.zoom_reverse, "import keeps the active settings"); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"Next profile"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile:Travel:delete"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Desk", "Laptop"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + h.step(&json!({"click":{"handle":"prefs:profile:Desk:delete"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active, None); + assert!(!h.app.preferences.zoom_reverse, "deleting never applies another row"); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "deleting either an inactive or active row retains the draft" + ); +} + +#[test] +fn general_profile_row_rename_commits_or_cancels_without_closing_preferences() { + for action in ["Enter", "Tab", "ok", "Escape", "cancel", "outside"] { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + let popup = crate::scenario::rect_of("prefs:profile:rename_popup").unwrap(); + let editor = crate::scenario::rect_of("rename:name").unwrap(); + assert!(popup.contains_rect(editor), "the editor is outside the page's scroll clip"); + h.step(&json!({"text":" Travel "})).unwrap(); + match action { + "ok" | "cancel" => h.step(&json!({"click":{"handle":format!("prefs:profile:rename:{action}")}})).unwrap(), + "outside" => { + let page = crate::scenario::rect_of("prefs:page_body").unwrap(); + let at = egui::pos2(page.right() - 4.0, page.top() + 4.0); + assert!(!popup.contains(at)); + h.step(&json!({"click":[at.x, at.y]})).unwrap(); + } + _ => h.step(&json!({"key":action})).unwrap(), + } + h.frames(3); + let committed = matches!(action, "Enter" | "Tab" | "ok"); + assert_eq!(h.app.settings_profiles.names(), [if committed { "Travel" } else { "Laptop" }, "Desk"], "{action}"); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(h.app.prefs_window.open && h.app.prefs_window.filter.is_empty(), "{action}"); + assert!(crate::scenario::rect_of("prefs:profile:rename_popup").is_none()); + } +} + +#[test] +fn general_profile_row_rename_keeps_validation_and_unchanged_name_noop() { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"language":"en", "open":"General"})); + for to in ["Laptop".to_string(), "Desk".to_string(), "x".repeat(65)] { + h.step(&json!({"click":{"handle":"prefs:profile:Laptop:rename"}})).unwrap(); + h.frames(3); + if to != "Laptop" { + h.step(&json!({"text":to})).unwrap(); + } + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert!(h.app.prefs_window.open); + if to == "Desk" { + assert!(h.widget("another settings profile is called `Desk`").is_some(), "the existing act outcome surfaces duplicates"); + } + } +} + #[test] fn profile_keys_preserve_the_literal_pre_split_baseline() { let expected = [ @@ -370,6 +728,8 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "history_in_browser", "history_play_button", "history_sketches", + "home_fit_size", + "invert_navigator_arrows", "keep_work_on_tab_switch", "language", "layout", @@ -385,6 +745,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "nav_bridge_enabled", "nav_bridge_port", "navigation_bar", + "navigator_fit", "navigator_style", "orbit_around", "orbit_reverse", @@ -396,6 +757,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "project_options_dialog", "quick_measure", "radial_context_menu", + "ribbon_click_ends_tool", "ribbon_icon_grid", "ribbon_labels", "ribbon_menus_skip_pinned", @@ -410,6 +772,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { "sketch_conflict_colour", "sketch_construction_lines", "sketch_exit_view", + "sketch_face_activates_part", "sketch_open_look_at", "sketch_recent_snap", "sketch_state_icons", @@ -447,7 +810,7 @@ fn profile_keys_preserve_the_literal_pre_split_baseline() { ]; let mut actual: Vec<_> = keys().collect(); actual.sort_unstable(); - assert_eq!(expected.len(), 93); + assert_eq!(expected.len(), 98); assert_eq!(actual, expected); } @@ -468,15 +831,37 @@ fn moved_profile_settings_survive_saved_roundtrip_and_apply() { h.app.preferences.viewport_analysis_status = false; h.app.preferences.keep_work_on_tab_switch = true; h.app.preferences.line_hover_tangent_arc = true; + h.app.preferences.navigator_fit = true; + h.app.preferences.invert_navigator_arrows = true; + h.app.preferences.home_fit_size = 0.5; + h.app.preferences.ribbon_click_ends_tool = false; + h.app.preferences.sketch_face_activates_part = true; run(&mut h, "settings.profile.save", json!({"name":"Moved"})); let saved = h.app.settings_profiles.prefs(); let doc = h.app.settings_profiles.list[0].clone(); assert_eq!(doc["preferences"]["keep_work_on_tab_switch"], true); assert_eq!(doc["preferences"]["line_hover_tangent_arc"], true); + for (key, value) in [ + ("home_fit_size", json!(0.5)), + ("invert_navigator_arrows", json!(true)), + ("navigator_fit", json!(true)), + ("ribbon_click_ends_tool", json!(false)), + ("sketch_face_activates_part", json!(true)), + ] { + assert_eq!(doc["preferences"][key], value, "capture retains {key}"); + } assert!(doc["preferences"].get("dark_parts_shading").is_none()); assert_eq!(sanitize(&doc).unwrap(), doc); h.app.settings_profiles.load(&serde_json::from_str::(&serde_json::to_string(&saved).unwrap()).unwrap()); assert_eq!(h.app.settings_profiles.list[0], doc); + let path = h.dir.join("moved.solidcraft-profile.json"); + let exported = run(&mut h, "settings.profile.export", json!({"name":"Moved", "path":path})); + assert_eq!(exported["profile"], doc); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written, doc); + run(&mut h, "settings.profile.delete", json!({"name":"Moved"})); + assert_eq!(run(&mut h, "settings.profile.import", json!({"path":path})), json!({"name":"Moved"})); + assert_eq!(h.app.settings_profiles.list[0], doc, "file import retains the complete profile"); h.app.preferences.nav_bridge_enabled = false; h.app.preferences.nav_bridge_port = 8765; h.app.preferences.origin_plane_names = crate::prefs::OriginPlaneNames::Xyz; @@ -485,28 +870,64 @@ fn moved_profile_settings_survive_saved_roundtrip_and_apply() { h.app.preferences.viewport_analysis_status = true; h.app.preferences.keep_work_on_tab_switch = false; h.app.preferences.line_hover_tangent_arc = false; + h.app.preferences.navigator_fit = defaults.navigator_fit; + h.app.preferences.invert_navigator_arrows = defaults.invert_navigator_arrows; + h.app.preferences.home_fit_size = defaults.home_fit_size; + h.app.preferences.ribbon_click_ends_tool = defaults.ribbon_click_ends_tool; + h.app.preferences.sketch_face_activates_part = defaults.sketch_face_activates_part; let out = run(&mut h, "settings.profile.apply", json!({"name":"Moved"})); assert_eq!(out["skipped"], json!([])); assert!(h.app.preferences.keep_work_on_tab_switch && h.app.preferences.line_hover_tangent_arc); assert_eq!(capture(&h.app, "Moved"), doc); + for value in [0.05, 1.1] { + let mut invalid = doc.clone(); + invalid["preferences"]["home_fit_size"] = json!(value); + let imported = run(&mut h, "settings.profile.import", json!({"profile":invalid, "name":"Invalid fit"})); + let out = run(&mut h, "settings.profile.apply", json!({"name":imported["name"]})); + assert_eq!(out["skipped"], json!(["home_fit_size"])); + assert_eq!(h.app.preferences.home_fit_size, 0.5, "an out-of-range profile value is refused"); + } } #[test] fn profile_sidebar_switches_saves_and_marks_edits_with_existing_commands() { let mut h = Harness::new(); h.app.home.open = false; + h.app.preferences.language = "en".into(); run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); h.app.preferences.zoom_reverse = true; run(&mut h, "settings.profile.save", json!({"name":"Desk"})); h.call("ui.prefs", json!({"open":"Tools"})); h.frames(3); assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); - h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + let card = crate::scenario::rect_of("prefs:profile").unwrap(); + h.step(&json!({"click":[card.left() + 3.0, card.center().y]})).unwrap(); h.frames(2); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_some(), "the card's padding opens its real menu"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.prefs_window.open); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_none(), "Escape dismisses the sidebar menu itself"); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(2); + assert!(crate::scenario::rect_of("dialog:list:Laptop").is_some(), "the same trigger opens from the keyboard"); h.step(&json!({"click":{"handle":"dialog:list:Laptop"}})).unwrap(); h.frames(3); assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); assert!(!h.app.preferences.zoom_reverse); + h.step(&json!({"widget":"General"})).unwrap(); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "sidebar Apply does not fill the independent name entry" + ); + h.step(&json!({"widget":"Tools"})).unwrap(); h.app.preferences.direct_interaction = true; h.frames(3); assert!(crate::scenario::rect_of("prefs:profile:edited").is_some()); @@ -524,4 +945,52 @@ fn profile_sidebar_switches_saves_and_marks_edits_with_existing_commands() { h.step(&json!({"widget":profiles_label})).unwrap(); h.frames(3); assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GENERAL); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some(""), + "sidebar Save does not fill the independent name entry either" + ); + h.step(&json!({"click":{"handle":"dialog:Profile name"}})).unwrap(); + h.step(&json!({"text":"Next profile"})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"click":{"handle":"dialog:list:Desk"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Desk")); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "selecting another sidebar profile preserves typed input" + ); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"widget":"Save"})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.names(), ["Laptop", "Desk"]); + assert_eq!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .filter(|(_, node)| node.label() == Some("Profile name")) + .find_map(|(_, node)| node.value()), + Some("Next profile"), + "sidebar Save preserves typed input and saves the selected profile" + ); } diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs index 2cb1a4062a..2daf18131a 100644 --- a/crates/ui-egui/src/shell_tests.rs +++ b/crates/ui-egui/src/shell_tests.rs @@ -576,7 +576,9 @@ fn tool_sheet_dropdowns_and_preferences_still_open_above_the_sheet() { let overlap = sheet.intersect(preferences); assert!(overlap.is_positive(), "Preferences overlaps the feature dialog"); assert_eq!(h.ctx.layer_id_at(overlap.center()).unwrap().id, egui::Id::new("sc_prefs"), "Preferences receives input above the sheet"); - h.step(&json!({"widget":"Cancel"})).unwrap(); + let cancel = rect_of("prefs:cancel").expect("Preferences Cancel"); + assert_eq!(h.ctx.layer_id_at(cancel.center()).unwrap().id, egui::Id::new("sc_prefs"), "Preferences owns its Cancel target"); + h.step(&json!({"click":{"handle":"prefs:cancel"}})).unwrap(); assert!(!h.app.prefs_window.open && h.app.dialog.is_some(), "Cancel closes Preferences, leaving the feature dialog open"); h.step(&json!({"widget":"Cancel"})).unwrap(); assert!(h.app.dialog.is_none()); diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs index d2276f0141..b28584ce1d 100644 --- a/crates/ui-egui/src/shortcut_box.rs +++ b/crates/ui-egui/src/shortcut_box.rs @@ -257,7 +257,7 @@ fn design_rows(app: &mut SolveApp, query: &str) -> Vec { .iter() .take(64) .find_map(|name| values.get(name.as_str())) - .map(|p| p.value.filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit))) + .map(|p| p.display_value.or(p.value).filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit))) .unwrap_or_default(); rows.push(DesignRow { label: bounded(&feature.name), @@ -268,7 +268,7 @@ fn design_rows(app: &mut SolveApp, query: &str) -> Vec { } let icon = solidcraft_engine::find_command("parameters.change").map_or("", |c| c.icon); for p in params.iter().filter(|p| p.source == "user").take(10_000) { - let value = p.value.filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit)); + let value = p.display_value.or(p.value).filter(|v| v.is_finite()).map_or_else(|| bounded(&p.expression), |v| format!("{v} {}", p.unit)); rows.push(DesignRow { label: bounded(&p.name), detail: format!("{} · {value}", crate::i18n::tr("search.parameter")), diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index cdc3cf6802..cb7ea01a75 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -290,11 +290,25 @@ fn retype(h: &mut crate::scenario::Harness, handle: &str, text: &str) { h.frames(2); } +/// Run frames until the preview of the typed size is in. It computes on its own thread, and +/// its result (a failure shows its reason in the window, which moves the buttons) arrives on +/// whichever frame the thread finishes. A test that reads the window or clicks a handle +/// before then races that frame, and on a loaded machine loses. +fn preview_in(h: &mut crate::scenario::Harness) { + let began = std::time::Instant::now(); + while h.app.preview.busy && began.elapsed() < std::time::Duration::from_secs(60) { + h.frame(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + h.frames(2); +} + fn apply(h: &mut crate::scenario::Harness) { // Lay the window out once before reading its OK handle. An error line that wraps onto a // second row moves the button, and a click on the handle from the frame before then // lands above it and commits nothing. h.frames(2); + preview_in(h); h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap(); h.frames(2); } @@ -559,7 +573,7 @@ fn a_refused_size_gives_way_to_one_that_fits() { let shown = |h: &mut crate::scenario::Harness, size: &str| { set_size(&mut h.app, size); h.settle(); - h.frames(2); + preview_in(h); h.app.dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| h.app.preview.error.clone()) }; assert!(shown(&mut h, "4 m").is_some_and(|e| e.contains("too large"))); diff --git a/crates/ui-egui/src/sketch_edit_tests.rs b/crates/ui-egui/src/sketch_edit_tests.rs index 5617c07d1c..ace851042c 100644 --- a/crates/ui-egui/src/sketch_edit_tests.rs +++ b/crates/ui-egui/src/sketch_edit_tests.rs @@ -8,6 +8,86 @@ fn first_sketch(app: &SolveApp) -> u64 { app.session.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).unwrap() } +fn plate_and_cylinder() -> crate::scenario::Harness { + use serde_json::json; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + for (command, params) in [ + ("sketch.create", json!({"plane": "XY", "name": "Sketch1"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"name": "Plate", "distance": 10})), + ("solid.cylinder", json!({"name": "Cyl", "base": [60, 15, 0], "radius": 8, "height": 20, "operation": "new"})), + ] { + h.app.run(command, params).unwrap(); + } + h +} + +#[test] +fn finishing_an_edited_sketch_undoes_the_edit_with_the_complete_design_visible() { + use serde_json::json; + for engine_finish in [false, true] { + let mut h = plate_and_cylinder(); + let before = h.app.session.doc.to_json(); + let id = first_sketch(&h.app); + h.app.edit_sketch(id); + h.app.run("sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [20, 20]})).unwrap(); + if engine_finish { + h.app.run("sketch.finish", json!({})).unwrap(); + } else { + h.app.finish_sketch(); + } + assert_eq!(h.app.session.doc.marker, None); + let edited = h.app.session.doc.to_json(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.marker, None, "Undo must not expose the temporary sketch rollback"); + assert_eq!(h.app.session.active_sketch, None); + assert_eq!(h.app.session.doc.to_json(), before, "one Undo takes back the sketch edit"); + assert_eq!(h.app.session.model.state().bodies.len(), 2); + let volume = h.app.run("inspect.measure", json!({"bodies": ["Body1"]})).unwrap(); + assert!((volume["total"]["volume_mm3"].as_f64().unwrap() - 12000.0).abs() < 1e-6); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), edited); + assert_eq!(h.app.session.active_sketch, None); + assert_eq!(h.app.session.model.state().bodies.len(), 2); + } +} + +#[test] +fn engine_finish_restores_the_marker_from_a_ui_sketch_edit() { + use serde_json::json; + for marker in [None, Some(0), Some(2)] { + let mut h = plate_and_cylinder(); + h.app.run("timeline.roll_to", json!({"position": marker})).unwrap(); + let before = h.app.session.doc.to_json(); + let bodies = h.app.session.model.state().bodies.len(); + let id = first_sketch(&h.app); + h.app.edit_sketch(id); + // Deliberately bypass the window's completion callback: the engine owns restoration. + h.app.session.execute("sketch.finish", &json!({})).unwrap(); + assert_eq!(h.app.session.active_sketch, None); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.model.state().bodies.len(), bodies); + } +} + +#[test] +fn unchanged_sketch_edit_keeps_undo_and_redo() { + use serde_json::json; + let mut h = plate_and_cylinder(); + h.app.run("edit.undo", json!({})).unwrap(); + let before = h.app.session.doc.to_json(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + let id = first_sketch(&h.app); + h.app.edit_sketch(id); + h.app.finish_sketch(); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.model.state().bodies.len(), 2); +} + #[test] fn edit_sketch_rolls_back_and_finish_restores() { let mut app = sample_app(); @@ -456,9 +536,10 @@ mod exit_view_preference { #[test] fn a_sketch_opened_by_a_command_leaves_the_view_alone() { let (mut h, _) = sketching(SketchExitView::Previous); - // Finished by the command, so the views saved for this sketch stay behind. + // A programmatic finish clears this sketch's saved view too. A later sketch opened + // entirely by commands must not inherit that view. h.app.run("sketch.finish", json!({})).unwrap(); - assert!(h.app.pre_sketch_cam.is_some()); + assert!(h.app.pre_sketch_cam.is_none()); h.app.run("sketch.create", json!({"plane": "XZ"})).unwrap(); h.app.cam.orbit(60.0, -40.0); h.app.cam_anim = None; @@ -559,6 +640,29 @@ mod extrude_view_preference { assert_eq!(h.app.cam, iso_of(top), "{close}: isometric again"); } } + + /// #10571: Extrude clicked with the sketch still open ends the sketch on its way, and that + /// end turns the view back where it opened (Preferences › Sketch). The turn must not become + /// the view Extrude goes back to: it is the view the command was clicked in. + #[test] + fn extrude_from_an_open_sketch_goes_back_to_the_view_it_was_clicked_in() { + let mut h = Harness::new(); + h.app.preferences.extrude_view_isometric = true; + h.app.preferences.extrude_view_return = true; + h.app.preferences.sketch_exit_view = crate::prefs::SketchExitView::Previous; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + h.settle(); + let top = h.app.cam; + open_extrude(&mut h); + assert_eq!(h.app.cam, iso_of(top), "isometric while Extrude is open"); + h.step(&json!({"widget": "Cancel"})).unwrap(); + h.settle(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.active_sketch, None, "Extrude ended the sketch"); + assert_eq!(h.app.cam, top, "back to the sketch view it was clicked in"); + } } /// #8127: a sketch inside an inserted fastener, opened from its row in the browser, activates the diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index eb9c399fa3..ec45ea6110 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -942,6 +942,7 @@ mod tests { #[test] fn feature_options_show_every_mode_of_the_running_tool_and_switch_between_them() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); h.app.start("sketch.circle.center"); h.settle(); diff --git a/crates/ui-egui/src/sketch_shortcut_tests.rs b/crates/ui-egui/src/sketch_shortcut_tests.rs index 41fcfa8ad7..db3895cc73 100644 --- a/crates/ui-egui/src/sketch_shortcut_tests.rs +++ b/crates/ui-egui/src/sketch_shortcut_tests.rs @@ -336,7 +336,7 @@ fn a_feature_started_while_the_marker_rolls_back_waits_for_it() { h.app.edit_feature(sketch); h.app.apply_off_thread = Some(std::time::Duration::ZERO); h.app.start("solid.extrude"); - assert!(h.app.session.active_sketch.is_none()); + assert_eq!(h.app.session.active_sketch, Some(sketch), "Finish and the marker restore commit together after evaluation"); assert!(h.app.deferred.is_some(), "the marker is not rolling back off the window thread"); assert!(h.app.dialog.is_none(), "Extrude opened before the marker was back"); for _ in 0..500 { @@ -347,6 +347,7 @@ fn a_feature_started_while_the_marker_rolls_back_waits_for_it() { std::thread::sleep(std::time::Duration::from_millis(10)); } assert_eq!(h.app.session.doc.marker, None); + assert_eq!(h.app.session.active_sketch, None); let items = h.app.dialog.as_ref().and_then(|d| d.inputs.first()).map(|i| i.items.clone()).unwrap_or_default(); assert_eq!(items, vec![Sel::Profile { sketch, index: 0 }], "Extrude did not open once the marker was back"); } diff --git a/crates/ui-egui/src/sketch_trim_drag_ui_tests.rs b/crates/ui-egui/src/sketch_trim_drag_ui_tests.rs index a0a56c02a0..460e24f6e6 100644 --- a/crates/ui-egui/src/sketch_trim_drag_ui_tests.rs +++ b/crates/ui-egui/src/sketch_trim_drag_ui_tests.rs @@ -149,3 +149,63 @@ fn play(h: &mut Harness) { } h.frames(2); } + +#[test] +fn trimming_construction_source_and_offsets_by_click_or_drag_is_independent() { + for drag in [false, true] { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "sketch.create", json!({"plane":"XY"})); + run(&mut h, "sketch.line", json!({"points":[[0,0],[40,40]],"construction":true,"infer":false,"connect":false,"ids":["source"]})); + let out = run(&mut h, "sketch.offset", json!({"curves":["source"],"distance":2,"symmetric":true,"chain":false})); + let parameter = out["param"].as_str().unwrap().to_string(); + let mut curves = vec!["source".to_string()]; + curves.extend(out["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string())); + run(&mut h, "sketch.line", json!({"points":[[-20,20],[60,20]],"infer":false,"connect":false})); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 100.0; + h.frames(3); + h.call("ui.start", json!({"command":"sketch.trim"})); + h.frames(2); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + if drag { + let a = screen(&mut h, 4.0, 5.0); + let b = screen(&mut h, 15.0, 5.0); + let mut path = vec![a]; + path.extend(steps(a, b, 16)); + let c = screen(&mut h, -10.0, 5.0); + path.extend(steps(b, c, 16)); + path.extend(steps(c, b, 16)); + press_and_move(&mut h, &path); + assert_eq!(h.app.session.doc.to_json(), before); + release(&mut h, b); + } else { + let points = [[10.0, 10.0], [10.0 - 2f64.sqrt(), 10.0 + 2f64.sqrt()], [10.0 + 2f64.sqrt(), 10.0 - 2f64.sqrt()]]; + for point in points { + h.step(&json!({"click":{"sketch":point}})).unwrap(); + h.frames(2); + } + } + assert_eq!(h.app.session.undo.len(), undo + if drag { 1 } else { 3 }, "drag {drag}: {:?}", h.app.status); + let sk = sketch(&h); + assert_eq!( + sk.constraints.iter().filter(|c| c.param.as_deref() == Some(¶meter)).count(), + 2, + "distance dimensions survive actual Trim gestures" + ); + let Shape::Line { a: origin, b: end } = sk.shape(sk.curve_index("source").unwrap()).unwrap() else { panic!("source line") }; + let direction = (end - origin).normalized().unwrap(); + for id in curves { + let Shape::Line { a, .. } = sk.shape(sk.curve_index(&id).unwrap()).unwrap() else { panic!("line") }; + assert!((a.y - 20.0).abs() < 1e-5, "drag {drag}: {id} {a:?}"); + if id != "source" { + assert!((direction.cross(a - origin).abs() - 2.0).abs() < 1e-6, "drag {drag}: {id} keeps 2 mm"); + } + } + for _ in 0..if drag { 1 } else { 3 } { + run(&mut h, "edit.undo", json!({})); + } + assert_eq!(h.app.session.doc.to_json(), before); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index da0952af44..81e45380f3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -964,6 +964,8 @@ pub struct Tokens { pub font_subtitle: f32, /// Introductory text on the home page. pub font_intro: f32, + /// Preferences page title, independent of dialog and article headings. + pub font_page_heading: f32, /// Top-level headings in assistant Markdown. pub font_article_heading: f32, /// Application title in the About dialog. @@ -1191,6 +1193,10 @@ pub struct Tokens { pub figure_ground: Color32, /// Outlines and reference lines of Preferences figures. pub figure_line: Color32, + /// Neutral light reflected by a model in a Preferences figure, independent of theme polarity. + pub figure_highlight: Color32, + /// Neutral shadow cast by a model in a Preferences figure, independent of theme polarity. + pub figure_shadow: Color32, /// Top face of the Preferences figure cube. pub figure_face_top: Color32, /// Left face of the Preferences figure cube. @@ -1215,8 +1221,10 @@ pub struct Tokens { pub figure_axis_z: Color32, /// Done or fully constrained mark in a Preferences figure. pub figure_ok: Color32, - /// Warning mark in a Preferences figure. + /// Under-constrained sketch mark in a Preferences figure. pub figure_warn: Color32, + /// Conflicting sketch geometry or state glyph in a Preferences figure. + pub figure_conflict: Color32, /// Segmented control well and tint behind figures in Preferences. pub pref_well: Color32, /// Warning badge text and stroke in Preferences. @@ -1444,11 +1452,20 @@ impl Tokens { /// These tokens with a colour vision mode's values on top (the tables in `palettes.rs`); Off /// gives them back unchanged. pub fn with_vision(self, dark: bool, mode: ColorVision) -> Tokens { - match mode { - ColorVision::Off => self, + let mut t = match mode { + ColorVision::Off => return self, ColorVision::Protan | ColorVision::Deutan => self.red_green(dark), ColorVision::Tritan => self.blue_yellow(dark), - } + }; + // Figure axes carry the same X/Y/Z meanings as the viewport and its navigation triad. + t.figure_axis_x = t.axis_x; + t.figure_axis_y = t.axis_y; + t.figure_axis_z = t.axis_z; + // Match SketchState::ink: fully constrained, under constrained, and conflicting. + t.figure_ok = t.sketch_fixed; + t.figure_warn = t.sketch; + t.figure_conflict = t.error; + t } /// Fade idle cube colours into the viewport while retaining opaque facets. @@ -1792,6 +1809,27 @@ mod tests { assert_eq!(ctx.style_of(theme).visuals, expected, "the new theme's visuals must be in place"); } + #[test] + fn preferences_foreground_pairs_hold_small_text_contrast() { + for dark in [false, true] { + for vision in ColorVision::ALL { + let t = Tokens::themed(dark, vision); + for (ink_name, ink, surface_name, surface) in [ + ("pref_note", t.pref_note, "pref_note_fill", t.pref_note_fill), + ("pref_ok", t.pref_ok, "pref_ok_fill", t.pref_ok_fill), + ("pref_on_accent", t.pref_on_accent, "figure_accent", t.figure_accent), + ("figure_accent", t.figure_accent, "figure_card", t.figure_card), + ("figure_accent", t.figure_accent, "selected_bg", t.selected_bg), + ] { + let contrast = contrast_ratio(ink, surface); + assert!(contrast >= 4.5, "dark={dark} {vision:?}: {ink_name} on {surface_name}: {contrast:.2}"); + } + assert_eq!(t.figure_highlight, Color32::WHITE, "a model highlight stays light in either theme"); + assert_eq!(t.figure_shadow, Color32::BLACK, "a model shadow stays dark in either theme"); + } + } + } + #[test] fn file_view_foreground_pairs_hold_small_text_contrast_without_changing_accent() { for dark in [false, true] { diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index ecd761a620..fd82e2fde6 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -465,12 +465,12 @@ macro_rules! theme_keys { space_12: _, space_14: _, space_16: _, space_24: _, radius_sm: _, radius_md: _, radius_lg: _, radius_xl: _, font_badge: _, font_micro: _, font_panel: _, font_small: _, font_caption: _, font_body: _, font_label: _, font_button: _, font_heading: _, font_triad: _, - font_compass: _, font_subtitle: _, font_intro: _, font_article_heading: _, font_title: _, font_display: _, + font_compass: _, font_subtitle: _, font_intro: _, font_page_heading: _, font_article_heading: _, font_title: _, font_display: _, control_height: _, control_radius: _, form_label_width: _, form_gap_y: _, form_footer_height: _, form_header_height: _, form_card_width: _, form_icon_size: _, form_close_size: _, form_inset: _, form_icon_inset: _, form_section_icon_size: _, form_checkbox_height: _, form_button_height: _, sketch_palette_width: _, sketch_mode_size: _, sketch_mode_radius: _, sketch_mode_border: _, sketch_palette_alpha: _, sketch_mode_tint: _, sketch_footer_icon_size: _, font_section_tracking: _, - figure_ground: _, figure_line: _, figure_face_top: _, figure_face_left: _, figure_face_right: _, figure_card: _, figure_white: _, figure_black: _, figure_accent: _, figure_reference: _, figure_axis_x: _, figure_axis_y: _, figure_axis_z: _, figure_ok: _, figure_warn: _, pref_well: _, pref_note: _, pref_note_fill: _, pref_on_accent: _, pref_ok: _, pref_ok_fill: _, pref_disabled: _, + figure_ground: _, figure_line: _, figure_highlight: _, figure_shadow: _, figure_face_top: _, figure_face_left: _, figure_face_right: _, figure_card: _, figure_white: _, figure_black: _, figure_accent: _, figure_reference: _, figure_axis_x: _, figure_axis_y: _, figure_axis_z: _, figure_ok: _, figure_warn: _, figure_conflict: _, pref_well: _, pref_note: _, pref_note_fill: _, pref_on_accent: _, pref_ok: _, pref_ok_fill: _, pref_disabled: _, } = t; let IconColors { $($ione: _,)* } = &t.icons; } diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index bbfb75e7fd..8a113f1f58 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -16,6 +16,10 @@ use crate::theme::Tokens; /// Apply transport actions from the current marker; endpoints stay bounded, including an /// empty design. All orientations reach the same rollback command. pub(crate) fn run_transport(app: &mut SolveApp, control: crate::ui_kit::HistoryTransport) { + // A step moves from the marker the design has, not the one an open editor rolled to (#10688). + if app.close_editor_for_history().is_err() { + return; + } let n = app.session.doc.features.len(); let marker = app.session.doc.marker.unwrap_or(n); let position = match control { @@ -1406,7 +1410,7 @@ mod tests { ("axis_point", PlaneRef::AxisPoint { axis: reference(), point: reference() }), ("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }), ("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }), - ("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }), + ("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO, reference: None, angle: None }), ("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }), ("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }), ] { diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index d7a9ec220a..c963816b88 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -1068,7 +1068,7 @@ fn timeline_group_name( if escape { // Dropping the shared state cancels the edit and leaves the old name intact. } else if commit { - crate::context_menu::commit_rename(app, &rename); + let _ = crate::context_menu::commit_rename(app, &rename); } else { app.menu.rename = Some(rename); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6618df3637..fc96161978 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -3125,7 +3125,8 @@ pub(crate) fn cancel_unfinished(app: &mut SolveApp) -> bool { pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::viewport_status::reconcile(app, ctx); crate::browser::update_folder_pick(app, ctx); - if design_shown(app) { + let chat_owner = crate::agent_chat::owns_keyboard_input(app, ctx); + if design_shown(app) && !chat_owner { crate::navigation::step_key_hold(app, ctx); } else { app.viewport.key_hold = None; @@ -3201,6 +3202,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } if popup_owner || inline_editor + || chat_owner || (crate::viewport_status::enabled(app) && app.ui.palette_open) || (ctx.egui_wants_keyboard_input() && !(crate::viewport_status::enabled(app) && app.viewport_status.owns_focus(ctx))) { diff --git a/crates/ui-egui/src/ui_kit/actions.rs b/crates/ui-egui/src/ui_kit/actions.rs index e0c9ea418c..662bad908c 100644 --- a/crates/ui-egui/src/ui_kit/actions.rs +++ b/crates/ui-egui/src/ui_kit/actions.rs @@ -567,6 +567,9 @@ pub fn button(ui: &mut Ui, label: impl Into, options: Button) return crate::prefs_search::hidden_response(ui); } let response = match options { + Button::Enabled(enabled) if super::settings_page_active(ui) => { + ui.add_enabled(enabled, button_widget(crate::i18n::tr_label(&key).into(), options)) + } Button::Enabled(enabled) => ui.add_enabled_ui(enabled, |ui| ui.add(button_widget(crate::i18n::tr_label(&key).into(), options))).inner, _ => ui.add(button_widget(label, options)), }; @@ -760,60 +763,60 @@ pub(crate) fn selection_actions(ui: &mut Ui, name: &str, actions: &[super::Stage fn icon_label_presentation(ui: &mut Ui, label: &str, icon: Option<&str>, enabled: bool, primary: bool, danger: bool, dense: bool) -> Response { let t = Tokens::get(); let width = icon_label_width(ui, label, icon, primary); - let response = ui - .add_enabled_ui(enabled, |ui| { - let (rect, response) = - ui.allocate_exact_size(vec2(width, if dense { t.control_height - t.space_2 } else { t.control_height }), egui::Sense::click()); - let ink = if danger { - t.form_error - } else if primary { - t.on_accent + let paint = |ui: &mut Ui| { + let (rect, response) = + ui.allocate_exact_size(vec2(width, if dense { t.control_height - t.space_2 } else { t.control_height }), egui::Sense::click()); + let ink = if danger { + t.form_error + } else if primary { + t.on_accent + } else { + t.form_ink + }; + ui.painter().rect( + rect, + t.control_radius, + if primary { + t.form_primary + } else if response.hovered() { + t.hover } else { - t.form_ink + t.control_fill + }, + if primary { egui::Stroke::NONE } else { t.control_border_stroke(t.control_border) }, + egui::StrokeKind::Inside, + ); + let mut left = rect.left() + + if primary { + t.space_16 + } else if icon.is_some() { + t.space_10 + } else { + t.space_12 }; - ui.painter().rect( - rect, - t.control_radius, - if primary { - t.form_primary - } else if response.hovered() { - t.hover - } else { - t.control_fill - }, - if primary { egui::Stroke::NONE } else { t.control_border_stroke(t.control_border) }, - egui::StrokeKind::Inside, - ); - let mut left = rect.left() - + if primary { - t.space_16 - } else if icon.is_some() { - t.space_10 - } else { - t.space_12 - }; - if let Some(icon) = icon { - crate::icons::paint_ink( - ui.painter(), - egui::Rect::from_center_size(egui::pos2(left + t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)), - icon, - ink, - ); - left += t.space_14 + t.space_6; - } - super::text::paint_text( - ui, - egui::Rect::from_min_max(egui::pos2(left, rect.top()), rect.max), - label, - if primary { crate::fonts::semibold(t.font_button) } else { egui::FontId::proportional(t.font_label) }, + if let Some(icon) = icon { + crate::icons::paint_ink( + ui.painter(), + egui::Rect::from_center_size(egui::pos2(left + t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)), + icon, ink, - false, ); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - pointer_on_hover(ui, &response); - response - }) - .inner; + left += t.space_14 + t.space_6; + } + super::text::paint_text( + ui, + egui::Rect::from_min_max(egui::pos2(left, rect.top()), rect.max), + label, + if primary { crate::fonts::semibold(t.font_button) } else { egui::FontId::proportional(t.font_label) }, + ink, + false, + ); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + pointer_on_hover(ui, &response); + response + }; + // A scope cannot reserve its natural width in the parent's wrapping row. + let response = if super::settings_page_active(ui) { ui.add_enabled(enabled, paint) } else { ui.add_enabled_ui(enabled, paint).inner }; let key = format!("dialog:button:{}:{label}", super::layout::label(ui)); scenario::publish_handle(&key, response.rect.center()); scenario::publish_rect(&key, response.rect); diff --git a/crates/ui-egui/src/ui_kit/details.rs b/crates/ui-egui/src/ui_kit/details.rs index 250a0c182e..99dab28cf7 100644 --- a/crates/ui-egui/src/ui_kit/details.rs +++ b/crates/ui-egui/src/ui_kit/details.rs @@ -45,13 +45,33 @@ pub(crate) fn details_field_owned_escape(ctx: &egui::Context) -> bool { #[derive(Clone, Debug, PartialEq)] pub(crate) enum DetailValue { - Number { value: f64, min: f64, max: f64, suffix: String }, - Length { value: f64, units: String }, + Number { + value: f64, + min: f64, + max: f64, + suffix: String, + }, + /// Shown to `decimals` places, as the design shows lengths. + Length { + value: f64, + units: String, + decimals: u8, + }, Angle(f64), - Slider { value: f64, min: f64, max: f64 }, - Axis { value: [f64; 3], current: String }, + Slider { + value: f64, + min: f64, + max: f64, + }, + Axis { + value: [f64; 3], + current: String, + }, Check(bool), - Choice { selected: usize, options: Vec }, + Choice { + selected: usize, + options: Vec, + }, } pub(crate) struct DetailSetting { @@ -136,7 +156,7 @@ fn setting(ui: &mut Ui, field: &DetailSetting, focus: bool) -> Option Some(super::number_field(ui, value, *min..=*max, 0.01, suffix)), - DetailValue::Length { value, units } => Some(super::length_field(ui, value, units, 6)), + DetailValue::Length { value, units, decimals } => Some(super::length_field(ui, value, units, *decimals)), DetailValue::Angle(value) => Some(super::angle_field(ui, value)), DetailValue::Slider { value, min, max } => Some(super::slider(ui, value, *min..=*max, Default::default())), DetailValue::Check(value) => Some(super::checkbox(ui, "", value)), @@ -228,6 +248,9 @@ pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height: ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(CONTROL_FOCUS), Vec::::new())); ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(OWNED_POPUPS), Vec::::new())); ui.set_width((t.form_card_width + if description.findings.is_empty() { 0.0 } else { t.space_24 * 2.0 }).min(ui.available_width())); + // A card's area is as tall as it was last frame: allowed `max_height`, a body that grew + // after the first frame scrolls instead, and its last row was cut off (#10708). + ui.set_max_height(max_height); super::scope(ui, |ui| { let mut caption_height = 0.0; if let Some(caption) = &description.caption { diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 69c37be779..54ff001a2c 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -667,6 +667,9 @@ fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: boo ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("sc_focused_editor"), response.id)); } super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE); + if response.has_focus() { + crate::prefs_search::note_key_sink(ui.ctx()); + } if completion && ui.is_enabled() { ui.ctx().data_mut(|data| { let id = egui::Id::new("sc_value_fields"); diff --git a/crates/ui-egui/src/ui_kit/gallery.rs b/crates/ui-egui/src/ui_kit/gallery.rs index 1897f41770..3bf12296c8 100644 --- a/crates/ui-egui/src/ui_kit/gallery.rs +++ b/crates/ui-egui/src/ui_kit/gallery.rs @@ -472,6 +472,33 @@ const ELEMENTS: &[Element] = &[ }); }); }), + element!("below_row_control", |ui, state| { + settings_form(ui, Tokens::get().form_label_width, |ui| { + row(ui, "Autosave every", |ui| { + field(ui, &mut state.value); + }); + below_row_control(ui, "Autosave every", 260.0, |ui| settings_autosave_timeline(ui, 5.0)); + }); + }), + element!("named_control", "dialog:widget:Profile name", Some("dialog:Profile name"), |ui, state| { + named_control(ui, "Profile name", |ui| { + text_field(ui, &mut state.value, "Name this setup"); + }); + }), + element!("settings_row_card", |ui, _| { + let t = Tokens::get(); + for selected in [true, false] { + settings_row_card(ui, selected, t.control_height + t.space_14, |ui| { + text(ui, "Default", if selected { Text::SelectedNameCell } else { Text::NameCell(false) }); + if selected { + settings_badge(ui, "Active", false); + } else { + button(ui, "Apply", Button::Normal); + } + }); + ui.add_space(t.space_8); + } + }), element!("settings_composition", |ui, _| { settings_heading(ui, "Interface", "Appearance and workspace"); settings_section(ui, "gallery_settings", "Layout", |ui| { diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index a76da8b194..12bb94de06 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -279,11 +279,34 @@ fn sized_row_body(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce( // Measuring takes the original text and prepares it the same way, so column and row agree. let label_width = label_column(ui, text, width); let label_rect = egui::Rect::from_min_size(rect.min, vec2(label_width, rect.height())); - ui.scope_builder(egui::UiBuilder::new().max_rect(label_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { - // Left aligned in the label column, as in the design. - ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()).on_hover_text(shown); - }); - let control_rect = egui::Rect::from_min_max(egui::pos2((label_rect.right() + label_gap(ui, width)).min(rect.right()), rect.top()), rect.max); + let help = ui.stack().iter().find_map(|node| node.tags().get_downcast::(super::preferences_small::ROW_HELP)).cloned(); + if label_width > 0.0 { + ui.scope_builder(egui::UiBuilder::new().max_rect(label_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + // Left aligned in the label column, as in the design. + if let Some(help) = &help { + ui.spacing_mut().item_spacing.x = t.space_4; + let width = (label_width - t.space_16 - t.space_4).max(0.0); + let label = ui + .scope_builder( + egui::UiBuilder::new() + .max_rect(egui::Rect::from_min_size(label_rect.min, vec2(width, height))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()) + .on_hover_text(&shown) + }, + ) + .inner; + let help = super::help_icon(ui, help); + scenario::publish_rect(&format!("dialog:row_label:{text}"), label.rect); + scenario::publish_rect(&format!("dialog:row_help:{text}"), help.rect); + } else { + ui.add(egui::Label::new(RichText::new(shown.clone()).size(t.font_label).color(t.dialog_label)).truncate()).on_hover_text(shown); + } + }); + } + let gutter = if label_width > 0.0 { label_gap(ui, width) } else { 0.0 }; + let control_rect = egui::Rect::from_min_max(egui::pos2((label_rect.right() + gutter).min(rect.right()), rect.top()), rect.max); let result = ui .scope_builder( egui::UiBuilder::new() @@ -321,6 +344,18 @@ pub(crate) fn caption(ui: &mut Ui, aligned_to: &str, text: &str) { scenario::publish_rect(&format!("dialog:caption:{aligned_to}"), text_rect); } +/// Natural-height content aligned beneath an indexed row's control, bounded independently of the form. +pub(crate) fn below_row_control(ui: &mut Ui, label: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let available = dimension(ui.available_width()); + let inset = (label_column(ui, label, available) + label_gap(ui, available)).min(available); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(inset); + ui.allocate_ui_with_layout(vec2(width.min((available - inset).max(0.0)), 0.0), egui::Layout::top_down(egui::Align::Min), add).inner + }) + .inner +} + /// Where a [`figure_rows`] field went (in the order given): the field's rect, and whether it is /// hovered or has the keyboard (its part of the figure lights up). pub(crate) struct FigureSlot { @@ -467,7 +502,16 @@ fn section_with_badge( let id = ui.make_persistent_id(id); let mut open = !pending && (searching || ui.ctx().data_mut(|data| data.get_persisted::(id)).unwrap_or(default_open)); let (rect, response) = ui.allocate_exact_size( - vec2(dimension(ui.available_width()), if compact { t.control_height - t.space_4 } else { row_height() }), + vec2( + dimension(ui.available_width()), + if compact { + t.control_height - t.space_4 + } else if super::settings_page_active(ui) { + t.control_height + t.space_2 + } else { + row_height() + }, + ), egui::Sense::click(), ); crate::prefs_search::follow(ui, title, rect); @@ -637,7 +681,7 @@ pub(crate) fn divider(ui: &mut Ui, key: &str) { /// Give a compound row's subfield its accessible name without allocating layout space. /// Name a subfield without allocating another row. Tokens: `none`. -pub(super) fn named_control(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { +pub(crate) fn named_control(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { ui.scope_builder(egui::UiBuilder::new().id_salt(label).ui_stack_info(egui::UiStackInfo::default().with_tag_value(LABEL, label.to_owned())), add) .inner } @@ -679,6 +723,7 @@ pub(crate) enum Text<'a> { Inline, Cell, NameCell(bool), + SelectedNameCell, MonoCell, Value(&'a str, bool), } @@ -721,16 +766,17 @@ fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) } } let t = Tokens::get(); - let fixed = matches!(style, Text::Cell | Text::NameCell(_) | Text::MonoCell | Text::Value(_, _)); + let fixed = matches!(style, Text::Cell | Text::NameCell(_) | Text::SelectedNameCell | Text::MonoCell | Text::Value(_, _)); let ink = match style { Text::Hint(_) | Text::Note(_) | Text::MonoNote(_) => t.form_dim, Text::Inline if in_catalog(ui) => t.form_dim, Text::Report(_, true) | Text::Value(_, true) | Text::Error(_) => t.form_error, + Text::SelectedNameCell => t.selected_ink, _ => t.form_ink, }; let font = match style { Text::Inline if in_catalog(ui) => crate::fonts::semibold(t.font_small), - Text::NameCell(true) => crate::fonts::semibold(t.font_label), + Text::NameCell(true) | Text::SelectedNameCell => crate::fonts::semibold(t.font_label), Text::NameCell(false) => crate::fonts::medium(t.font_label), Text::Heading(_) => crate::fonts::semibold(t.font_button), Text::MonoCell | Text::Value(_, _) => egui::FontId::monospace(t.font_body), @@ -782,7 +828,7 @@ fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) Text::Note(key) | Text::MonoNote(key) => ("note", key), Text::Report(key, _) | Text::Value(key, _) | Text::Error(key) | Text::Heading(key) => ("report", key), Text::Inline => ("inline", text), - Text::Cell | Text::MonoCell | Text::NameCell(_) => ("cell", text), + Text::Cell | Text::MonoCell | Text::NameCell(_) | Text::SelectedNameCell => ("cell", text), }; let id = format!("dialog:{kind}:{key}"); scenario::publish_rect(&id, response.rect); diff --git a/crates/ui-egui/src/ui_kit/panel.rs b/crates/ui-egui/src/ui_kit/panel.rs index d9bd34b29a..e130e24b43 100644 --- a/crates/ui-egui/src/ui_kit/panel.rs +++ b/crates/ui-egui/src/ui_kit/panel.rs @@ -41,7 +41,7 @@ fn header_actions( collapsed: Option<&mut bool>, closeable: bool, on_close: impl FnOnce(), - actions: Option<&mut dyn FnMut(&mut Ui)>, + actions: Option<(f32, &mut dyn FnMut(&mut Ui))>, ) { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), header_height()), egui::Sense::hover()); @@ -68,13 +68,21 @@ fn header_actions( on_close(); } } - let right = if let Some(actions) = actions { + let right = if let Some((width, actions)) = actions { + let end = if closeable { close_rect.left() - t.space_8 } else { rect.right() - t.space_8 }; + let width = if closeable { width.min((end - icon_rect.right() - t.space_24 * 4.0).max(0.0)) } else { width }; let action_rect = egui::Rect::from_center_size( - egui::pos2(rect.right() - t.space_8 - t.control_height, rect.center().y), - vec2(t.control_height * 2.0, t.control_height), + egui::pos2(end - width / 2.0, rect.center().y), + vec2(width, if closeable { t.form_close_size } else { t.control_height }), ); - let mut action_ui = ui.new_child(egui::UiBuilder::new().max_rect(action_rect)); + let builder = egui::UiBuilder::new().max_rect(action_rect); + let builder = if closeable { builder.layout(egui::Layout::left_to_right(egui::Align::Center)) } else { builder }; + let mut action_ui = ui.new_child(builder); + if closeable { + action_ui.set_clip_rect(ui.clip_rect().intersect(action_rect)); + } actions(&mut action_ui); + scenario::publish_rect("dialog:header:accessory", action_rect); action_rect.left() - t.space_8 } else if let Some(collapsed) = collapsed { let fold = egui::Rect::from_min_size(egui::pos2(close_rect.left() - t.form_close_size - t.space_4, close_rect.top()), close_rect.size()); @@ -432,7 +440,15 @@ impl<'a> Panel<'a> { width, false, |ui, options| { - header_actions(ui, (options.icon, None), options.title, None, false, || {}, actions); + header_actions( + ui, + (options.icon, None), + options.title, + None, + false, + || {}, + actions.map(|actions| (Tokens::get().control_height * 2.0, actions)), + ); }, add, ) @@ -940,6 +956,7 @@ pub struct Window<'a> { page_padding: egui::Margin, page_chrome: bool, left_slot: bool, + page_header_in_titlebar: bool, } impl<'a> Window<'a> { @@ -964,6 +981,7 @@ impl<'a> Window<'a> { page_padding: egui::Margin::same(Tokens::get().space_16 as i8), page_chrome: false, left_slot: false, + page_header_in_titlebar: false, } } @@ -1031,6 +1049,12 @@ impl<'a> Window<'a> { self } + /// Place the existing page-header callback beside the title; other windows retain their body header. + pub(crate) fn page_header_in_titlebar(mut self) -> Self { + self.page_header_in_titlebar = true; + self + } + /// A required choice, such as web recovery, has no dismiss action. Existing data remains /// pending until the body explicitly handles a choice. pub fn required(mut self) -> Self { @@ -1068,7 +1092,9 @@ impl<'a> Window<'a> { let t = Tokens::get(); ui.spacing_mut().item_spacing.y = 0.0; let mut close = false; - header_impl(ui, (self.icon, self.page_chrome.then_some(t.form_primary)), self.title, None, self.closeable, || close = true); + if !self.page_header_in_titlebar { + header_impl(ui, (self.icon, self.page_chrome.then_some(t.form_primary)), self.title, None, self.closeable, || close = true); + } egui::Frame::NONE.inner_margin(if self.body_padding { t.space_12 } else { 0.0 }).show(ui, |ui| { ui.spacing_mut().item_spacing.y = t.form_gap_y; if self.body_padding { @@ -1154,7 +1180,46 @@ impl<'a> Window<'a> { self.page_chrome = true; let rows = self.page_rows; let padding = self.page_padding; - self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding, header, on_select, sidebar_footer), left) + let in_titlebar = self.page_header_in_titlebar; + let (icon, title, closeable) = (self.icon, self.title, self.closeable); + let header_closed = std::cell::Cell::new(false); + let result = self.show_with_left( + ctx, + true, + |ui| { + if in_titlebar { + ui.spacing_mut().item_spacing.y = 0.0; + let mut accessory = |ui: &mut Ui| { + if let Some(header) = header { + header(ui, state); + } + }; + header_actions( + ui, + (icon, Some(Tokens::get().form_primary)), + title, + None, + closeable, + || header_closed.set(true), + Some((Tokens::get().space_24 * 9.0 + Tokens::get().space_14, &mut accessory)), + ); + } + page_list_sized( + ui, + selected, + pages, + state, + rows, + padding, + if in_titlebar { None } else { header }, + on_select, + sidebar_footer, + header.is_some(), + ); + }, + left, + ); + (result.0 || header_closed.get(), result.1) } /// The same bounded card frame, either movable or an input-blocking modal. @@ -1222,7 +1287,7 @@ pub(crate) struct WindowPage { #[cfg(test)] pub(super) fn page_list(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage], state: &mut S) { - page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None, None); + page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None, None, false); } fn page_list_sized( @@ -1235,6 +1300,7 @@ fn page_list_sized( header: Option, on_select: Option, sidebar_footer: Option<(f32, fn(&mut Ui, &mut S))>, + search_scroll: bool, ) { let t = Tokens::get(); // The search strip, the gap under it, and the following item spacing sit above the page. @@ -1254,7 +1320,11 @@ fn page_list_sized( t.secondary_vline(ui.painter(), nav.right(), nav.y_range(), t.dialog_rule); scenario::publish_rect("dialog:pages", nav); *selected = (*selected).min(pages.len().saturating_sub(1)); - let nav_inner = nav.shrink2(vec2(t.space_8, t.space_6)); + let nav_inner = if sidebar_footer.is_some() { + egui::Rect::from_min_max(nav.min + vec2(t.space_8, t.space_6), nav.max - vec2(t.space_8, t.space_10)) + } else { + nav.shrink2(vec2(t.space_8, t.space_6)) + }; let footer_height = sidebar_footer.map_or(0.0, |(height, _)| dimension(height).min(nav_inner.height())); let list = egui::Rect::from_min_max(nav_inner.min, egui::pos2(nav_inner.right(), nav_inner.bottom() - footer_height)); ui.scope_builder(egui::UiBuilder::new().max_rect(list), |ui| { @@ -1339,7 +1409,7 @@ fn page_list_sized( if let Some(header) = header { header(ui, state); } - let force_scroll = header.is_some() && crate::prefs_search::force_scroll(ui.ctx()); + let force_scroll = search_scroll && crate::prefs_search::force_scroll(ui.ctx()); if let Some(page) = pages.get(*selected) { if page.scroll || force_scroll { let scroll = super::settings_scroll(ui, ("sc_prefs_page", *selected), |ui| (page.draw)(ui, state)); diff --git a/crates/ui-egui/src/ui_kit/panel_tests.rs b/crates/ui-egui/src/ui_kit/panel_tests.rs index e0bf02cd4a..5c7cb5d2aa 100644 --- a/crates/ui-egui/src/ui_kit/panel_tests.rs +++ b/crates/ui-egui/src/ui_kit/panel_tests.rs @@ -1173,14 +1173,33 @@ fn preferences_preserves_every_page_and_footer_in_both_themes() { let row = scenario::rect_of(&format!("prefs:page:{page}")).unwrap(); assert!(nav.contains_rect(row), "{dark}: {page} stays visible"); let window = scenario::rect_of("prefs:window").unwrap(); + let header = scenario::rect_of("dialog:header").unwrap(); + let search = scenario::rect_of("prefs:search:frame").unwrap(); + assert!((header.height() - 40.0).abs() < 0.5); + assert!(header.contains_rect(search), "{dark}: {page}: search stays inside the titlebar"); + assert!((search.width() - 230.0).abs() < 0.5 && (search.height() - 26.0).abs() < 0.5); + assert!(search.left() > header.center().x); for action in ["prefs:ok", "prefs:apply", "prefs:cancel"] { assert!(window.contains_rect(scenario::rect_of(action).unwrap()), "{dark}: {page}: {action}"); } assert!(scenario::rect_of("prefs:page_body").is_some()); - assert!(h.widget(h.app.tr("ui.restore_defaults")).is_some()); + assert!(h.widget(h.app.tr("prefs.restore_page_defaults")).is_some()); let footer = scenario::rect_of("dialog:pages_footer").expect("pinned profile footer"); assert!(nav.contains_rect(footer)); - assert!(footer.contains_rect(scenario::rect_of("prefs:profile").expect("real switcher"))); + let profile_card = scenario::rect_of("prefs:profile:sidebar_card").expect("bordered profile card"); + assert!(footer.contains_rect(profile_card)); + assert!((profile_card.height() - 48.0).abs() < 0.5); + assert_eq!(profile_card, scenario::rect_of("prefs:profile").expect("the whole card is the real switcher")); + let icon = scenario::rect_of("prefs:profile:icon").unwrap(); + let label = scenario::rect_of("prefs:profile:label").unwrap(); + let name = scenario::rect_of("prefs:profile:name").unwrap(); + let chevron = scenario::rect_of("prefs:profile:chevron").unwrap(); + for part in [icon, label, name, chevron] { + assert!(profile_card.contains_rect(part)); + } + assert!(icon.right() < label.left() && label.bottom() <= name.top() && name.right() < chevron.left()); + assert!((label.left() - name.left()).abs() < 0.5); + assert!((icon.center().y - profile_card.center().y).abs() < 0.5); let tagged = [ crate::prefs::PAGE_GENERAL, crate::prefs::PAGE_APPEARANCE, @@ -1191,6 +1210,12 @@ fn preferences_preserves_every_page_and_footer_in_both_themes() { ] .contains(&index); assert_eq!(scenario::rect_of("prefs:in_profile").is_some(), tagged, "{page}"); + if index == crate::prefs::PAGE_GENERAL { + let tag = scenario::rect_of("prefs:in_profile").unwrap(); + let body = scenario::rect_of("prefs:page_body").unwrap(); + assert!((tag.height() - 22.0).abs() < 0.5); + assert!((tag.right() - body.right()).abs() < 0.5, "{dark}: the profile tag aligns to the page's right edge"); + } } } crate::theme::set_dark(previous); diff --git a/crates/ui-egui/src/ui_kit/preferences.rs b/crates/ui-egui/src/ui_kit/preferences.rs index 470345cf08..8404ab02e9 100644 --- a/crates/ui-egui/src/ui_kit/preferences.rs +++ b/crates/ui-egui/src/ui_kit/preferences.rs @@ -363,23 +363,40 @@ pub(crate) fn settings_heading_with_tag(ui: &mut Ui, title: &str, description: & return; } let t = Tokens::get(); - ui.scope(|ui| { + let page = super::settings_page_active(ui); + let heading = ui.scope(|ui| { ui.spacing_mut().item_spacing.y = t.space_3; - let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_16 + t.space_3), Sense::hover()); + let (rect, response) = + ui.allocate_exact_size(vec2(dimension(ui.available_width()), if page { t.space_24 } else { t.space_16 + t.space_3 }), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), title)); - paint_text(ui, rect, title, crate::fonts::semibold(t.font_intro), t.form_ink, false); + paint_text(ui, rect, title, crate::fonts::semibold(if page { t.font_page_heading } else { t.font_intro }), t.form_ink, false); if let Some(tag) = tag { - let width = ui.painter().layout_no_wrap(title.to_owned(), crate::fonts::semibold(t.font_intro), t.form_ink).size().x; + let width = ui + .painter() + .layout_no_wrap(title.to_owned(), crate::fonts::semibold(if page { t.font_page_heading } else { t.font_intro }), t.form_ink) + .size() + .x; let start = (rect.left() + width + t.space_8).min(rect.right()); let tag_rect = Rect::from_min_max(egui::pos2(start, rect.top()), rect.max); - ui.scope_builder(egui::UiBuilder::new().max_rect(tag_rect), tag); + ui.scope_builder( + egui::UiBuilder::new().max_rect(tag_rect).layout(if page { egui::Layout::right_to_left(egui::Align::Center) } else { *ui.layout() }), + tag, + ); } super::text(ui, description, super::Text::Hint("settings_heading")); }); + if page { + scenario::publish_rect("prefs:page_heading", heading.response.rect); + } } /// Settings reuse the same disclosure, uppercase typography and rule as the design system. -pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, mut add: impl FnMut(&mut Ui) -> R) -> Option { +pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, add: impl FnMut(&mut Ui) -> R) -> Option { + settings_section_count(ui, key, title, None, add) +} + +/// A settings disclosure with a real item count, using the shared section badge. +pub(crate) fn settings_section_count(ui: &mut Ui, key: &str, title: &str, count: Option<&str>, mut add: impl FnMut(&mut Ui) -> R) -> Option { if crate::prefs_search::querying(ui) { crate::prefs_search::begin_section(ui, title); if !ui.is_visible() { @@ -408,14 +425,31 @@ pub(crate) fn settings_section(ui: &mut Ui, key: &str, title: &str, mut add: return Some(result); } let t = Tokens::get(); - ui.add_space(t.space_8); - super::section(ui, key, title, true, |ui| { + let page = super::settings_page_active(ui); + if page { + // The page's 10 pt item gap plus the shared disclosure's 2 pt inset complete 38 pt. + ui.add_space(t.space_24 + t.space_14 - t.space_10 - t.space_2); + } else { + ui.add_space(t.space_8); + } + let gap = ui.spacing().item_spacing.y; + if page { + ui.spacing_mut().item_spacing.y = t.space_10; + } + let result = super::section_count(ui, key, title, true, count, |ui| { if let Some(rect) = scenario::rect_of(&format!("dialog:section:{title}")) { scenario::publish_rect(&format!("prefs:section:{key}"), rect); } - ui.spacing_mut().item_spacing.y = t.space_8; - add(ui) - }) + ui.spacing_mut().item_spacing.y = if page { t.space_14 } else { t.space_8 }; + let start = ui.next_widget_position(); + let result = add(ui); + if page { + scenario::publish_rect(&format!("prefs:section:{key}:body"), Rect::from_min_max(start, ui.min_rect().max)); + } + result + }); + ui.spacing_mut().item_spacing.y = gap; + result } /// Full-width settings detail group. Container owns its surface; content cannot shrink the card. @@ -619,6 +653,10 @@ pub(crate) fn settings_badge(ui: &mut Ui, text: &str, warning: bool) { return; } let t = Tokens::get(); + if super::settings_page_active(ui) { + super::settings_state_badge(ui, text, if warning { super::SettingsBadgeTone::Warning } else { super::SettingsBadgeTone::Success }); + return; + } let ink = if warning { if crate::theme::contrast_ratio(t.warning, t.dialog_body) >= 4.5 { t.warning } else { t.form_ink } } else { t.form_success }; let fill = if warning { t.warning.gamma_multiply(0.1) } else { t.form_success_fill }; @@ -989,7 +1027,11 @@ pub(crate) fn settings_inline_note(ui: &mut Ui, text: &str) { return; } let t = Tokens::get(); - let galley = ui.painter().layout_no_wrap(text.to_owned(), FontId::proportional(t.font_body), t.form_dim); + let galley = if super::settings_page_active(ui) && ui.layout().main_wrap() { + ui.painter().layout(text.to_owned(), FontId::proportional(t.font_body), t.form_dim, dimension(ui.max_rect().width())) + } else { + ui.painter().layout_no_wrap(text.to_owned(), FontId::proportional(t.font_body), t.form_dim) + }; let (r, response) = ui.allocate_exact_size(galley.size(), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text)); ui.painter().galley(r.min, galley, t.form_dim); diff --git a/crates/ui-egui/src/ui_kit/preferences_cards.rs b/crates/ui-egui/src/ui_kit/preferences_cards.rs index 7b227d209f..4f59499c54 100644 --- a/crates/ui-egui/src/ui_kit/preferences_cards.rs +++ b/crates/ui-egui/src/ui_kit/preferences_cards.rs @@ -86,6 +86,29 @@ fn gap(t: &Tokens) -> f32 { (t.space_4 + t.space_6) * 0.5 } +/// A bounded settings row/card using the same selection treatment as picture cards. +pub(crate) fn settings_row_card(ui: &mut Ui, selected: bool, height: f32, add: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), height), Sense::hover()); + let look = card_look(selected, ui.is_enabled(), false, false); + ui.painter().rect( + rect, + t.radius_lg, + if selected { t.selected_bg } else { t.control_fill }, + Stroke::new(look.border, if selected { t.form_primary } else { t.dialog_border }), + StrokeKind::Inside, + ); + let inner = rect.shrink2(vec2(t.space_10, 0.0)); + let result = ui + .scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(inner)); + ui.set_min_size(inner.size()); + add(ui) + }) + .inner; + egui::InnerResponse { inner: result, response } +} + fn paint_card(ui: &mut Ui, card: &Card, width: f32, tile: bool) -> Response { let t = Tokens::get(); let width = dimension(width); diff --git a/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs b/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs index b3656dc3f4..cd8aa00c85 100644 --- a/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs +++ b/crates/ui-egui/src/ui_kit/preferences_figure_ops.rs @@ -27,10 +27,15 @@ pub(super) enum Ink { Border, ControlBorder, Control, + /// Legacy SVG role for conflicting sketch geometry and the conflict-state glyph. Note, NoteFill, + /// Legacy SVG ink: lettering uses accent foreground; strokes use model highlights. White, + /// Legacy SVG ink: hatch strokes use figure lines; filled silhouettes use model shadows. Black, + Highlight, + Shadow, /// Colour vision sample colours: Z axis blue, projected, X axis, bend up and bend down. Cv0, Cv1, @@ -181,10 +186,12 @@ impl Ctx<'_> { Ink::Border => t.dialog_border, Ink::ControlBorder => t.control_border, Ink::Control => t.control_fill, - Ink::Note => t.pref_note, + Ink::Note => t.figure_conflict, Ink::NoteFill => t.pref_note_fill, Ink::White => t.figure_white, Ink::Black => t.figure_black, + Ink::Highlight => t.figure_highlight, + Ink::Shadow => t.figure_shadow, Ink::Cv0 => t.axis_z, Ink::Cv1 => t.sketch_projected, Ink::Cv2 => t.axis_x, @@ -200,7 +207,11 @@ impl Ctx<'_> { } fn stroke(&self, line: &L) -> Stroke { - Stroke::new((line.w * self.scale).clamp(0.0, 64.0), self.ink(line.ink, line.a)) + let ink = match line.ink { + Ink::White => Ink::Highlight, + ink => ink, + }; + Stroke::new((line.w * self.scale).clamp(0.0, 64.0), self.ink(ink, line.a)) } } @@ -278,7 +289,7 @@ fn draw(p: &Painter, ctx: &Ctx, op: &Op, clip: Option<&[Pos2]>) { Op::Glow { c, a } => { let centre = ctx.at([c[0], c[1]]); let radius = vec2(c[2] * ctx.scale, c[3] * ctx.scale); - glow(p, centre, radius, ctx.ink(Ink::Black, *a)); + glow(p, centre, radius, ctx.ink(Ink::Shadow, *a)); } Op::Text { at, size, anchor, ink, a, s, .. } => { if clip.is_some() { @@ -326,7 +337,11 @@ fn poly(p: &Painter, ctx: &Ctx, pts: &[Pos2], closed: bool, fill: &Option

, gr let span = (y1 - y0).max(1e-3); fill_polygon(p, shape, |q| top.lerp_to_gamma(bottom, ((q.y - y0) / span).clamp(0.0, 1.0))); } else if let Some(f) = fill { - let c = ctx.ink(f.ink, f.a); + let ink = match f.ink { + Ink::Black => Ink::Shadow, + ink => ink, + }; + let c = ctx.ink(ink, f.a); fill_polygon(p, shape, |_| c); } } diff --git a/crates/ui-egui/src/ui_kit/preferences_small.rs b/crates/ui-egui/src/ui_kit/preferences_small.rs index 23d8e0827c..f533071d32 100644 --- a/crates/ui-egui/src/ui_kit/preferences_small.rs +++ b/crates/ui-egui/src/ui_kit/preferences_small.rs @@ -2,6 +2,190 @@ use crate::theme::Tokens; use egui::{Align, FontId, Layout, Rect, Sense, Stroke, Ui, pos2, vec2}; +const SETTINGS_PAGE: &str = "sc_settings_page"; +pub(super) const ROW_HELP: &str = "sc_settings_row_help"; + +/// Opt-in page rhythm; other forms retain their existing spacing and heading sizes. +pub(crate) fn settings_page(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(egui::UiStackInfo::default().with_tag(SETTINGS_PAGE)), |ui| { + ui.spacing_mut().item_spacing.y = Tokens::get().space_10; + add(ui) + }) + .inner +} + +pub(super) fn settings_page_active(ui: &Ui) -> bool { + ui.stack().iter().any(|node| node.tags().contains(SETTINGS_PAGE)) +} + +/// Indexed standard row with guidance attached to its label, rather than an orphan help row. +pub(crate) fn settings_row_help(ui: &mut Ui, label: &str, help: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(egui::UiStackInfo::default().with_tag_value(ROW_HELP, help.to_owned())), |ui| { + super::row(ui, label, add) + }) + .inner +} + +/// A compound form's independently indexed editor, with a short caption above its control. +pub(crate) fn settings_compound_field(ui: &mut Ui, label: &str, caption: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(); + super::settings_form(ui, 0.0, |ui| { + super::layout::sized_row(ui, label, t.control_height + t.space_16 + t.space_4, |ui| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = t.space_4; + super::settings_inline_note(ui, caption); + ui.horizontal(add).inner + }) + .inner + }) + }) +} + +#[derive(Clone, Copy)] +pub(crate) enum SettingsBadgeTone { + Neutral, + Accent, + Warning, + Success, +} + +/// A named change indicator; its hover and accessible text explain the dot's meaning. +pub(crate) fn settings_change_dot(ui: &mut Ui, label: &str) -> egui::Response { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(t.space_8, t.space_8), Sense::hover()); + ui.painter().circle_filled(rect.center(), (t.space_8 - t.space_2 / 2.0) / 2.0, t.pref_note); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + response.on_hover_text(label) +} + +/// One compact card trigger for the pinned profile menu, without a nested input frame. +pub(crate) fn settings_profile_button(ui: &mut Ui, label: &str, name: &str, edited: bool, changed_hint: &str) -> egui::Response { + let t = Tokens::get(); + let card = super::settings_row_card(ui, false, t.space_24 * 2.0, |ui| { + let rect = ui.max_rect(); + let side = t.space_16 + t.space_2 / 2.0; + let icon = Rect::from_center_size(pos2(rect.left() + side / 2.0, rect.center().y), vec2(side, side)); + crate::icons::paint_ink(ui.painter(), icon, "settings_profile", t.form_dim); + let chevron = Rect::from_center_size(pos2(rect.right() - t.space_14 / 2.0, rect.center().y), vec2(t.space_14, t.space_14)); + super::dropdown_indicator(ui.painter(), chevron, t.form_dim); + let text_left = (icon.right() + t.space_8).min(chevron.left()); + let text_right = (chevron.left() - t.space_6 - if edited { t.space_8 + t.space_6 } else { 0.0 }).max(text_left); + let overline = Rect::from_min_max(pos2(text_left, rect.center().y - t.space_16), pos2(text_right, rect.center().y - t.space_2)); + let value = Rect::from_min_max(pos2(text_left, overline.bottom()), pos2(text_right, rect.center().y + t.space_16)); + super::text::paint_text(ui, overline, &label.to_uppercase(), crate::fonts::semibold(t.font_panel), t.form_dim, true); + super::text::paint_text(ui, value, name, crate::fonts::semibold(t.font_label), t.form_ink, false); + crate::scenario::publish_rect("prefs:profile:icon", icon); + crate::scenario::publish_rect("prefs:profile:label", overline); + crate::scenario::publish_rect("prefs:profile:name", value); + crate::scenario::publish_rect("prefs:profile:chevron", chevron); + if edited { + let dot = Rect::from_min_size(pos2(text_right + t.space_6, value.center().y - t.space_4), vec2(t.space_8, t.space_8)); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(dot), |ui| settings_change_dot(ui, changed_hint)).inner; + crate::scenario::publish_target("prefs:profile:edited", response.rect); + } + }); + let response = ui.interact(card.response.rect, ui.make_persistent_id("profile_card"), Sense::click()); + if response.clicked() { + response.request_focus(); + } + if response.hovered() || response.has_focus() { + ui.painter().rect_stroke( + response.rect, + t.radius_lg, + Stroke::new(if response.has_focus() { t.space_2 } else { 1.0 }, t.form_primary), + egui::StrokeKind::Inside, + ); + } + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name)); + crate::scenario::publish_rect("prefs:profile:sidebar_card", response.rect); + response.on_hover_text(label) +} + +/// Compact 18 pt informational badge, with semantic foreground/background pairs. +pub(crate) fn settings_state_badge(ui: &mut Ui, label: &str, tone: SettingsBadgeTone) -> egui::Response { + let t = Tokens::get(); + let (ink, fill) = match tone { + SettingsBadgeTone::Neutral => (t.form_dim, t.dialog_footer), + SettingsBadgeTone::Accent => (t.selected_ink, t.selected_bg), + SettingsBadgeTone::Warning => (t.pref_note, t.pref_note_fill), + SettingsBadgeTone::Success => (t.pref_ok, t.pref_ok_fill), + }; + let galley = ui.painter().layout_no_wrap(label.to_owned(), crate::fonts::semibold(t.font_small), ink); + let natural = galley.size().x + t.space_16; + let width = if settings_page_active(ui) { natural.min(super::metrics::dimension(ui.max_rect().width())) } else { natural }; + let clipped = width < natural; + let (rect, response) = ui.allocate_exact_size(vec2(width, t.space_16 + t.space_2), Sense::hover()); + ui.painter().rect_filled(rect, t.radius_xl, fill); + if clipped { + super::text::paint_text(ui, rect.shrink2(vec2(t.space_8.min(width / 2.0), 0.0)), label, crate::fonts::semibold(t.font_small), ink, false); + } else { + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink); + } + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + if clipped { response.on_hover_text(label) } else { response } +} + +#[derive(Default)] +pub(crate) struct SettingsChip<'a> { + pub key: Option<&'a str>, + pub icon: Option<&'a str>, + pub accent: bool, + pub monospace: bool, + pub remove: Option<&'a str>, + pub compact: bool, +} + +/// Bounded informational pill; an optional removal target lives inside the same outline. +pub(crate) fn settings_chip(ui: &mut Ui, label: &str, chip: SettingsChip<'_>) -> egui::Response { + let t = Tokens::get(); + let ink = if chip.accent { t.selected_ink } else { t.form_dim }; + let font = if chip.accent { + crate::fonts::semibold(t.font_caption) + } else if chip.monospace { + FontId::monospace(t.font_caption) + } else { + FontId::proportional(t.font_caption) + }; + let icon_side = t.space_12; + let icon_width = if chip.icon.is_some() { icon_side + t.space_4 } else { 0.0 }; + let remove_width = if chip.remove.is_some() { t.space_24 } else { 0.0 }; + let natural = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), ink).size().x + t.space_16 + icon_width + remove_width; + let height = if chip.compact { t.space_24 - t.space_2 } else { t.space_24 }; + let (rect, response) = ui.allocate_exact_size(vec2(natural.min(super::metrics::dimension(ui.max_rect().width())), height), Sense::hover()); + ui.painter().rect( + rect, + t.radius_xl, + if chip.accent { t.selected_bg } else { t.control_fill }, + Stroke::new(1.0, if chip.accent { t.form_primary } else { t.dialog_border }), + egui::StrokeKind::Inside, + ); + if let Some(icon) = chip.icon { + let at = Rect::from_center_size(pos2(rect.left() + t.space_8 + icon_side / 2.0, rect.center().y), vec2(icon_side, icon_side)); + if icon == "lock" { + crate::icons::glyphs::badge(ui.painter(), at, "lock", ink); + } else { + crate::icons::paint_ink(ui.painter(), at, icon, ink); + } + } + let text_left = (rect.left() + t.space_8 + icon_width).min(rect.right()); + let text_rect = Rect::from_min_max(pos2(text_left, rect.top()), pos2((rect.right() - t.space_8 - remove_width).max(text_left), rect.bottom())); + super::text::paint_text(ui, text_rect, label, font, ink, false); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + if let Some(remove) = chip.remove { + let target = Rect::from_min_max(pos2((rect.right() - t.space_24).max(rect.left()), rect.top()), rect.max); + if let Some(key) = chip.key { + // Allocate the pill in the wrapping parent before giving its action a unique identity. + let mut action_ui = ui.new_child(egui::UiBuilder::new().id_salt(key).max_rect(target)); + return super::quiet_icon_button_at(&mut action_ui, target, "close", remove).on_hover_text(label); + } + return super::quiet_icon_button_at(ui, target, "close", remove).on_hover_text(label); + } + response.on_hover_text(label) +} + /// A labelled form block whose control column can contain several natural-height rows. pub(crate) fn settings_block(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { settings_block_content(ui, label, None, label_width, add) @@ -81,6 +265,9 @@ pub(crate) fn settings_autosave_timeline(ui: &mut Ui, minutes: f64) { let t = Tokens::get(); let (r, _) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), t.space_24 + t.space_12 + t.space_2), Sense::hover()); let line = r; + if settings_page_active(ui) { + crate::scenario::publish_rect("prefs:autosave_timeline", r); + } ui.painter().hline(line.x_range(), r.top() + t.space_10, Stroke::new(2.0, t.dialog_rule)); if minutes.is_finite() && minutes > 0.0 { let ticks = (30.0 / minutes).floor().clamp(0.0, 60.0) as u32; @@ -90,6 +277,9 @@ pub(crate) fn settings_autosave_timeline(ui: &mut Ui, minutes: f64) { } } for (share, key) in [(0.0, "prefs.timeline_half_hour"), (0.5, "prefs.timeline_quarter_hour"), (1.0, "prefs.timeline_now")] { + if settings_page_active(ui) && share == 0.5 { + continue; + } ui.painter().text( pos2(line.left() + line.width() * share, r.top() + t.space_24), if share == 0.0 { diff --git a/crates/ui-egui/src/ui_kit/search.rs b/crates/ui-egui/src/ui_kit/search.rs index 0eea5586c8..709726e3a6 100644 --- a/crates/ui-egui/src/ui_kit/search.rs +++ b/crates/ui-egui/src/ui_kit/search.rs @@ -156,7 +156,7 @@ impl CommandSearch { response } - fn key(ui: &Ui, right: egui::Pos2, label: &str) -> f32 { + pub(super) fn key(ui: &Ui, right: egui::Pos2, label: &str) -> f32 { Self::key_with_padding(ui, right, label, Tokens::get().space_10) } diff --git a/crates/ui-egui/src/ui_kit/shortcuts.rs b/crates/ui-egui/src/ui_kit/shortcuts.rs index e4b27f96ba..5d23e0fa2f 100644 --- a/crates/ui-egui/src/ui_kit/shortcuts.rs +++ b/crates/ui-egui/src/ui_kit/shortcuts.rs @@ -36,7 +36,7 @@ pub(crate) fn search_actions_row( let filter_width = (width.max(minimum) - action_width).max(0.0); let response = ui .allocate_ui_with_layout(vec2(filter_width, t.control_height), egui::Layout::left_to_right(Align::Center), |ui| { - search_with_icon(ui, id, value, hint) + search_with_icon(ui, id, value, hint, false) }) .inner; let mut action = None; @@ -59,9 +59,14 @@ pub(crate) fn search_actions_row( } /// Native text editing with the same token frame and leading magnifier as command search. -fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { +pub(crate) fn preferences_search_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { + search_with_icon(ui, id, value, hint, true) +} + +fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, titlebar: bool) -> Response { let t = Tokens::get(); - let (r, frame) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), t.control_height), Sense::click()); + let height = if titlebar { t.form_close_size } else { t.control_height }; + let (r, frame) = ui.allocate_exact_size(vec2(super::metrics::dimension(ui.available_width()), height), Sense::click()); let focused = ui.memory(|m| m.has_focus(id)); ui.painter().rect( r, @@ -70,10 +75,20 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) - if focused { Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); - let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + t.space_16 * 0.5, r.center().y), vec2(t.space_16, t.space_16)); + let side = if titlebar { t.space_14 } else { t.space_16 }; + let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + side * 0.5, r.center().y), vec2(side, side)); icons::paint_ink(ui.painter(), icon, "zoom", t.form_dim); let show_clear = !value.is_empty(); - let clear_side = if show_clear { t.control_height } else { 0.0 }; + let clear_side = if show_clear { + height + } else if titlebar { + super::CommandSearch::key(ui, r.right_center() - vec2(t.space_6, 0.0), "Ctrl F") + t.space_12 + } else { + 0.0 + }; + if titlebar { + crate::scenario::publish_rect("prefs:search:frame", r); + } let editor = Rect::from_min_max(pos2((icon.right() + t.space_6).min(r.right()), r.top()), pos2((r.right() - clear_side).max(icon.right()), r.bottom())); let response = ui @@ -96,7 +111,7 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) - .inner; let mut cleared = false; if show_clear { - let clear_rect = Rect::from_center_size(pos2(r.right() - t.control_height * 0.5, r.center().y), vec2(t.control_height, t.control_height)); + let clear_rect = Rect::from_center_size(pos2(r.right() - height * 0.5, r.center().y), vec2(height, height)); let clear = ui.push_id(id.with("clear"), |ui| super::quiet_icon_button_at(ui, clear_rect, "close", "Clear")).inner; if id == egui::Id::new("prefs_search") { crate::scenario::publish_handle("prefs:search:clear", clear_rect.center()); diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index 9f27e25c8b..f9c40eacbe 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -1162,30 +1162,6 @@ enum State { pub fn prefs_rows(app: &mut SolveApp, ui: &mut egui::Ui) { let can = available(app); let kind = app.updater.kind().clone(); - // Where nothing can check (the browser app, development builds, the Flatpak), the box and - // Check now would do nothing: the note below says how this copy updates instead. - if can { - ui_kit::checkbox(ui, crate::i18n::tr("update.check_automatically"), &mut app.preferences.check_updates); - ui.horizontal_wrapped(|ui| { - let busy = app.updater.status == Status::Checking; - let (status, warning) = match (&app.updater.status, offered_or_newer(app)) { - (Status::Checking, _) => (crate::i18n::tr("update.checking").to_string(), false), - (Status::Failed(e), _) => (crate::i18n::format("update.failed", &[("error", e)]), true), - (_, Some(v)) => (crate::i18n::format("update.available", &[("version", &v)]), false), - (Status::UpToDate, None) => (crate::i18n::tr("update.up_to_date").to_string(), false), - (Status::Idle, None) => (String::new(), false), - }; - if !status.is_empty() { - ui_kit::settings_badge(ui, &status, warning); - } - let r = ui_kit::button(ui, crate::i18n::tr("update.check_now"), ui_kit::Button::Enabled(!busy)); - crate::scenario::publish_rect("prefs:check_now", r.rect); - if r.clicked() { - let ctx = ui.ctx().clone(); - check_now(app, &ctx); - } - }); - } let note = if kind == InstallKind::Web { crate::i18n::tr("update.prefs_web").to_string() } else if can && let Some(manager) = app.updater.managed_by().map(str::to_string) { @@ -1197,7 +1173,41 @@ pub fn prefs_rows(app: &mut SolveApp, ui: &mut egui::Ui) { } else { crate::i18n::format("update.prefs_dev", &[("version", current_version())]) }; - ui_kit::text(ui, ¬e, ui_kit::Text::Hint("updates")); + ui.horizontal_wrapped(|ui| { + if can { + let (status, warning) = match (&app.updater.status, offered_or_newer(app)) { + (Status::Checking, _) => (crate::i18n::tr("update.checking").to_string(), false), + (Status::Failed(e), _) => (crate::i18n::format("update.failed", &[("error", e)]), true), + (_, Some(v)) => (crate::i18n::format("update.available", &[("version", &v)]), false), + (Status::UpToDate, None) => (crate::i18n::tr("update.up_to_date").to_string(), false), + (Status::Idle, None) => (String::new(), false), + }; + if !status.is_empty() { + ui_kit::settings_badge(ui, &status, warning); + } + let last = app.preferences.update.last_check; + if last.is_finite() && last > 0.0 { + let time = solidcraft_engine::crash::utc(last as u64); + let checked = crate::i18n::format("prefs.general.last_check", &[("time", &time)]); + ui_kit::settings_inline_note(ui, &checked); + } + } else { + ui_kit::settings_inline_note(ui, crate::i18n::tr("update.check_unavailable")); + } + let busy = app.updater.status == Status::Checking; + let r = ui_kit::button(ui, crate::i18n::tr("update.check_now"), ui_kit::Button::Enabled(can && !busy)); + crate::scenario::publish_target("prefs:check_now", r.rect); + if can && r.clicked() { + let ctx = ui.ctx().clone(); + check_now(app, &ctx); + } + let help = ui_kit::help_icon(ui, ¬e); + crate::scenario::publish_target("prefs:updates_help", help.rect); + }); + ui.add_enabled_ui(can, |ui| { + let check = ui_kit::checkbox(ui, crate::i18n::tr("update.check_automatically"), &mut app.preferences.check_updates); + crate::scenario::publish_target("prefs:check_updates", check.rect); + }); if let Some(v) = offered_or_newer(app) && offered(app).is_none() && ui_kit::button(ui, crate::i18n::format("update.show", &[("version", &v)]), ui_kit::Button::Normal).clicked() diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 0f2606a465..23647d0c98 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -674,20 +674,103 @@ fn the_version_links_to_its_release_notes() { } #[test] -fn web_preferences_explain_reload_without_update_controls() { - let mut h = crate::scenario::Harness::new(); - h.app.updater.kind = Some(InstallKind::Web); - let mut texts = Vec::new(); - for _ in 0..2 { - let mut output = h.ctx.run_ui(egui::RawInput::default(), |ui| prefs_rows(&mut h.app, ui)); - output.textures_delta.clear(); - texts = - output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }).collect(); +fn unavailable_preferences_show_disabled_update_controls_and_real_values() { + use egui::accesskit::{Role, Toggled}; + let _language = crate::i18n::scope("en"); + for kind in [InstallKind::Dev, InstallKind::Web, InstallKind::Flatpak("io.solidcraft.SolidCraft".into())] { + for checked in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + h.app.preferences.check_updates = checked; + h.app.updater.kind = Some(kind.clone()); + h.app.updater.feed = None; + assert!(!available(&mut h.app)); + let memo = h.app.preferences.update.clone(); + let launch_checked = h.app.updater.launch_checked; + let last_manual = h.app.updater.last_manual; + h.call("ui.prefs", json!({"open":"General"})); + crate::prefs::reveal_preference(&mut h, "prefs:check_updates"); + let body = crate::scenario::rect_of("prefs:page_body").unwrap(); + let button = crate::scenario::rect_of("prefs:check_now").unwrap(); + assert!(body.contains_rect(button)); + assert!(body.contains_rect(crate::scenario::rect_of("prefs:check_updates").unwrap())); + let tree = h.output.accesskit_update.as_ref().unwrap(); + for (label, role) in [("Check now", Role::Button), ("Check at start and once a day", Role::CheckBox)] { + let controls: Vec<_> = tree.nodes.iter().filter(|(_, node)| node.role() == role && node.label() == Some(label)).collect(); + assert_eq!(controls.len(), 1, "{kind:?}: one {label} control"); + assert!(controls[0].1.is_disabled(), "{kind:?}: {label} is disabled"); + if role == Role::CheckBox { + assert_eq!(controls[0].1.toggled(), Some(if checked { Toggled::True } else { Toggled::False })); + } + } + let reason = crate::i18n::tr("update.check_unavailable"); + assert_eq!(tree.nodes.iter().filter(|(_, node)| node.role() == Role::Label && node.value() == Some(reason)).count(), 1); + let texts: Vec<_> = h + .shapes + .iter() + .filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some(text.galley.text()) } else { None }) + .collect(); + assert!(texts.contains(&reason), "{kind:?}: the unavailable reason is painted"); + assert!(h.widget("Up to date").is_none(), "unavailability is not a successful check"); + let note = match &kind { + InstallKind::Web => crate::i18n::tr("update.prefs_web").to_string(), + InstallKind::Flatpak(id) => { + crate::i18n::format("update.prefs_flatpak", &[("version", current_version()), ("command", &format!("flatpak update {id}"))]) + } + _ => crate::i18n::format("update.prefs_dev", &[("version", current_version())]), + }; + assert!( + tree.nodes.iter().any(|(_, node)| node.role() == Role::Label && node.value() == Some(note.as_str())), + "the platform help stays accessible" + ); + assert!(!texts.contains(¬e.as_str()), "the longer explanation stays in hover help"); + assert!(crate::scenario::rect_of("prefs:updates_help").is_some()); + h.step(&json!({"click":{"handle":"prefs:check_now"}})).unwrap(); + h.step(&json!({"widget":"Check at start and once a day"})).unwrap(); + assert_eq!(h.app.preferences.check_updates, checked); + assert_eq!(h.app.preferences.update, memo); + assert_eq!(h.app.updater.status, Status::Idle); + assert_eq!(h.app.updater.launch_checked, launch_checked); + assert_eq!(h.app.updater.last_manual, last_manual); + assert!(h.app.updater.rx.is_none() && h.app.updater.job.is_none() && h.app.updater.release.is_none()); + assert!(!h.app.updater.open, "attempting the disabled action does not open the checker"); + } } - assert!(!texts.iter().any(|t| t.contains("development build")), "{texts:?}"); - assert!(!texts.iter().any(|t| t == crate::i18n::tr("update.check_automatically")), "{texts:?}"); - assert!(!texts.iter().any(|t| t == crate::i18n::tr("update.check_now")), "{texts:?}"); - assert!(texts.iter().any(|t| t == crate::i18n::tr("update.prefs_web")), "{texts:?}"); +} + +#[test] +fn general_updates_show_real_status_and_time_and_check_now_uses_the_existing_feed() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = "en".into(); + h.app.preferences.check_updates = false; + h.app.preferences.update.last_check = 1_700_000_000.0; + h.app.updater.feed = Some(feed); + h.app.updater.kind = Some(InstallKind::WindowsMsi); + h.app.updater.managed = Some(None); + h.app.updater.status = Status::UpToDate; + h.call("ui.prefs", json!({"open":"General"})); + crate::prefs::reveal_preference(&mut h, "prefs:check_now"); + let tree = h.output.accesskit_update.as_ref().unwrap(); + for (label, role) in [("Check now", egui::accesskit::Role::Button), ("Check at start and once a day", egui::accesskit::Role::CheckBox)] { + let controls: Vec<_> = tree.nodes.iter().filter(|(_, node)| node.role() == role && node.label() == Some(label)).collect(); + assert_eq!(controls.len(), 1); + assert!(!controls[0].1.is_disabled(), "available update controls stay enabled"); + if role == egui::accesskit::Role::CheckBox { + assert_eq!(controls[0].1.toggled(), Some(egui::accesskit::Toggled::False)); + } + } + let checked = crate::i18n::format("prefs.general.last_check", &[("time", &solidcraft_engine::crash::utc(1_700_000_000))]); + assert!(h.widget(&checked).is_some(), "the persisted check time is shown"); + let button = crate::scenario::rect_of("prefs:check_now").unwrap(); + h.step(&json!({"click":[button.center().x, button.center().y]})).unwrap(); + wait_for_check(&mut h); + assert_eq!(h.app.updater.release.as_ref().map(|r| r.version().to_string()), Some(version)); + assert!(h.app.preferences.update.last_check > 1_700_000_000.0); + assert!(!h.app.preferences.check_updates, "a manual check keeps the schedule preference"); } fn no_env(_: &str) -> Option { diff --git a/crates/ui-egui/src/view_pick.rs b/crates/ui-egui/src/view_pick.rs index 2c864fc924..65b940581e 100644 --- a/crates/ui-egui/src/view_pick.rs +++ b/crates/ui-egui/src/view_pick.rs @@ -150,9 +150,22 @@ pub fn pick_command(app: &mut SolveApp, p: &Value) -> Result { .and_then(|(so, sn)| section_cap(app, &st, &bounds, input, so, sn, kept.first().map(|c| c.t), include_ghosted, replaced, &active_bodies)); let face_t = kept.first().map(|c| c.t).or(cap.as_ref().map(|(t, _, _)| *t)); let (edge, vertex) = match (edges_on, input.pixel) { - (true, Some(pos)) => { - near_edges(app, &st, &bounds, &proj, input, pos, face_t, edge_px, &active_bodies, replaced, include_ghosted, wireframe, slack) - } + (true, Some(pos)) => near_edges( + app, + &st, + &bounds, + &proj, + input, + pos, + face_t, + edge_px, + &active_bodies, + replaced, + include_ghosted, + wireframe, + slack, + section, + ), _ => (None, None), }; hits.push(match (all, cap, kept) { @@ -382,11 +395,16 @@ fn near_edges( include_ghosted: bool, wireframe: bool, slack: f64, + section: Option<(Vec3, Vec3)>, ) -> (Option, Option) { let reach = crate::pick_index::reach(proj, pos, edge_px.max(6.0)); + // The section removes a half-space and nothing there is drawn, so the mouse picker skips + // edges and vertices in it; a pick must not answer with what the user cannot see (#10704, + // the same `kept` test `viewport::pick` applies). + let kept = |p: Vec3| section.is_none_or(|(so, sn)| sn.dot(p) <= sn.dot(so) + SECTION_EPS); // `slack` is the body's own: what counts as lying on the face in front depends on how // coarsely that body is meshed, never on where the camera stands. - let visible = |p: Vec3, slack: f64| wireframe || face_t.is_none_or(|t| (p - input.o).dot(input.d) <= t + slack); + let visible = |p: Vec3, slack: f64| kept(p) && (wireframe || face_t.is_none_or(|t| (p - input.o).dot(input.d) <= t + slack)); let mut best_e: Option = None; let mut best_v: Option = None; for (i, b) in st.bodies.iter().enumerate() { diff --git a/crates/ui-egui/src/view_pick_tests.rs b/crates/ui-egui/src/view_pick_tests.rs index 16148beadc..46c493e1dc 100644 --- a/crates/ui-egui/src/view_pick_tests.rs +++ b/crates/ui-egui/src/view_pick_tests.rs @@ -237,3 +237,40 @@ fn the_mouse_picker_stops_at_a_section_cap() { other => panic!("the top face: {other:?}"), } } + +/// With a Section Analysis on, `view.pick` must not answer with edges or vertices in the +/// half the section removes: the mouse picker has skipped them since #6722, and an agent +/// must not get an answer the user cannot see (#10704). +#[test] +fn a_section_pick_skips_edges_and_vertices_in_the_removed_half() { + let mut h = Harness::new(); + h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}})); + h.app.cam = Camera { + target: Vec3::new(20.0, 15.0, 10.0), + yaw: 0.0, + pitch: std::f64::consts::FRAC_PI_2, + roll: 0.0, + distance: 200.0, + fov: 0.0, + up: Default::default(), + }; + // Section XZ at 15 mm: keeps y 0..15, removes y 15..30. + h.call("engine.execute", json!({"command": "inspect.section", "params": {"plane": {"origin": [0, 15, 0], "normal": [0, 1, 0]}}})); + // The same projection the model-frame pick builds, so a world point's pixel can be picked. + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(960.0, 600.0)); + let cam = h.app.cam; + let proj = crate::viewport::Proj { cam, vp: cam.view_proj(960.0 / 600.0, crate::viewport::scene_radius(&h.app)), rect }; + let px = |p: [f64; 3]| { + let sp = proj.to_screen(Vec3::new(p[0], p[1], p[2])).unwrap(); + json!([sp.x as f64, sp.y as f64]) + }; + // Top-view pixels on the removed half's top edge, corner, and mid-edge: nothing is drawn + // there, so the pick answers with nothing (it used to return the edge, with `body: null`). + for w in [[40.0, 25.0, 20.0], [40.0, 30.0, 20.0], [20.0, 30.0, 20.0]] { + let r = pick(&mut h, json!({"pixels": [px(w)]})); + assert_eq!(r["hits"][0], serde_json::Value::Null, "the removed half must pick nothing at {w:?}: {}", r["hits"][0]); + } + // The same pick on the kept half's edge (y < 15) still answers with that edge. + let r = pick(&mut h, json!({"pixels": [px([40.0, 5.0, 20.0])]})); + assert!(r["hits"][0]["edge"].get("index").is_some(), "the kept half's edge must still pick: {}", r["hits"][0]); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index f9c78e1749..301242ae8a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -92,13 +92,10 @@ pub struct ViewportState { lod_rest: CameraRest, /// Each body mesh's box (the mesh held, so its address stays its own), from the last state. mesh_bounds: std::sync::Mutex, - /// Each shown body mesh's section index for the cut's normal (the mesh held, so its address - /// stays its own): a Section Analysis dragged along its normal cuts through it. + /// Each shown body mesh's section index (the mesh held, so its address stays its own): a + /// Section Analysis dragged or tilted cuts through it. section_index: std::sync::Mutex< - std::collections::HashMap< - (usize, [u64; 3]), - (std::sync::Arc, std::sync::Arc), - >, + std::collections::HashMap, std::sync::Arc)>, >, /// Each body mesh's picking index (made the first time a pick reaches the body), kept while /// the mesh is part of the world state. @@ -456,6 +453,13 @@ fn scene_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 { b } +/// The ambient occlusion radius (0: off): 4% of what the frame draws, the box the depth range uses, +/// so a preview in an empty design has its own. Nothing to draw has no occlusion (#9327). +fn ao_radius(app: &SolveApp) -> f32 { + let size = scene_bounds(app).diagonal(); + if app.preferences.ambient_occlusion && app.ui.visual_style != 2 && size > 0.0 { (size * 0.04).clamp(1e-4, 1e6) as f32 } else { 0.0 } +} + pub fn scene_radius(app: &SolveApp) -> f64 { let b = scene_bounds(app); let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) }; @@ -1636,15 +1640,14 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { let col = sc.color(c4(Tokens::view().text.gamma_multiply(0.9))); let mut kept = app.viewport.section_index.lock().map(|mut k| std::mem::take(&mut *k)).unwrap_or_default(); let mut made = std::collections::HashMap::with_capacity(kept.len()); - let normal = [n.x.to_bits(), n.y.to_bits(), n.z.to_bits()]; for b in st.bodies.iter().filter(|b| app.body_visible(&b.name)) { let mesh = b.mesh(); - let key = (std::sync::Arc::as_ptr(&mesh) as usize, normal); - let index = match kept.remove(&key).filter(|(m, i)| std::sync::Arc::ptr_eq(m, &mesh) && i.normal() == n) { + let key = std::sync::Arc::as_ptr(&mesh) as usize; + let index = match kept.remove(&key).filter(|(m, _)| std::sync::Arc::ptr_eq(m, &mesh)) { Some((_, index)) => index, - None => std::sync::Arc::new(solidcraft_engine::view::SectionIndex::new(&mesh, n)), + None => std::sync::Arc::new(solidcraft_engine::view::SectionIndex::new(&mesh)), }; - for (a, c) in index.segments(&mesh, o) { + for (a, c) in index.segments(&mesh, o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } made.insert(key, (mesh, index)); @@ -1959,22 +1962,39 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> { .collect() } -/// The plane Create Sketch would put the sketch on, while it waits for one (#6975): the origin -/// or construction plane under the pointer, in the view or in the browser. The grid is drawn on -/// it, so the sketch's plane shows before the click. +/// The plane Create Sketch would put the sketch on: the origin or construction plane under the +/// pointer while it waits (#6975), or the plane selected in the view (#10804). The grid is drawn +/// on it, so the sketch's plane shows before the click. pub fn previewed_sketch_plane(app: &SolveApp) -> Option { - if app.session.active_sketch.is_some() || !app.picking_sketch_plane() { + if app.session.active_sketch.is_some() { return None; } - let name = match (&app.viewport.hover_plane, &app.viewport.hover) { - (Some(name), _) | (None, Some(Hit::Plane { name, .. })) => name, - _ => return None, - }; + if app.picking_sketch_plane() { + let name = match (&app.viewport.hover_plane, &app.viewport.hover) { + (Some(name), _) | (None, Some(Hit::Plane { name, .. })) => name, + _ => return selected_plane(app), + }; + return named_plane(app, name); + } + selected_plane(app) +} + +/// The plane selected in the view, when there is one: Create Sketch takes it, so the grid shows it. +fn selected_plane(app: &SolveApp) -> Option { + let name = app.session.selection.iter().find_map(|sel| match sel { + Sel::Plane { name } => Some(name.as_str()), + _ => None, + })?; + named_plane(app, name) +} + +/// Origin or construction plane `name`. +fn named_plane(app: &SolveApp, name: &str) -> Option { solidcraft_engine::view::construction_planes(&app.session) .into_iter() .find(|(_, n, _)| n == name) .map(|(_, _, p)| p) - .or_else(|| origin_planes(app).into_iter().find(|(n, _, _)| *n == name.as_str()).and_then(|(n, _, _)| Plane::named(n))) + .or_else(|| origin_planes(app).into_iter().find(|(n, _, _)| *n == name).and_then(|(n, _, _)| Plane::named(n))) } /// A point on origin plane `name`'s square that a click at its screen point picks: the middle, @@ -3139,7 +3159,9 @@ pub(crate) fn sketch_constraint_at(app: &SolveApp, pos: Pos2, hit: Option<&Hit>) pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { let snap = d.face_snap(&app.session, hits); - return hits.iter().find_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))); + return hits + .iter() + .find_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), crate::dialogs_move::component_pick(app, h, s)))); } hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h)) } @@ -3150,7 +3172,10 @@ pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> { // The face under the cursor is looked up once for every sketch point a face place may // snap to, before Select Other caps its rows. let snap = d.face_snap(&app.session, hits); - return hits.iter().filter_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))).collect(); + return hits + .iter() + .filter_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), crate::dialogs_move::component_pick(app, h, s)))) + .collect(); } hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect() } @@ -4443,11 +4468,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { analysis_colors: crate::gpu::analysis_colors(&t.analysis), access: crate::access::map(app), outline: matches!(app.ui.visual_style, 0 | 3), - ao_radius: if app.preferences.ambient_occlusion && app.ui.visual_style != 2 { - (model_bounds(app).diagonal() * 0.04).clamp(1e-4, 1e6) as f32 - } else { - 0.0 - }, + ao_radius: ao_radius(app), eye: { let e = proj.cam.eye().to_f32(); [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }] @@ -7273,6 +7294,9 @@ mod tests { s.execute("solid.box", &json!({"length": 300, "width": 300, "height": 4, "corner": [-150, -150, -4], "body_name": "plate"})).unwrap(); s.execute("solid.box", &json!({"length": 30, "width": 160, "height": 16, "corner": [0, -80, 0], "operation": "new", "body_name": "block"})) .unwrap(); + // On one side of the view only, too far from the block to shade the rows measured below. + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 16, "corner": [-110, 60, 0], "operation": "new", "body_name": "marker"})) + .unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); let (yaw, pitch) = solidcraft_engine::render::StandardView::Top.angles(); app.cam = Camera { target: Vec3::new(-30.0, 0.0, 0.0), yaw, pitch, roll: 0.0, distance: 200.0, fov: 0.0, ..Default::default() }; @@ -7333,8 +7357,31 @@ mod tests { } // The shader takes the count it is given (the moving frame is a different picture), and // spreads it over the same hemisphere: both darken the contact alike. - assert_ne!(pictures[0], pictures[1], "8 samples draw another grain than 32"); + assert_ne!(pictures[0], pictures[1], "8 samples draw another picture than 32"); assert!((beside[0] - beside[1]).abs() < 0.02, "the same darkening beside the block at 32 and 8 samples: {beside:?}"); + // No grain (#9327): where the plate darkens, neighbouring pixels agree at either count. + let (from, to) = ((edge - reach) as usize, (edge - 4.0) as usize); + for picture in &pictures { + let grain = (y0..y1).flat_map(|y| (from..to).map(move |x| (lum(picture, x, y) - lum(picture, x + 1, y)).abs() / 3.0)).fold(0.0, f64::max); + eprintln!("grain {grain:.2}"); + assert!(grain <= 2.0, "neighbouring pixels differ by {grain:.2} of 255"); + } + for picture in &pictures { + // Blurred by depth: the block's top right beside the edge stays as clear as without AO. + for x in (edge + 1.0) as usize..=(edge + 3.0) as usize { + let y = (y0 + y1) / 2; + assert!((lum(picture, x, y) - lum(&off, x, y)).abs() <= 6.0, "the plate's shade crosses onto the block's top at {x}"); + } + // The map is read the right way up: the plate darkens beside the marker, not at its mirror image. + let shade = |p: Vec3| { + let (x, y, _) = at(p); + lum(picture, x as usize, y as usize) / lum(&off, x as usize, y as usize).max(1.0) + }; + let (beside, mirror) = (shade(Vec3::new(-100.0, 58.0, 0.0)), shade(Vec3::new(-100.0, -58.0, 0.0))); + assert!(beside < 0.99 && (mirror - 1.0).abs() < 0.004, "beside the marker {beside:.4}, at its mirror image {mirror:.4}"); + } + // Off, nothing is read from the released maps: the plate is as bright beside the block as away from it. + assert!((lum(&off, to, y0) - lum(&off, from, y0)).abs() <= 3.0, "AO off darkens nothing"); } /// Faded and see-through bodies are drawn from their plans where the renderer blends them @@ -11258,6 +11305,28 @@ mod tests { assert!(solidcraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty()); } + /// Tilting a section keeps each body's section index: one serves every normal, so a tilt + /// drag does not rebuild it every frame (#10373). + #[test] + fn tilting_the_section_keeps_its_index() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let o = Vec3::new(0.0, 15.0, 0.0); + s.section = Some((o, Vec3::new(0.0, -1.0, 0.0))); + let mut app = SolveApp::new(s, crate::Services::default()); + let indexes = + |app: &SolveApp| app.viewport.section_index.lock().unwrap().values().map(|(_, i)| std::sync::Arc::as_ptr(i) as usize).collect::>(); + build_scene(&app); + let first = indexes(&app); + assert_eq!(first.len(), 1); + for k in 1..4 { + let a = (10.0 * k as f64).to_radians(); + app.session.section = Some((o, Vec3::new(0.0, -a.cos(), a.sin()))); + build_scene(&app); + assert_eq!(indexes(&app), first, "tilted {k}"); + } + } + #[test] fn selection_shows_where_the_model_hides_it() { let mut s = solidcraft_engine::Session::default(); @@ -11695,6 +11764,7 @@ mod tests { fn colour_vision_mode_reaches_the_frame_and_the_view_caches() { use crate::theme::ColorVision; let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; let keys = |app: &SolveApp| (scene_key(app), highlight_key(app)); h.call("ui.prefs", json!({"open": "Interface"})); @@ -12086,6 +12156,37 @@ mod grid_plane_tests { assert!(on(&app, 1), "the preview grid is drawn even with the grid off"); } + /// #10804: a plane selected in the view, before Create Sketch, is the plane it would use, so + /// the grid lies on it until it is deselected. + #[test] + fn a_selected_plane_previews_on_the_grid_before_create_sketch() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.show_grid = true; + app.preferences.grid_follows_view = false; + let on = |app: &SolveApp, axis: usize| { + let points: Vec<[f32; 3]> = build_sketch_scene(app, &projection(app, rect)) + .under + .as_chunks::<32>() + .0 + .iter() + .flat_map(|l| [&l[0..12], &l[12..24]]) + .map(|b| [0, 4, 8].map(|i| f32::from_le_bytes(b[i..i + 4].try_into().unwrap()))) + .collect(); + !points.is_empty() && points.iter().all(|p| p[axis].abs() < 1e-3) + }; + assert!(on(&app, 2), "the ground grid"); + // A plane under the pointer, with nothing selected, moves nothing. + app.viewport.hover = Some(Hit::Plane { name: "XZ".into(), point: Vec3::ZERO }); + assert!(on(&app, 2), "a hover alone moves nothing"); + // Select XZ: the grid lies on it before Create Sketch is clicked. + app.session.selection = vec![Sel::Plane { name: "XZ".into() }]; + assert!(on(&app, 1), "the selected plane shows on the grid"); + // Deselecting it returns the grid to the ground. + app.session.selection.clear(); + assert!(on(&app, 2), "deselecting returns the grid"); + } + /// The grid's centre is where the view's axis meets the ground, but at a grazing slant (a /// camera 10 mm above the ground, looking almost along it) no further than the grid's /// half-size from the point under the target, so the ground in front of the camera keeps diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index 3f3f7580b1..f572f3bb3c 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -404,6 +404,7 @@ fn cancelling_theme_preview_restores_viewport_palette_keys() { #[test] fn inspect_menu_toggles_component_colors_without_a_dialog_and_refreshes_scene() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.language = "en".into(); h.frames(3); let command = solidcraft_engine::find_command("inspect.colored_components").unwrap(); let info = command.info(&h.app.session); @@ -570,3 +571,25 @@ fn vertex_markers_follow_the_edges_display_mode() { assert!(front.iter().all(|(_, c)| Some(*c) == edge), "style {style}: markers in the edge colour"); } } + +/// The radius is 4% of what the frame draws (#9327): in an empty design a preview's box, not the +/// floor that left the depth map's precision to show as specks; nothing drawn, or AO off, is off. +#[test] +fn the_ambient_occlusion_radius_follows_what_is_drawn() { + use solidcraft_engine::geom::Aabb3; + + let mut app = SolveApp::new(Session::default(), Services::default()); + app.preferences.ambient_occlusion = true; + app.ui.visual_style = 0; + assert_eq!(super::ao_radius(&app), 0.0, "nothing to occlude"); + app.preview.bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(30.0, 40.0, 0.0) }; + assert_eq!(super::ao_radius(&app), 2.0, "the preview's box, its diagonal 50"); + app.preview.bounds = Aabb3::EMPTY; + app.run("solid.box", json!({"length": 300, "width": 400, "height": 0.001})).unwrap(); + assert!((super::ao_radius(&app) - 20.0).abs() < 1e-3, "the body's box"); + app.ui.visual_style = 2; + assert_eq!(super::ao_radius(&app), 0.0, "wireframe"); + app.ui.visual_style = 0; + app.preferences.ambient_occlusion = false; + assert_eq!(super::ao_radius(&app), 0.0, "off"); +} diff --git a/crates/ui-egui/src/viewport_details.rs b/crates/ui-egui/src/viewport_details.rs index e37c508f65..748a49eab0 100644 --- a/crates/ui-egui/src/viewport_details.rs +++ b/crates/ui-egui/src/viewport_details.rs @@ -16,7 +16,7 @@ fn field(id: &str, label: &str, value: DetailValue) -> DetailSetting { DetailSetting { id: id.into(), label: tr(label), value, enabled: true } } fn length(app: &SolveApp, value: f64) -> DetailValue { - DetailValue::Length { value, units: app.session.doc.units.clone() } + DetailValue::Length { value, units: app.session.doc.units.clone(), decimals: app.preferences.length_decimals } } fn number(value: f64, min: f64, max: f64, suffix: &str) -> DetailValue { DetailValue::Number { value, min, max, suffix: suffix.into() } @@ -26,7 +26,13 @@ fn axis(value: [f64; 3]) -> DetailValue { let current = if axes.iter().any(|axis| *axis == value || axis.map(|v| -v) == value) { crate::viewport_status::direction(value) } else { - let [x, y, z] = value; + // Rounded as the card's other numbers are: a tilted normal reads (0.707, 0.707, 0). + let short = |v: f64| { + let s = format!("{v:.3}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + }; + let [x, y, z] = value.map(short); format!("({x}, {y}, {z})") }; DetailValue::Axis { value, current } diff --git a/crates/ui-egui/src/viewport_overlays_tests.rs b/crates/ui-egui/src/viewport_overlays_tests.rs index 7f9583dff4..2d66513e27 100644 --- a/crates/ui-egui/src/viewport_overlays_tests.rs +++ b/crates/ui-egui/src/viewport_overlays_tests.rs @@ -67,7 +67,8 @@ fn section_details_live_fields_reconstruct_arbitrary_plane_without_losing_compan assert!((original.1 + flipped.1).len() < 1e-9); h.app.session.doc_mut().units = "in".into(); let effect = h.app.session.status_items().remove(0); - let params = crate::viewport_details::edit_params(&h.app, &effect, "offset", &V::Length { value: -25.4, units: "in".into() }).unwrap(); + let params = + crate::viewport_details::edit_params(&h.app, &effect, "offset", &V::Length { value: -25.4, units: "in".into(), decimals: 2 }).unwrap(); assert_eq!(params["offset"], "-25.4 mm"); assert_eq!(params["hatch"], true); assert_eq!(params["flip"], !current_flip); @@ -121,7 +122,7 @@ fn details_record_scope_preserves_mixed_limits_and_stale_targets_never_move_came crate::viewport_details::dispatch(&mut h.app, &h.ctx, &owner, DetailEvent::Target(target)); assert_eq!(h.app.session.camera, before); let effect = h.app.session.status_items().remove(0); - assert!(crate::viewport_details::edit_params(&h.app, &effect, "gap", &V::Length { value: 1.0, units: "in".into() }).is_none()); + assert!(crate::viewport_details::edit_params(&h.app, &effect, "gap", &V::Length { value: 1.0, units: "in".into(), decimals: 2 }).is_none()); assert!(crate::viewport_details::description(&h.app, &effect).findings.iter().all(|row| !row.target)); } @@ -2670,3 +2671,29 @@ fn summary_preferences_cancel_restores_layout_independently_of_all_effect_polici assert_eq!(crate::scenario::rect_of("viewport_status:summary").is_some(), saved); } } + +#[test] +fn a_section_card_shows_every_row_and_reads_like_its_pill() { + // #10708: the card was cut off below Hatch (and Flip for a tilted plane), and showed + // "15.000000 mm" and a normal with 16 digits. + use crate::ui_kit::DetailValue as V; + for (plane, rows, normal) in [(json!("XZ"), 3, None), (json!({"origin":[20,15,10],"normal":[1,1,0]}), 3, Some("(0.707, 0.707, 0)"))] { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.app.run("inspect.section", json!({"plane":plane,"offset":15})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let owner = crate::viewport_status::identity(&effect); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:setting")}})).unwrap(); + h.frames(4); + let card = crate::scenario::rect_of("viewport_status:card").expect("the card"); + let fields: Vec<_> = ["offset", "flip", "hatch"].iter().filter_map(|f| crate::scenario::rect_of(&format!("details:field:{f}"))).collect(); + assert_eq!(fields.len(), rows); + assert!(fields.iter().all(|f| card.contains_rect(*f)), "{plane}: card {card:?}, rows {fields:?}"); + let description = crate::viewport_details::description(&h.app, &effect); + assert!(description.settings.iter().any(|s| matches!(&s.value, V::Length { decimals: 2, .. }))); + if let Some(normal) = normal { + assert!(description.settings.iter().any(|s| matches!(&s.value, V::Axis { current, .. } if current == normal)), "{plane}"); + } + } +} diff --git a/crates/ui-egui/tests/scenarios/colour_vision_setting.json b/crates/ui-egui/tests/scenarios/colour_vision_setting.json index dab7d3ef57..b7c263fb2c 100644 --- a/crates/ui-egui/tests/scenarios/colour_vision_setting.json +++ b/crates/ui-egui/tests/scenarios/colour_vision_setting.json @@ -1,8 +1,9 @@ [ - {"note": "Colour vision lives on Interface: choices apply immediately; Cancel restores them, OK keeps them, and Interface defaults reset them. Page scrolling preserves the fixed window width."}, + {"note": "Colour vision lives on Appearance: choices apply immediately; Cancel restores them, OK keeps them, and Appearance defaults reset them. Use English for exact captions. Page scrolling preserves the fixed window width."}, + {"call": "ui.prefs", "params": {"language": "en"}}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"expect": {"shown": "Color vision", "prefs": {"dialog": "Appearance"}, "same_width": "prefs:window"}}, @@ -11,18 +12,18 @@ {"widget": "Cancel"}, {"expect": {"prefs": {"dialog": null, "color_vision": "off"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"widget": "Tritan"}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"expect": {"shown": "Tritan", "same_width": "prefs:window"}}, - {"widget": "Restore Defaults"}, + {"widget": "Restore page defaults"}, {"expect": {"prefs": {"color_vision": "off"}}}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "color_vision": "off"}}} diff --git a/crates/ui-egui/tests/scenarios/preferences_search.json b/crates/ui-egui/tests/scenarios/preferences_search.json index 5c653fd3ff..c26c57e6fc 100644 --- a/crates/ui-egui/tests/scenarios/preferences_search.json +++ b/crates/ui-egui/tests/scenarios/preferences_search.json @@ -1,7 +1,7 @@ [ {"note": "With Preferences open and nothing being edited, typing focuses Search preferences and the letters are the query. Enter opens that setting on its own page."}, {"click": {"handle": "titlebar:preferences"}}, - {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General"}}}, + {"expect": {"shown": "Restore page defaults", "prefs": {"dialog": "General"}}}, {"text": "Reverse zoom direction"}, {"frames": 3}, {"expect": {"shown": "Reverse zoom direction", "not_shown": "Units of new designs"}}, @@ -16,5 +16,5 @@ {"frames": 2}, {"expect": {"shown": "Direct interaction", "prefs": {"dialog": "Tools"}}}, {"key": "Escape"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null}}} + {"expect": {"not_shown": "Restore page defaults", "prefs": {"dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/prefs_theme_files.json b/crates/ui-egui/tests/scenarios/prefs_theme_files.json index f9e4d5a571..76a5b1ae6b 100644 --- a/crates/ui-egui/tests/scenarios/prefs_theme_files.json +++ b/crates/ui-egui/tests/scenarios/prefs_theme_files.json @@ -2,7 +2,7 @@ {"note": "#2255: theme tiles offer the built-in themes; choosing one applies it immediately, and Cancel restores the previous theme."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}, "shown_handles": ["prefs:theme"]}}, {"click": {"handle": "prefs:theme:gruvbox-dark"}}, diff --git a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json index ab63a0ab3c..1e5ee20ce2 100644 --- a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json +++ b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json @@ -1,7 +1,7 @@ [ {"note": "#4146: under the theme tiles, Viewport draws the 3D view in the Light or Dark theme's colours whatever the window uses; it applies at once, and Cancel puts it back."}, {"click": {"handle": "titlebar:preferences"}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "theme", "viewport_dark": true}}}, {"widget": "Light", "near": "Viewport"}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "light", "viewport_dark": false}}}, diff --git a/crates/ui-egui/tests/scenarios/s44_about_licences.json b/crates/ui-egui/tests/scenarios/s44_about_licences.json index 72d338fb3c..8b005b928e 100644 --- a/crates/ui-egui/tests/scenarios/s44_about_licences.json +++ b/crates/ui-egui/tests/scenarios/s44_about_licences.json @@ -1,5 +1,6 @@ [ {"note": "Help ▸ About ▸ Licenses shows SolidCraft's licenses and every third-party crate's, built into the program."}, + {"call": "ui.prefs", "params": {"language": "en"}}, {"call": "ui.help", "params": {"item": "about"}}, {"widget": "Licenses"}, {"expect": {"shown": "Third-party licenses"}}, diff --git a/crates/ui-egui/tests/scenarios/s45_preferences.json b/crates/ui-egui/tests/scenarios/s45_preferences.json index 9dc110b346..c6054c863e 100644 --- a/crates/ui-egui/tests/scenarios/s45_preferences.json +++ b/crates/ui-egui/tests/scenarios/s45_preferences.json @@ -1,25 +1,28 @@ [ {"note": "#36: one Preferences dialog, from the gear at the top right (and Ctrl+,). Changes apply at once; Cancel puts them back, OK keeps them."}, {"click": {"handle": "titlebar:preferences"}}, - {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Appearance"}, + {"expect": {"shown": "Restore page defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, + {"expect": {"shown": "Profiles"}}, + {"widget": "Reopen the designs that were open last time"}, + {"expect": {"prefs": {"reopen_designs": false}}}, + {"click": {"handle": "prefs:page:Appearance"}}, {"click": {"handle": "prefs:theme"}}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}}}, {"widget": "Cancel"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true}}}, + {"expect": {"not_shown": "Restore page defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true, "reopen_designs": true}}}, {"note": "Ctrl+, opens it again; the shortcuts page is in the same dialog."}, {"key": "Comma", "cmd": true}, {"expect": {"prefs": {"dialog": "General"}}}, - {"widget": "Shortcuts"}, + {"click": {"handle": "prefs:page:Shortcuts"}}, {"expect": {"shown": "Reset All", "prefs": {"dialog": "Shortcuts"}}}, - {"widget": "Navigation"}, + {"click": {"handle": "prefs:page:Navigation"}}, {"widget": "Fusion"}, {"widget": "Tinkercad"}, {"expect": {"prefs": {"nav": "tinkercad"}}}, - {"widget": "Design"}, + {"click": {"handle": "prefs:page:Design"}}, {"widget": "Y up"}, {"expect": {"prefs": {"up_axis": "y", "dialog": "Design"}}}, {"widget": "OK"}, - {"expect": {"not_shown": "Restore Defaults", "prefs": {"up_axis": "y", "dialog": null}}} + {"expect": {"not_shown": "Restore page defaults", "prefs": {"up_axis": "y", "dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json index a6ca40c8db..babd22c115 100644 --- a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json +++ b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json @@ -2,7 +2,7 @@ {"note": "#290: Preferences keeps one size on every page; descriptions wrap inside it, and the kit footer keeps Cancel / Apply / OK flush right inside its action inset."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}, "spans": {"inner": "prefs:footer", "outer": "prefs:window", "axis": "x", "tol": 12}}}, - {"widget": "Appearance"}, + {"click": {"handle": "prefs:page:Appearance"}}, {"expect": {"prefs": {"dialog": "Appearance"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Layout"}, {"expect": {"prefs": {"dialog": "Layout"}, "same_width": "prefs:window"}}, diff --git a/crates/ui-egui/tests/ui_layout.rs b/crates/ui-egui/tests/ui_layout.rs index e5c196fac1..5d549e6441 100644 --- a/crates/ui-egui/tests/ui_layout.rs +++ b/crates/ui-egui/tests/ui_layout.rs @@ -20,6 +20,14 @@ fn harness() -> Harness { h } +/// These tests and the scenarios name English words, whatever the PC's language (#10297). +#[test] +fn the_harness_takes_english_as_the_system_language() { + let h = Harness::new(); + assert_eq!(h.app.preferences.language, solidcraft_ui_egui::i18n::SYSTEM); + assert_eq!(solidcraft_ui_egui::i18n::effective_language(&h.app.preferences.language), "en"); +} + #[test] fn every_dialog_opens_docked_within_its_width_limits() { let mut bad = Vec::new(); diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 5a3f31b1a5..7d3717730a 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -48,6 +48,11 @@ In the floating layout, Assistant fills the viewport height in the shared rounde space around its edges. The compass and navigation bar move into the remaining space beside it. It overlays the model without changing the viewport projection; clicks and scrolling over the panel stay in the chat. The docked layout retains its side panel. +After clicking in Assistant, keyboard input stays in the chat, including when selecting text +in an answer. Copy, Cut and Paste do not also operate on selected CAD geometry. Moving the +pointer back over the model does not change this: click outside Assistant to return the +keyboard to CAD. Closing or minimising the panel also releases it. + - **Docking:** on the left, Assistant is the first panel, before vertical history and the browser. The drag highlight shows the actual destination bounds. Drag the panel's header (the dotted grip and the title) to the other half of the window and let go. The side it would land on lights up while you drag. You can also pick @@ -167,7 +172,9 @@ messages go as one prompt, in order, when the turn ends normally. short note that the agent's previous step was interrupted. What the interrupted message changed undoes as one step, like any other message. - **Stop** (the square next to the input, or **Esc** in the input) stops the agent and keeps - the queue: the bubbles wait for **Send**. + the queue: the bubbles wait for **Send**. If an external ACP agent does not finish its cancelled + prompt within five seconds, SolidCraft disconnects it and stops its process in the background. + Completed design changes stay undoable, and the queue stays put. Reconnect to start a new agent session. - **New chat** (the header button, **Ctrl+Shift+L** / **Cmd+Shift+L** with the chat focused) stops what runs, clears the conversation, the queue and the files on the chips, and starts a fresh session on the same agent, keeping the agent and its mode. It asks first when a message @@ -414,9 +421,12 @@ per agent, with **Reset**. These settings cover SolidCraft's tools. A coding agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the panel's header. When an agent asks before -using one of SolidCraft's tools, SolidCraft answers for you, since its own gate checks the call, -and notes it in the chat as *Auto-allowed*. With **Allow everything**, or after **Always allow**, -the agent's other questions are answered the same way and noted too. +using one of the chat's own SolidCraft tools and names it exactly as the Claude adapter does +(`mcp__solidcraft-app__batch`), SolidCraft answers for you, since its own gate checks the call, +and notes it in the chat as *Auto-allowed*. Any other question appears in the panel, including +a SolidCraft tool an agent words differently and a SolidCraft MCP server you added to the agent +yourself. With **Allow everything**, or after **Always allow**, those questions are answered the +same way and noted too. What counts as "a command given a file name": a parameter that holds a file or folder (`file`, `dir`, `output`, …), or `path` and `source` when the value reads like a file name (a separator, `~`, diff --git a/docs/ambient-occlusion.md b/docs/ambient-occlusion.md index 3bee560d0b..a6838cc9e3 100644 --- a/docs/ambient-occlusion.md +++ b/docs/ambient-occlusion.md @@ -33,12 +33,11 @@ only display preferences, without adding a modelling undo step or moving the cam The GPU viewport uses 32 hemisphere samples per shaded pixel against an opaque depth prepass (at most 2048 pixels on its longest side), the sample pattern turned by a different angle at each pixel. One pattern for every pixel drew faint shifted copies of -nearby parts (#4167) and stepped bands along contacts (#3407); turned, they become an -even fine grain, which 2× supersampling smooths further. While the view moves (orbit, pan, -zoom, resize), frames take 8 samples on the same turned pattern, and the full 32 follow once -the view has held still for 0.25 s, the settling time adaptive supersampling uses (#7165). -The AO grain changes when the full count arrives, as the edges do when adaptive supersampling -does. `ui.screenshot` waits for that full-quality frame (at most half a second). The radius follows +nearby parts (#4167) and stepped bands along contacts (#3407); turned, they become a grain, +which a 4 × 4 blur by depth takes out: the term has its own texture, worked out once for each +pixel of the prepass (#9327). While the view moves (orbit, pan, zoom, resize), frames take 8 +samples, and the full 32 follow once the view has held still for 0.25 s, the settling time +adaptive supersampling uses (#7165). `ui.screenshot` waits for that full-quality frame (at most half a second). The radius follows model size, and depth-range rejection avoids shadows from distant foreground objects. It supports orthographic and perspective cameras and respects section clipping. Wireframe, transparent bodies, overlays and surface-analysis colours are unaffected. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 78d8ac7b65..63bee380dd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -79,7 +79,10 @@ launch and writes it, as one line of hex, to a file only the user can read: | Windows | `%LOCALAPPDATA%\SolidCraft\control\PORT.token` | On Unix the folder is `0700` and the file `0600`; on Windows the file's inherited permissions are -replaced by a single entry for the user. `SOLIDCRAFT_CONTROL_DIR` moves the folder. The file is +replaced by a single entry for the user. `SOLIDCRAFT_CONTROL_DIR` moves the folder. If Windows +doesn't finish restricting the file within five seconds, the default folder (private to the user) +keeps it, but a custom folder gets no token file, and clients then need +`SOLIDCRAFT_CONTROL_TOKEN`. The file is removed when the app exits, unless another launch has written its own token there since, and the next launch writes a new token. The token file's path is printed to stderr at start-up. Beside it, `.pid` holds the app's process id (and, on Linux, @@ -96,7 +99,9 @@ The first request on a connection must carry the token as a top-level `"token"` (`{"id": 1, "method": "ui.inspect", "params": {}, "token": "…"}`); later requests on the same connection may leave it out. A first line without the right token is answered with `{"ok": false, "error": "unauthorized: …"}` and the connection is closed, without running -anything. Tokens are compared in constant time. +anything. Tokens are compared in constant time. A connection that hasn't sent that first request +within five seconds is closed without a reply, and blank lines don't extend the time. So connect +when you have something to send; once authenticated, a connection may stay idle. `solidcraft-cli mcp --connect 127.0.0.1:PORT` reads the token file for that port by itself (`--token T` or `SOLIDCRAFT_CONTROL_TOKEN` give one explicitly). For a test harness, or a diff --git a/docs/design/preferences/pages/general.md b/docs/design/preferences/pages/general.md index 64b8243652..3f0cbe8e12 100644 --- a/docs/design/preferences/pages/general.md +++ b/docs/design/preferences/pages/general.md @@ -7,6 +7,16 @@ not in a profile). Not shown on the board: everything in the Page title and tag: "General", "In profile" (hover "Saved in settings profiles"). +Layout clarification from the supplied HTML version of the same design: saved profiles are +42 px rounded, bordered cards with 8 px gaps and a selected fill/accent border on the active +card. Export, Rename and Delete are quiet compact icon actions. The save row starts with the +220 px independent name field (placeholder "Name this setup"), then primary Save as new and +Import. Held and This computer use aligned 84 px label columns: Held pills have accent outlines +and page icons; machine pills and removable host pills have neutral outlines, with lock icons +on machine pills. Section heads have no pictograms. Autosave's 260 px timeline sits under its +control column, not across the page. Search sits in the window title bar; the shared footer +retains Apply alongside Cancel and OK for the existing transaction behavior. + ## Profiles (3 saved) Source: today's General > Profiles section (`crates/ui-egui/src/settings_profiles.rs`, #6237). Section @@ -16,7 +26,7 @@ title "Profiles", with the count "3 saved" at the right of the title. |---|---|---|---|---|---|---| | Profile rows (Laptop, Large monitor, Presentation) | List | `app.settings_profiles` (saved list) | none saved | Icon buttons: "Export", "Rename", "Delete" | none (17 px profile icon) | The active row shows badges "Edited" (when changed since saved) and "Active", and no Apply. Other rows show "Apply". Names are sample. | | Apply | Small button per row | settings profile apply | n/a | none | none | Applying replaces what a profile holds (see README). | -| Profile name | Text field (220 wide) | the name for "Save as new" | empty | none | none | aria-label "Profile name". | +| Profile name | Text field (220 wide) | the name for "Save as new" | empty | none | none | Placeholder "Name this setup"; aria-label "Profile name"; no separate label column. | | Save as new | Button | settings profile save | n/a | none | none | | | Import... | Button | settings profile import | n/a | none | none | | | Held / This computer chips | Two chip rows | none (information) | n/a | none | none | "Held": Appearance, Layout, Navigation, Sketch, Shortcuts, Language. "This computer": 3D mouse, Graphics, Assistant. Matches `settings_profiles::PAGES` plus language, shortcuts, toolbar, S-box favourites and the imported theme; mismatch: the board lists page names, the code lists the Interface, Navigation and Sketch pages (`settings_profiles::PAGES`), so after the page split map Appearance and Layout to the old Interface fields. | @@ -25,7 +35,7 @@ title "Profiles", with the count "3 saved" at the right of the title. | Label | Control | Edits | Default | Hover | Figure | Notes | |---|---|---|---|---|---|---| -| Interface language | Dropdown, 280 wide ("English (United Kingdom)") | `Prefs.language` | `"en"` | none | none | The dropdown lists `i18n::LOCALES`. Page: 0 (General). | +| Interface language | Dropdown, 280 wide ("English (United Kingdom)") | `Prefs.language` | `"system"` (System) | none | none | The dropdown includes System and `i18n::LOCALES`; the effective language follows the operating system when System is selected. Page: 0 (General). | ## Start and save @@ -38,8 +48,7 @@ title "Profiles", with the count "3 saved" at the right of the title. | Label | Control | Edits | Default | Hover | Figure | Notes | |---|---|---|---|---|---|---| -| Largest file to open: STEP and import | Number field, "MB" (72 wide) | `Prefs.max_step_file_mb` | `DEFAULT_STEP_MB` | none | none | Moves here from Design/General. | -| Largest file to open: Design | Number field, "MB" | `Prefs.max_design_file_mb` | `DEFAULT_DESIGN_MB` | none | none | | +| Largest file to open | Two side-by-side 72 px number fields, each with MB | `Prefs.max_step_file_mb`, `Prefs.max_design_file_mb` | `DEFAULT_STEP_MB`, `DEFAULT_DESIGN_MB` | existing file-size guidance | none | One group label; short captions "STEP and import" and "Design" above the controls. Wrap on narrow pages. Each editor retains its full independent accessible name and search result. | | Allow network locations in designs | Checkbox | `Prefs.network_locations` | `false` | "For organisations that keep designs on file servers. Off: only servers you allow." | none | Moves from Design (`PAGE_DESIGN`). | | `\\files.studio`, `\\nas01` | Chip list with a remove icon (11 px) | `Prefs.network_hosts` | empty | none | none | Shown only when network locations is on. Names are sample. | | Open files in a slicer that is already running | Checkbox | `Prefs.slicer_single_instance` | `true` | "For slicers that load a file into a running window." | none | | diff --git a/docs/localization.md b/docs/localization.md index 2e71098dfd..a195be2029 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -10,7 +10,8 @@ the app then matches the system's preferred locales — the OS locale on desktop `navigator.languages` in the browser, read with the `sys-locale` crate — against the registry: exact tag, then base language, then English (`match_system_tags` in `crates/ui-egui/src/i18n.rs`). A saved specific language always wins, so existing users keep -their choice. +their choice. In tests and scenarios the system's language is English, so they read the same +words on every PC. ## Messages diff --git a/docs/mcp.md b/docs/mcp.md index a12f8f4423..c8e093f7d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -107,6 +107,9 @@ Tool calls read, write and delete files only inside the server's folders: - every `--allow-path DIR` (repeat it for more). A folder named here is reached in full, hidden files included. +The CLI removes its own scratch folder if it is empty when the MCP command returns, including +after a startup error. A nonempty scratch folder is kept, so saved files are not deleted. + The working folder the server was started in does not count by itself, so `~/.ssh`, `~/.aws`, `~/.bashrc` and other dotfiles are never in reach by default. stderr lists the folders at start-up and says when the `--in` design's folder was left out; `--allow-path` adds it back. diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index 9d27aeee7f..36400a9220 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -7,12 +7,14 @@ # An AppImage installs nothing, so every AppImage run would otherwise show the # generic "Wayland" icon. Best-effort desktop integration: on first run # (and whenever the AppImage's path changes) install the .desktop entry and the -# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave -# the launch untouched. Opt out with SOLIDCRAFT_NO_DESKTOP_INTEGRATION=1 (the pre-rename -# SOLVECRAFT_NO_DESKTOP_INTEGRATION still works: compatibility shim, docs/compatibility.md), -# or for good with an empty file $XDG_DATA_HOME/solidcraft/no-desktop-integration. Nothing is -# installed either when appimaged (go-appimage) already made an entry for this AppImage, or when -# a launcher that manages AppImages (Gear Lever, AppImageLauncher) started it: it sets +# hicolor icons into $XDG_DATA_HOME, refresh the icon cache, point Icon= at the +# installed PNG (panels that skip the theme cache still show the mark; #10673), +# then launch. Every failure here must leave the launch untouched. Opt out with +# SOLIDCRAFT_NO_DESKTOP_INTEGRATION=1 (the pre-rename SOLVECRAFT_NO_DESKTOP_INTEGRATION +# still works: compatibility shim, docs/compatibility.md), or for good with an empty +# file $XDG_DATA_HOME/solidcraft/no-desktop-integration. Nothing is installed either +# when appimaged (go-appimage) already made an entry for this AppImage, or when a +# launcher that manages AppImages (Gear Lever, AppImageLauncher) started it: it sets # DESKTOPINTEGRATION and owns the menu entry. # # The same step puts `solidcraft-cli` on PATH: a small wrapper script in ~/.local/bin that starts this @@ -98,12 +100,37 @@ integrate() { return 0 done - # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop - # TryExec, which would hide the entry; everything else is shipped as packaged. + # Icons first: the desktop entry's Icon= points at the installed 256×256 PNG so panels + # that skip the theme cache still show the mark (#10673: Mint Cinnamon AppImage taskbar). + src_icons="$APPDIR/usr/share/icons/hicolor" + icons="$data/icons/hicolor" + if [ -d "$src_icons" ]; then + (cd "$src_icons" && find . -type f) | while IFS= read -r rel; do + mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue + if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then + cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true + fi + done + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f "$icons" 2>/dev/null || true + elif command -v update-icon-caches >/dev/null 2>&1; then + update-icon-caches "$icons" 2>/dev/null || true + fi + fi + + # Rewrite Exec to this AppImage (the binary is not on the host PATH), drop TryExec + # (which would hide the entry), and point Icon at the installed PNG when we have one. mkdir -p "$apps" 2>/dev/null || return 0 tmp="$apps/.$DESK_ID.tmp.$$" esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" - if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then + icon_png="$icons/256x256/apps/$DESK_ID.png" + if [ -f "$icon_png" ]; then + icon_esc="$(printf '%s' "$icon_png" | sed 's/[\\&|]/\\&/g')" + icon_sed="s|^Icon=.*|Icon=$icon_esc|" + else + icon_sed='s|^Icon=.*|Icon='"$DESK_ID"'|' + fi + if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' -e "$icon_sed" "$src_desktop" 2>/dev/null >"$tmp"; then if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then rm -f "$tmp" else @@ -127,17 +154,6 @@ integrate() { command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 fi done - - # Icons: per-file compare so an unchanged install costs a handful of cmp calls. - src_icons="$APPDIR/usr/share/icons/hicolor" - [ -d "$src_icons" ] || return 0 - icons="$data/icons/hicolor" - (cd "$src_icons" && find . -type f) | while IFS= read -r rel; do - mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue - if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then - cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true - fi - done } # Started through the wrapper `integrate` installs: the command-line tool, with no integration. diff --git a/packaging/tests/apprun_test.py b/packaging/tests/apprun_test.py index 999936dc4e..a7703f464c 100644 --- a/packaging/tests/apprun_test.py +++ b/packaging/tests/apprun_test.py @@ -82,6 +82,21 @@ class AppRunCli(unittest.TestCase): self.run_apprun(SOLIDCRAFT_NO_DESKTOP_INTEGRATION="1") self.assertFalse(self.wrapper.exists()) + def test_desktop_entry_icon_is_an_absolute_png_path(self): + # Ship a hicolor mark so AppRun can copy it and point Icon= at the install (#10673). + src = self.appdir / "usr/share/icons/hicolor/256x256/apps" + src.mkdir(parents=True) + (src / "io.tangencylabs.solidcraft.png").write_bytes(b"\x89PNG\r\n\x1a\n") + (self.appdir / "usr/share/applications/io.tangencylabs.solidcraft.desktop").write_text( + "[Desktop Entry]\nExec=solidcraft\nIcon=io.tangencylabs.solidcraft\n" + ) + self.run_apprun() + desk = (self.home / ".local/share/applications/io.tangencylabs.solidcraft.desktop").read_text() + installed = self.home / ".local/share/icons/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" + self.assertTrue(installed.is_file()) + self.assertIn(f"Icon={installed}", desk) + self.assertNotIn("Icon=io.tangencylabs.solidcraft\n", desk) + if __name__ == "__main__": unittest.main() diff --git a/scripts/pr-checks/pr-checks.js b/scripts/pr-checks/pr-checks.js index 06ff601131..781c642717 100644 --- a/scripts/pr-checks/pr-checks.js +++ b/scripts/pr-checks/pr-checks.js @@ -1,7 +1,8 @@ // Pull request checks for .github/workflows/pr-checks.yml (CONTRIBUTING.md, "Sending a change"): // 1. duplicate: fails when a lower-numbered open PR already closes the same issue; // 2. defaults: the template line `Changes a default: no | yes - , old behaviour kept behind -// preference ` must be present and well formed; `yes` earns the `changes-default` label. +// preference ` must be present and well formed; `yes` earns the `changes-default` label; +// 3. picture: a PR adding to the showcase or docs/screenshots shows a picture in its description. // The parsing is pure and tested in scripts/pr-checks/pr-checks.test.js; the functions that talk to // GitHub take the github-script `github` client and `context`, so the tests can pass fakes. 'use strict'; @@ -10,6 +11,11 @@ const SKIP_LABEL = 'not-a-duplicate'; const DEFAULT_LABEL = 'changes-default'; // PRs opened before this predate the template line; they pass the defaults check with a notice. const DEFAULTS_ENFORCED_FROM = Date.parse('2026-10-25T00:00:00Z'); +// PRs opened before this predate the picture rule; they pass the picture check with a notice. +const PICTURE_ENFORCED_FROM = Date.parse('2026-11-02T00:00:00Z'); +const PICTURE_HELP = + 'Show it in the description: drag a picture or video into the PR text so it can be seen without ' + + 'opening the changed files, and say in plain words what it is (CONTRIBUTING.md, "Sending a change").'; const BOT_LOGINS = new Set(['dependabot[bot]', 'github-actions[bot]', 'dependabot-preview[bot]']); // Template comments and code blocks hold examples, not claims. @@ -106,6 +112,33 @@ async function checkDuplicate({ github, context, core }) { return { dups }; } +// A picture or video someone can see in the description: not one in a comment, a code block or the +// template's own text. +function hasPicture(body) { + const text = stripNoise(body); + return /!\[[^\]]*\]\([^)\s]+[^)]*\)/.test(text) + || /<(?:img|video)\b[^>]*\bsrc=/i.test(text) + || /https:\/\/github\.com\/user-attachments\/assets\/\S+/.test(text); +} + +// Files that are about something to look at, whatever the rest of the PR does. +function needsPicture(files) { + return files.some((f) => f === 'docs/showcase.md' || f.startsWith('docs/screenshots/')); +} + +async function checkPicture({ github, context, core }) { + const pull = context.payload.pull_request; + if (isBot(pull.user)) return { skipped: 'bot' }; + if (!(Date.parse(pull.created_at) >= PICTURE_ENFORCED_FROM)) return { skipped: 'opened before enforcement' }; + const files = (await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, pull_number: pull.number, per_page: 100, + })).map((f) => f.filename); + if (!needsPicture(files)) return { skipped: 'nothing to show' }; + const shown = hasPicture(pull.body); + if (!shown) core.setFailed(`This PR adds something to look at, but the description shows no picture. ${PICTURE_HELP}`); + return { shown }; +} + async function checkDefaults({ github, context, core }) { const pull = context.payload.pull_request; if (isBot(pull.user)) return { skipped: 'bot' }; @@ -149,4 +182,5 @@ async function syncDefaultLabel({ github, context, core }) { module.exports = { DEFAULTS_ENFORCED_FROM, SKIP_LABEL, DEFAULT_LABEL, stripNoise, parseClosing, parseDefaults, isBot, isDocsOnly, findDuplicates, checkDuplicate, checkDefaults, syncDefaultLabel, + PICTURE_ENFORCED_FROM, hasPicture, needsPicture, checkPicture, }; diff --git a/scripts/pr-checks/pr-checks.test.js b/scripts/pr-checks/pr-checks.test.js index b64b984f2b..c3e479ef44 100644 --- a/scripts/pr-checks/pr-checks.test.js +++ b/scripts/pr-checks/pr-checks.test.js @@ -158,3 +158,39 @@ test('checkDefaults only enforces from 2026-10-25', async () => { await pc.checkDefaults(f); assert.equal(f.failures.length, 1); }); + +test('hasPicture needs one a reader can see in the description', () => { + assert.equal(pc.hasPicture('![tray](https://example.com/a.gif)'), true); + assert.equal(pc.hasPicture('x'), true); + assert.equal(pc.hasPicture(''), true); + assert.equal(pc.hasPicture('https://github.com/user-attachments/assets/abc'), true); + assert.equal(pc.hasPicture(''), false); + assert.equal(pc.hasPicture(null), false); + assert.equal(pc.hasPicture('The GIF is the change.'), false); + assert.equal(pc.hasPicture('See docs/screenshots/box.gif'), false); + assert.equal(pc.hasPicture(''), false); + assert.equal(pc.hasPicture('```\n![x](https://example.com/a.gif)\n```'), false); + assert.equal(pc.hasPicture('`![x](https://example.com/a.gif)`'), false); +}); + +test('needsPicture covers the showcase and its media only', () => { + assert.equal(pc.needsPicture(['docs/showcase.md']), true); + assert.equal(pc.needsPicture(['crates/a.rs', 'docs/screenshots/a.gif']), true); + assert.equal(pc.needsPicture(['docs/mcp.md', 'crates/a.rs']), false); +}); + +test('checkPicture fails a showcase PR with no picture, from 2026-11-02 on', async () => { + const files = [{ filename: 'docs/showcase.md' }]; + const pr = mk(30, 'alice', 'The GIF is the change.', { created_at: '2026-11-02T00:00:00Z' }); + let f = fakes(pr, { files }); + assert.deepEqual(await pc.checkPicture(f), { shown: false }); + assert.equal(f.failures.length, 1); + f = fakes({ ...pr, body: '![it](https://example.com/a.gif)' }, { files }); + assert.deepEqual(await pc.checkPicture(f), { shown: true }); + assert.equal(f.failures.length, 0); + f = fakes({ ...pr, created_at: '2026-11-01T23:59:59Z' }, { files }); + assert.deepEqual(await pc.checkPicture(f), { skipped: 'opened before enforcement' }); + f = fakes(pr, { files: [{ filename: 'crates/a.rs' }] }); + assert.deepEqual(await pc.checkPicture(f), { skipped: 'nothing to show' }); + assert.equal(f.failures.length, 0); +}); diff --git a/skills/solidcraft-modeling/reference.md b/skills/solidcraft-modeling/reference.md index b2f8774725..ce581d136a 100644 --- a/skills/solidcraft-modeling/reference.md +++ b/skills/solidcraft-modeling/reference.md @@ -82,7 +82,7 @@ Distances are unsigned. Draw the geometry on the correct side first, and the dim | `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove, or its name from `body_topology`. Fillet first, except an edge of a removed face or a fillet between curved faces (see Order in `SKILL.md`). Bodies with lofted or swept faces are refused (`offset: only bodies with planes, cylinders, cones, spheres and tori`): hollow a loft with a smaller loft and `operation: cut`. | | `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point; to tie it to the part, add `references: [{edge:[x,y,z], distance}]` (up to two straight edges of the face): the centre then sits at those distances from the edges and follows them. | | `solid.pattern.rectangular` | `features`, `dir1`, `count1`, `spacing1` (+ `dir2`, `count2`, `spacing2`) | Spacing takes expressions and follows parameters. | -| `solid.pattern.circular` | `features`, `axis` (`"Z"`, `{origin, dir}`, or `{face: [x,y,z]}` / `{edge: [x,y,z]}` on a cylinder wall or circular edge, which the pattern follows), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. | +| `solid.pattern.circular` | `features`, `axis` (`"Z"`, `{origin, dir}` whose origin may be length expressions the pattern follows, or `{face: [x,y,z]}` / `{edge: [x,y,z]}` on a cylinder wall or circular edge, which the pattern follows), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. | | `solid.mirror` | `features:[names]` or `bodies:[names]`, `plane` (`XY`/`XZ`/`YZ`, a construction plane, or `{origin, normal}` whose origin may use length expressions) | Follows parameters. A mirror or pattern feature cannot be listed in `features` of another mirror or pattern, but `bodies` can mirror the body it made. The mirror is a new body with the first free default name: `Body1`, `Body2` from `features`, and `Side (2)`, `Side (3)` from `bodies:["Side"]`. Read the name from `created_bodies`. With `combine:true`, a mirror that overlaps the original joins it and makes no new body. `features` copies only the features listed, so a later cut is left out unless you list it too. `bodies` copies the finished body. To name the mirror, use `body.rename {"body":"Side (2)","name":"Side_L"}`. The new name stays when parameters change. | - **Names:** name every feature (`"name":"Standoff"`) and keep names unique across sketches and features. `timeline.roll_to {"feature":"Plate"}` picks the first match, which can be the sketch.