mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-02 18:16:19 +00:00
Release 0.30.1
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+18
-18
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+40
-12
@@ -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.
|
||||
|
||||
@@ -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") => {
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TcpStream>) {
|
||||
fn refuse(out: &mut TcpStream, reader: &mut BufReader<ClientInput>) {
|
||||
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<Instant>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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<Pending> = 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::<ControlRequest>();
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Settings profile</title>
|
||||
<circle cx="32" cy="19" r="8" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 53C12 44.7157 20.9543 38 32 38C43.0457 38 52 44.7157 52 53" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 413 B |
+36
-11
@@ -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": "التحقق من التحديثات غير متاح لهذا التثبيت.",
|
||||
|
||||
+35
-10
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+35
-10
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+35
-10
@@ -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": "בדיקת עדכונים אינה זמינה עבור התקנה זו.",
|
||||
|
||||
+35
-10
@@ -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": "このインストールではアップデートを確認できません。",
|
||||
|
||||
+27
-5
@@ -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": "이 설치에서는 업데이트를 확인할 수 없습니다.",
|
||||
|
||||
+21
-1
@@ -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} мин назад",
|
||||
|
||||
+35
-10
@@ -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.",
|
||||
|
||||
+35
-10
@@ -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": "此安装版本无法检查更新。",
|
||||
|
||||
@@ -35,6 +35,7 @@ fn near_z(c: vec4<f32>) -> f32 {
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@group(2) @binding(0) var ao_depth: texture_2d<u32>;
|
||||
@group(2) @binding(1) var ao_map: texture_2d<f32>;
|
||||
|
||||
fn clipped(p: vec3<f32>) -> 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<f32>) -> vec4<f32> {
|
||||
}
|
||||
|
||||
struct TOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@invariant @builtin(position) pos: vec4<f32>,
|
||||
@location(0) n: vec3<f32>,
|
||||
@location(1) c: vec4<f32>,
|
||||
@location(2) wp: vec3<f32>,
|
||||
@@ -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<f32>,
|
||||
@invariant @builtin(position) pos: vec4<f32>,
|
||||
@location(0) rp: vec3<f32>,
|
||||
@location(1) c: vec4<f32>,
|
||||
};
|
||||
@@ -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<f32> {
|
||||
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<f32> {
|
||||
@@ -346,11 +353,89 @@ fn opaque_depth(i: TOut, front: bool) -> AoDepth {
|
||||
return AoDepth(depth, bitcast<u32>(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<f32> {
|
||||
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) { return vec4<f32>(1.0); }
|
||||
return vec4<f32>(occlusion_term(i));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_ao_term(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return ao_term(i, front);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_ao_blur(@builtin(vertex_index) v: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((v << 1u) & 2u), f32(v & 2u));
|
||||
return vec4<f32>(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<i32>) -> f32 {
|
||||
let at = clamp(p, vec2<i32>(0), vec2<i32>(textureDimensions(ao_depth)) - vec2<i32>(1));
|
||||
let d = bitcast<f32>(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<f32>) -> @location(0) vec4<f32> {
|
||||
let c = vec2<i32>(pos.xy);
|
||||
let d = map_depth(c);
|
||||
if (d >= 1.0) { return vec4<f32>(1.0); }
|
||||
let left = d - map_depth(c + vec2<i32>(-1, 0));
|
||||
let right = map_depth(c + vec2<i32>(1, 0)) - d;
|
||||
let above = d - map_depth(c + vec2<i32>(0, -1));
|
||||
let below = map_depth(c + vec2<i32>(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<i32>(textureDimensions(ao_map)) - vec2<i32>(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<i32>(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<i32>(0), last), 0).x;
|
||||
weight = weight + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec4<f32>(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>) -> 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<i32>(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<f32>(i.wp, 1.0);
|
||||
if (q.w <= 0.0) { return 1.0; }
|
||||
let uv = vec2<f32>(q.x / q.w * 0.5 + 0.5, 0.5 - q.y / q.w * 0.5);
|
||||
return textureLoad(ao_map, clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), size - vec2<i32>(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<f32>(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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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)?,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
+102
-3
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec3> {
|
||||
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<Vec3> {
|
||||
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<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for g in refs {
|
||||
let found: Vec<String> = 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::<str>::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> {
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -101,8 +101,17 @@ pub enum PlaneRef {
|
||||
AxisPoint { axis: Box<GeoRef>, point: Box<GeoRef> },
|
||||
/// Through three points.
|
||||
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
|
||||
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
|
||||
Tangent { face: Box<GeoRef>, 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<GeoRef>,
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reference: Option<Box<GeoRef>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle: Option<Expr>,
|
||||
},
|
||||
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
|
||||
AlongPath { path: Box<GeoRef>, 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<BodyName>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<BodyName>,
|
||||
/// 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<SketchId>,
|
||||
/// 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<ProfileSel>,
|
||||
},
|
||||
/// 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,
|
||||
|
||||
+285
-19
@@ -40,6 +40,11 @@ pub struct ModelBody {
|
||||
joint_faces: Arc<OnceLock<JointFaces>>,
|
||||
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
|
||||
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
|
||||
/// 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<Arc<placed_mesh::PlacedMesh>>,
|
||||
/// The first mirrored copy made of this body, shared by its clones.
|
||||
opposite_hand: Arc<OnceLock<placed_mesh::OppositeHand>>,
|
||||
/// 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<Option<Arc<TessPlan>>>; 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<u64>], 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<String, usize> =
|
||||
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<Vec<Option<solidcraft_geom::Aabb3>>> =
|
||||
(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<crate::BodyName>, 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<Vec3> = 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<String, Value>,
|
||||
f: &Feature,
|
||||
st: &ModelState,
|
||||
copy: bool,
|
||||
constructor: &mut Option<ExtrudeConstructor>,
|
||||
to_body: &mut Option<String>,
|
||||
) -> Result<Vec<Traced>> {
|
||||
@@ -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<String, Value>, 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<Traced>, 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<f64> {
|
||||
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<Vec2> = Vec::new();
|
||||
for r in regions {
|
||||
let ring: Vec<Vec2> = 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<usize> = (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<Vec3> {
|
||||
let m = b.mesh();
|
||||
@@ -7432,7 +7616,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
@@ -7459,7 +7643,23 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let handled = sheet_hole_cut(st, f, &tools, warning)?;
|
||||
let tools: Vec<Traced> = 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<String> = 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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
|
||||
|
||||
@@ -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<Counts> =
|
||||
const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0, measures: 0, body_searches: 0, feature_searches: 0 }) };
|
||||
static COUNTS: Cell<Counts> = 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<String>,
|
||||
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<ThreadInfo>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Aabb3> {
|
||||
pub(super) fn body_box(body: &Body) -> Option<Aabb3> {
|
||||
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<Aabb3> {
|
||||
pub(super) fn mesh_box(mb: &ModelBody) -> Option<Aabb3> {
|
||||
padded_box(&mb.mesh(), mb.display_tol())
|
||||
}
|
||||
|
||||
@@ -1574,8 +1584,25 @@ fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option<Aabb3> {
|
||||
(!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<Aabb3>, b: &Option<Aabb3>) -> bool {
|
||||
pub(super) fn boxes_apart(a: &Option<Aabb3>, b: &Option<Aabb3>) -> 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
|
||||
}
|
||||
|
||||
+69
-1
@@ -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<Value> {
|
||||
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<Value, DocError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -724,6 +724,13 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
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<Document> {
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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<Vec<f64>> {
|
||||
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()?);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<f64>,
|
||||
/// The value in the parameter's own `unit`, as the Parameters window shows it.
|
||||
pub display_value: Option<f64>,
|
||||
pub error: Option<String>,
|
||||
pub comment: String,
|
||||
/// "user", "sketch" (a dimension) or "feature".
|
||||
@@ -367,14 +371,20 @@ impl Document {
|
||||
}
|
||||
}
|
||||
let used_by = |n: &str| -> Vec<String> { 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
|
||||
);
|
||||
|
||||
@@ -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<Option<Arc<TessPlan>>>; 4]>,
|
||||
plan_meshes: Arc<[OnceLock<Option<Arc<Mesh>>>; 4]>,
|
||||
/// The definition's own opposite hand: a copy of a copy is a rigid move of that.
|
||||
opposite_hand: Arc<OnceLock<OppositeHand>>,
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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,
|
||||
|
||||
@@ -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<u64, u64>) -> 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() {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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<Mesh>, Option<u32>)> {
|
||||
/// 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<Mesh>, Option<Vec<u32>>)> {
|
||||
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<u32> = 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<u32>) -> 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<f64> {
|
||||
/// miss the face split the result.
|
||||
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
|
||||
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<u32>, mb: &Mesh, fb: Option<u32>) -> Vec<Vec<Vec3>> {
|
||||
/// 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<Vec<Vec3>> {
|
||||
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.
|
||||
|
||||
+596
-43
@@ -529,6 +529,10 @@ pub struct SheetFlange {
|
||||
/// (flat coordinates, counter-clockwise).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exact: Option<Loop2>,
|
||||
/// 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<Loop2>,
|
||||
/// Which feature made it: `F<feature>.<k>` (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<Vec<Region2>>,
|
||||
}
|
||||
|
||||
#[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<FoldEnds> {
|
||||
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<Vec2> {
|
||||
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<Seg2>) {
|
||||
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<CurvedFold> {
|
||||
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<Seg2>)> = 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<Loop2> {
|
||||
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<Vec<Region2>> {
|
||||
let mut owners: Vec<Option<usize>> = Vec::new();
|
||||
let mut slots: Vec<Option<Region2>> = Vec::new();
|
||||
for (pi, r) in self.panels() {
|
||||
owners.push(pi);
|
||||
slots.push(Some(planar_region(&r)?));
|
||||
}
|
||||
let panel_slot = |pi: Option<usize>| 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<usize> = (0..slots.len()).collect();
|
||||
let free_form = |slots: &[Option<Region2>], 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<Vec<Region2>> {
|
||||
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<Vec<Region2>> {
|
||||
let on = |pi: Option<usize>| {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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<Vec<Vec2>> {
|
||||
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::<Aabb2>());
|
||||
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<f64> = 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::<Vec<_>>()
|
||||
};
|
||||
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::<Vec<_>>(), 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,
|
||||
|
||||
@@ -429,7 +429,8 @@ fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option<Chamfer> {
|
||||
|
||||
/// 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<Face> {
|
||||
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<Vec3> {
|
||||
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<Vec3> {
|
||||
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<Vec3> {
|
||||
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<usize>)> = 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<usize>, 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<f64> {
|
||||
|
||||
/// 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<Vec2>, Vec<Option<usize>>)> {
|
||||
/// 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<Vec2>, Vec<Option<usize>>)> {
|
||||
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<Vec2>, Vec<Option<usize>>)> {
|
||||
}
|
||||
from.push(Some(i));
|
||||
}
|
||||
let moved_by = |from: &[Option<usize>], 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::<Vec<_>>())),
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<_>>(), 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::<Vec<_>>(), ["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).
|
||||
|
||||
+16
-2
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
{
|
||||
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<u64>| {
|
||||
if let Some(at) = path.iter().position(|x| *x == id) {
|
||||
let moved: Vec<u64> = 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 {
|
||||
|
||||
@@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("plane_tangent")
|
||||
.contract(&PLANE_TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.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<Value> {
|
||||
(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<Value> {
|
||||
|
||||
@@ -1330,7 +1330,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
{
|
||||
*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<Value> {
|
||||
})
|
||||
.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) {
|
||||
|
||||
@@ -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<u64> = 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());
|
||||
|
||||
@@ -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<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
/// 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<Value> {
|
||||
let mut features = Vec::new();
|
||||
let mut lists: [(&str, Vec<Value>); 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<Option<Vec<Vec<u64>>>> {
|
||||
let cmd = "solid.move";
|
||||
let Some(list) = p.get("occurrences") else { return Ok(None) };
|
||||
let paths: Vec<Vec<u64>> = list
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|path| path.as_array().and_then(|ids| ids.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>()).filter(|ids| !ids.is_empty()))
|
||||
.collect::<Option<_>>()
|
||||
.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<f64>, Option<f64>)> {
|
||||
let cmd = "solid.move";
|
||||
let length = |v: &Value| -> Result<f64> {
|
||||
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<Vec<u64>>) -> Result<Value> {
|
||||
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<Vec<u64>> = 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<Value> {
|
||||
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<Value> {
|
||||
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<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
|
||||
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<Value> {
|
||||
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,
|
||||
|
||||
@@ -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<Value> {
|
||||
/// 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<Value> {
|
||||
// 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<Value> {
|
||||
};
|
||||
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<Value> {
|
||||
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");
|
||||
|
||||
+136
-14
@@ -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<Value> {
|
||||
j.kind
|
||||
.dofs()
|
||||
@@ -240,8 +241,8 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
.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<u64, Vec<u64>> = 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<Value> {
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
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<u64, Option<usize>> = 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<Vec<(usize, usize)>> = 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<Value> {
|
||||
let cmd = "joint.origin";
|
||||
let origin = origin_param(s, Some(p), cmd)?;
|
||||
@@ -798,13 +927,6 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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::<f64>();
|
||||
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();
|
||||
|
||||
@@ -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<f64> = 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:?}");
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
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<String, Value> = list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| v.v)))).collect();
|
||||
let values: serde_json::Map<String, Value> =
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
.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<Value> {
|
||||
(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<Value> {
|
||||
// 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})"));
|
||||
|
||||
@@ -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<Value> = serde_json::from_str(&json_text).unwrap_or_default();
|
||||
let vals: Vec<f64> = rows.iter().filter_map(|r| r["value"].as_f64()).collect();
|
||||
assert_eq!(vals, vec![30.0, 30.48, 0.3], "{json_text}");
|
||||
}
|
||||
|
||||
+303
-26
@@ -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<f64> {
|
||||
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<solidcraft_doc::TappedThread> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<f64>, 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<f64>, 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<f64>) -> std::result::Result<S
|
||||
"socket_head_cap_screw" => {
|
||||
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<Value> {
|
||||
"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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
|
||||
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<f64> = 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}));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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],
|
||||
|
||||
@@ -56,7 +56,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&PERFORATE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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<f64>) {
|
||||
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::<f64>().abs() / 2.0
|
||||
};
|
||||
let area = f["outline"].as_array().into_iter().flatten().map(ring).sum();
|
||||
let mut bends: Vec<f64> = 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
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::<Vec<_>>());
|
||||
assert!(count(&es, "LINE") < 12, "the arc went to chords: {} lines", count(&es, "LINE"));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<f64>().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)));
|
||||
}
|
||||
|
||||
+125
-14
@@ -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<EditParams> = TypedContract::new();
|
||||
const EXIT_CONTRACT: TypedContract<ExitParams> = 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::<EditResult>)
|
||||
.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::<FinishResult>)
|
||||
.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::<CancelResult>)
|
||||
.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<Value> {
|
||||
let restore_marker = p
|
||||
.get("restore_marker")
|
||||
.map(|v| {
|
||||
serde_json::from_value::<Option<usize>>(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<Value> {
|
||||
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<Value> {
|
||||
|
||||
/// 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<bool> {
|
||||
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<Value> {
|
||||
fn cancel_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
fn finish_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize>) {
|
||||
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<Value> {
|
||||
/// fillet run in a scratch session that replaces the design only when both succeed.
|
||||
fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let (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<Vec<String>> {
|
||||
let Some(curve) = sk.curves.get(ci) else { return Ok(Vec::new()) };
|
||||
if curve.infinite || sk.is_linked_curve(ci) || !matches!(curve.kind, CurveKind::Line { .. }) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let id = curve.id.clone();
|
||||
let affected: Vec<usize> = 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<String> = 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<f64> = 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<usize> = 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<Value> {
|
||||
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::<Vec<_>>()));
|
||||
@@ -598,6 +769,9 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<usize>)> = 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::<Vec<_>>().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<f64>, Vec<Option<usize>>) = 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 {
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
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::<Vec<_>>());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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,
|
||||
|
||||
@@ -315,7 +315,7 @@ fn render_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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<String> = 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:?}");
|
||||
}
|
||||
|
||||
@@ -57,12 +57,17 @@ pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListene
|
||||
|
||||
/// The folder of the token files: `<data folder>/control` (`SOLIDCRAFT_CONTROL_DIR` replaces it).
|
||||
fn control_dir() -> Option<PathBuf> {
|
||||
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<std::ffi::OsString> {
|
||||
solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
|
||||
/// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder).
|
||||
pub fn token_path(port: u16) -> Option<PathBuf> {
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -341,6 +341,9 @@ thread_local! {
|
||||
static DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
/// The outermost command's coordinates were mapped here (its table lists them).
|
||||
static MAPPED: std::cell::Cell<bool> = 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<bool> = 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<Vec<(Vec3, Vec<String>)>> = 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<Value> {
|
||||
// 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<Vec<Value>> {
|
||||
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<Value>)> = 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<Value> {
|
||||
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<Value> {
|
||||
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"
|
||||
|
||||
@@ -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<Vec3>| {
|
||||
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]
|
||||
|
||||
@@ -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"), "");
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -297,6 +297,10 @@ pub(crate) struct SketchEntry {
|
||||
pub(crate) doc: Arc<Document>,
|
||||
pub(crate) undo_len: usize,
|
||||
pub(crate) undo_top: Option<Arc<Document>>,
|
||||
/// A temporary editor rollback, restored by the engine even without a window.
|
||||
pub(crate) restore_marker: Option<Option<usize>>,
|
||||
pub(crate) pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
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<String>,
|
||||
/// 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<Option<usize>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Point3>,
|
||||
/// 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<GeoRefParam>,
|
||||
/// 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<AngleExpr>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: PlaneCommon,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<PlaneAxisPointParams>(), &["axis", "name", "participants", "point"]);
|
||||
assert_keys(input_schema_for::<PlaneTwoEdgesParams>(), &["a", "b", "name", "participants"]);
|
||||
assert_keys(input_schema_for::<PlaneThreePointsParams>(), &["a", "b", "c", "name", "participants"]);
|
||||
assert_keys(input_schema_for::<PlaneTangentParams>(), &["at", "face", "name", "participants"]);
|
||||
assert_keys(input_schema_for::<PlaneTangentParams>(), &["angle", "at", "face", "name", "participants", "reference"]);
|
||||
assert_keys(input_schema_for::<PlaneAlongPathParams>(), &["name", "participants", "path", "t"]);
|
||||
assert_keys(input_schema_for::<PlanePerpendicularParams>(), &["line", "name", "participants", "plane"]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LengthExpr>,
|
||||
/// 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<String>,
|
||||
/// 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<IdOrName>,
|
||||
/// 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<ProfilesParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user