mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c07ab5f8f9 | |||
| 728f753b80 | |||
| f22a026c27 | |||
| 4b37c046e4 | |||
| 54072b7014 | |||
| cb3cabeda1 | |||
| 775e5155bb | |||
| e570c0b4eb | |||
| d9059d8da5 | |||
| 463cea0615 | |||
| 0225e4d2a5 | |||
| fea32f8aac | |||
| 034aa60d47 | |||
| 971b223246 | |||
| 8c861f9f46 | |||
| e04a016e7c | |||
| 93a2922c5e | |||
| ff8b09c225 | |||
| 975a08c992 | |||
| 55587f4ac4 | |||
| e94900933d | |||
| 776541b081 | |||
| 79a843c963 | |||
| 1bcc3602b4 | |||
| 93684a4728 | |||
| 9db39f3d7d | |||
| 29369a0bb5 | |||
| 5a9e771cc9 | |||
| 63cb48a4ee | |||
| 36c732ddb7 | |||
| fdec948abc | |||
| 7803b3968f | |||
| cbac4eab3f | |||
| 08eff4f9dd | |||
| 9b80163421 | |||
| 164554edba | |||
| 453c6bcb14 | |||
| f44ff18ca4 | |||
| 98cd67f029 | |||
| d9d6af51c6 | |||
| 38c710e72e | |||
| 5b5b051e02 | |||
| 686576e368 | |||
| 68e4186080 | |||
| b66ad0a9c9 | |||
| 2eacbc37f4 | |||
| f2f9c6fe27 | |||
| d21e2b1afd |
@@ -290,8 +290,8 @@ jobs:
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
|
||||
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
|
||||
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: read # to list a PR's changed paths
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
paused:
|
||||
name: Kernel paused bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/kernel-paused
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const paused = require('./scripts/kernel-paused/kernel-paused.js');
|
||||
const done = context.eventName === 'discussion'
|
||||
? await paused.handleDiscussion({ github, context })
|
||||
: await paused.handleOpened({ github, context });
|
||||
core.info(done);
|
||||
@@ -15,6 +15,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work is paused
|
||||
|
||||
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
|
||||
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
|
||||
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
|
||||
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
|
||||
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
|
||||
are still taken as issues, and everything outside the kernel carries on as normal.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
|
||||
Generated
+18
-17
@@ -4162,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4193,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
@@ -4208,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4217,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4262,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4302,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4322,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4331,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4345,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4356,14 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5920,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.2"
|
||||
version = "0.18.4"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -23,6 +23,7 @@ path = "src/alias.rs"
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
|
||||
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
|
||||
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
|
||||
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
|
||||
//! sets one level for every crate.
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
struct Stderr {
|
||||
/// The level for SolidCraft's crates.
|
||||
ours: LevelFilter,
|
||||
/// The level for every other crate.
|
||||
others: LevelFilter,
|
||||
}
|
||||
|
||||
impl Log for Stderr {
|
||||
fn enabled(&self, m: &Metadata) -> bool {
|
||||
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
|
||||
}
|
||||
|
||||
fn log(&self, r: &Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
use std::io::Write as _;
|
||||
// A closed stderr is no reason to stop.
|
||||
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
|
||||
/// logger when it or `RUST_LOG` asks for one.
|
||||
pub fn init(args: &mut Vec<String>) {
|
||||
let before = args.len();
|
||||
args.retain(|a| a != "--debug");
|
||||
let debug = args.len() != before;
|
||||
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
|
||||
let (ours, others) = match (debug, env) {
|
||||
(false, Some(all)) => (all, all),
|
||||
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
|
||||
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
|
||||
(false, None) => return,
|
||||
};
|
||||
// Installed once, for the life of the process.
|
||||
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
|
||||
log::set_max_level(ours.max(others));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//!
|
||||
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -24,6 +26,7 @@
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
@@ -82,10 +85,13 @@ const USAGE: &str = "usage:
|
||||
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
|
||||
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
|
||||
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
logger::init(&mut args);
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
|
||||
@@ -52,6 +52,7 @@ raw-window-handle = "0.6"
|
||||
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
@@ -178,11 +179,137 @@ struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS);
|
||||
/// Every request the app has been sent and not answered yet, from every connection, for
|
||||
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
|
||||
/// a running request.
|
||||
struct Running {
|
||||
list: Mutex<Vec<InFlight>>,
|
||||
connections: AtomicU64,
|
||||
keys: AtomicU64,
|
||||
}
|
||||
|
||||
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
|
||||
/// What `control.running` says about a request: never its parameters beyond the command id.
|
||||
struct InFlight {
|
||||
key: u64,
|
||||
connection: u64,
|
||||
id: Value,
|
||||
method: String,
|
||||
command: Option<&'static str>,
|
||||
steps: Option<usize>,
|
||||
since: Instant,
|
||||
started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The app's requests, from both of its listeners.
|
||||
static RUNNING: Running = Running::new();
|
||||
|
||||
impl Running {
|
||||
const fn new() -> Self {
|
||||
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
|
||||
self.list.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped.
|
||||
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
Value::Number(_) => id.clone(),
|
||||
Value::String(s) if s.len() <= 64 => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let p = &req.params;
|
||||
let command = match req.method.as_str() {
|
||||
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
|
||||
_ => None,
|
||||
};
|
||||
let steps = match req.method.as_str() {
|
||||
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
|
||||
_ => None,
|
||||
};
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Ticket { running: self, key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
fn result(&self, asker: u64) -> Value {
|
||||
let rows: Vec<Value> = self
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut r = json!({
|
||||
"connection": e.connection,
|
||||
"id": e.id,
|
||||
"method": e.method,
|
||||
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
|
||||
"queued": !e.started.load(Ordering::Relaxed),
|
||||
});
|
||||
if let Some(c) = e.command {
|
||||
r["command"] = json!(c);
|
||||
}
|
||||
if let Some(n) = e.steps {
|
||||
r["steps"] = json!(n);
|
||||
}
|
||||
if e.connection == asker {
|
||||
r["self"] = json!(true);
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
json!({"running": rows})
|
||||
}
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Ticket {
|
||||
running: &'static Running,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Ticket {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
// The reply is passed on before the request leaves the list: once `control.running` no longer
|
||||
// shows it, its connection's `control.pending` has it.
|
||||
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
|
||||
if let Ok(v) = reply.recv() {
|
||||
let _ = late_tx.send(v);
|
||||
}
|
||||
drop(late_tx);
|
||||
drop(ticket);
|
||||
});
|
||||
late_rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
|
||||
}
|
||||
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
) {
|
||||
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);
|
||||
@@ -216,13 +343,16 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A request that timed out may have finished since (#1618).
|
||||
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
|
||||
// A request that timed out may have finished since (#1618). `control.running` only
|
||||
// looks, so it leaves that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.pending" {
|
||||
let mut r = if method == "control.running" {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
|
||||
@@ -243,6 +373,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let ticket = running.add(connection, &id, &req);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -251,7 +382,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: rrx });
|
||||
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -307,9 +438,10 @@ mod tests {
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
|
||||
static RUNNING: Running = Running::new();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits);
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
@@ -349,6 +481,102 @@ mod tests {
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #3654: `control.running` answers on every connection, at once, with what any connection
|
||||
/// has the app running or queued (command ids, never parameters); a timed-out request stays
|
||||
/// listed until the app answers it, and an idle app lists nothing.
|
||||
#[test]
|
||||
fn every_connection_sees_what_the_app_is_running() {
|
||||
static RUNNING: Running = Running::new();
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The window: one request at a time, as `drain_control` takes them; the export is held
|
||||
// until the test lets it go.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
req.started.store(true, Ordering::Relaxed);
|
||||
if req.method == "engine.execute" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
|
||||
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));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
move |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
}
|
||||
};
|
||||
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
|
||||
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
|
||||
if until(&rows) || Instant::now() > deadline {
|
||||
return (r, rows);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
let mut watcher = open();
|
||||
let (r, rows) = running(&mut watcher, &|_| true);
|
||||
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
|
||||
|
||||
let mut export = open();
|
||||
let path = "/private/part.step";
|
||||
let params = json!({"command": "file.export", "params": {"path": path}});
|
||||
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
// A batch from another connection waits behind the export.
|
||||
let mut batch = open();
|
||||
let queued = std::thread::spawn(move || {
|
||||
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
|
||||
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
|
||||
});
|
||||
// The window may not have taken the export yet, even though it timed out.
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
|
||||
assert_eq!(rows.len(), 2, "{r}");
|
||||
let (e, b) = (&rows[0], &rows[1]);
|
||||
assert_eq!(
|
||||
(&e["id"], &e["method"], &e["command"], &e["queued"]),
|
||||
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
|
||||
"{r}"
|
||||
);
|
||||
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
|
||||
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
|
||||
assert_ne!(e["connection"], b["connection"], "{r}");
|
||||
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
|
||||
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
|
||||
// The connection whose request timed out may ask too, and sees its own marked.
|
||||
let r = export(json!({"id": 8, "method": "control.running"}));
|
||||
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
|
||||
assert_eq!(own.len(), 1, "{r}");
|
||||
assert_eq!(own[0]["command"], "file.export", "{r}");
|
||||
|
||||
gate_tx.send(()).unwrap();
|
||||
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
|
||||
assert!(rows.is_empty(), "done: {r}");
|
||||
// Asking `control.running` did not take the export's reply from its connection.
|
||||
let r = export(json!({"id": 9, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
|
||||
}
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
|
||||
@@ -28,16 +28,54 @@ mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod window_fit;
|
||||
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
/// Windows: put the restored window back inside the monitor's work area if it is out of reach
|
||||
/// (a gone monitor, another scale, a taskbar edge). Sizes are in points, the work area in pixels.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn fit_window_to_work_area(ctx: &egui::Context, frame: &eframe::Frame) {
|
||||
use window_fit::Area;
|
||||
let Some((x, y, w, h)) = native_frame::work_area(frame) else { return };
|
||||
let (outer, inner, scale, maximized) = ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
(v.outer_rect, v.inner_rect, v.native_pixels_per_point, v.maximized.unwrap_or(false) || v.fullscreen.unwrap_or(false))
|
||||
});
|
||||
let (Some(outer), Some(inner), Some(scale)) = (outer, inner, scale.filter(|s| s.is_finite() && *s > 0.0)) else { return };
|
||||
if maximized {
|
||||
return;
|
||||
}
|
||||
let work = Area::new(x as f32 / scale, y as f32 / scale, w as f32 / scale, h as f32 / scale);
|
||||
let now = Area::new(outer.min.x, outer.min.y, outer.width(), outer.height());
|
||||
let fitted = window_fit::fit(now, work, (960.0, 600.0));
|
||||
if fitted != now {
|
||||
// The frame (title bar, borders) keeps its size; only the inner size follows.
|
||||
let inner_size = egui::vec2((inner.width() - (now.w - fitted.w)).max(1.0), (inner.height() - (now.h - fitted.h)).max(1.0));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner_size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(fitted.x, fitted.y)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `SOLIDCRAFT_PROFILE_STARTUP=1` prints the time since launch at each startup step.
|
||||
fn mark(step: &str) {
|
||||
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
|
||||
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
|
||||
if *on {
|
||||
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
/// The settings folder whose start marker the first frame clears (`startup::started`).
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
/// Started over the control channel: the window doesn't ask for the foreground.
|
||||
take_focus_unwanted: bool,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
@@ -46,9 +84,23 @@ impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.frames < 2 {
|
||||
self.frames += 1;
|
||||
// Before the window is shown: its title bar must be within reach (window_fit.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
if self.frames == 1 {
|
||||
fit_window_to_work_area(ctx, _frame);
|
||||
}
|
||||
mark(if self.frames == 1 { "first frame begins" } else { "second frame begins (first presented)" });
|
||||
// A second frame right away, which means the first was presented: the app is up.
|
||||
ctx.request_repaint();
|
||||
if self.frames == 2 {
|
||||
// The window is shown only once its first frame is presented, and Windows then
|
||||
// keeps a window of a process started from a shortcut or an installer behind the
|
||||
// others. Ask for the foreground once, now that it is on screen; never again, so
|
||||
// later frames don't take the focus from whatever the user moved to. Automation
|
||||
// over the control channel keeps the focus where it is.
|
||||
if !self.take_focus_unwanted {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
@@ -477,6 +529,7 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
mark("main");
|
||||
adopt_legacy_settings();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
@@ -587,13 +640,17 @@ fn main() -> eframe::Result {
|
||||
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_app = created.clone();
|
||||
let starting = settings.clone();
|
||||
mark("run_native");
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
solidcraft_engine::crash::phase("app setup");
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
mark("window and device ready");
|
||||
solidcraft_ui_egui::fonts::setup_bundled_fonts(&cc.egui_ctx);
|
||||
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
|
||||
mark("fonts");
|
||||
let mut app = SolveApp::new(Session::default(), services(cc));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
@@ -611,6 +668,7 @@ fn main() -> eframe::Result {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
mark("app state and prefs");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
@@ -628,6 +686,7 @@ fn main() -> eframe::Result {
|
||||
info.backend, info.name, info.driver, rs.msaa_samples
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -680,11 +739,13 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
mark("app created");
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
take_focus_unwanted: control_port.is_some(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
|
||||
@@ -26,6 +26,7 @@ use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use solidcraft_ui_egui::titlebar;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
|
||||
use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
|
||||
@@ -33,6 +34,26 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
|
||||
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
|
||||
/// pixels: left, top, width, height.
|
||||
pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)> {
|
||||
let handle = window.window_handle().ok()?;
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return None };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
// SAFETY: `hwnd` is the live main window; `info` is a MONITORINFO with its size set, as
|
||||
// GetMonitorInfoW requires, and the monitor handle comes from the call before it.
|
||||
let rc = unsafe {
|
||||
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
let mut info: MONITORINFO = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if monitor.is_null() || GetMonitorInfoW(monitor, &mut info) == 0 {
|
||||
return None;
|
||||
}
|
||||
info.rcWork
|
||||
};
|
||||
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
|
||||
}
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ mod hidraw;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spacenavd;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -19,9 +21,22 @@ use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_space_mouse::Layout;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
#[cfg(target_os = "linux")]
|
||||
use solidcraft_ui_egui::prefs::SpaceMouseDriver;
|
||||
#[cfg(target_os = "linux")]
|
||||
use solidcraft_ui_egui::space_mouse::DriversPresent;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// How long a held (spacenavd) reading that is nearly at rest may go without a new record before
|
||||
/// it reads as rest: spacenavd sends no final record when the cap springs back, only the last
|
||||
/// small leftover. Longer than `STALE_AFTER` so a light, steady hold gets a moment before it ends.
|
||||
const HELD_SETTLE_AFTER: Duration = Duration::from_millis(500);
|
||||
/// How far from rest (raw units, full deflection is 350) every axis of a held reading must be for
|
||||
/// it to settle. The dead zone is 20, but the last records of real releases reach 129 on one axis
|
||||
/// (tangency-labs/solidcraft#3523), so this sits well above it. A firmer push is a deliberate
|
||||
/// hold and stays.
|
||||
const HELD_SETTLE_BAND: u16 = 150;
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
/// the HID device would fail or report it twice.
|
||||
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -199,9 +214,11 @@ struct Reading {
|
||||
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
|
||||
/// translation and rotation in separate reports, or spread even one group over several.
|
||||
at: [Instant; 6],
|
||||
/// The source says when the cap is back at rest, so a reading stays until the next one.
|
||||
/// A HID device repeats its report while the cap is held; spacenavd sends a record only when
|
||||
/// the values change, and a steady hold can go seconds without one.
|
||||
/// The source sends a record only when the values change, so a reading stays until the next
|
||||
/// one: a steady hold can go seconds without one. It never says when the cap springs back,
|
||||
/// though, so a reading within `HELD_SETTLE_BAND` of rest on every axis settles after
|
||||
/// `HELD_SETTLE_AFTER`. A HID device repeats its report while the cap is held, so it ages out
|
||||
/// per axis instead.
|
||||
held: bool,
|
||||
}
|
||||
|
||||
@@ -214,7 +231,14 @@ impl Reading {
|
||||
/// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting.
|
||||
fn current(self, now: Instant, rest: Axes) -> Axes {
|
||||
let mut axes = self.axes;
|
||||
if !self.held {
|
||||
if self.held {
|
||||
let values = axes.translation.into_iter().chain(axes.rotation);
|
||||
let rests = rest.translation.into_iter().chain(rest.rotation);
|
||||
let near_rest = values.zip(rests).all(|(value, rest)| value.abs_diff(rest) <= HELD_SETTLE_BAND);
|
||||
if near_rest && now.duration_since(self.at[0]) >= HELD_SETTLE_AFTER {
|
||||
return rest;
|
||||
}
|
||||
} else {
|
||||
let rest = rest.translation.into_iter().chain(rest.rotation);
|
||||
for ((value, at), rest) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at).zip(rest) {
|
||||
if now.duration_since(at) >= STALE_AFTER {
|
||||
@@ -359,39 +383,130 @@ fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted
|
||||
/// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it reads
|
||||
/// `/dev/hidrawN` with plain blocking reads, which end with an error when the device is unplugged.
|
||||
/// Opening needs permission: most distributions grant it to the logged-in user for devices that
|
||||
/// carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
/// What the user chose for the reader and what the reader found, shared with the egui thread.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
#[derive(Default)]
|
||||
struct Drivers {
|
||||
/// `SpaceMouseDriver::to_u8` of the preference.
|
||||
choice: AtomicU8,
|
||||
/// `PRESENT_SPACENAVD` and `PRESENT_DIRECT`, as of the reader's last look.
|
||||
found: AtomicU8,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PRESENT_SPACENAVD: u8 = 1;
|
||||
#[cfg(target_os = "linux")]
|
||||
const PRESENT_DIRECT: u8 = 2;
|
||||
/// How often a connected reader looks again at what is there, and how long a read of spacenavd
|
||||
/// waits for a record before it checks the choice.
|
||||
#[cfg(target_os = "linux")]
|
||||
const LOOK_EVERY: Duration = Duration::from_secs(1);
|
||||
#[cfg(target_os = "linux")]
|
||||
const SPACENAVD_WAKE: Duration = Duration::from_millis(250);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drivers {
|
||||
fn choice(&self) -> SpaceMouseDriver {
|
||||
SpaceMouseDriver::from_u8(self.choice.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn present(&self) -> DriversPresent {
|
||||
let found = self.found.load(Ordering::Relaxed);
|
||||
DriversPresent { spacenavd: found & PRESENT_SPACENAVD != 0, direct: found & PRESENT_DIRECT != 0 }
|
||||
}
|
||||
|
||||
fn publish(&self, present: DriversPresent) {
|
||||
let found = (u8::from(present.spacenavd) * PRESENT_SPACENAVD) | (u8::from(present.direct) * PRESENT_DIRECT);
|
||||
self.found.store(found, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader that was picked.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Pick {
|
||||
Spacenavd,
|
||||
Direct,
|
||||
}
|
||||
|
||||
/// Which reader to use: the one asked for when it is there, else the other, else none. Automatic
|
||||
/// asks for spacenavd, which is what has always been read first.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn choose(wanted: SpaceMouseDriver, spacenavd: bool, direct: bool) -> Option<Pick> {
|
||||
let first = match wanted {
|
||||
SpaceMouseDriver::Automatic | SpaceMouseDriver::Spacenavd => Pick::Spacenavd,
|
||||
SpaceMouseDriver::Direct => Pick::Direct,
|
||||
};
|
||||
let there = |pick| match pick {
|
||||
Pick::Spacenavd => spacenavd,
|
||||
Pick::Direct => direct,
|
||||
};
|
||||
let other = if first == Pick::Spacenavd { Pick::Direct } else { Pick::Spacenavd };
|
||||
[first, other].into_iter().find(|&pick| there(pick))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse through spacenavd or `/dev/hidrawN`, as `Drivers::choice` says (Automatic
|
||||
/// is spacenavd when it runs, so what `spnavcfg` sets, swapped and inverted axes, sensitivity,
|
||||
/// dead zones, applies here as in other programs). Both reads wake up when idle to see whether
|
||||
/// the choice changed, which then moves to the other reader without a restart. hidraw is opened
|
||||
/// non-blocking and ends with an error when the device is unplugged. Opening needs permission:
|
||||
/// most distributions grant it to the logged-in user for devices that carry the `uaccess` udev
|
||||
/// tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, drivers: Arc<Drivers>) {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let look = || DriversPresent { spacenavd: spacenavd::exists(), direct: hidraw::find().is_some() };
|
||||
let mut denied = None;
|
||||
let mut told = None;
|
||||
loop {
|
||||
if let Some(daemon) = spacenavd::connect() {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
connected(&ctx, &shared, Source::Spacenavd);
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
|
||||
reset(&ctx, &shared);
|
||||
} else if let Some(device) = hidraw::find() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
let device = hidraw::find();
|
||||
let present = DriversPresent { spacenavd: spacenavd::exists(), direct: device.is_some() };
|
||||
drivers.publish(present);
|
||||
let wanted = drivers.choice();
|
||||
let mut pick = choose(wanted, present.spacenavd, present.direct);
|
||||
// A socket file with no daemon behind it is no spacenavd.
|
||||
let daemon = if pick == Some(Pick::Spacenavd) { spacenavd::connect() } else { None };
|
||||
if pick == Some(Pick::Spacenavd) && daemon.is_none() {
|
||||
pick = choose(wanted, false, present.direct);
|
||||
}
|
||||
let asked_for = match wanted {
|
||||
SpaceMouseDriver::Automatic => None,
|
||||
SpaceMouseDriver::Spacenavd => Some(Pick::Spacenavd),
|
||||
SpaceMouseDriver::Direct => Some(Pick::Direct),
|
||||
};
|
||||
if let (Some(asked), Some(pick)) = (asked_for, pick)
|
||||
&& asked != pick
|
||||
&& told != Some((wanted, pick))
|
||||
{
|
||||
log::info!("3D mouse: {asked:?} is not available, reading {pick:?} instead");
|
||||
told = Some((wanted, pick));
|
||||
}
|
||||
// Asked at every idle moment of a read: has the choice changed, and what is there now.
|
||||
let mut looked = Instant::now();
|
||||
let mut stop = || {
|
||||
if looked.elapsed() >= LOOK_EVERY {
|
||||
looked = Instant::now();
|
||||
drivers.publish(look());
|
||||
}
|
||||
drivers.choice() != wanted
|
||||
};
|
||||
match (pick, daemon, device) {
|
||||
(Some(Pick::Spacenavd), Some(daemon), _) => {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
if let Err(error) = daemon.set_read_timeout(Some(SPACENAVD_WAKE)) {
|
||||
log::warn!("3D mouse: spacenavd reads cannot time out ({error}); a change of driver waits for the next event");
|
||||
}
|
||||
connected(&ctx, &shared, Source::Spacenavd);
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes), &mut stop);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
(Some(Pick::Direct), _, Some(device)) => match std::fs::OpenOptions::new().read(true).custom_flags(hidraw::O_NONBLOCK).open(&device.path)
|
||||
{
|
||||
Ok(file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {}", device.path.display());
|
||||
connected(&ctx, &shared, Source::Hid(device.ids));
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
hidraw::read_reports(file, |report| publish(&ctx, &shared, device.layout, report), &mut stop);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
@@ -400,9 +515,13 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
// A new choice is taken up at once; anything else waits to try again.
|
||||
if drivers.choice() == wanted {
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +564,8 @@ fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc<Drivers>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
@@ -462,13 +583,25 @@ impl SpaceMouse {
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
let worker_ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let drivers = Arc::new(Drivers::default());
|
||||
#[cfg(target_os = "linux")]
|
||||
let worker_drivers = Arc::clone(&drivers);
|
||||
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
|
||||
#[cfg(target_os = "linux")]
|
||||
reader(worker_ctx, worker_state, worker_drivers);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
reader(worker_ctx, worker_state);
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
start_driver_client(ctx, &shared);
|
||||
Self {
|
||||
shared,
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers,
|
||||
last_frame: now,
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
|
||||
mapping_checked_at: now,
|
||||
@@ -516,6 +649,11 @@ impl SpaceMouse {
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.drivers.choice.store(app.preferences.space_mouse_driver.to_u8(), Ordering::Relaxed);
|
||||
app.space_mouse.drivers = Some(self.drivers.present());
|
||||
}
|
||||
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
|
||||
app.space_mouse.device = reading.device.map(Source::key);
|
||||
let calibration = calibration(app);
|
||||
@@ -529,11 +667,108 @@ impl SpaceMouse {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream that answers reads from a script, for the timeout paths.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod fake {
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Error, ErrorKind, Read, Result};
|
||||
|
||||
enum Step {
|
||||
Data(Vec<u8>),
|
||||
Fail(ErrorKind),
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Script(VecDeque<Step>);
|
||||
|
||||
impl Script {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn data(&mut self, bytes: &[u8]) -> &mut Self {
|
||||
self.0.push_back(Step::Data(bytes.to_vec()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn timeout(&mut self, kind: ErrorKind) -> &mut Self {
|
||||
self.fail(kind)
|
||||
}
|
||||
|
||||
pub fn fail(&mut self, kind: ErrorKind) -> &mut Self {
|
||||
self.0.push_back(Step::Fail(kind));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn end(&mut self) -> &mut Self {
|
||||
self.0.push_back(Step::End);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the script's last step the stream has ended.
|
||||
impl Read for Script {
|
||||
fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
|
||||
match self.0.pop_front() {
|
||||
Some(Step::Data(bytes)) => {
|
||||
let size = bytes.len().min(buf.len());
|
||||
buf[..size].copy_from_slice(&bytes[..size]);
|
||||
Ok(size)
|
||||
}
|
||||
Some(Step::Fail(kind)) => Err(Error::from(kind)),
|
||||
Some(Step::End) | None => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_reader_asked_for_is_used_when_it_is_there_and_the_other_when_it_is_not() {
|
||||
use Pick::{Direct, Spacenavd};
|
||||
use SpaceMouseDriver::{Automatic, Direct as AskDirect, Spacenavd as AskSpacenavd};
|
||||
// (asked, spacenavd there, device there) and what is read.
|
||||
let cases = [
|
||||
(Automatic, true, true, Some(Spacenavd)),
|
||||
(Automatic, true, false, Some(Spacenavd)),
|
||||
(Automatic, false, true, Some(Direct)),
|
||||
(Automatic, false, false, None),
|
||||
(AskSpacenavd, true, true, Some(Spacenavd)),
|
||||
(AskSpacenavd, true, false, Some(Spacenavd)),
|
||||
(AskSpacenavd, false, true, Some(Direct)),
|
||||
(AskSpacenavd, false, false, None),
|
||||
(AskDirect, true, true, Some(Direct)),
|
||||
(AskDirect, true, false, Some(Spacenavd)),
|
||||
(AskDirect, false, true, Some(Direct)),
|
||||
(AskDirect, false, false, None),
|
||||
];
|
||||
for (asked, spacenavd, direct, expected) in cases {
|
||||
assert_eq!(choose(asked, spacenavd, direct), expected, "{asked:?}, spacenavd {spacenavd}, device {direct}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn what_the_reader_found_and_the_choice_pass_between_threads() {
|
||||
let drivers = Drivers::default();
|
||||
assert_eq!(drivers.present(), DriversPresent::default());
|
||||
for (spacenavd, direct) in [(true, false), (false, true), (true, true), (false, false)] {
|
||||
drivers.publish(DriversPresent { spacenavd, direct });
|
||||
assert_eq!(drivers.present(), DriversPresent { spacenavd, direct });
|
||||
}
|
||||
assert_eq!(drivers.choice(), SpaceMouseDriver::Automatic);
|
||||
for choice in SpaceMouseDriver::ALL {
|
||||
drivers.choice.store(choice.to_u8(), Ordering::Relaxed);
|
||||
assert_eq!(drivers.choice(), choice);
|
||||
}
|
||||
}
|
||||
|
||||
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
|
||||
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
|
||||
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
|
||||
@@ -625,6 +860,55 @@ mod tests {
|
||||
assert_eq!(hid.current(later, Axes::default()), Axes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_reading_near_rest_settles_after_the_timeout() {
|
||||
// The last records of seven releases from tangency-labs/solidcraft#3523: spacenavd sends
|
||||
// nothing after them.
|
||||
let leftovers = [
|
||||
[15, -60, 13, 59, 34, 129],
|
||||
[-12, -69, 4, -26, 7, -6],
|
||||
[0, -45, -3, 16, 8, -60],
|
||||
[-31, 27, 22, -5, 6, -10],
|
||||
[0, -30, -3, 3, -6, 0],
|
||||
[5, 32, -2, 43, -8, 12],
|
||||
[3, 2, -22, 3, 6, 5],
|
||||
];
|
||||
let now = Instant::now();
|
||||
for [x, y, z, rx, ry, rz] in leftovers {
|
||||
let axes = Axes { translation: [x, y, z], rotation: [rx, ry, rz] };
|
||||
let held = Reading { axes, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER - Duration::from_millis(1), Axes::default()), axes);
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER, Axes::default()), Axes::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settled_held_reading_reads_as_the_devices_own_rest() {
|
||||
let now = Instant::now();
|
||||
let rest = Axes { translation: [10, -8, 0], rotation: [0, 5, 0] };
|
||||
// 40 from zero on X, but only 30 from this device's rest.
|
||||
let held = Reading { axes: Axes { translation: [40, -8, 0], rotation: [0, 5, 0] }, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(now + HELD_SETTLE_AFTER, rest), rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_firm_held_push_stays_however_long_it_lasts() {
|
||||
let now = Instant::now();
|
||||
let later = now + Duration::from_secs(10);
|
||||
let push = Axes { translation: [0, 350, 0], rotation: [0; 3] };
|
||||
let held = Reading { axes: push, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later, Axes::default()), push);
|
||||
// One firm axis keeps the small ones with it.
|
||||
let mixed = Axes { translation: [5, 300, -10], rotation: [3, 0, 20] };
|
||||
let held = Reading { axes: mixed, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later, Axes::default()), mixed);
|
||||
// The band is per reading, edge included: at it is a leftover, one past it is a hold.
|
||||
let band = HELD_SETTLE_BAND as i16;
|
||||
let twist = |value| Reading { axes: Axes { translation: [0; 3], rotation: [0, 0, value] }, held: true, ..Reading::new(now) };
|
||||
assert_eq!(twist(band).current(later, Axes::default()), Axes::default());
|
||||
assert_eq!(twist(band + 1).current(later, Axes::default()).rotation, [0, 0, band + 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
@@ -689,6 +973,8 @@ mod tests {
|
||||
reading.axes.rotation = [0, 350, 0];
|
||||
let mut mouse = SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc::default(),
|
||||
last_frame: now - Duration::from_millis(20),
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
@@ -717,6 +1003,8 @@ mod tests {
|
||||
fn mouse(reading: Reading, now: Instant) -> SpaceMouse {
|
||||
SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
#[cfg(target_os = "linux")]
|
||||
drivers: Arc::default(),
|
||||
last_frame: now,
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
|
||||
//! file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use solidcraft_space_mouse::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
/// `O_NONBLOCK` on Linux's common architectures (x86, ARM, RISC-V). The device is opened with it so
|
||||
/// a read with nothing to read returns at once and the reader can see whether it should stop.
|
||||
pub const O_NONBLOCK: i32 = 0o4000;
|
||||
|
||||
/// How long the reader waits when the device has nothing to say, which is also the most a report
|
||||
/// can be delayed.
|
||||
const IDLE_POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
@@ -23,6 +33,30 @@ pub fn find() -> Option<Device> {
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
/// Hands every report to `publish` until the device is gone (end of file or an error) or `stop`
|
||||
/// says so. `source` is non-blocking: when it has nothing, `stop` is asked and the reader waits
|
||||
/// [`IDLE_POLL`]. Reports are read and handed over exactly as they were with blocking reads.
|
||||
pub fn read_reports(mut source: impl Read, mut publish: impl FnMut(&[u8]), mut stop: impl FnMut() -> bool) {
|
||||
let mut report = [0_u8; super::REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match source.read(&mut report) {
|
||||
Ok(0) => return,
|
||||
Ok(size) => publish(report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(IDLE_POLL);
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
// A controller that declares no motion axis (buttons only, say) isn't read.
|
||||
@@ -42,6 +76,35 @@ fn ids(uevent: &str) -> Option<(u16, u16)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let mut script = Script::new();
|
||||
script.timeout(std::io::ErrorKind::WouldBlock).data(&[1, 2, 3]).timeout(std::io::ErrorKind::WouldBlock).data(&[4]).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read_reports(
|
||||
script,
|
||||
|report| seen.push(report.to_vec()),
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
);
|
||||
assert_eq!(seen, [vec![1, 2, 3], vec![4]]);
|
||||
assert_eq!(asked, 2);
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).timeout(std::io::ErrorKind::WouldBlock).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || true);
|
||||
assert_eq!(seen, [vec![1]], "a stop at an idle moment ends the read");
|
||||
// An unplugged device is an error, which ends the read as before.
|
||||
let mut script = Script::new();
|
||||
script.data(&[1]).fail(std::io::ErrorKind::BrokenPipe).data(&[2]);
|
||||
let mut seen = Vec::new();
|
||||
read_reports(script, |report| seen.push(report.to_vec()), || false);
|
||||
assert_eq!(seen, [vec![1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_come_from_the_uevent() {
|
||||
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
|
||||
|
||||
@@ -23,6 +23,11 @@ const RECORD_BYTES: usize = 32;
|
||||
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
|
||||
const DAEMON_FULL_SCALE: i64 = 500;
|
||||
|
||||
/// Whether the daemon's socket is there, without connecting to it.
|
||||
pub fn exists() -> bool {
|
||||
SOCKETS.iter().any(|path| Path::new(path).exists())
|
||||
}
|
||||
|
||||
pub fn connect() -> Option<UnixStream> {
|
||||
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
|
||||
}
|
||||
@@ -56,18 +61,34 @@ fn unit(value: impl Into<i64>) -> i16 {
|
||||
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Hands every motion record to `publish` until the daemon closes the connection or fails. A
|
||||
/// partial record at the end is dropped.
|
||||
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes)) {
|
||||
/// Hands every motion record to `publish` until the daemon closes the connection or fails, or
|
||||
/// `stop` says so. A stream with a read timeout (`set_read_timeout`) times out when the daemon is
|
||||
/// quiet, which is no record and no disconnect: `stop` is asked, and the read goes on, with what
|
||||
/// of a record had arrived kept. A partial record at the end is dropped.
|
||||
pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes), mut stop: impl FnMut() -> bool) {
|
||||
let mut record = [0_u8; RECORD_BYTES];
|
||||
let mut filled = 0;
|
||||
loop {
|
||||
match stream.read_exact(&mut record) {
|
||||
Ok(()) => {
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
match stream.read(record.get_mut(filled..).unwrap_or_default()) {
|
||||
Ok(0) => {
|
||||
log::info!("3D mouse: spacenavd connection ended");
|
||||
return;
|
||||
}
|
||||
Ok(size) => {
|
||||
filled += size;
|
||||
if filled == RECORD_BYTES {
|
||||
filled = 0;
|
||||
if let Some(axes) = decode(&record) {
|
||||
publish(axes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
if stop() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: spacenavd connection ended: {error}");
|
||||
return;
|
||||
@@ -132,10 +153,41 @@ mod tests {
|
||||
daemon.write_all(&bytes).expect("written");
|
||||
drop(daemon);
|
||||
let mut seen = Vec::new();
|
||||
read(client, |axes| seen.push(axes.translation));
|
||||
read(client, |axes| seen.push(axes.translation), || false);
|
||||
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quiet_daemon_times_out_without_losing_the_record_it_was_sending() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
use std::io::ErrorKind::{TimedOut, WouldBlock};
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.timeout(WouldBlock).data(&bytes[..10]).timeout(TimedOut).timeout(WouldBlock).data(&bytes[10..]).timeout(WouldBlock).end();
|
||||
let (mut seen, mut asked) = (Vec::new(), 0);
|
||||
read(
|
||||
script,
|
||||
|axes| seen.push(axes.translation),
|
||||
|| {
|
||||
asked += 1;
|
||||
false
|
||||
},
|
||||
);
|
||||
assert_eq!(seen, [[70, 0, 0]]);
|
||||
assert_eq!(asked, 4, "each timeout asks whether to stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_ends_the_read_at_the_next_timeout_only() {
|
||||
use crate::space_mouse::fake::Script;
|
||||
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
|
||||
let mut script = Script::new();
|
||||
script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes);
|
||||
let mut seen = 0;
|
||||
read(script, |_| seen += 1, || true);
|
||||
assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_daemon_means_no_connection() {
|
||||
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Keeping the restored window reachable.
|
||||
//!
|
||||
//! The window comes back where it was left. After a monitor was unplugged, the scale changed or
|
||||
//! the settings came from a larger screen, that can put its title bar above the top edge or
|
||||
//! under the taskbar, out of reach of the mouse. [`fit`] pulls a window back into the monitor's
|
||||
//! work area (the screen without the taskbar), in whatever unit the caller measures both, as
|
||||
//! long as it is the same one.
|
||||
|
||||
/// A rectangle: left, top, width, height.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Area {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Area {
|
||||
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Area { x, y, w, h }
|
||||
}
|
||||
fn right(self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
fn bottom(self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
fn sane(self) -> bool {
|
||||
[self.x, self.y, self.w, self.h].iter().all(|v| v.is_finite()) && self.w > 0.0 && self.h > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the title bar must stay over the work area at least, to grab it.
|
||||
pub const REACHABLE: f32 = 100.0;
|
||||
/// The size a window gets when its saved geometry is unusable, before it is fitted to the screen.
|
||||
pub const DEFAULT_SIZE: (f32, f32) = (1600.0, 1000.0);
|
||||
|
||||
/// `window` moved and shrunk so that it can be reached on `work`: no larger than the work area
|
||||
/// (but not below `min`), the title bar not above the work area's top, the window not below its
|
||||
/// bottom, and at least [`REACHABLE`] of it over the work area horizontally. A window that is
|
||||
/// fine is returned unchanged. Geometry that makes no sense (not finite, empty), or a work
|
||||
/// area that does not, gives the default size centred on the work area.
|
||||
pub fn fit(window: Area, work: Area, min: (f32, f32)) -> Area {
|
||||
if !work.sane() {
|
||||
return window;
|
||||
}
|
||||
let min = (min.0.min(work.w), min.1.min(work.h));
|
||||
if !window.sane() {
|
||||
let w = DEFAULT_SIZE.0.min(work.w * 0.9).max(min.0);
|
||||
let h = DEFAULT_SIZE.1.min(work.h * 0.9).max(min.1);
|
||||
return Area::new(work.x + (work.w - w) / 2.0, work.y + (work.h - h) / 2.0, w, h);
|
||||
}
|
||||
let w = window.w.clamp(min.0, work.w);
|
||||
let h = window.h.clamp(min.1, work.h);
|
||||
let reach = REACHABLE.min(w);
|
||||
// A window that had to shrink goes fully onto the screen; one that fits may keep straddling two.
|
||||
let x = if w < window.w { window.x.max(work.x).min(work.right() - w) } else { window.x.max(work.x + reach - w).min(work.right() - reach) };
|
||||
let y = window.y.min(work.bottom() - h).max(work.y);
|
||||
Area::new(x, y, w, h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WORK: Area = Area::new(0.0, 0.0, 2560.0, 1400.0);
|
||||
const MIN: (f32, f32) = (960.0, 600.0);
|
||||
|
||||
#[test]
|
||||
fn a_window_on_screen_is_left_alone() {
|
||||
let w = Area::new(300.0, 120.0, 1600.0, 1000.0);
|
||||
assert_eq!(fit(w, WORK, MIN), w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_bar_above_the_top_or_under_the_taskbar_comes_back() {
|
||||
let fitted = fit(Area::new(300.0, -400.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!((fitted.y, fitted.h), (0.0, 1000.0));
|
||||
let fitted = fit(Area::new(300.0, 1300.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(fitted.y + fitted.h, 1400.0);
|
||||
// A taskbar at the top moves the work area down.
|
||||
let below_bar = Area::new(0.0, 48.0, 2560.0, 1352.0);
|
||||
assert_eq!(fit(Area::new(300.0, 0.0, 1600.0, 1000.0), below_bar, MIN).y, 48.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_from_a_monitor_that_is_gone_keeps_a_grabbable_part_on_screen() {
|
||||
let right = fit(Area::new(5000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(right.x, 2560.0 - REACHABLE);
|
||||
let left = fit(Area::new(-4000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
|
||||
assert_eq!(left.x + left.w, REACHABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_larger_than_the_screen_shrinks_to_it() {
|
||||
let small = Area::new(0.0, 0.0, 1280.0, 720.0);
|
||||
let fitted = fit(Area::new(-20.0, -30.0, 3200.0, 2000.0), small, MIN);
|
||||
assert_eq!(fitted, small);
|
||||
// Never below the minimum, unless the screen itself is smaller.
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), WORK, MIN).w, 960.0);
|
||||
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), Area::new(0.0, 0.0, 800.0, 500.0), MIN).h, 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_geometry_is_centred_at_a_sensible_size() {
|
||||
for bad in [Area::new(f32::NAN, 0.0, 1600.0, 1000.0), Area::new(0.0, 0.0, 0.0, 0.0), Area::new(0.0, 0.0, f32::INFINITY, 10.0)] {
|
||||
let fitted = fit(bad, WORK, MIN);
|
||||
assert_eq!(fitted, Area::new(480.0, 200.0, 1600.0, 1000.0));
|
||||
}
|
||||
let small = Area::new(100.0, 50.0, 1366.0, 728.0);
|
||||
let fitted = fit(Area::new(f32::NAN, 0.0, 1.0, 1.0), small, MIN);
|
||||
assert!(fitted.w <= small.w && fitted.h <= small.h && fitted.x >= small.x && fitted.y >= small.y);
|
||||
// No work area to go by: nothing to change.
|
||||
let w = Area::new(1.0, 2.0, 3.0, 4.0);
|
||||
assert_eq!(fit(w, Area::new(0.0, 0.0, 0.0, 0.0), MIN), w);
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Úhel",
|
||||
"ui.angle_decimals": "Desetinná místa úhlu",
|
||||
"ui.animateJoint": "Animovat kloub",
|
||||
"ui.animate_view_changes": "Animovat změny pohledu",
|
||||
"ui.animation": "Animace",
|
||||
"ui.animation_speed": "Rychlost animace",
|
||||
"ui.anti_aliasing": "Vyhlazování hran",
|
||||
"ui.apply": "Použít",
|
||||
"ui.arc": "Oblouk",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Zamknout",
|
||||
"ui.loft": "Spojení profilů",
|
||||
"ui.look_at": "Pohled kolmo",
|
||||
"ui.look_at_anim_desc": "Pohled podél osy, pohled kolmo, výchozí pohled a přizpůsobení",
|
||||
"ui.manipulator_free_alt": "Volné tažení manipulátorem také s Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Zapnuto: Alt/Option nebo Ctrl/Cmd na šipkách vysunutí / manipulátoru vypne přichytávání hodnot. Vypnuto: pouze Ctrl/Cmd.",
|
||||
"ui.manufacture": "Výroba",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem",
|
||||
"ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání",
|
||||
"ui.snap": "Přichytávání",
|
||||
"ui.space_mouse_axes_reassigned": "Osy jsou v Předvolbách přeřazené a to platí i po této kalibraci.",
|
||||
"ui.space_mouse_calibrate": "Kalibrovat…",
|
||||
"ui.space_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.",
|
||||
"ui.space_mouse_calibrate_title": "Kalibrace 3D myši",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.",
|
||||
"ui.space_mouse_dead_zone": "Mrtvá zóna",
|
||||
"ui.space_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.",
|
||||
"ui.space_mouse_driven_by": "Který kalibrovaný pohyb zařízení ovládá tuto osu. Když vyberete pohyb, který už používá jiná osa, obě se prohodí.",
|
||||
"ui.space_mouse_driven_by_label": "ovládá",
|
||||
"ui.space_mouse_driver": "Ovladač 3D myši",
|
||||
"ui.space_mouse_driver_auto": "Automaticky",
|
||||
"ui.space_mouse_driver_direct": "Přímo (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Zařízení se čte přímo; spacenavd neběží",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Čte se přes spacenavd, jiná možnost není",
|
||||
"ui.space_mouse_forget": "Zapomenout kalibraci",
|
||||
"ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotace X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotace Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotace Z",
|
||||
"ui.space_mouse_motion_translation_x": "Posun X",
|
||||
"ui.space_mouse_motion_translation_y": "Posun Y",
|
||||
"ui.space_mouse_motion_translation_z": "Posun Z",
|
||||
"ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D myš nenalezena",
|
||||
"ui.space_mouse_not_calibrated": "Nezkalibrováno",
|
||||
"ui.space_mouse_reading": "Hodnoty",
|
||||
"ui.space_mouse_reset_axes": "Obnovit přiřazení os",
|
||||
"ui.space_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.",
|
||||
"ui.space_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.",
|
||||
"ui.space_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.",
|
||||
"ui.space_mouse_view_axis": "Osa pohledu",
|
||||
"ui.space_mouse_view_off": "Vypnuto",
|
||||
"ui.space_mouse_view_off_tip": "Profil ovladače 3D myši tuto osu vypíná, takže její pohyb s pohledem nehýbe. Když tu vyberete pohyb jiné osy, prohodí se s ní a s pohledem přestane hýbat.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbit vlevo / vpravo",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbit nahoru / dolů",
|
||||
"ui.space_mouse_view_pan_sideways": "Posun vlevo / vpravo",
|
||||
"ui.space_mouse_view_pan_up_down": "Posun nahoru / dolů",
|
||||
"ui.space_mouse_view_roll": "Natočení",
|
||||
"ui.space_mouse_view_unmapped": "Osa {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Rozteč",
|
||||
"ui.spacing_2": "Rozteč 2",
|
||||
"ui.sphere": "Koule",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Vinkel",
|
||||
"ui.angle_decimals": "Vinkeldecimaler",
|
||||
"ui.animateJoint": "Animér led",
|
||||
"ui.animate_view_changes": "Animér visningsskift",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationshastighed",
|
||||
"ui.anti_aliasing": "Kantudjævning",
|
||||
"ui.apply": "Anvend",
|
||||
"ui.arc": "Bue",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Lås",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Se på",
|
||||
"ui.look_at_anim_desc": "Aksevisning, Se på, Hjem og Tilpas",
|
||||
"ui.manipulator_free_alt": "Brug også Alt/Option til at trække frit",
|
||||
"ui.manipulator_free_alt_tip": "Slået til: Hold Alt/Option eller Ctrl/Cmd nede, mens du trækker i pilene ved ekstrudering eller flytning, for at undgå faste værditrin. Slået fra: Kun Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fremstilling",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
"ui.animateJoint": "Gelenk animieren",
|
||||
"ui.animate_view_changes": "Ansichtsänderungen animieren",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationsgeschwindigkeit",
|
||||
"ui.anti_aliasing": "Kantenglättung",
|
||||
"ui.apply": "Anwenden",
|
||||
"ui.arc": "Kreisbogen",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Sperren",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Ansehen",
|
||||
"ui.look_at_anim_desc": "Achsenansicht, Ansehen, Home und Einpassen",
|
||||
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
|
||||
"ui.manufacture": "Fertigung",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Sanftes Zoomen mit dem Rad",
|
||||
"ui.smooth_wheel_zoom_tip": "Ausschalten, um jedes Radereignis sofort anzuwenden, ohne Nachlauf",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.space_mouse_axes_reassigned": "Die Achsen sind in den Einstellungen neu zugeordnet, und das gilt auch nach dieser Kalibrierung.",
|
||||
"ui.space_mouse_calibrate": "Kalibrieren…",
|
||||
"ui.space_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.",
|
||||
"ui.space_mouse_calibrate_title": "3D-Maus kalibrieren",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.",
|
||||
"ui.space_mouse_dead_zone": "Totzone",
|
||||
"ui.space_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.",
|
||||
"ui.space_mouse_driven_by": "Welche kalibrierte Bewegung des Geräts diese Achse steuert. Wählen Sie eine, die schon eine andere Achse nutzt, werden die beiden getauscht.",
|
||||
"ui.space_mouse_driven_by_label": "gesteuert von",
|
||||
"ui.space_mouse_driver": "3D-Maus-Treiber",
|
||||
"ui.space_mouse_driver_auto": "Automatisch",
|
||||
"ui.space_mouse_driver_direct": "Direkt (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Das Gerät wird direkt gelesen; spacenavd läuft nicht",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lesen über spacenavd, die einzige verfügbare Möglichkeit",
|
||||
"ui.space_mouse_forget": "Kalibrierung vergessen",
|
||||
"ui.space_mouse_hint": "Ansicht gleichzeitig verschieben und drehen; Skizzen verwenden nur Schwenken und Zoomen.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotation X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotation Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotation Z",
|
||||
"ui.space_mouse_motion_translation_x": "Translation X",
|
||||
"ui.space_mouse_motion_translation_y": "Translation Y",
|
||||
"ui.space_mouse_motion_translation_z": "Translation Z",
|
||||
"ui.space_mouse_navigate": "Mit SpaceNavigator / SpaceMouse navigieren",
|
||||
"ui.space_mouse_none": "Keine 3D-Maus gefunden",
|
||||
"ui.space_mouse_not_calibrated": "Nicht kalibriert",
|
||||
"ui.space_mouse_reading": "Werte",
|
||||
"ui.space_mouse_reset_axes": "Achsenzuordnung zurücksetzen",
|
||||
"ui.space_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_view_axis": "Ansichtsachse",
|
||||
"ui.space_mouse_view_off": "Aus",
|
||||
"ui.space_mouse_view_off_tip": "Das Profil des 3D-Maus-Treibers schaltet diese Achse ab, daher bewegt ihre Bewegung die Ansicht nicht. Wählen Sie hier die Bewegung einer anderen Achse, werden die beiden getauscht und sie bewegt die Ansicht nicht mehr.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbit links / rechts",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbit hoch / runter",
|
||||
"ui.space_mouse_view_pan_sideways": "Schwenken links / rechts",
|
||||
"ui.space_mouse_view_pan_up_down": "Schwenken hoch / runter",
|
||||
"ui.space_mouse_view_roll": "Rollen",
|
||||
"ui.space_mouse_view_unmapped": "Achse {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Abstand",
|
||||
"ui.spacing_2": "Abstand 2",
|
||||
"ui.sphere": "Sphäre",
|
||||
|
||||
+27
-3
@@ -1209,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "View attached",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.agent_off_thread": "Keep the window responsive while agents run commands",
|
||||
"ui.agent_off_thread_note": "An agent's slow commands (from the AI panel or an MCP client) compute beside the window: you can keep turning and inspecting the view, and stop one from the chip in the view. Off: each runs on the window's thread and the window waits for it.",
|
||||
"ui.ai_add": "Add",
|
||||
"ui.ai_add_agent": "Add agent…",
|
||||
"ui.ai_add_agent_title": "Add agent",
|
||||
@@ -1293,9 +1295,7 @@
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Angle decimals",
|
||||
"ui.animateJoint": "Animate Joint",
|
||||
"ui.animate_view_changes": "Animate view changes",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animation speed",
|
||||
"ui.anti_aliasing": "Anti-aliasing",
|
||||
"ui.apply": "Apply",
|
||||
"ui.arc": "Arc",
|
||||
@@ -1557,7 +1557,6 @@
|
||||
"ui.lock": "Lock",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Look At",
|
||||
"ui.look_at_anim_desc": "Axis view, Look At, Home and Fit",
|
||||
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
|
||||
"ui.manufacture": "Manufacture",
|
||||
@@ -1743,6 +1742,7 @@
|
||||
"ui.smooth_wheel_zoom": "Smooth wheel zoom",
|
||||
"ui.smooth_wheel_zoom_tip": "Turn off to apply each wheel event immediately, without a smoothing tail",
|
||||
"ui.snap": "Snap",
|
||||
"ui.space_mouse_axes_reassigned": "Axes are reassigned in Preferences, and that still applies on top of this calibration.",
|
||||
"ui.space_mouse_calibrate": "Calibrate…",
|
||||
"ui.space_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrate 3D mouse",
|
||||
@@ -1752,12 +1752,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.",
|
||||
"ui.space_mouse_dead_zone": "Dead zone",
|
||||
"ui.space_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.",
|
||||
"ui.space_mouse_driven_by": "Which calibrated motion of the device drives this axis. Picking a motion another axis already uses swaps the two.",
|
||||
"ui.space_mouse_driven_by_label": "driven by",
|
||||
"ui.space_mouse_driver": "3D mouse driver",
|
||||
"ui.space_mouse_driver_auto": "Automatic",
|
||||
"ui.space_mouse_driver_direct": "Direct (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Reading the device directly; spacenavd is not running",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Reading through spacenavd, the only way available",
|
||||
"ui.space_mouse_forget": "Forget calibration",
|
||||
"ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotation X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotation Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotation Z",
|
||||
"ui.space_mouse_motion_translation_x": "Translation X",
|
||||
"ui.space_mouse_motion_translation_y": "Translation Y",
|
||||
"ui.space_mouse_motion_translation_z": "Translation Z",
|
||||
"ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "No 3D mouse found",
|
||||
"ui.space_mouse_not_calibrated": "Not calibrated",
|
||||
"ui.space_mouse_reading": "Reading",
|
||||
"ui.space_mouse_reset_axes": "Reset axis assignment",
|
||||
"ui.space_mouse_step_lift": "Lift the cap up, hold it, then let go.",
|
||||
"ui.space_mouse_step_press": "Press the cap down, hold it, then let go.",
|
||||
"ui.space_mouse_step_pull": "Pull the cap toward you, hold it, then let go.",
|
||||
@@ -1771,6 +1785,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.",
|
||||
"ui.space_mouse_view_axis": "View axis",
|
||||
"ui.space_mouse_view_off": "Off",
|
||||
"ui.space_mouse_view_off_tip": "The 3D mouse driver's profile turns this axis off, so its motion doesn't move the view. Picking another axis's motion here swaps the two, and that motion stops moving the view.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbit left / right",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbit up / down",
|
||||
"ui.space_mouse_view_pan_sideways": "Pan left / right",
|
||||
"ui.space_mouse_view_pan_up_down": "Pan up / down",
|
||||
"ui.space_mouse_view_roll": "Roll",
|
||||
"ui.space_mouse_view_unmapped": "Axis {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Spacing",
|
||||
"ui.spacing_2": "Spacing 2",
|
||||
"ui.sphere": "Sphere",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Ángulo",
|
||||
"ui.angle_decimals": "Decimales de ángulo",
|
||||
"ui.animateJoint": "Animar unión",
|
||||
"ui.animate_view_changes": "Animar cambios de vista",
|
||||
"ui.animation": "Animación",
|
||||
"ui.animation_speed": "Velocidad de animación",
|
||||
"ui.anti_aliasing": "Suavizado de bordes",
|
||||
"ui.apply": "Aplicar",
|
||||
"ui.arc": "Arco",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Bloquear",
|
||||
"ui.loft": "Solevación",
|
||||
"ui.look_at": "Mirar hacia",
|
||||
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
|
||||
"ui.manipulator_free_alt": "Arrastre libre del manipulador también con Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Activado: Alt/Option o Ctrl/Cmd sobre las flechas de extrusión / manipulador desactiva el ajuste de valores. Desactivado: solo Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fabricación",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
|
||||
"ui.snap": "Referencia a objetos",
|
||||
"ui.space_mouse_axes_reassigned": "Los ejes están reasignados en Preferencias, y eso sigue aplicándose sobre esta calibración.",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar ratón 3D",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona muerta",
|
||||
"ui.space_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.",
|
||||
"ui.space_mouse_driven_by": "Qué movimiento calibrado del dispositivo controla este eje. Si eliges uno que ya usa otro eje, se intercambian.",
|
||||
"ui.space_mouse_driven_by_label": "controlado por",
|
||||
"ui.space_mouse_driver": "Controlador del ratón 3D",
|
||||
"ui.space_mouse_driver_auto": "Automático",
|
||||
"ui.space_mouse_driver_direct": "Directo (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Lectura directa del dispositivo; spacenavd no se está ejecutando",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, la única opción disponible",
|
||||
"ui.space_mouse_forget": "Olvidar calibración",
|
||||
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotación X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotación Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotación Z",
|
||||
"ui.space_mouse_motion_translation_x": "Traslación X",
|
||||
"ui.space_mouse_motion_translation_y": "Traslación Y",
|
||||
"ui.space_mouse_motion_translation_z": "Traslación Z",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "No se encontró ningún ratón 3D",
|
||||
"ui.space_mouse_not_calibrated": "Sin calibrar",
|
||||
"ui.space_mouse_reading": "Lectura",
|
||||
"ui.space_mouse_reset_axes": "Restablecer asignación de ejes",
|
||||
"ui.space_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_view_axis": "Eje de la vista",
|
||||
"ui.space_mouse_view_off": "Desactivado",
|
||||
"ui.space_mouse_view_off_tip": "El perfil del controlador del ratón 3D desactiva este eje, así que su movimiento no mueve la vista. Si eliges aquí el movimiento de otro eje, se intercambian y ese movimiento deja de mover la vista.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbitar izquierda / derecha",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abajo",
|
||||
"ui.space_mouse_view_pan_sideways": "Desplazar izquierda / derecha",
|
||||
"ui.space_mouse_view_pan_up_down": "Desplazar arriba / abajo",
|
||||
"ui.space_mouse_view_roll": "Girar sobre la vista",
|
||||
"ui.space_mouse_view_unmapped": "Eje {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Espaciado",
|
||||
"ui.spacing_2": "Espaciado 2",
|
||||
"ui.sphere": "Esfera",
|
||||
|
||||
+25
-3
@@ -1293,9 +1293,7 @@
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Décimales des angles",
|
||||
"ui.animateJoint": "Animer la liaison",
|
||||
"ui.animate_view_changes": "Animer les changements de vue",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Vitesse d'animation",
|
||||
"ui.anti_aliasing": "Anticrénelage",
|
||||
"ui.apply": "Appliquer",
|
||||
"ui.arc": "Arc",
|
||||
@@ -1557,7 +1555,6 @@
|
||||
"ui.lock": "Verrouiller",
|
||||
"ui.loft": "Lissage",
|
||||
"ui.look_at": "Vue normale",
|
||||
"ui.look_at_anim_desc": "Vue d'axe, Vue normale, Accueil et Cadrer",
|
||||
"ui.manipulator_free_alt": "Glissement libre du manipulateur aussi avec Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Activé : Alt/Option ou Ctrl/Cmd sur les flèches d’extrusion / du manipulateur désactive l’accrochage aux valeurs. Désactivé : Ctrl/Cmd uniquement.",
|
||||
"ui.manufacture": "Fabrication",
|
||||
@@ -1743,6 +1740,7 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom fluide à la molette",
|
||||
"ui.smooth_wheel_zoom_tip": "Désactiver pour appliquer chaque cran de molette immédiatement, sans lissage",
|
||||
"ui.snap": "Accrochage",
|
||||
"ui.space_mouse_axes_reassigned": "Les axes sont réattribués dans les Préférences, et cela s'applique toujours en plus de ce calibrage.",
|
||||
"ui.space_mouse_calibrate": "Calibrer…",
|
||||
"ui.space_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrer la souris 3D",
|
||||
@@ -1752,12 +1750,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.",
|
||||
"ui.space_mouse_dead_zone": "Zone morte",
|
||||
"ui.space_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.",
|
||||
"ui.space_mouse_driven_by": "Quel mouvement calibré de l'appareil pilote cet axe. Choisir un mouvement déjà utilisé par un autre axe les échange.",
|
||||
"ui.space_mouse_driven_by_label": "piloté par",
|
||||
"ui.space_mouse_driver": "Pilote de la souris 3D",
|
||||
"ui.space_mouse_driver_auto": "Automatique",
|
||||
"ui.space_mouse_driver_direct": "Direct (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Lecture directe du périphérique ; spacenavd n'est pas lancé",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lecture via spacenavd, seule possibilité disponible",
|
||||
"ui.space_mouse_forget": "Oublier le calibrage",
|
||||
"ui.space_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotation X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotation Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotation Z",
|
||||
"ui.space_mouse_motion_translation_x": "Translation X",
|
||||
"ui.space_mouse_motion_translation_y": "Translation Y",
|
||||
"ui.space_mouse_motion_translation_z": "Translation Z",
|
||||
"ui.space_mouse_navigate": "Naviguer avec SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Aucune souris 3D trouvée",
|
||||
"ui.space_mouse_not_calibrated": "Non calibrée",
|
||||
"ui.space_mouse_reading": "Lecture",
|
||||
"ui.space_mouse_reset_axes": "Réinitialiser l'attribution des axes",
|
||||
"ui.space_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.",
|
||||
@@ -1771,6 +1783,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_view_axis": "Axe de la vue",
|
||||
"ui.space_mouse_view_off": "Désactivé",
|
||||
"ui.space_mouse_view_off_tip": "Le profil du pilote de la souris 3D désactive cet axe : son mouvement ne déplace pas la vue. Choisir ici le mouvement d'un autre axe les échange, et ce mouvement cesse de déplacer la vue.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbite gauche / droite",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbite haut / bas",
|
||||
"ui.space_mouse_view_pan_sideways": "Panoramique gauche / droite",
|
||||
"ui.space_mouse_view_pan_up_down": "Panoramique haut / bas",
|
||||
"ui.space_mouse_view_roll": "Roulis",
|
||||
"ui.space_mouse_view_unmapped": "Axe {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Espacement",
|
||||
"ui.spacing_2": "Espacement 2",
|
||||
"ui.sphere": "Sphère",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Ángulo",
|
||||
"ui.angle_decimals": "Decimais dos ángulos",
|
||||
"ui.animateJoint": "Animar a unión",
|
||||
"ui.animate_view_changes": "Animar os cambios de vista",
|
||||
"ui.animation": "Animación",
|
||||
"ui.animation_speed": "Velocidade da animación",
|
||||
"ui.anti_aliasing": "Antialiasing",
|
||||
"ui.apply": "Aplicar",
|
||||
"ui.arc": "Arco",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Bloquear",
|
||||
"ui.loft": "Transición",
|
||||
"ui.look_at": "Mirar cara a",
|
||||
"ui.look_at_anim_desc": "Vista de eixe, Mirar cara a, Inicio e Axustar",
|
||||
"ui.manipulator_free_alt": "Arrastre libre do manipulador tamén con Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Activado: Alt/Option ou Ctrl/Cmd nas frechas de extrusión / do gizmo desactiva o axuste de valores. Desactivado: só Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fabricación",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave coa roda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado",
|
||||
"ui.snap": "Axuste",
|
||||
"ui.space_mouse_axes_reassigned": "Os eixes están reasignados en Preferencias, e iso segue aplicándose sobre esta calibración.",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar o rato 3D",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.",
|
||||
"ui.space_mouse_driven_by": "Que movemento calibrado do dispositivo controla este eixe. Se escolles un que xa usa outro eixe, intercámbianse.",
|
||||
"ui.space_mouse_driven_by_label": "controlado por",
|
||||
"ui.space_mouse_driver": "Controlador do rato 3D",
|
||||
"ui.space_mouse_driver_auto": "Automático",
|
||||
"ui.space_mouse_driver_direct": "Directo (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Lectura directa do dispositivo; spacenavd non está en execución",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, a única opción dispoñible",
|
||||
"ui.space_mouse_forget": "Esquecer a calibración",
|
||||
"ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotación X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotación Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotación Z",
|
||||
"ui.space_mouse_motion_translation_x": "Translación X",
|
||||
"ui.space_mouse_motion_translation_y": "Translación Y",
|
||||
"ui.space_mouse_motion_translation_z": "Translación Z",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Non se atopou ningún rato 3D",
|
||||
"ui.space_mouse_not_calibrated": "Sen calibrar",
|
||||
"ui.space_mouse_reading": "Lectura",
|
||||
"ui.space_mouse_reset_axes": "Restablecer asignación de eixes",
|
||||
"ui.space_mouse_step_lift": "Levanta o capuchón, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_press": "Preme o capuchón cara abaixo, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_pull": "Tira do capuchón cara a ti, mantéño e sóltao.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantéño e sóltao.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantéño e sóltao.",
|
||||
"ui.space_mouse_view_axis": "Eixe da vista",
|
||||
"ui.space_mouse_view_off": "Desactivado",
|
||||
"ui.space_mouse_view_off_tip": "O perfil do controlador do rato 3D desactiva este eixe, así que o seu movemento non move a vista. Se escolles aquí o movemento doutro eixe, intercámbianse e ese movemento deixa de mover a vista.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbitar á esquerda / dereita",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abaixo",
|
||||
"ui.space_mouse_view_pan_sideways": "Desprazar á esquerda / dereita",
|
||||
"ui.space_mouse_view_pan_up_down": "Desprazar arriba / abaixo",
|
||||
"ui.space_mouse_view_roll": "Xirar sobre a vista",
|
||||
"ui.space_mouse_view_unmapped": "Eixe {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Espazado",
|
||||
"ui.spacing_2": "Espazado 2",
|
||||
"ui.sphere": "Esfera",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Szög",
|
||||
"ui.angle_decimals": "Szög tizedesjegyei",
|
||||
"ui.animateJoint": "Csukló animálása",
|
||||
"ui.animate_view_changes": "Nézetváltozások animálása",
|
||||
"ui.animation": "Animáció",
|
||||
"ui.animation_speed": "Animáció sebessége",
|
||||
"ui.anti_aliasing": "Élsimítás",
|
||||
"ui.apply": "Alkalmaz",
|
||||
"ui.arc": "Ív",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Zárolás",
|
||||
"ui.loft": "Áthúzás",
|
||||
"ui.look_at": "Ránézés",
|
||||
"ui.look_at_anim_desc": "Tengelynézet, Ránézés, Kezdőlap és Illesztés",
|
||||
"ui.manipulator_free_alt": "A manipulátor szabad húzása Alt/Option billentyűvel is",
|
||||
"ui.manipulator_free_alt_tip": "Be: az Alt/Option vagy Ctrl/Cmd a kihúzás / gizmo nyilakon letiltja az értékillesztést. Ki: csak a Ctrl/Cmd.",
|
||||
"ui.manufacture": "Gyártás",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Sudut",
|
||||
"ui.angle_decimals": "Desimal sudut",
|
||||
"ui.animateJoint": "Animasikan Sambungan",
|
||||
"ui.animate_view_changes": "Animasikan perubahan tampilan",
|
||||
"ui.animation": "Animasi",
|
||||
"ui.animation_speed": "Kecepatan animasi",
|
||||
"ui.anti_aliasing": "Anti-aliasing",
|
||||
"ui.apply": "Terapkan",
|
||||
"ui.arc": "Busur",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Kunci",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Look At",
|
||||
"ui.look_at_anim_desc": "Tampilan sumbu, Look At, Home, dan Fit",
|
||||
"ui.manipulator_free_alt": "Seret bebas manipulator juga dengan Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Aktif: Alt/Option atau Ctrl/Cmd pada panah ekstrusi / gizmo menonaktifkan snap nilai. Mati: hanya Ctrl/Cmd.",
|
||||
"ui.manufacture": "Manufaktur",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Angolo",
|
||||
"ui.angle_decimals": "Decimali degli angoli",
|
||||
"ui.animateJoint": "Anima giunto",
|
||||
"ui.animate_view_changes": "Anima cambi di vista",
|
||||
"ui.animation": "Animazione",
|
||||
"ui.animation_speed": "Velocità dell'animazione",
|
||||
"ui.anti_aliasing": "Antialiasing",
|
||||
"ui.apply": "Applica",
|
||||
"ui.arc": "Arco",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Blocca",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Vista normale",
|
||||
"ui.look_at_anim_desc": "Viste degli assi, Vista normale, Vista iniziale e Adatta",
|
||||
"ui.manipulator_free_alt": "Trascinamento libero del manipolatore anche con Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Attivo: Alt/Option o Ctrl/Cmd sulle frecce di estrusione / gizmo disattivano l'aggancio ai valori. Disattivo: solo Ctrl/Cmd.",
|
||||
"ui.manufacture": "Produzione",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom fluido con rotellina",
|
||||
"ui.smooth_wheel_zoom_tip": "Disattiva per applicare immediatamente ogni evento della rotellina, senza coda di interpolazione",
|
||||
"ui.snap": "Aggancio",
|
||||
"ui.space_mouse_axes_reassigned": "Gli assi sono riassegnati nelle Preferenze, e questo vale anche dopo questa calibrazione.",
|
||||
"ui.space_mouse_calibrate": "Calibra…",
|
||||
"ui.space_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibra mouse 3D",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.",
|
||||
"ui.space_mouse_driven_by": "Quale movimento calibrato del dispositivo comanda questo asse. Se ne scegli uno già usato da un altro asse, i due si scambiano.",
|
||||
"ui.space_mouse_driven_by_label": "comandato da",
|
||||
"ui.space_mouse_driver": "Driver del mouse 3D",
|
||||
"ui.space_mouse_driver_auto": "Automatico",
|
||||
"ui.space_mouse_driver_direct": "Diretto (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Lettura diretta del dispositivo; spacenavd non è in esecuzione",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lettura tramite spacenavd, l'unica possibilità disponibile",
|
||||
"ui.space_mouse_forget": "Dimentica calibrazione",
|
||||
"ui.space_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotazione X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotazione Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotazione Z",
|
||||
"ui.space_mouse_motion_translation_x": "Traslazione X",
|
||||
"ui.space_mouse_motion_translation_y": "Traslazione Y",
|
||||
"ui.space_mouse_motion_translation_z": "Traslazione Z",
|
||||
"ui.space_mouse_navigate": "Naviga con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Nessun mouse 3D trovato",
|
||||
"ui.space_mouse_not_calibrated": "Non calibrato",
|
||||
"ui.space_mouse_reading": "Lettura",
|
||||
"ui.space_mouse_reset_axes": "Ripristina assegnazione assi",
|
||||
"ui.space_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.",
|
||||
"ui.space_mouse_view_axis": "Asse della vista",
|
||||
"ui.space_mouse_view_off": "Disattivato",
|
||||
"ui.space_mouse_view_off_tip": "Il profilo del driver del mouse 3D disattiva questo asse, quindi il suo movimento non muove la vista. Se qui scegli il movimento di un altro asse, i due si scambiano e quel movimento smette di muovere la vista.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbita sinistra / destra",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbita su / giù",
|
||||
"ui.space_mouse_view_pan_sideways": "Panoramica sinistra / destra",
|
||||
"ui.space_mouse_view_pan_up_down": "Panoramica su / giù",
|
||||
"ui.space_mouse_view_roll": "Rollio",
|
||||
"ui.space_mouse_view_unmapped": "Asse {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Spaziatura",
|
||||
"ui.spacing_2": "Spaziatura 2",
|
||||
"ui.sphere": "Sfera",
|
||||
|
||||
+27
-3
@@ -1209,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "ビューを添付済み",
|
||||
"ui.agent_cursor": "エージェントカーソル",
|
||||
"ui.agent_cursor_speed": "エージェントカーソル速度",
|
||||
"ui.agent_off_thread": "エージェントのコマンド実行中もウィンドウを操作可能にする",
|
||||
"ui.agent_off_thread_note": "エージェント(AI パネルまたは MCP クライアント)の時間のかかるコマンドをウィンドウとは別に計算します。実行中もビューの回転や確認ができ、ビュー内の表示から停止できます。オフ:各コマンドはウィンドウのスレッドで実行され、終わるまでウィンドウは待ちます。",
|
||||
"ui.ai_add": "追加",
|
||||
"ui.ai_add_agent": "エージェントを追加…",
|
||||
"ui.ai_add_agent_title": "エージェントを追加",
|
||||
@@ -1293,9 +1295,7 @@
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度の小数点以下の桁数",
|
||||
"ui.animateJoint": "ジョイントをアニメート",
|
||||
"ui.animate_view_changes": "視点変更をアニメーション表示",
|
||||
"ui.animation": "アニメーション",
|
||||
"ui.animation_speed": "アニメーション速度",
|
||||
"ui.anti_aliasing": "アンチエイリアス",
|
||||
"ui.apply": "適用",
|
||||
"ui.arc": "円弧",
|
||||
@@ -1557,7 +1557,6 @@
|
||||
"ui.lock": "ロック",
|
||||
"ui.loft": "ロフト",
|
||||
"ui.look_at": "正面視",
|
||||
"ui.look_at_anim_desc": "軸ビュー、正面視、ホーム、全体表示",
|
||||
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
|
||||
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
|
||||
"ui.manufacture": "製造",
|
||||
@@ -1743,6 +1742,7 @@
|
||||
"ui.smooth_wheel_zoom": "滑らかなホイールズーム",
|
||||
"ui.smooth_wheel_zoom_tip": "オフにすると、ホイール操作のたびにズームが即座に反映され、滑らかな減速効果は適用されません。",
|
||||
"ui.snap": "スナップ",
|
||||
"ui.space_mouse_axes_reassigned": "軸の割り当ては環境設定で変更されており、このキャリブレーションの後も適用されます。",
|
||||
"ui.space_mouse_calibrate": "キャリブレーション…",
|
||||
"ui.space_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。",
|
||||
"ui.space_mouse_calibrate_title": "3Dマウスのキャリブレーション",
|
||||
@@ -1752,12 +1752,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。",
|
||||
"ui.space_mouse_dead_zone": "デッドゾーン",
|
||||
"ui.space_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。",
|
||||
"ui.space_mouse_driven_by": "この軸を操作するデバイスのキャリブレーション済みの動き。他の軸で使われている動きを選ぶと、2つが入れ替わります。",
|
||||
"ui.space_mouse_driven_by_label": "操作元",
|
||||
"ui.space_mouse_driver": "3Dマウスのドライバー",
|
||||
"ui.space_mouse_driver_auto": "自動",
|
||||
"ui.space_mouse_driver_direct": "直接 (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "デバイスを直接読み取り中(spacenavd は起動していません)",
|
||||
"ui.space_mouse_driver_only_spacenavd": "spacenavd 経由で読み取り中(他の方法はありません)",
|
||||
"ui.space_mouse_forget": "キャリブレーションを削除",
|
||||
"ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)",
|
||||
"ui.space_mouse_motion_rotation_x": "回転 X",
|
||||
"ui.space_mouse_motion_rotation_y": "回転 Y",
|
||||
"ui.space_mouse_motion_rotation_z": "回転 Z",
|
||||
"ui.space_mouse_motion_translation_x": "平行移動 X",
|
||||
"ui.space_mouse_motion_translation_y": "平行移動 Y",
|
||||
"ui.space_mouse_motion_translation_z": "平行移動 Z",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作",
|
||||
"ui.space_mouse_none": "3Dマウスが見つかりません",
|
||||
"ui.space_mouse_not_calibrated": "未キャリブレーション",
|
||||
"ui.space_mouse_reading": "読み取り値",
|
||||
"ui.space_mouse_reset_axes": "軸の割り当てをリセット",
|
||||
"ui.space_mouse_step_lift": "キャップを持ち上げて保持し、離してください。",
|
||||
"ui.space_mouse_step_press": "キャップを押し下げて保持し、離してください。",
|
||||
"ui.space_mouse_step_pull": "キャップを手前に引いて保持し、離してください。",
|
||||
@@ -1771,6 +1785,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。",
|
||||
"ui.space_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。",
|
||||
"ui.space_mouse_view_axis": "ビューの軸",
|
||||
"ui.space_mouse_view_off": "オフ",
|
||||
"ui.space_mouse_view_off_tip": "3D マウスのドライバーのプロファイルがこの軸をオフにしているため、この軸の動きではビューは動きません。ここで他の軸の動きを選ぶと 2 つが入れ替わり、その動きではビューが動かなくなります。",
|
||||
"ui.space_mouse_view_orbit_sideways": "左右オービット",
|
||||
"ui.space_mouse_view_orbit_up_down": "上下オービット",
|
||||
"ui.space_mouse_view_pan_sideways": "左右パン",
|
||||
"ui.space_mouse_view_pan_up_down": "上下パン",
|
||||
"ui.space_mouse_view_roll": "ロール",
|
||||
"ui.space_mouse_view_unmapped": "軸 {n}",
|
||||
"ui.space_mouse_view_zoom": "ズーム",
|
||||
"ui.spacing": "間隔",
|
||||
"ui.spacing_2": "間隔 2",
|
||||
"ui.sphere": "球",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "각도",
|
||||
"ui.angle_decimals": "각도 소수점 자릿수",
|
||||
"ui.animateJoint": "조인트 애니메이션",
|
||||
"ui.animate_view_changes": "뷰 변경 애니메이션",
|
||||
"ui.animation": "애니메이션",
|
||||
"ui.animation_speed": "애니메이션 속도",
|
||||
"ui.anti_aliasing": "안티앨리어싱",
|
||||
"ui.apply": "적용",
|
||||
"ui.arc": "호",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "잠금",
|
||||
"ui.loft": "로프트",
|
||||
"ui.look_at": "바라보기",
|
||||
"ui.look_at_anim_desc": "축 뷰, 보기, 홈 및 맞춤",
|
||||
"ui.manipulator_free_alt": "Alt/Option으로도 매니퓰레이터 자유 드래그",
|
||||
"ui.manipulator_free_alt_tip": "켬: 돌출 / 기즈모 화살표에서 Alt/Option 또는 Ctrl/Cmd를 누르면 값 스냅이 해제됩니다. 끔: Ctrl/Cmd만 해당.",
|
||||
"ui.manufacture": "제조",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "부드러운 휠 확대/축소",
|
||||
"ui.smooth_wheel_zoom_tip": "끄면 휠 이벤트를 부드러운 꼬리 없이 즉시 적용합니다",
|
||||
"ui.snap": "스냅",
|
||||
"ui.space_mouse_axes_reassigned": "축이 환경설정에서 다시 지정되어 있으며, 이 보정 후에도 적용됩니다.",
|
||||
"ui.space_mouse_calibrate": "보정…",
|
||||
"ui.space_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.",
|
||||
"ui.space_mouse_calibrate_title": "3D 마우스 보정",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.",
|
||||
"ui.space_mouse_dead_zone": "데드존",
|
||||
"ui.space_mouse_dead_zone_tip": "보기가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 보기가 움직이면 높이세요.",
|
||||
"ui.space_mouse_driven_by": "이 축을 움직이는 장치의 보정된 동작입니다. 다른 축이 이미 쓰는 동작을 고르면 둘이 서로 바뀝니다.",
|
||||
"ui.space_mouse_driven_by_label": "제어 동작",
|
||||
"ui.space_mouse_driver": "3D 마우스 드라이버",
|
||||
"ui.space_mouse_driver_auto": "자동",
|
||||
"ui.space_mouse_driver_direct": "직접 (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "장치를 직접 읽는 중 (spacenavd가 실행 중이 아님)",
|
||||
"ui.space_mouse_driver_only_spacenavd": "spacenavd로 읽는 중 (다른 방법이 없음)",
|
||||
"ui.space_mouse_forget": "보정 삭제",
|
||||
"ui.space_mouse_hint": "뷰를 함께 이동하고 회전합니다. 스케치는 이동과 확대/축소만 사용합니다.",
|
||||
"ui.space_mouse_motion_rotation_x": "회전 X",
|
||||
"ui.space_mouse_motion_rotation_y": "회전 Y",
|
||||
"ui.space_mouse_motion_rotation_z": "회전 Z",
|
||||
"ui.space_mouse_motion_translation_x": "이동 X",
|
||||
"ui.space_mouse_motion_translation_y": "이동 Y",
|
||||
"ui.space_mouse_motion_translation_z": "이동 Z",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse로 탐색",
|
||||
"ui.space_mouse_none": "3D 마우스를 찾을 수 없음",
|
||||
"ui.space_mouse_not_calibrated": "보정 안 됨",
|
||||
"ui.space_mouse_reading": "읽은 값",
|
||||
"ui.space_mouse_reset_axes": "축 지정 초기화",
|
||||
"ui.space_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_view_axis": "보기 축",
|
||||
"ui.space_mouse_view_off": "끔",
|
||||
"ui.space_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.",
|
||||
"ui.space_mouse_view_orbit_sideways": "좌우 궤도",
|
||||
"ui.space_mouse_view_orbit_up_down": "상하 궤도",
|
||||
"ui.space_mouse_view_pan_sideways": "좌우 이동",
|
||||
"ui.space_mouse_view_pan_up_down": "상하 이동",
|
||||
"ui.space_mouse_view_roll": "롤",
|
||||
"ui.space_mouse_view_unmapped": "축 {n}",
|
||||
"ui.space_mouse_view_zoom": "확대/축소",
|
||||
"ui.spacing": "간격",
|
||||
"ui.spacing_2": "간격 2",
|
||||
"ui.sphere": "구",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Sudut",
|
||||
"ui.angle_decimals": "Perpuluhan sudut",
|
||||
"ui.animateJoint": "Animasikan Sendi",
|
||||
"ui.animate_view_changes": "Animasikan perubahan pandangan",
|
||||
"ui.animation": "Animasi",
|
||||
"ui.animation_speed": "Kelajuan animasi",
|
||||
"ui.anti_aliasing": "Antialias",
|
||||
"ui.apply": "Gunakan",
|
||||
"ui.arc": "Lengkok",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Kunci",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Pandang Tepat",
|
||||
"ui.look_at_anim_desc": "Pandangan paksi, Pandang Tepat, Utama dan Muatkan",
|
||||
"ui.manipulator_free_alt": "Seret bebas manipulator juga dengan Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Hidup: Alt/Option atau Ctrl/Cmd pada anak panah ekstrud / gizmo mematikan pelekapan nilai. Mati: Ctrl/Cmd sahaja.",
|
||||
"ui.manufacture": "Pembuatan",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Hoek",
|
||||
"ui.angle_decimals": "Hoekdecimalen",
|
||||
"ui.animateJoint": "Verbinding animeren",
|
||||
"ui.animate_view_changes": "Weergavewijzigingen animeren",
|
||||
"ui.animation": "Animatie",
|
||||
"ui.animation_speed": "Animatiesnelheid",
|
||||
"ui.anti_aliasing": "Anti-aliasing",
|
||||
"ui.apply": "Toepassen",
|
||||
"ui.arc": "Boog",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Vergrendelen",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Kijken naar",
|
||||
"ui.look_at_anim_desc": "Aanzicht op as, Kijken naar, Home en Passend",
|
||||
"ui.manipulator_free_alt": "Manipulator vrij slepen ook met Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Aan: Alt/Option of Ctrl/Cmd op extrusie-/gizmopijlen schakelt waardesnap uit. Uit: alleen Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fabricage",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Kąt",
|
||||
"ui.angle_decimals": "Miejsca dziesiętne kąta",
|
||||
"ui.animateJoint": "Animuj złącze",
|
||||
"ui.animate_view_changes": "Animuj zmiany widoku",
|
||||
"ui.animation": "Animacja",
|
||||
"ui.animation_speed": "Szybkość animacji",
|
||||
"ui.anti_aliasing": "Wygładzanie krawędzi",
|
||||
"ui.apply": "Zastosuj",
|
||||
"ui.arc": "Łuk",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Zablokuj",
|
||||
"ui.loft": "Wyciągnięcie złożone",
|
||||
"ui.look_at": "Patrz na",
|
||||
"ui.look_at_anim_desc": "Widok osi, Patrz na, Widok główny i Dopasuj",
|
||||
"ui.manipulator_free_alt": "Swobodne przeciąganie manipulatora także z Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Wł.: Alt/Option lub Ctrl/Cmd na strzałkach wyciągnięcia / uchwytu wyłącza przyciąganie wartości. Wył.: tylko Ctrl/Cmd.",
|
||||
"ui.manufacture": "Wytwarzanie",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Ângulo",
|
||||
"ui.angle_decimals": "Casas decimais de ângulo",
|
||||
"ui.animateJoint": "Animar junta",
|
||||
"ui.animate_view_changes": "Animar mudanças de vista",
|
||||
"ui.animation": "Animação",
|
||||
"ui.animation_speed": "Velocidade da animação",
|
||||
"ui.anti_aliasing": "Antisserrilhamento",
|
||||
"ui.apply": "Aplicar",
|
||||
"ui.arc": "Arco",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Bloquear",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Olhar para",
|
||||
"ui.look_at_anim_desc": "Vista do eixo, Olhar para, Início e Ajustar",
|
||||
"ui.manipulator_free_alt": "Arrasto livre do manipulador também com Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Ativado: Alt/Option ou Ctrl/Cmd nas setas de extrusão / gizmo desativa o ajuste de valores. Desativado: somente Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fabricação",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave com a roda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização",
|
||||
"ui.snap": "Encaixe",
|
||||
"ui.space_mouse_axes_reassigned": "Os eixos estão reatribuídos nas Preferências, e isso continua valendo sobre esta calibração.",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar mouse 3D",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.",
|
||||
"ui.space_mouse_driven_by": "Qual movimento calibrado do dispositivo controla este eixo. Escolher um que outro eixo já usa troca os dois.",
|
||||
"ui.space_mouse_driven_by_label": "controlado por",
|
||||
"ui.space_mouse_driver": "Driver do mouse 3D",
|
||||
"ui.space_mouse_driver_auto": "Automático",
|
||||
"ui.space_mouse_driver_direct": "Direto (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Lendo o dispositivo diretamente; o spacenavd não está em execução",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Lendo pelo spacenavd, a única opção disponível",
|
||||
"ui.space_mouse_forget": "Esquecer calibração",
|
||||
"ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.",
|
||||
"ui.space_mouse_motion_rotation_x": "Rotação X",
|
||||
"ui.space_mouse_motion_rotation_y": "Rotação Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Rotação Z",
|
||||
"ui.space_mouse_motion_translation_x": "Translação X",
|
||||
"ui.space_mouse_motion_translation_y": "Translação Y",
|
||||
"ui.space_mouse_motion_translation_z": "Translação Z",
|
||||
"ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Nenhum mouse 3D encontrado",
|
||||
"ui.space_mouse_not_calibrated": "Não calibrado",
|
||||
"ui.space_mouse_reading": "Leitura",
|
||||
"ui.space_mouse_reset_axes": "Redefinir atribuição de eixos",
|
||||
"ui.space_mouse_step_lift": "Levante a tampa, segure e solte.",
|
||||
"ui.space_mouse_step_press": "Pressione a tampa para baixo, segure e solte.",
|
||||
"ui.space_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
|
||||
"ui.space_mouse_view_axis": "Eixo da vista",
|
||||
"ui.space_mouse_view_off": "Desligado",
|
||||
"ui.space_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Orbitar esquerda / direita",
|
||||
"ui.space_mouse_view_orbit_up_down": "Orbitar cima / baixo",
|
||||
"ui.space_mouse_view_pan_sideways": "Deslocar esquerda / direita",
|
||||
"ui.space_mouse_view_pan_up_down": "Deslocar cima / baixo",
|
||||
"ui.space_mouse_view_roll": "Girar a vista",
|
||||
"ui.space_mouse_view_unmapped": "Eixo {n}",
|
||||
"ui.space_mouse_view_zoom": "Zoom",
|
||||
"ui.spacing": "Espaçamento",
|
||||
"ui.spacing_2": "Espaçamento 2",
|
||||
"ui.sphere": "Esfera",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Unghi",
|
||||
"ui.angle_decimals": "Zecimale unghi",
|
||||
"ui.animateJoint": "Animă îmbinarea",
|
||||
"ui.animate_view_changes": "Animă schimbările de vizualizare",
|
||||
"ui.animation": "Animație",
|
||||
"ui.animation_speed": "Viteza animației",
|
||||
"ui.anti_aliasing": "Anti-aliasing",
|
||||
"ui.apply": "Aplică",
|
||||
"ui.arc": "Arc",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Blochează",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Privește spre",
|
||||
"ui.look_at_anim_desc": "Vizualizare pe axă, Privește spre, Acasă și Potrivire",
|
||||
"ui.manipulator_free_alt": "Tragere liberă a manipulatorului și cu Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Activat: Alt/Option sau Ctrl/Cmd pe săgețile de extrudare / manipulator dezactivează agățarea la valori. Dezactivat: doar Ctrl/Cmd.",
|
||||
"ui.manufacture": "Fabricație",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Угол",
|
||||
"ui.angle_decimals": "Знаков после запятой (углы)",
|
||||
"ui.animateJoint": "Анимировать сопряжение",
|
||||
"ui.animate_view_changes": "Анимировать смену вида",
|
||||
"ui.animation": "Анимация",
|
||||
"ui.animation_speed": "Скорость анимации",
|
||||
"ui.anti_aliasing": "Сглаживание",
|
||||
"ui.apply": "Применить",
|
||||
"ui.arc": "Дуга",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Заблокировать",
|
||||
"ui.loft": "Лофт",
|
||||
"ui.look_at": "Взгляд на",
|
||||
"ui.look_at_anim_desc": "Осевые виды, «Взгляд на», «Главная» и «Вписать»",
|
||||
"ui.manipulator_free_alt": "Свободное перетаскивание манипулятора также по Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Вкл.: Alt/Option или Ctrl/Cmd на стрелках выдавливания / гизмо отключают привязку значений. Выкл.: только Ctrl/Cmd.",
|
||||
"ui.manufacture": "Производство",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Плавное масштабирование колесом",
|
||||
"ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания",
|
||||
"ui.snap": "Привязка",
|
||||
"ui.space_mouse_axes_reassigned": "Оси переназначены в настройках, и это действует и поверх этой калибровки.",
|
||||
"ui.space_mouse_calibrate": "Калибровать…",
|
||||
"ui.space_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.",
|
||||
"ui.space_mouse_calibrate_title": "Калибровка 3D-мыши",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.",
|
||||
"ui.space_mouse_dead_zone": "Мёртвая зона",
|
||||
"ui.space_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.",
|
||||
"ui.space_mouse_driven_by": "Какое откалиброванное движение устройства управляет этой осью. Если выбрать движение, которое уже использует другая ось, они поменяются местами.",
|
||||
"ui.space_mouse_driven_by_label": "управляется",
|
||||
"ui.space_mouse_driver": "Драйвер 3D-мыши",
|
||||
"ui.space_mouse_driver_auto": "Автоматически",
|
||||
"ui.space_mouse_driver_direct": "Напрямую (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Чтение напрямую с устройства; spacenavd не запущен",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Чтение через spacenavd, других вариантов нет",
|
||||
"ui.space_mouse_forget": "Забыть калибровку",
|
||||
"ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.",
|
||||
"ui.space_mouse_motion_rotation_x": "Вращение X",
|
||||
"ui.space_mouse_motion_rotation_y": "Вращение Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Вращение Z",
|
||||
"ui.space_mouse_motion_translation_x": "Смещение X",
|
||||
"ui.space_mouse_motion_translation_y": "Смещение Y",
|
||||
"ui.space_mouse_motion_translation_z": "Смещение Z",
|
||||
"ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D-мышь не найдена",
|
||||
"ui.space_mouse_not_calibrated": "Не откалибрована",
|
||||
"ui.space_mouse_reading": "Показания",
|
||||
"ui.space_mouse_reset_axes": "Сбросить назначение осей",
|
||||
"ui.space_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.",
|
||||
"ui.space_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.",
|
||||
"ui.space_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.",
|
||||
"ui.space_mouse_view_axis": "Ось вида",
|
||||
"ui.space_mouse_view_off": "Выкл.",
|
||||
"ui.space_mouse_view_off_tip": "Профиль драйвера 3D-мыши отключает эту ось, поэтому её движение не двигает вид. Если выбрать здесь движение другой оси, они поменяются местами, и это движение перестанет двигать вид.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Орбита влево / вправо",
|
||||
"ui.space_mouse_view_orbit_up_down": "Орбита вверх / вниз",
|
||||
"ui.space_mouse_view_pan_sideways": "Сдвиг влево / вправо",
|
||||
"ui.space_mouse_view_pan_up_down": "Сдвиг вверх / вниз",
|
||||
"ui.space_mouse_view_roll": "Крен",
|
||||
"ui.space_mouse_view_unmapped": "Ось {n}",
|
||||
"ui.space_mouse_view_zoom": "Масштаб",
|
||||
"ui.spacing": "Интервал",
|
||||
"ui.spacing_2": "Интервал 2",
|
||||
"ui.sphere": "Сфера",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Vinkel",
|
||||
"ui.angle_decimals": "Vinkeldecimaler",
|
||||
"ui.animateJoint": "Animera led",
|
||||
"ui.animate_view_changes": "Animera vyändringar",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationshastighet",
|
||||
"ui.anti_aliasing": "Kantutjämning",
|
||||
"ui.apply": "Tillämpa",
|
||||
"ui.arc": "Båge",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Lås",
|
||||
"ui.loft": "Loftning",
|
||||
"ui.look_at": "Titta på",
|
||||
"ui.look_at_anim_desc": "Axelvy, Titta på, Hem och Anpassa",
|
||||
"ui.manipulator_free_alt": "Fri manipulatordragning även med Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "På: Alt/Option eller Ctrl/Cmd på extruderings-/gizmopilar inaktiverar värdesnappning. Av: endast Ctrl/Cmd.",
|
||||
"ui.manufacture": "Tillverkning",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Açı",
|
||||
"ui.angle_decimals": "Açı ondalık basamakları",
|
||||
"ui.animateJoint": "Bağlantıyı canlandır",
|
||||
"ui.animate_view_changes": "Görünüm değişikliklerini animasyonla göster",
|
||||
"ui.animation": "Animasyon",
|
||||
"ui.animation_speed": "Animasyon hızı",
|
||||
"ui.anti_aliasing": "Kenar yumuşatma",
|
||||
"ui.apply": "Uygula",
|
||||
"ui.arc": "Yay",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Kilitle",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Bak",
|
||||
"ui.look_at_anim_desc": "Eksen görünümü, Bak, Başlangıç ve Sığdır",
|
||||
"ui.manipulator_free_alt": "Manipülatör serbest sürükleme Alt/Option ile de",
|
||||
"ui.manipulator_free_alt_tip": "Açık: ekstrüzyon / gizmo oklarında Alt/Option veya Ctrl/Cmd değer yakalamayı devre dışı bırakır. Kapalı: yalnızca Ctrl/Cmd.",
|
||||
"ui.manufacture": "Üretim",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Yumuşak tekerlek yakınlaştırması",
|
||||
"ui.smooth_wheel_zoom_tip": "Her tekerlek hareketini yumuşatma olmadan hemen uygulamak için kapatın",
|
||||
"ui.snap": "Yakala",
|
||||
"ui.space_mouse_axes_reassigned": "Eksenler Tercihler'de yeniden atanmış; bu, bu kalibrasyonun üstüne de uygulanır.",
|
||||
"ui.space_mouse_calibrate": "Kalibre et…",
|
||||
"ui.space_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.",
|
||||
"ui.space_mouse_calibrate_title": "3B fareyi kalibre et",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.",
|
||||
"ui.space_mouse_dead_zone": "Ölü bölge",
|
||||
"ui.space_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.",
|
||||
"ui.space_mouse_driven_by": "Bu ekseni cihazın hangi kalibre edilmiş hareketinin yönettiği. Başka bir eksenin zaten kullandığını seçerseniz ikisi yer değiştirir.",
|
||||
"ui.space_mouse_driven_by_label": "kumanda eden",
|
||||
"ui.space_mouse_driver": "3B fare sürücüsü",
|
||||
"ui.space_mouse_driver_auto": "Otomatik",
|
||||
"ui.space_mouse_driver_direct": "Doğrudan (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Aygıt doğrudan okunuyor; spacenavd çalışmıyor",
|
||||
"ui.space_mouse_driver_only_spacenavd": "spacenavd üzerinden okunuyor, tek seçenek bu",
|
||||
"ui.space_mouse_forget": "Kalibrasyonu unut",
|
||||
"ui.space_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.",
|
||||
"ui.space_mouse_motion_rotation_x": "Dönme X",
|
||||
"ui.space_mouse_motion_rotation_y": "Dönme Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Dönme Z",
|
||||
"ui.space_mouse_motion_translation_x": "Öteleme X",
|
||||
"ui.space_mouse_motion_translation_y": "Öteleme Y",
|
||||
"ui.space_mouse_motion_translation_z": "Öteleme Z",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse ile gezin",
|
||||
"ui.space_mouse_none": "3B fare bulunamadı",
|
||||
"ui.space_mouse_not_calibrated": "Kalibre edilmedi",
|
||||
"ui.space_mouse_reading": "Okunan değer",
|
||||
"ui.space_mouse_reset_axes": "Eksen atamasını sıfırla",
|
||||
"ui.space_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_view_axis": "Görünüm ekseni",
|
||||
"ui.space_mouse_view_off": "Kapalı",
|
||||
"ui.space_mouse_view_off_tip": "3D fare sürücüsünün profili bu ekseni kapatıyor, bu yüzden hareketi görünümü oynatmaz. Burada başka bir eksenin hareketini seçerseniz ikisi yer değiştirir ve o hareket görünümü oynatmayı bırakır.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Sola / sağa yörünge",
|
||||
"ui.space_mouse_view_orbit_up_down": "Yukarı / aşağı yörünge",
|
||||
"ui.space_mouse_view_pan_sideways": "Sola / sağa kaydır",
|
||||
"ui.space_mouse_view_pan_up_down": "Yukarı / aşağı kaydır",
|
||||
"ui.space_mouse_view_roll": "Yuvarlanma",
|
||||
"ui.space_mouse_view_unmapped": "Eksen {n}",
|
||||
"ui.space_mouse_view_zoom": "Yakınlaştır",
|
||||
"ui.spacing": "Aralık",
|
||||
"ui.spacing_2": "Aralık 2",
|
||||
"ui.sphere": "Küre",
|
||||
|
||||
+25
-3
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Кут",
|
||||
"ui.angle_decimals": "Знаки після коми для кутів",
|
||||
"ui.animateJoint": "Анімувати з'єднання",
|
||||
"ui.animate_view_changes": "Анімувати зміни виду",
|
||||
"ui.animation": "Анімація",
|
||||
"ui.animation_speed": "Швидкість анімації",
|
||||
"ui.anti_aliasing": "Згладжування",
|
||||
"ui.apply": "Застосувати",
|
||||
"ui.arc": "Дуга",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Заблокувати",
|
||||
"ui.loft": "Лофт",
|
||||
"ui.look_at": "Наведення",
|
||||
"ui.look_at_anim_desc": "Вид осі, наведення, головний і вписування",
|
||||
"ui.manipulator_free_alt": "Вільне перетягування маніпулятора також з Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Увімк.: Alt/Option або Ctrl/Cmd на стрілках видавлювання / гізмо вимикає прив'язку до значень. Вимк.: лише Ctrl/Cmd.",
|
||||
"ui.manufacture": "Виготовлення",
|
||||
@@ -1737,6 +1734,7 @@
|
||||
"ui.smooth_wheel_zoom": "Плавне масштабування колесом",
|
||||
"ui.smooth_wheel_zoom_tip": "Вимкніть, щоб застосовувати кожну подію колеса негайно, без плавного завершення",
|
||||
"ui.snap": "Прив'язка",
|
||||
"ui.space_mouse_axes_reassigned": "Осі перепризначено в налаштуваннях, і це діє й поверх цього калібрування.",
|
||||
"ui.space_mouse_calibrate": "Калібрувати…",
|
||||
"ui.space_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.",
|
||||
"ui.space_mouse_calibrate_title": "Калібрування 3D-миші",
|
||||
@@ -1746,12 +1744,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.",
|
||||
"ui.space_mouse_dead_zone": "Мертва зона",
|
||||
"ui.space_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.",
|
||||
"ui.space_mouse_driven_by": "Який відкалібрований рух пристрою керує цією віссю. Якщо вибрати рух, який уже використовує інша вісь, вони поміняються місцями.",
|
||||
"ui.space_mouse_driven_by_label": "керується",
|
||||
"ui.space_mouse_driver": "Драйвер 3D-миші",
|
||||
"ui.space_mouse_driver_auto": "Автоматично",
|
||||
"ui.space_mouse_driver_direct": "Напряму (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "Читання напряму з пристрою; spacenavd не запущено",
|
||||
"ui.space_mouse_driver_only_spacenavd": "Читання через spacenavd, інших варіантів немає",
|
||||
"ui.space_mouse_forget": "Забути калібрування",
|
||||
"ui.space_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.",
|
||||
"ui.space_mouse_motion_rotation_x": "Обертання X",
|
||||
"ui.space_mouse_motion_rotation_y": "Обертання Y",
|
||||
"ui.space_mouse_motion_rotation_z": "Обертання Z",
|
||||
"ui.space_mouse_motion_translation_x": "Зміщення X",
|
||||
"ui.space_mouse_motion_translation_y": "Зміщення Y",
|
||||
"ui.space_mouse_motion_translation_z": "Зміщення Z",
|
||||
"ui.space_mouse_navigate": "Навігація за допомогою SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D-мишу не знайдено",
|
||||
"ui.space_mouse_not_calibrated": "Не відкалібровано",
|
||||
"ui.space_mouse_reading": "Показники",
|
||||
"ui.space_mouse_reset_axes": "Скинути призначення осей",
|
||||
"ui.space_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.",
|
||||
@@ -1765,6 +1777,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.",
|
||||
"ui.space_mouse_view_axis": "Вісь вигляду",
|
||||
"ui.space_mouse_view_off": "Вимк.",
|
||||
"ui.space_mouse_view_off_tip": "Профіль драйвера 3D-миші вимикає цю вісь, тож її рух не рухає вигляд. Якщо вибрати тут рух іншої осі, вони поміняються місцями, і цей рух перестане рухати вигляд.",
|
||||
"ui.space_mouse_view_orbit_sideways": "Орбіта ліворуч / праворуч",
|
||||
"ui.space_mouse_view_orbit_up_down": "Орбіта вгору / вниз",
|
||||
"ui.space_mouse_view_pan_sideways": "Зсув ліворуч / праворуч",
|
||||
"ui.space_mouse_view_pan_up_down": "Зсув угору / вниз",
|
||||
"ui.space_mouse_view_roll": "Крен",
|
||||
"ui.space_mouse_view_unmapped": "Вісь {n}",
|
||||
"ui.space_mouse_view_zoom": "Масштаб",
|
||||
"ui.spacing": "Крок",
|
||||
"ui.spacing_2": "Крок 2",
|
||||
"ui.sphere": "Куля",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "Góc",
|
||||
"ui.angle_decimals": "Số chữ số thập phân cho góc",
|
||||
"ui.animateJoint": "Mô phỏng chuyển động khớp nối",
|
||||
"ui.animate_view_changes": "Hiệu ứng chuyển góc nhìn",
|
||||
"ui.animation": "Hoạt họa",
|
||||
"ui.animation_speed": "Tốc độ chuyển động",
|
||||
"ui.anti_aliasing": "Khử răng cưa",
|
||||
"ui.apply": "Áp dụng",
|
||||
"ui.arc": "Cung tròn",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "Khóa",
|
||||
"ui.loft": "Vuốt nối biên dạng (Loft)",
|
||||
"ui.look_at": "Nhìn trực diện",
|
||||
"ui.look_at_anim_desc": "Góc nhìn theo trục, Nhìn thẳng, Chuẩn và Vừa vặn",
|
||||
"ui.manipulator_free_alt": "Kéo tự do bộ thao tác cũng bằng Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "Bật: Alt/Option hoặc Ctrl/Cmd trên các mũi tên đùn / gizmo sẽ tắt bước nhảy giá trị. Tắt: chỉ Ctrl/Cmd.",
|
||||
"ui.manufacture": "Gia công chế tạo",
|
||||
|
||||
@@ -1287,9 +1287,7 @@
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度小數位數",
|
||||
"ui.animateJoint": "動畫接頭",
|
||||
"ui.animate_view_changes": "檢視變化動畫",
|
||||
"ui.animation": "動畫",
|
||||
"ui.animation_speed": "動畫速度",
|
||||
"ui.anti_aliasing": "抗鋸齒",
|
||||
"ui.apply": "套用",
|
||||
"ui.arc": "圓弧",
|
||||
@@ -1551,7 +1549,6 @@
|
||||
"ui.lock": "鎖定",
|
||||
"ui.loft": "放樣",
|
||||
"ui.look_at": "注視",
|
||||
"ui.look_at_anim_desc": "軸檢視、注視、主檢視與適合視窗",
|
||||
"ui.manipulator_free_alt": "操縱器自由拖曳也適用於 Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "開:在擠出/小工具箭頭上按 Alt/Option 或 Ctrl/Cmd 可停用數值鎖點。關:僅 Ctrl/Cmd。",
|
||||
"ui.manufacture": "製造",
|
||||
|
||||
+27
-3
@@ -1209,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "已附加视图",
|
||||
"ui.agent_cursor": "代理光标",
|
||||
"ui.agent_cursor_speed": "代理光标速度",
|
||||
"ui.agent_off_thread": "代理运行命令时保持窗口响应",
|
||||
"ui.agent_off_thread_note": "代理(来自 AI 面板或 MCP 客户端)的耗时命令在窗口之外计算:运行时仍可旋转和查看视图,并可在视图中的提示上停止它。关闭:每条命令在窗口线程上运行,窗口会等待其完成。",
|
||||
"ui.ai_add": "添加",
|
||||
"ui.ai_add_agent": "添加智能体…",
|
||||
"ui.ai_add_agent_title": "添加智能体",
|
||||
@@ -1293,9 +1295,7 @@
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度小数位",
|
||||
"ui.animateJoint": "动画接头",
|
||||
"ui.animate_view_changes": "视图变化动画",
|
||||
"ui.animation": "动画",
|
||||
"ui.animation_speed": "动画速度",
|
||||
"ui.anti_aliasing": "抗锯齿",
|
||||
"ui.apply": "应用",
|
||||
"ui.arc": "圆弧",
|
||||
@@ -1557,7 +1557,6 @@
|
||||
"ui.lock": "锁定",
|
||||
"ui.loft": "放样",
|
||||
"ui.look_at": "朝向",
|
||||
"ui.look_at_anim_desc": "轴视图、注视、主视图与适合窗口",
|
||||
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
|
||||
"ui.manufacture": "制造",
|
||||
@@ -1743,6 +1742,7 @@
|
||||
"ui.smooth_wheel_zoom": "平滑滚轮缩放",
|
||||
"ui.smooth_wheel_zoom_tip": "关闭后每个滚轮事件立即生效,不做平滑延迟",
|
||||
"ui.snap": "捕捉",
|
||||
"ui.space_mouse_axes_reassigned": "轴已在偏好设置中重新分配,在此次校准之上仍然生效。",
|
||||
"ui.space_mouse_calibrate": "校准…",
|
||||
"ui.space_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。",
|
||||
"ui.space_mouse_calibrate_title": "校准 3D 鼠标",
|
||||
@@ -1752,12 +1752,26 @@
|
||||
"ui.space_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。",
|
||||
"ui.space_mouse_dead_zone": "死区",
|
||||
"ui.space_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。",
|
||||
"ui.space_mouse_driven_by": "由设备的哪个校准动作控制此轴。选择另一个轴已在使用的动作时,两者会互换。",
|
||||
"ui.space_mouse_driven_by_label": "控制来源",
|
||||
"ui.space_mouse_driver": "3D 鼠标驱动",
|
||||
"ui.space_mouse_driver_auto": "自动",
|
||||
"ui.space_mouse_driver_direct": "直接 (hidraw)",
|
||||
"ui.space_mouse_driver_only_direct": "直接读取设备(spacenavd 未运行)",
|
||||
"ui.space_mouse_driver_only_spacenavd": "通过 spacenavd 读取(没有其他方式)",
|
||||
"ui.space_mouse_forget": "清除校准",
|
||||
"ui.space_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。",
|
||||
"ui.space_mouse_motion_rotation_x": "旋转 X",
|
||||
"ui.space_mouse_motion_rotation_y": "旋转 Y",
|
||||
"ui.space_mouse_motion_rotation_z": "旋转 Z",
|
||||
"ui.space_mouse_motion_translation_x": "平移 X",
|
||||
"ui.space_mouse_motion_translation_y": "平移 Y",
|
||||
"ui.space_mouse_motion_translation_z": "平移 Z",
|
||||
"ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 导航",
|
||||
"ui.space_mouse_none": "未找到 3D 鼠标",
|
||||
"ui.space_mouse_not_calibrated": "未校准",
|
||||
"ui.space_mouse_reading": "读数",
|
||||
"ui.space_mouse_reset_axes": "重置轴分配",
|
||||
"ui.space_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。",
|
||||
@@ -1771,6 +1785,16 @@
|
||||
"ui.space_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。",
|
||||
"ui.space_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_view_axis": "视图轴",
|
||||
"ui.space_mouse_view_off": "关闭",
|
||||
"ui.space_mouse_view_off_tip": "3D 鼠标驱动程序的配置文件关闭了此轴,因此它的动作不会移动视图。在此选择另一个轴的动作会将两者互换,该动作将不再移动视图。",
|
||||
"ui.space_mouse_view_orbit_sideways": "左右环绕",
|
||||
"ui.space_mouse_view_orbit_up_down": "上下环绕",
|
||||
"ui.space_mouse_view_pan_sideways": "左右平移",
|
||||
"ui.space_mouse_view_pan_up_down": "上下平移",
|
||||
"ui.space_mouse_view_roll": "滚转",
|
||||
"ui.space_mouse_view_unmapped": "轴 {n}",
|
||||
"ui.space_mouse_view_zoom": "缩放",
|
||||
"ui.spacing": "间距",
|
||||
"ui.spacing_2": "间距 2",
|
||||
"ui.sphere": "球体",
|
||||
|
||||
@@ -7,8 +7,13 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
|
||||
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
|
||||
let relative = anchor.source_to_author(doc, author, raw)?;
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solidcraft_kernel::transform_matrix(&raw.body, relative)? };
|
||||
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
|
||||
// In place, the body is the model's own, display mesh included: a sketch projecting the
|
||||
// face's edges meshes it once for the features after it too, rather than a copy.
|
||||
let mut body = if crate::is_identity(&relative) {
|
||||
raw.clone()
|
||||
} else {
|
||||
ModelBody::new(raw.name.clone(), solidcraft_kernel::transform_matrix(&raw.body, relative)?, raw.feature)
|
||||
};
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
|
||||
@@ -180,6 +180,9 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
|
||||
current["format"] = json!("solidcraft/12");
|
||||
crate::format::pieces_in_shell_order(&mut legacy["features"]);
|
||||
for f in legacy["features"].as_array_mut().into_iter().flatten() {
|
||||
f["kernel_piece_order"] = json!(true);
|
||||
}
|
||||
assert_eq!(legacy, current, "the migration preserves every prior field");
|
||||
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
|
||||
@@ -950,6 +950,15 @@ pub enum FeatureKind {
|
||||
/// Normals the same way instead of facing each other.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
/// A plane the bodies move with (an origin or construction plane): its origin and normal
|
||||
/// are the `from` point and normal, worked out again whenever the plane moves.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
from_plane: Option<PlaneRef>,
|
||||
/// A plane the bodies move onto: after turning to meet it (when `from` has a normal) the
|
||||
/// `from` point is brought onto the plane along its normal, so the plane may move and the
|
||||
/// bodies follow. Replaces `to`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to_plane: Option<PlaneRef>,
|
||||
},
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
@@ -2320,6 +2329,12 @@ pub struct Feature {
|
||||
/// where newer ones number them after the first name (`Ribs (4)`, #373).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub shell_piece_order: bool,
|
||||
/// The pieces of a face this feature splits are numbered in the kernel's order and by the
|
||||
/// index of the face they came from, as builds before #3902 numbered them, instead of by
|
||||
/// place. Set on the features of designs saved before then, whose stored `#k` face names
|
||||
/// mean that numbering.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub kernel_piece_order: bool,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -3021,6 +3036,7 @@ impl Document {
|
||||
face_names: Vec::new(),
|
||||
participants: Vec::new(),
|
||||
shell_piece_order: false,
|
||||
kernel_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
self.place_feature(f);
|
||||
|
||||
+487
-65
@@ -324,11 +324,12 @@ fn format_thread(d: f64, p: f64) -> String {
|
||||
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
|
||||
/// a thread feature without an explicit size. This query does not change the model.
|
||||
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
|
||||
thread_on(st, 0, p, None, None).map(|t| t.designation)
|
||||
thread_on(st, 0, p, None, None, false).map(|t| t.designation)
|
||||
}
|
||||
|
||||
/// A cosmetic thread on the cylindrical face at `p`.
|
||||
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>) -> Result<ThreadInfo> {
|
||||
/// A cosmetic thread on the cylindrical face at `p`. A `turned` (modelled) thread also fits a
|
||||
/// shaft thicker than it, which it turns down to its major diameter first.
|
||||
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>, turned: bool) -> Result<ThreadInfo> {
|
||||
let (body, cyl) = st
|
||||
.bodies
|
||||
.iter()
|
||||
@@ -346,7 +347,7 @@ fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>,
|
||||
let fits = if cyl.internal {
|
||||
(major - pitch * 1.0825 - dia).abs() < 0.2 * major || (major - pitch - dia).abs() < 0.15 * major
|
||||
} else {
|
||||
(major - dia).abs() < 0.15 * major
|
||||
(major - dia).abs() < 0.15 * major || (turned && dia > major)
|
||||
};
|
||||
if !fits {
|
||||
return Err(DocError::Invalid(format!(
|
||||
@@ -468,6 +469,73 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, *k, p, (s1 - s0) / p, stations as usize)?)
|
||||
}
|
||||
|
||||
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
|
||||
/// cuts, as a lathe would: the ring between the major diameter and the shaft over the threaded
|
||||
/// length, run on past an end of the face that nothing of the part (`mb`) lies beyond. `None`
|
||||
/// for a shaft no thicker than the thread. Its inner wall is the turned face, named `turned`;
|
||||
/// an end that stops inside the face leaves a shoulder, `shoulder:start` or `shoulder:end`.
|
||||
fn thread_turn(t: &ThreadInfo, cyl: &kernel::CylinderFace, mb: &ModelBody) -> Result<Option<Traced>> {
|
||||
let (p, r) = (t.pitch, t.major_diameter / 2.0);
|
||||
if t.internal || !(cyl.radius > r * (1.0 + 1e-6)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let k = t.axis;
|
||||
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
|
||||
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
|
||||
let v = k.cross(u);
|
||||
let mesh = mb.mesh();
|
||||
let solid_round = |s: f64, radius: f64| {
|
||||
(0..12).any(|i| {
|
||||
let a = std::f64::consts::TAU * f64::from(i) / 12.0;
|
||||
mesh.contains(t.axis_point + *k * s + (u * a.cos() + v * a.sin()) * radius)
|
||||
})
|
||||
};
|
||||
// The ring reaches a little past the shaft; the part must not go on there (a shaft standing
|
||||
// in a corner, a rounded edge), or the turning would cut into it.
|
||||
let out = cyl.radius + 0.1 * p;
|
||||
if [0.25, 0.5, 0.75].iter().any(|f| solid_round(t.start + (t.end - t.start) * f, cyl.radius + 0.05 * p)) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"this {} mm shaft is too thick for a modelled {} thread, and the part around it keeps it from being turned down; make the shaft {} mm",
|
||||
show_mm(2.0 * cyl.radius),
|
||||
t.designation,
|
||||
show_mm(t.major_diameter)
|
||||
)));
|
||||
}
|
||||
// Past a free end of the face out into the air; elsewhere square at the threaded length's end.
|
||||
let tol = p * 1e-3;
|
||||
let mid = (r + cyl.radius) / 2.0;
|
||||
let free = |at: f64, s: f64, past: f64| (at - s).abs() < tol && !solid_round(s + past * 0.5 * p, mid);
|
||||
let (open0, open1) = (free(cyl.start, t.start, -1.0), free(cyl.end, t.end, 1.0));
|
||||
let s0 = if open0 { t.start - p } else { t.start };
|
||||
let s1 = if open1 { t.end + p } else { t.end };
|
||||
let plane = Plane { origin: t.axis_point, x: u, y: *k };
|
||||
let ring = [Vec2::new(r, s0), Vec2::new(out, s0), Vec2::new(out, s1), Vec2::new(r, s1)];
|
||||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&ring), holes: vec![] };
|
||||
let ring = kernel::revolve(&plane, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("thread: the shaft turned down to the thread".into()))?;
|
||||
// The ring's sides by profile segment: its start end, outside, end end, inner wall.
|
||||
let role = |segment: usize| -> Vec<String> {
|
||||
match segment {
|
||||
0 if !open0 => vec!["shoulder:start".into()],
|
||||
2 if !open1 => vec!["shoulder:end".into()],
|
||||
3 => vec!["turned".into()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let roles = ring.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[kernel::history::FaceOrigin::Profile { segment }] => role(*segment),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(Some(Traced::made(ring, roles)))
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
|
||||
/// for each group of edges sharing a face; the radius per edge is returned. With
|
||||
@@ -777,6 +845,48 @@ impl EvalCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The results an evaluation ahead on a copy of a model computed or took from the cache, kept for
|
||||
/// the run that follows on the model itself (#1989). The evaluation cache is bounded by bytes and
|
||||
/// charges every entry its whole model, so on a design with large bodies it holds only the last
|
||||
/// few states: by the time the run came, the cache had dropped most of what was computed ahead,
|
||||
/// and the run computed it all again, on the window thread.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeptResults(std::sync::Mutex<std::collections::HashMap<u64, CachedEval>>);
|
||||
|
||||
impl KeptResults {
|
||||
/// Results kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.lock().map_or(0, |m| m.len())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
fn get(&self, key: u64) -> Option<CachedEval> {
|
||||
self.0.lock().ok().and_then(|m| m.get(&key).cloned())
|
||||
}
|
||||
|
||||
fn put(&self, key: u64, e: &CachedEval) {
|
||||
if let Ok(mut m) = self.0.lock()
|
||||
&& (m.len() < CACHE_ENTRIES || m.contains_key(&key))
|
||||
{
|
||||
m.insert(key, e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a model keeps its results besides the cache ([`Model::keep`]). A copy of the model
|
||||
/// starts without: it keeps nothing it computes for someone else's run.
|
||||
#[derive(Debug, Default)]
|
||||
struct KeptSlot(Option<Arc<KeptResults>>);
|
||||
|
||||
impl Clone for KeptSlot {
|
||||
fn clone(&self) -> Self {
|
||||
KeptSlot(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl SolvedSketch {
|
||||
/// Rough heap footprint (see [`ModelState::retained_bytes`]).
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
@@ -810,6 +920,14 @@ impl ModelState {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// [`ModelState::warm_meshes`], and every body's display plan at the current display quality
|
||||
/// (what a GPU viewport draws).
|
||||
pub fn warm_display(&self) {
|
||||
kernel::for_each_on_threads(&self.bodies, 16, |b| {
|
||||
let _ = b.mesh();
|
||||
let _ = b.display_plan();
|
||||
});
|
||||
}
|
||||
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
||||
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
|
||||
}
|
||||
@@ -844,6 +962,9 @@ pub struct Model {
|
||||
empty: Arc<ModelState>,
|
||||
/// Features recomputed by the last `evaluate`.
|
||||
pub last_recomputed: usize,
|
||||
/// Features recomputed by every `evaluate` of this model (and of the model it was copied
|
||||
/// from) so far.
|
||||
pub recomputed: usize,
|
||||
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
|
||||
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
|
||||
/// configuration row). Their geometry did change since the previous evaluation.
|
||||
@@ -852,6 +973,8 @@ pub struct Model {
|
||||
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||||
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||||
pub no_cache: bool,
|
||||
/// Results kept besides the cache ([`Model::keep`]).
|
||||
kept: KeptSlot,
|
||||
/// The imported texts' digests the last `evaluate` fingerprinted the features with.
|
||||
step_digests: StepDigests,
|
||||
}
|
||||
@@ -1163,7 +1286,12 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
};
|
||||
// A mirror of placed bodies follows where they are placed.
|
||||
let placed_mirror = matches!(&f.kind, FeatureKind::Mirror { placements, .. } if !placements.is_empty());
|
||||
if to_object || placed_mirror {
|
||||
// A combine moves tools placed differently from its target into the target's frame (#3383).
|
||||
let combine = matches!(&f.kind, FeatureKind::Combine { .. });
|
||||
if !to_object && !combine && drawn_tools_change_bodies(&f.kind) {
|
||||
return drawn_tools_digest(doc, f);
|
||||
}
|
||||
if to_object || placed_mirror || combine {
|
||||
return if doc.occurrences.is_empty() && doc.body_offsets.is_empty() { 0 } else { placements_hash(doc) };
|
||||
}
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||
@@ -1290,6 +1418,20 @@ impl Model {
|
||||
})
|
||||
}
|
||||
|
||||
/// While `kept` is set, every result this model evaluates or takes from the cache is also
|
||||
/// kept there, and a result the cache no longer has is taken from there (#1989): an
|
||||
/// evaluation ahead on a copy keeps what it computes, and the run that follows on the model
|
||||
/// itself finds it all, whatever the cache dropped meanwhile. Copies of the model keep
|
||||
/// nothing.
|
||||
pub fn keep(&mut self, kept: Option<Arc<KeptResults>>) {
|
||||
self.kept = KeptSlot(kept);
|
||||
}
|
||||
|
||||
/// Where this model keeps its results besides the cache, if it does ([`Model::keep`]).
|
||||
pub fn kept(&self) -> Option<Arc<KeptResults>> {
|
||||
self.kept.0.clone()
|
||||
}
|
||||
|
||||
/// Whether the results are `doc`'s evaluation: one per feature, each on the inputs `doc`
|
||||
/// gives it, none stale, so [`Model::evaluate`] would keep every one of them. A document
|
||||
/// changed without an evaluation since is not.
|
||||
@@ -1363,9 +1505,13 @@ impl Model {
|
||||
}
|
||||
// The same feature on the same inputs: its earlier result.
|
||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f), ctx, &state);
|
||||
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
|
||||
if !self.no_cache
|
||||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)).or_else(|| kept.and_then(|k| k.get(key)))
|
||||
{
|
||||
if let Some(k) = kept {
|
||||
k.put(key, &c);
|
||||
}
|
||||
restored += 1;
|
||||
if c.error.is_none() {
|
||||
state = c.state.clone();
|
||||
@@ -1494,20 +1640,21 @@ impl Model {
|
||||
}
|
||||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||||
};
|
||||
if !self.no_cache
|
||||
&& let Ok(mut m) = self.cache.lock()
|
||||
{
|
||||
m.put(
|
||||
key,
|
||||
CachedEval {
|
||||
error: error.clone(),
|
||||
warning: warning.clone(),
|
||||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: state.retained_bytes(),
|
||||
state: state.clone(),
|
||||
},
|
||||
);
|
||||
if !self.no_cache {
|
||||
let e = CachedEval {
|
||||
error: error.clone(),
|
||||
warning: warning.clone(),
|
||||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: state.retained_bytes(),
|
||||
state: state.clone(),
|
||||
};
|
||||
if let Some(k) = &self.kept.0 {
|
||||
k.put(key, &e);
|
||||
}
|
||||
if let Ok(mut m) = self.cache.lock() {
|
||||
m.put(key, e);
|
||||
}
|
||||
}
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
@@ -1524,6 +1671,7 @@ impl Model {
|
||||
}
|
||||
self.results = out;
|
||||
self.last_recomputed = recomputed;
|
||||
self.recomputed += recomputed;
|
||||
self.last_restored = restored;
|
||||
}
|
||||
}
|
||||
@@ -1957,9 +2105,10 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(op, Operation::Cut | Operation::Intersect) {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
for &i in &target_idx {
|
||||
if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) {
|
||||
let m = tool_frame(&mb.name);
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| tool_in(t, m).map(|t| t.body)).collect::<Result<_>>()?;
|
||||
sheet_boolean_unchanged(&mb.body, &bodies, op)?;
|
||||
}
|
||||
}
|
||||
@@ -2019,16 +2168,24 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
if mb.body.is_mesh() || (!met.is_empty() && sheet) {
|
||||
continue;
|
||||
}
|
||||
if mb.body.meets(&tool.body)? {
|
||||
// Each body is met where it is shown: the tool goes into its frame (#3383).
|
||||
if mb.body.meets(&tool_in(tool, tool_frame(name))?.body)? {
|
||||
met.push(name.clone());
|
||||
}
|
||||
}
|
||||
let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) };
|
||||
let Some(base) = state.body(&keep).map(ModelBody::traced) else { continue };
|
||||
let mut acc = union(&base, tool)?;
|
||||
let into_keep = tool_frame(&keep);
|
||||
let mut acc = union(&base, &tool_in(tool, into_keep)?)?;
|
||||
for name in met.iter().skip(1) {
|
||||
if let Some(b) = state.body(name) {
|
||||
acc = union(&acc, &b.traced())?;
|
||||
// A body placed differently from the one kept joins it where it is shown.
|
||||
let from = match tool_frame(name) {
|
||||
Some(m) => crate::mat_inverse(&m),
|
||||
None => Some(crate::IDENTITY),
|
||||
};
|
||||
let m = from.map(|from| crate::mat_mul(&into_keep.unwrap_or(crate::IDENTITY), &from)).filter(|m| !crate::is_identity(m));
|
||||
acc = union(&acc, &tool_in(&b.traced(), m)?)?;
|
||||
}
|
||||
}
|
||||
let absorbed: std::collections::BTreeSet<String> = met.into_iter().skip(1).collect();
|
||||
@@ -2051,7 +2208,10 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
let mut remove = Vec::new();
|
||||
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));
|
||||
for t in &tools {
|
||||
let t = &tool_in(t, into)?;
|
||||
acc = match acc {
|
||||
Some(a) => kernel::boolean(&a.body, &t.body, bop)?.map(|r| Traced::step(r, &[&a, t])),
|
||||
None => None,
|
||||
@@ -2617,12 +2777,13 @@ 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.
|
||||
fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 {
|
||||
/// 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 {
|
||||
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;
|
||||
for b in &st.bodies {
|
||||
for b in st.bodies.iter().filter(|b| in_hole_scope(scope, b)) {
|
||||
let m = b.mesh();
|
||||
// Already on the surface: the drill enters right here.
|
||||
if let Some((t, _)) = m.raycast(p - dir * eps, dir)
|
||||
@@ -2903,12 +3064,12 @@ pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, V
|
||||
|
||||
/// Whether a drill from `p` along `d` reaches material: it starts into a body (`touches`), or,
|
||||
/// when `ahead` is set, a body lies further along `d`. A spot exactly on a surface counts only
|
||||
/// when the drill enters there, not when it leaves.
|
||||
fn hole_reaches(st: &ModelState, p: Vec3, d: Vec3, ahead: bool) -> bool {
|
||||
/// when the drill enters there, not when it leaves. Only the bodies in `scope` count.
|
||||
fn hole_reaches(st: &ModelState, p: Vec3, d: Vec3, ahead: bool, scope: &[String]) -> bool {
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let eps = size * 1e-6;
|
||||
let q = p + d * eps;
|
||||
st.bodies.iter().any(|b| {
|
||||
st.bodies.iter().filter(|b| in_hole_scope(scope, b)).any(|b| {
|
||||
let m = b.mesh();
|
||||
m.contains(q) || (ahead && m.raycast(q, d).is_some())
|
||||
})
|
||||
@@ -2926,13 +3087,13 @@ pub fn sketch_hole_direction(st: &ModelState, sketch: u64, ids: &[String], flip:
|
||||
let ss = st.sketch(sketch)?;
|
||||
let spots: Vec<Vec3> =
|
||||
ids.iter().map(|id| ss.sketch.points.iter().find(|p| &p.id == id).map(|p| ss.plane.to_world(p.pos))).collect::<Option<_>>()?;
|
||||
let dir = sketch_hole_dir(st, &spots, ss.plane.normal());
|
||||
let dir = sketch_hole_dir(st, &spots, ss.plane.normal(), &[]);
|
||||
Some(if flip { -dir } else { dir })
|
||||
}
|
||||
|
||||
fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 {
|
||||
fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3, scope: &[String]) -> Vec3 {
|
||||
let down = -n;
|
||||
let any = |d: Vec3, ahead: bool| spots.iter().any(|p| hole_reaches(st, *p, d, ahead));
|
||||
let any = |d: Vec3, ahead: bool| spots.iter().any(|p| hole_reaches(st, *p, d, ahead, scope));
|
||||
if any(down, false) {
|
||||
down
|
||||
} else if any(n, false) {
|
||||
@@ -2944,6 +3105,20 @@ fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies a hole at sketch points finds its side and its faces on: the bodies it cuts when it
|
||||
/// names them (a hole from a sketch on a body's face cuts that body, #2882), so a body further
|
||||
/// along can't take the holes meant for it. Empty (every body) otherwise.
|
||||
fn hole_scope(f: &Feature) -> &[String] {
|
||||
match &f.kind {
|
||||
FeatureKind::Hole { points: Some(sp), .. } if !sp.single && !f.participants.iter().any(|b| b == "*") => &f.participants,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn in_hole_scope(scope: &[String], b: &ModelBody) -> bool {
|
||||
scope.is_empty() || scope.contains(&b.name)
|
||||
}
|
||||
|
||||
/// Where a hole feature drills (each on the surface) and in which direction: at its sketch
|
||||
/// points perpendicular to the sketch (into the material), else at its position.
|
||||
fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||||
@@ -2957,21 +3132,21 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
|
||||
let p = ss.sketch.points.iter().find(|p| &p.id == id).ok_or_else(|| DocError::Unknown(format!("sketch point `{id}`")))?;
|
||||
v.push(ss.plane.to_world(p.pos));
|
||||
}
|
||||
let dir = sketch_hole_dir(st, &v, ss.plane.normal());
|
||||
let dir = sketch_hole_dir(st, &v, ss.plane.normal(), hole_scope(f));
|
||||
let dir = if sp.flip { -dir } else { dir };
|
||||
(v, dir)
|
||||
}
|
||||
_ => {
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir);
|
||||
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir, &[]);
|
||||
let dir = hole_dir_into_face(st, at, dir);
|
||||
return Ok((vec![hole_on_surface(st, at, dir)], dir));
|
||||
return Ok((vec![hole_on_surface(st, at, dir, &[])], dir));
|
||||
}
|
||||
};
|
||||
if spots.is_empty() || spots.len() > 10_000 {
|
||||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||||
}
|
||||
Ok((spots.into_iter().map(|p| hole_on_surface(st, p, dir)).collect(), dir))
|
||||
Ok((spots.into_iter().map(|p| hole_on_surface(st, p, dir, hole_scope(f))).collect(), dir))
|
||||
}
|
||||
|
||||
/// A hole's depth: down to the To Object (where the hole's axis meets the face there, else the
|
||||
@@ -3004,14 +3179,14 @@ fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature)
|
||||
let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
|
||||
let v: Vec<Vec3> =
|
||||
sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect();
|
||||
let dir = sketch_hole_dir(st, &v, ss.plane.normal());
|
||||
let dir = sketch_hole_dir(st, &v, ss.plane.normal(), hole_scope(f));
|
||||
let dir = if sp.flip { -dir } else { dir };
|
||||
Ok((v, dir))
|
||||
}
|
||||
_ => {
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let p = single_hole_position(vals, st, position, at, points)?;
|
||||
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir), dir)))
|
||||
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3564,17 +3739,20 @@ fn feature_tools_with_constructor(
|
||||
let n = ss.plane.normal();
|
||||
let tst =
|
||||
if extent.to.is_some() || extent.from.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) };
|
||||
// Through all, and where the tool meets its targets, is measured where they are shown.
|
||||
let shown = targets_as_shown(st, f, &crate::plain(targets))?;
|
||||
let st: &ModelState = &shown;
|
||||
// The face names follow `face_points`: To, then From.
|
||||
let name = |k: usize| f.face_names.get(k).map(String::as_str);
|
||||
let (to_name, from_name) = if extent.to.is_some() { (name(0), name(1)) } else { (None, name(0)) };
|
||||
let to_face = extent.to.and_then(|p| object_face(&tst, to_name, p.0));
|
||||
let to_face = extent.to.and_then(|p| object_face(&tst, to_name, p.0, Some(n)));
|
||||
let centre = {
|
||||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64);
|
||||
ss.plane.to_world(c)
|
||||
};
|
||||
let from = match extent.from {
|
||||
Some(p) => height_at(&tst, object_face(&tst, from_name, p.0), &ss.plane, centre, p.0),
|
||||
Some(p) => height_at(&tst, object_face(&tst, from_name, p.0, Some(n)), &ss.plane, centre, p.0),
|
||||
None => 0.0,
|
||||
};
|
||||
let d = match extent.to {
|
||||
@@ -3921,6 +4099,9 @@ fn feature_tools_with_constructor(
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
// To Object is in this feature's component frame: the model as seen from there.
|
||||
let tst = if to.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) };
|
||||
// Where the tool meets its targets is measured where they are shown (#3383).
|
||||
let shown = targets_as_shown(st, f, &feature_op(f).1)?;
|
||||
let st: &ModelState = &shown;
|
||||
let angles = if to.is_some() {
|
||||
(0.0, None)
|
||||
} else {
|
||||
@@ -3988,7 +4169,7 @@ fn feature_tools_with_constructor(
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// To a curved face: turn past the whole target body, then cut back to its surface
|
||||
// (the piece that starts at the profile).
|
||||
let curved = curved_face(&tst, to.and_then(|p| object_face(&tst, f.face_names.first().map(String::as_str), p.0)), None);
|
||||
let curved = curved_face(&tst, to.and_then(|p| object_face(&tst, f.face_names.first().map(String::as_str), p.0, None)), None);
|
||||
let curved_body = curved.and_then(|bi| tst.bodies.get(bi));
|
||||
// Each region's body: its sides named by the sketch curves, its caps start and end.
|
||||
let tool = |r: &Region2, body: Body, built: Option<&Body>| crate::lineage::ProfileRoles::swept(Some(&ss), Some(r)).tool(body, built);
|
||||
@@ -4536,6 +4717,15 @@ fn rotation(o: Vec3, a: Vec3, ang: f64) -> Mat {
|
||||
[[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]
|
||||
}
|
||||
|
||||
/// A copy of a sheet metal body is sheet metal too: its sheet model goes with it, moved, or
|
||||
/// mirrored into the opposite hand ([`crate::sheet::SheetBody::copied`]), so the copy has its
|
||||
/// own flat pattern, DXF and bend table. A copy by any other transform stays a plain solid.
|
||||
fn copy_sheet(st: &mut ModelState, from: &str, to: &str, m: &crate::Mat) {
|
||||
let Some(sheet) = st.sheets.iter().find(|s| s.body == from).and_then(|s| s.copied(to, m)) else { return };
|
||||
st.sheets.retain(|s| s.body != to);
|
||||
st.sheets.push(sheet);
|
||||
}
|
||||
|
||||
/// Reflection in a plane (column-major).
|
||||
fn mirror_matrix(pl: &Plane) -> Mat {
|
||||
let n = pl.normal();
|
||||
@@ -4790,7 +4980,7 @@ fn replay(
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
for p in &spots {
|
||||
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
|
||||
let at = hole_on_surface(st, crate::apply_point(m, *p), d, hole_scope(src));
|
||||
let to = to.map(|t| crate::apply_point(m, t.0));
|
||||
let depth = hole_depth_to(st, &at, d, &to, depth)?;
|
||||
// Each copy's faces are named as the hole's, with the instance.
|
||||
@@ -4835,7 +5025,7 @@ mod surface;
|
||||
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
|
||||
/// normal meets the body there, else the point itself.
|
||||
pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
|
||||
height_at(st, face_at(st, p), plane, centre, p)
|
||||
height_at(st, face_at(st, p, Some(plane.normal())), plane, centre, p)
|
||||
}
|
||||
|
||||
/// [`object_height`] of the point `p` on `face` (`(body index, face index)`, see [`object_face`]).
|
||||
@@ -4867,31 +5057,63 @@ fn height_at(st: &ModelState, face: Option<(usize, usize)>, plane: &Plane, centr
|
||||
/// face index)`, within that body's pick tolerance. Not the first body with any face within
|
||||
/// its tolerance: that is 5 % of the body's size, so a large body beside a small target (a
|
||||
/// plate under a post, a frame around a pin) would otherwise take a pick made on the target.
|
||||
fn face_at(st: &ModelState, p: Vec3) -> Option<(usize, usize)> {
|
||||
fn face_at(st: &ModelState, p: Vec3, normal: Option<Vec3>) -> Option<(usize, usize)> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, b)| !b.body.is_mesh())
|
||||
.filter_map(|(bi, b)| {
|
||||
let (d, fi) = crate::appearance::nearest_face(b, p)?;
|
||||
let (d, fi) = nearest_object_face(b, p, normal)?;
|
||||
(d <= crate::appearance::pick_tolerance(b)).then_some((d, bi, fi))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, bi, fi)| (bi, fi))
|
||||
}
|
||||
|
||||
/// At a shared edge or corner an extrusion's end plane and side wall can be equally near.
|
||||
/// Prefer the plane square to the extrusion: choosing the wall would instead extend the tool
|
||||
/// past the whole body and try to trim it, even when the tool never intersects that body.
|
||||
/// Only settle a distance tie; a nearby plane must not steal a pick on another face.
|
||||
fn nearest_object_face(b: &ModelBody, p: Vec3, normal: Option<Vec3>) -> Option<(f64, usize)> {
|
||||
let nearest = crate::appearance::nearest_face(b, p)?;
|
||||
let Some(n) = normal else { return Some(nearest) };
|
||||
if nearest.0 > crate::appearance::pick_tolerance(b) {
|
||||
return Some(nearest);
|
||||
}
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let Ok(faces) = b.body.faces(tol) else { return Some(nearest) };
|
||||
let caps: std::collections::BTreeSet<usize> =
|
||||
faces.iter().filter(|f| f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6)).map(|f| f.index).collect();
|
||||
if caps.is_empty() || caps.contains(&nearest.1) {
|
||||
return Some(nearest);
|
||||
}
|
||||
let tie = (b.body.size() * 1e-9).max(1e-7);
|
||||
let mesh = b.mesh();
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, fi)| caps.contains(&(**fi as usize)))
|
||||
.filter_map(|(tri, fi)| {
|
||||
let [a, b, c] = mesh.tri(tri)?;
|
||||
let d = crate::appearance::closest_on_triangle(p, a, b, c).dist(p);
|
||||
(d <= nearest.0 + tie).then_some((d, *fi as usize))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.or(Some(nearest))
|
||||
}
|
||||
|
||||
/// The face a To / From Object point stands for, `(body index, face index)`: the face its
|
||||
/// stored `name` finds when the point lies on it (evaluation has moved the point onto its named
|
||||
/// face), else the nearest face at the point. The name settles a tie: in an assembly a part of
|
||||
/// another component may rest on the target and touch it at the point.
|
||||
fn object_face(st: &ModelState, name: Option<&str>, p: Vec3) -> Option<(usize, usize)> {
|
||||
fn object_face(st: &ModelState, name: Option<&str>, p: Vec3, normal: Option<Vec3>) -> Option<(usize, usize)> {
|
||||
if let Some(n) = name.filter(|n| !n.is_empty())
|
||||
&& let Some(hit) = crate::naming::locate_face(st, n, p)
|
||||
&& hit.on_point
|
||||
{
|
||||
return Some((hit.body, hit.face));
|
||||
}
|
||||
face_at(st, p)
|
||||
face_at(st, p, normal)
|
||||
}
|
||||
|
||||
/// The body of `face` when that face is not a plane (with `n`: not a plane square to `n`), so To
|
||||
@@ -4910,7 +5132,7 @@ fn curved_face(st: &ModelState, face: Option<(usize, usize)>, n: Option<Vec3>) -
|
||||
|
||||
/// The body at `p` when the face there is not planar.
|
||||
fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
|
||||
curved_face(st, face_at(st, p), None)
|
||||
curved_face(st, face_at(st, p, None), None)
|
||||
}
|
||||
|
||||
/// Tools run through `target`, cut back to its surface: each tool less the body, keeping the
|
||||
@@ -4954,8 +5176,10 @@ fn face_samples(b: &ModelBody) -> Vec<Vec3> {
|
||||
out.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// The picked faces and every face joined to them smoothly (across tangent edges), as points.
|
||||
fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
/// The picked faces and every face joined to them smoothly (across tangent edges).
|
||||
/// Each face appears once, with a point on it. Shell evaluation and selection feedback use
|
||||
/// the same expansion; callers keep the original picks when saving the feature.
|
||||
pub fn tangent_face_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<(usize, Vec3)> {
|
||||
let m = b.mesh();
|
||||
let samples = face_samples(b);
|
||||
let face_of = |p: Vec3| crate::appearance::face_index_at(b, p);
|
||||
@@ -4972,6 +5196,8 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
.and_then(|(_, n)| n.normalized())
|
||||
};
|
||||
let mut chosen: Vec<u32> = picks.iter().filter_map(|p| face_of(*p).map(|f| f as u32)).collect();
|
||||
chosen.sort_unstable();
|
||||
chosen.dedup();
|
||||
let mut k = 0;
|
||||
while k < chosen.len() && chosen.len() < 10_000 {
|
||||
let f = chosen[k];
|
||||
@@ -4996,15 +5222,21 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = picks.to_vec();
|
||||
let mut out = Vec::new();
|
||||
for f in chosen {
|
||||
if let Some(p) = samples.iter().find(|p| face_of(**p) == Some(f as usize)) {
|
||||
out.push(*p);
|
||||
if let Some(p) = picks.iter().chain(&samples).find(|p| face_of(**p) == Some(f as usize)) {
|
||||
out.push((f as usize, *p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
let mut out = picks.to_vec();
|
||||
out.extend(tangent_face_chain(b, picks).into_iter().map(|(_, point)| point));
|
||||
out
|
||||
}
|
||||
|
||||
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
|
||||
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
|
||||
/// attachment is reported, never moved to the nearest face.
|
||||
@@ -5141,6 +5373,8 @@ fn chamfer_with(
|
||||
}
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
// Its tools meet bodies in other placements where those are shown (#3383).
|
||||
let _frames = ToolFrames::install(doc, f, st);
|
||||
match &f.kind {
|
||||
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
|
||||
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
|
||||
@@ -5262,7 +5496,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
for p in spots {
|
||||
let wall = p + dir.any_perp() * r + dir * (r * 0.5);
|
||||
let t = thread_on(st, f.id, wall, Some(d), None)?;
|
||||
let t = thread_on(st, f.id, wall, Some(d), None, false)?;
|
||||
st.threads.push(t);
|
||||
}
|
||||
}
|
||||
@@ -5273,7 +5507,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?),
|
||||
None => None,
|
||||
};
|
||||
let t = thread_on(st, f.id, face.0, designation.as_deref(), l)?;
|
||||
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled)?;
|
||||
if !*modeled {
|
||||
st.threads.push(t);
|
||||
return Ok(());
|
||||
@@ -5282,14 +5516,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let cyl = kernel::cylinder_face_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||||
let tol = t.pitch * 1e-3;
|
||||
let groove = thread_groove(&t, cyl.radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
|
||||
// A shaft thicker than the thread is turned down to its major diameter first.
|
||||
let input = mb.traced();
|
||||
let (input, radius) = match thread_turn(&t, &cyl, &mb)? {
|
||||
Some(ring) => {
|
||||
let turned = kernel::boolean(&input.body, &ring.body, BoolOp::Cut)?
|
||||
.ok_or_else(|| DocError::Invalid("turning the shaft down removed everything".into()))?;
|
||||
(Traced::step(turned, &[&input, &ring]), t.major_diameter / 2.0)
|
||||
}
|
||||
None => (input, cyl.radius),
|
||||
};
|
||||
let groove = thread_groove(&t, radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
|
||||
// The B-rep boolean can't take helical walls in reasonable time: the body is cut
|
||||
// faceted (curved faces within a two-hundredth of the pitch).
|
||||
let nb = kernel::faceted_boolean(&mb.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
|
||||
let nb = kernel::faceted_boolean(&input.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
|
||||
.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
||||
// The groove's faces are its triangles, with no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove.triangles.len());
|
||||
let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let nb = Traced { body: nb, lineage };
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
@@ -5300,7 +5544,23 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let open = if *tangent_chain { smooth_chain(&mb, &crate::plain(faces)) } else { crate::plain(faces) };
|
||||
let picks = crate::plain(faces);
|
||||
let open = if *tangent_chain {
|
||||
let chain = tangent_face_chain(&mb, &picks);
|
||||
if !chain.is_empty() && chain.len() == mb.body.face_count() {
|
||||
return Err(DocError::Invalid(
|
||||
"Tangent Chain opens every face of this body, leaving no walls; turn off Tangent Chain to remove only the picked faces"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// Keep every authored pick, including any the kernel must reject, as before
|
||||
// chain expansion. The derived samples must not silently drop a bad input.
|
||||
let mut open = picks;
|
||||
open.extend(chain.into_iter().map(|(_, point)| point));
|
||||
open
|
||||
} else {
|
||||
picks
|
||||
};
|
||||
let input = mb.traced();
|
||||
// The shell, and the operands of the operation that made it.
|
||||
let (nb, operands) = match direction.as_str() {
|
||||
@@ -5489,7 +5749,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
*slot = ModelBody::traced_new(mb.name.clone(), first, mb.feature);
|
||||
}
|
||||
for (k, b) in it.enumerate() {
|
||||
let mut name = format!("{} ({})", mb.name, k + 1);
|
||||
// A piece renamed with `body.rename` keeps its own name (#3510).
|
||||
let mut name = match f.body_names.get(k).filter(|n| !n.trim().is_empty() && st.body(n).is_none()) {
|
||||
Some(n) => n.clone(),
|
||||
None => format!("{} ({})", mb.name, k + 1),
|
||||
};
|
||||
while st.body(&name).is_some() {
|
||||
name.push('\'');
|
||||
}
|
||||
@@ -5509,6 +5773,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
|
||||
let name = pattern_copy_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, m);
|
||||
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -5556,6 +5821,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
} else {
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, ©);
|
||||
st.bodies.push(ModelBody::traced_new(name, mirrored, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -5707,10 +5973,26 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if tools.iter().any(|t| t == target) {
|
||||
return Err(DocError::Invalid("a body cannot be combined with itself".into()));
|
||||
}
|
||||
// Each body is kept in its own component's frame: a tool placed differently from the
|
||||
// target is moved into the target's frame, so it is combined where it is shown (#3383).
|
||||
// A tool placed exactly as the target is left as it is.
|
||||
let target_world = st.body(target).map(|b| body_world(doc, b));
|
||||
let mut tb = Vec::new();
|
||||
for t in tools {
|
||||
match st.body(t) {
|
||||
Some(b) => tb.push(b.traced()),
|
||||
Some(b) => {
|
||||
let traced = b.traced();
|
||||
let world = body_world(doc, b);
|
||||
match target_world.filter(|w| *w != world) {
|
||||
Some(w) => {
|
||||
let into_target = crate::mat_inverse(&w)
|
||||
.ok_or_else(|| DocError::Invalid(format!("`{target}` is placed where it cannot be combined")))?;
|
||||
let rel = crate::mat_mul(&into_target, &world);
|
||||
tb.push(Traced::step(kernel::transform_matrix(&b.body, rel)?, &[&traced]));
|
||||
}
|
||||
None => tb.push(traced),
|
||||
}
|
||||
}
|
||||
// Designs saved before #2247 combined the pieces a cut split off a body that
|
||||
// was already several pieces (`Posts`, `Posts (1)`). A cut keeps them in one
|
||||
// body now: the piece is in `Posts`, which is a tool too or the body joined to.
|
||||
@@ -5829,6 +6111,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if *copy {
|
||||
let traced = moved_copy(&mb, nb, 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
copy_sheet(st, &mb.name, &name, &crate::rigid(t, Vec3::ZERO, axis, ang));
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
output += 1;
|
||||
} else if let Some(slot) = st.bodies.get_mut(i) {
|
||||
@@ -5994,6 +6277,134 @@ fn plane_reads_geometry(plane: &PlaneRef) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a body's geometry is placed in the world: through its component's placement and any
|
||||
/// offset it was moved into that component with (as [`state_in_frame_at`] places it).
|
||||
fn body_world(doc: &Document, mb: &ModelBody) -> Mat {
|
||||
let m = doc.component_transform(doc.model_body_component(mb));
|
||||
doc.model_body_offset(mb).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While a feature whose tools are drawn in its own component's frame is evaluated: each body
|
||||
/// placed differently, with the matrix from that frame into the body's own (#3383).
|
||||
static TOOL_FRAMES: std::cell::RefCell<Vec<(String, Mat)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Whether a feature joins, cuts or intersects with tools drawn in its own component's frame: a
|
||||
/// primitive, or a solid built from its sketches. (A combine's tools are bodies, each in its own
|
||||
/// frame; it moves them itself.)
|
||||
fn drawn_tools_change_bodies(kind: &FeatureKind) -> bool {
|
||||
match kind {
|
||||
FeatureKind::Box { operation, .. }
|
||||
| FeatureKind::Cylinder { operation, .. }
|
||||
| FeatureKind::Sphere { operation, .. }
|
||||
| FeatureKind::Torus { operation, .. }
|
||||
| FeatureKind::Extrude { operation, .. }
|
||||
| FeatureKind::Revolve { operation, .. }
|
||||
| FeatureKind::Loft { operation, .. }
|
||||
| FeatureKind::Sweep { operation, .. }
|
||||
| FeatureKind::Pipe { operation, .. }
|
||||
| FeatureKind::Coil { operation, .. } => *operation != Operation::NewBody,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames of [`TOOL_FRAMES`] for the feature being evaluated; what was there before is put
|
||||
/// back when it is dropped.
|
||||
struct ToolFrames(Vec<(String, Mat)>);
|
||||
|
||||
impl ToolFrames {
|
||||
/// For a feature with drawn tools (see [`drawn_tools_change_bodies`]): the bodies placed
|
||||
/// differently from its component, so its tools meet them where they are shown (#3383).
|
||||
fn install(doc: &Document, f: &Feature, st: &ModelState) -> Option<ToolFrames> {
|
||||
if !drawn_tools_change_bodies(&f.kind) || (doc.occurrences.is_empty() && doc.body_offsets.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let frame = doc.component_transform(f.component);
|
||||
let frames: Vec<(String, Mat)> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
let world = body_world(doc, b);
|
||||
(world != frame).then(|| crate::mat_inverse(&world).map(|inv| (b.name.clone(), crate::mat_mul(&inv, &frame)))).flatten()
|
||||
})
|
||||
.collect();
|
||||
if frames.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ToolFrames(TOOL_FRAMES.with(|t| std::mem::replace(&mut *t.borrow_mut(), frames))))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ToolFrames {
|
||||
fn drop(&mut self) {
|
||||
let before = std::mem::take(&mut self.0);
|
||||
TOOL_FRAMES.with(|t| *t.borrow_mut() = before);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`placement_digest`] of a feature with drawn tools: the placements, when it names the bodies
|
||||
/// it changes (they may lie in another component, where its tools meet them, #3383); nothing
|
||||
/// otherwise, as it then changes only bodies of its own component.
|
||||
fn drawn_tools_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
let targets = match &f.kind {
|
||||
FeatureKind::Extrude { targets, .. }
|
||||
| FeatureKind::Revolve { targets, .. }
|
||||
| FeatureKind::Loft { targets, .. }
|
||||
| FeatureKind::Sweep { targets, .. }
|
||||
| FeatureKind::Pipe { targets, .. }
|
||||
| FeatureKind::Coil { targets, .. } => !targets.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
if (!targets && f.participants.is_empty()) || (doc.occurrences.is_empty() && doc.body_offsets.is_empty()) {
|
||||
return 0;
|
||||
}
|
||||
placements_hash(doc)
|
||||
}
|
||||
|
||||
/// The matrix taking the evaluated feature's drawn tools into body `name`'s frame (`None`: the
|
||||
/// same frame).
|
||||
fn tool_frame(name: &str) -> Option<Mat> {
|
||||
TOOL_FRAMES.with(|t| t.borrow().iter().find(|(n, _)| n == name).map(|(_, m)| *m))
|
||||
}
|
||||
|
||||
/// The model as the evaluated feature's drawn tools meet it: the bodies it changes (`targets`,
|
||||
/// else the feature's participants; `*` for all) that are placed differently, moved into its
|
||||
/// component's frame. A through-all extent, and every test of where a tool meets its targets, is
|
||||
/// measured on it, so a target in another, moved component is met where it is shown (#3383).
|
||||
/// The model itself when no such body is placed differently.
|
||||
fn targets_as_shown<'a>(st: &'a ModelState, f: &Feature, targets: &[String]) -> Result<std::borrow::Cow<'a, ModelState>> {
|
||||
let names: &[String] = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = names.iter().any(|n| n == "*");
|
||||
let mut out: Option<ModelState> = None;
|
||||
for (i, b) in st.bodies.iter().enumerate() {
|
||||
if !(all || names.contains(&b.name)) {
|
||||
continue;
|
||||
}
|
||||
let Some(back) = tool_frame(&b.name).and_then(|m| crate::mat_inverse(&m)) else { continue };
|
||||
let shown = out.get_or_insert_with(|| st.clone());
|
||||
let mut moved = ModelBody::new(b.name.clone(), kernel::transform_matrix(&b.body, back)?, b.feature);
|
||||
moved.component = b.component;
|
||||
moved.transferred = b.transferred;
|
||||
moved.names = crate::naming::NameCell::same(b);
|
||||
if let Some(slot) = shown.bodies.get_mut(i) {
|
||||
*slot = moved;
|
||||
}
|
||||
}
|
||||
Ok(match out {
|
||||
Some(shown) => std::borrow::Cow::Owned(shown),
|
||||
None => std::borrow::Cow::Borrowed(st),
|
||||
})
|
||||
}
|
||||
|
||||
/// `tool` moved by `m` (unchanged for `None`).
|
||||
fn tool_in(tool: &Traced, m: Option<Mat>) -> Result<Traced> {
|
||||
match m {
|
||||
Some(m) => Ok(Traced::step(kernel::transform_matrix(&tool.body, m)?, &[tool])),
|
||||
None => Ok(tool.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
|
||||
}
|
||||
@@ -6020,13 +6431,19 @@ pub fn face_point_names(doc: &Document, st: &ModelState, kind: &FeatureKind, com
|
||||
return crate::naming::face_names_at(st, &pts);
|
||||
}
|
||||
let fst = face_point_state(doc, st, kind, comp);
|
||||
let normal = match kind {
|
||||
FeatureKind::Extrude { sketch, .. } => {
|
||||
st.sketch(sketch.0).and_then(|ss| sketch_in_feature_frame(doc, ss, comp).ok()).map(|ss| ss.plane.normal())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
pts.iter()
|
||||
.map(|p| {
|
||||
fst.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let (d, fi) = crate::appearance::nearest_face(b, *p)?;
|
||||
let (d, fi) = nearest_object_face(b, *p, normal)?;
|
||||
(d <= (b.body.size() * 1e-3).max(1e-3)).then_some((d, b, fi))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
@@ -6391,9 +6808,14 @@ mod chain_tests {
|
||||
m.evaluate(&doc);
|
||||
let st = m.state();
|
||||
let b = &st.bodies[0];
|
||||
let pts = smooth_chain(b, &[Vec3::new(20.0, 15.0, 20.0)]);
|
||||
let faces: std::collections::BTreeSet<usize> = pts.iter().filter_map(|p| crate::appearance::face_index_at(b, *p)).collect();
|
||||
let pts = tangent_face_chain(b, &[Vec3::new(20.0, 15.0, 20.0)]);
|
||||
let faces: std::collections::BTreeSet<usize> = pts.iter().map(|(index, _)| *index).collect();
|
||||
assert_eq!(faces.len(), 3, "{faces:?}");
|
||||
assert_eq!(pts.len(), faces.len());
|
||||
for (index, point) in &pts {
|
||||
assert_eq!(crate::appearance::face_index_at(b, *point), Some(*index));
|
||||
}
|
||||
assert_eq!(tangent_face_chain(b, &[Vec3::new(20.0, 15.0, 20.0); 2]), pts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,19 +116,35 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
|
||||
// A plane on either side is worked out now, so the bodies follow it when it moves.
|
||||
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
|
||||
if let Some(p) = from_plane {
|
||||
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
|
||||
(from, from_normal) = (pl.origin, Some(pl.normal()));
|
||||
}
|
||||
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
|
||||
let (to, to_normal) = match &target {
|
||||
Some(pl) => (pl.origin, Some(pl.normal())),
|
||||
None => (to.0, to_normal.and_then(|n| n.normalized())),
|
||||
};
|
||||
let mut m = translation(Vec3::ZERO);
|
||||
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
|
||||
if let (Some(a), Some(b)) = (from_normal, to_normal) {
|
||||
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
|
||||
let want = if *flip { b } else { b * -1.0 };
|
||||
let axis = a.cross(want);
|
||||
let ang = axis.len().atan2(a.dot(want));
|
||||
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
|
||||
if ang.abs() > 1e-12 {
|
||||
m = rotation(from.0, axis, ang);
|
||||
m = rotation(from, axis, ang);
|
||||
}
|
||||
}
|
||||
let d = to.0 - from.0;
|
||||
// Onto a plane the point goes straight along the plane's normal (nothing slides
|
||||
// across it); onto a point it lands on it.
|
||||
let d = match (&target, to_normal) {
|
||||
(Some(_), Some(n)) => n * (to - from).dot(n),
|
||||
_ => to - from,
|
||||
};
|
||||
m[3][0] += d.x;
|
||||
m[3][1] += d.y;
|
||||
m[3][2] += d.z;
|
||||
@@ -611,7 +627,7 @@ pub(super) fn sheet_eval(
|
||||
)));
|
||||
}
|
||||
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 });
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
|
||||
+37
-3
@@ -330,8 +330,13 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) {
|
||||
let st = i;
|
||||
// A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would.
|
||||
let leading_comma = c == ',';
|
||||
if leading_comma {
|
||||
i += 1;
|
||||
}
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
@@ -340,7 +345,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
// them a comma separates a function's arguments: `max(1,2)`).
|
||||
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
|
||||
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
|
||||
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
|
||||
if depth == 0 && !leading_comma && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
|
||||
i += 1;
|
||||
while digit(i) {
|
||||
i += 1;
|
||||
@@ -413,6 +418,22 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966).
|
||||
/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal
|
||||
/// comma), be followed by a digit, and stand where an operand is expected: at the start or right
|
||||
/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and
|
||||
/// `1,5,3` stay errors).
|
||||
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool {
|
||||
depth == 0
|
||||
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
|
||||
&& cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit())
|
||||
&& match prev {
|
||||
None => true,
|
||||
Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op),
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
|
||||
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
|
||||
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
|
||||
@@ -991,11 +1012,24 @@ mod tests {
|
||||
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
|
||||
// Not between two digits, or a second comma: still an error.
|
||||
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
|
||||
for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] {
|
||||
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_decimal_comma_is_a_number() {
|
||||
// #2966: `,5` is 0,5 where `.5` would be a number: at the start or after an operator.
|
||||
assert_eq!(ev(",5", Kind::Unitless).unwrap(), 0.5);
|
||||
assert_eq!(ev(",5 mm", Kind::Length).unwrap(), 0.5);
|
||||
assert_eq!(ev("-,5", Kind::Unitless).unwrap(), -0.5);
|
||||
assert_eq!(ev("2*,5", Kind::Unitless).unwrap(), 1.0);
|
||||
assert_eq!(ev("width - ,25", Kind::Length).unwrap(), 39.75);
|
||||
// Function arguments are still separated by commas.
|
||||
assert_eq!(ev("max(1,5)", Kind::Unitless).unwrap(), 5.0);
|
||||
assert_eq!(ev("max(,5; 2)", Kind::Unitless).ok(), None, "inside parentheses a comma is a separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inch_and_foot_marks() {
|
||||
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
|
||||
|
||||
@@ -1250,7 +1250,9 @@ mod tests {
|
||||
file["format"] = Value::from("solidcraft/23");
|
||||
let opened = read(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.format, format_string(FORMAT));
|
||||
assert_eq!(opened.features, plain.features);
|
||||
let mut kept = plain.features.clone();
|
||||
kept.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
assert_eq!(opened.features, kept);
|
||||
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
|
||||
let mut own = Document::new("A");
|
||||
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
|
||||
@@ -1742,7 +1744,7 @@ mod tests {
|
||||
legacy["format"] = Value::from("solidcraft/6");
|
||||
let original_table = legacy["configs"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
|
||||
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
|
||||
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
|
||||
assert_eq!(upgraded.format, format_string(FORMAT));
|
||||
@@ -1796,9 +1798,12 @@ mod tests {
|
||||
legacy["format"] = Value::from("solidcraft/7");
|
||||
let mut features = legacy["features"].clone();
|
||||
pieces_in_shell_order(&mut features);
|
||||
for f in features.as_array_mut().into_iter().flatten() {
|
||||
f["kernel_piece_order"] = Value::Bool(true);
|
||||
}
|
||||
let params = legacy["params"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
|
||||
assert_eq!(upgraded, d);
|
||||
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
assert_eq!(upgraded_json["features"], features);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An Align can name planes (`from_plane`, `to_plane`: an origin, construction or offset plane, or
|
||||
//! an explicit one) instead of a point or a face's point and normal, so the bodies follow the plane
|
||||
//! when it moves. Older designs need no transformation: an Align without them keeps the points and
|
||||
//! normals it was saved with. The last release refuses a design saved with this (the newer format
|
||||
//! number).
|
||||
@@ -0,0 +1,34 @@
|
||||
//! The pieces of a split face are numbered by place (#3902): `#1`, `#2`, … follow the spread of
|
||||
//! their centres, not the kernel's face order or the index of the operand face they came from,
|
||||
//! which can change from one evaluation to the next. Older designs' stored `#k` names mean the
|
||||
//! old numbering, so this step marks each of their features `kernel_piece_order`, which keeps
|
||||
//! it for them. Features added later number by place. The last release refuses a design saved
|
||||
//! with this (the newer format number).
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
let Some(Value::Array(features)) = v.get_mut("features") else {
|
||||
return Ok(());
|
||||
};
|
||||
for feature in features {
|
||||
if let Value::Object(o) = feature {
|
||||
o.insert("kernel_piece_order".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn older_features_keep_their_piece_numbering() {
|
||||
let mut v = json!({"features": [{"type": "sketch"}, {"type": "extrude"}]});
|
||||
super::step(&mut v).unwrap();
|
||||
assert!(v["features"].as_array().unwrap().iter().all(|f| f["kernel_piece_order"] == true));
|
||||
let mut empty = json!({});
|
||||
super::step(&mut empty).unwrap();
|
||||
assert_eq!(empty, json!({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A sheet's perforation field can run its grid from the flat's right edge (`from_right`): the
|
||||
//! field of a mirrored sheet copy, whose holes are the mirror image of its source's. Older designs
|
||||
//! need no transformation: a field without `from_right` runs from the left as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
@@ -17,6 +17,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::history::{FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::ModelBody;
|
||||
@@ -44,13 +45,14 @@ pub(crate) struct FaceInfo {
|
||||
|
||||
impl Names {
|
||||
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge), then numbered by
|
||||
/// place (`centres`, see [`qualify_at`]).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
|
||||
let named = names
|
||||
.into_iter()
|
||||
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
|
||||
.collect();
|
||||
qualify(named, adjacent)
|
||||
qualify_at(named, adjacent, centres)
|
||||
}
|
||||
|
||||
/// Names given as they are (a moved copy keeps its source's), taken as exact.
|
||||
@@ -95,7 +97,8 @@ pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Opti
|
||||
}
|
||||
}
|
||||
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
|
||||
Some(qualify(named, &adjacency(h)))
|
||||
let centres = || crate::naming::face_centres(b);
|
||||
Some(qualify_at(named, &adjacency(h), by_place(&o.feature, ¢res)))
|
||||
}
|
||||
|
||||
/// Pairs of faces that share an edge, from a history.
|
||||
@@ -140,7 +143,8 @@ impl<'a> OperandNames<'a> {
|
||||
.get_or_init(|| {
|
||||
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
|
||||
let names = crate::naming::geometric_names(body, o, body.body.face_count());
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body))
|
||||
let centres = || crate::naming::face_centres(body);
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body), by_place(&o.feature, ¢res))
|
||||
}))
|
||||
})
|
||||
.clone();
|
||||
@@ -266,7 +270,32 @@ pub(crate) fn descends(name: &str, stem: &str) -> bool {
|
||||
|
||||
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
|
||||
/// lack (`adjacent`: pairs of faces sharing an edge).
|
||||
#[cfg(test)]
|
||||
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
qualify_at(named, adjacent, None)
|
||||
}
|
||||
|
||||
/// How the pieces `f` splits a face into are numbered: by place (`centres`), else, for a feature
|
||||
/// saved before that (`Feature::kernel_piece_order`), as the build that saved it numbered them.
|
||||
pub(crate) fn by_place<'a>(f: &crate::Feature, centres: &'a dyn Fn() -> Vec<Vec3>) -> Option<&'a dyn Fn() -> Vec<Vec3>> {
|
||||
(!f.kernel_piece_order).then_some(centres)
|
||||
}
|
||||
|
||||
/// [`qualify`], with `centres` giving each face's centre (worked out only when pieces tie): pieces
|
||||
/// that nothing else tells apart are numbered along their spread, not in the kernel's order,
|
||||
/// which can differ from one evaluation to the next (#3902). Without `centres`, in the kernel's
|
||||
/// order and by the faces they came from, as builds before that numbered them.
|
||||
fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
|
||||
let by_place = centres.is_some();
|
||||
let worked_out = std::cell::OnceCell::new();
|
||||
// Tied pieces in the order of their spread; as they are when there are no centres.
|
||||
let in_order = |tied: Vec<usize>| -> Vec<usize> {
|
||||
let at: &Vec<Vec3> = worked_out.get_or_init(|| centres.map(|c| c()).unwrap_or_default());
|
||||
if at.len() != named.len() {
|
||||
return tied;
|
||||
}
|
||||
crate::naming::along_spread(tied.into_iter().filter_map(|i| Some((i, *at.get(i)?))).collect())
|
||||
};
|
||||
let n = named.len();
|
||||
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
|
||||
for &(a, b) in adjacent {
|
||||
@@ -320,8 +349,20 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let mut tied = tied;
|
||||
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
|
||||
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
|
||||
if distinct {
|
||||
if distinct && !by_place {
|
||||
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
|
||||
} else if distinct {
|
||||
// By what they came from, but not by where it stood in its operand's face order:
|
||||
// an operand that came out of a boolean can list its faces in another order on
|
||||
// the next evaluation (#3902). Pieces of faces of one kind are numbered by place.
|
||||
let mut by_kind: BTreeMap<Vec<FaceOrigin>, Vec<usize>> = BTreeMap::new();
|
||||
for i in tied {
|
||||
let kind = named.get(i).and_then(|x| x.origins.as_deref()).unwrap_or_default().iter().map(|o| without_index(*o)).collect();
|
||||
by_kind.entry(kind).or_default().push(i);
|
||||
}
|
||||
tied = by_kind.into_values().flat_map(&in_order).collect();
|
||||
} else {
|
||||
tied = in_order(tied);
|
||||
}
|
||||
for (k, i) in tied.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
@@ -333,17 +374,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A piece's new name can meet a face that already had it: numbered too, never shared.
|
||||
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in &names {
|
||||
*seen.entry(name.clone()).or_default() += 1;
|
||||
// A piece's new name can meet a face that already had it: numbered too (along their spread),
|
||||
// never shared.
|
||||
let mut seen: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
seen.entry(name.clone()).or_default().push(i);
|
||||
}
|
||||
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (i, name) in names.iter_mut().enumerate() {
|
||||
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
|
||||
let k = counter.entry(name.clone()).or_default();
|
||||
*k += 1;
|
||||
*name = format!("{name}#{k}");
|
||||
for (name, same) in seen.into_iter().filter(|(_, same)| same.len() > 1) {
|
||||
for (k, i) in in_order(same).into_iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(i) {
|
||||
*slot = format!("{name}#{}", k + 1);
|
||||
}
|
||||
if let Some(v) = verified.get_mut(i) {
|
||||
*v = false;
|
||||
}
|
||||
@@ -353,6 +394,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
Names { faces: Arc::new(names), info }
|
||||
}
|
||||
|
||||
/// `origin` with the face or edge it names left out (its kind and operand kept).
|
||||
fn without_index(origin: FaceOrigin) -> FaceOrigin {
|
||||
match origin {
|
||||
FaceOrigin::Face { operand, .. } => FaceOrigin::Face { operand, face: 0 },
|
||||
FaceOrigin::Edge { operand, .. } => FaceOrigin::Edge { operand, edge: 0 },
|
||||
FaceOrigin::Offset { operand, .. } => FaceOrigin::Offset { operand, face: 0 },
|
||||
// A profile's segments come in the sketch's order.
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a stored face name matches a current face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Match {
|
||||
|
||||
@@ -57,11 +57,12 @@ pub use assembly::{
|
||||
rigid,
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::KeptResults;
|
||||
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, metric_thread_sizes, object_height,
|
||||
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
|
||||
};
|
||||
pub use expr::Expr;
|
||||
pub use names::{BodyName, FeatureName, SketchId};
|
||||
|
||||
+46
-36
@@ -241,13 +241,11 @@ pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
|
||||
.get_or_init(|| {
|
||||
// Exact history needs no mesh; geometry does (its face count is the mesh's).
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => b
|
||||
.names
|
||||
.lineage
|
||||
.as_ref()
|
||||
.and_then(|l| crate::history_names::derive(b, o, l))
|
||||
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
|
||||
Some(o) if o.depth < MAX_DEPTH => b.names.lineage.as_ref().and_then(|l| crate::history_names::derive(b, o, l)).unwrap_or_else(|| {
|
||||
let centres = || face_centres(b);
|
||||
Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b), crate::history_names::by_place(&o.feature, ¢res))
|
||||
}),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[], None),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
@@ -519,35 +517,8 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let ps = pts.get(j).cloned().unwrap_or_default();
|
||||
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
|
||||
};
|
||||
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
|
||||
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs.clone();
|
||||
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
|
||||
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
|
||||
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
|
||||
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
|
||||
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
|
||||
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
|
||||
let bucket = |c: Vec3| {
|
||||
let k = key(c);
|
||||
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
|
||||
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
|
||||
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
|
||||
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
|
||||
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
|
||||
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
|
||||
};
|
||||
order.sort_by_key(|a| bucket(a.1));
|
||||
for (k, (j, _)) in order.iter().enumerate() {
|
||||
let order = along_spread(same.iter().map(|j| (*j, centre(*j))).collect());
|
||||
for (k, j) in order.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*j) {
|
||||
*slot = format!("{n}{mark}{}", k + 1);
|
||||
}
|
||||
@@ -555,6 +526,45 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The items of `cs` (index, centre) ordered along the longest axis of their centres' spread,
|
||||
/// then the next: how the pieces of one face or edge are numbered, the same on every run
|
||||
/// whatever order the kernel gives them in.
|
||||
pub(crate) fn along_spread(cs: Vec<(usize, Vec3)>) -> Vec<usize> {
|
||||
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs;
|
||||
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
|
||||
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
|
||||
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
|
||||
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
|
||||
// keeps its number across runs (solidcraft#569).
|
||||
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
|
||||
let bucket = |c: Vec3| {
|
||||
let k = key(c);
|
||||
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
|
||||
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
|
||||
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
|
||||
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
|
||||
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
|
||||
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
|
||||
};
|
||||
order.sort_by_key(|a| bucket(a.1));
|
||||
order.into_iter().map(|(j, _)| j).collect()
|
||||
}
|
||||
|
||||
/// Each face's centre: the mean of the sampled centres of its triangles.
|
||||
pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
|
||||
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
|
||||
}
|
||||
|
||||
/// The name of a face new to `f`.
|
||||
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
|
||||
+105
-3
@@ -541,6 +541,10 @@ pub struct SheetPerforation {
|
||||
pub pitch: f64,
|
||||
pub margin: f64,
|
||||
pub square: bool,
|
||||
/// The grid runs from the flat's right edge instead of its left: the field of a mirrored
|
||||
/// copy, whose holes are the mirror image of its source's.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub from_right: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -1992,8 +1996,11 @@ impl SheetBody {
|
||||
if y > hi.y - clear + 1e-9 {
|
||||
break;
|
||||
}
|
||||
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
while x <= hi.x - clear + 1e-9 {
|
||||
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 };
|
||||
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(&[]);
|
||||
@@ -2003,7 +2010,7 @@ impl SheetBody {
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
x += p.pitch;
|
||||
step += p.pitch;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
@@ -4355,6 +4362,101 @@ impl Letters {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat point mirrored across the flat frame's y axis (x → −x).
|
||||
fn flip_x(p: Vec2) -> Vec2 {
|
||||
Vec2::new(-p.x, p.y)
|
||||
}
|
||||
|
||||
/// A loop mirrored across the flat frame's y axis, exactly (arcs stay arcs, free-form curves
|
||||
/// follow their control points), and run backwards so it keeps its sense: an outer loop stays
|
||||
/// counter-clockwise, a hole clockwise.
|
||||
fn flip_x_loop(l: &Loop2) -> Loop2 {
|
||||
let segs = l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
|
||||
}
|
||||
other => map_controls(&other, flip_x),
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.reversed()
|
||||
}
|
||||
|
||||
fn flip_x_region(r: &Region2) -> Region2 {
|
||||
Region2 { outer: flip_x_loop(&r.outer), holes: r.holes.iter().map(flip_x_loop).collect() }
|
||||
}
|
||||
|
||||
impl SheetBody {
|
||||
/// The sheet model of a copy of this body moved by `m` and named `body`: a rigid motion
|
||||
/// moves its frame; a mirror moves its frame too and mirrors everything in the flat, so the
|
||||
/// copy is the opposite hand, with the same thickness side, bends, K-factor and flat
|
||||
/// length. `None` when `m` is not a rigid motion or a mirror (a scaled copy is no sheet).
|
||||
pub fn copied(&self, body: &str, m: &Mat) -> Option<SheetBody> {
|
||||
let axis = |i: usize| m.get(i).map(|c| Vec3::new(c[0], c[1], c[2]));
|
||||
let (x, y, z) = (axis(0)?, axis(1)?, axis(2)?);
|
||||
let unit = |v: Vec3| (v.dot(v) - 1.0).abs() < 1e-6;
|
||||
let isometry = m.iter().flatten().all(|v| v.is_finite())
|
||||
&& unit(x)
|
||||
&& unit(y)
|
||||
&& unit(z)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6;
|
||||
if !isometry {
|
||||
return None;
|
||||
}
|
||||
let (origin, fx, fy) = (apply_point(m, self.frame.origin), apply_vector(m, self.frame.x), apply_vector(m, self.frame.y));
|
||||
let mut out = self.clone();
|
||||
out.body = body.to_string();
|
||||
if x.cross(y).dot(z) > 0.0 {
|
||||
out.frame = Plane::new(origin, fx, fy)?;
|
||||
} else {
|
||||
// The mirror of flat point (u, v, w) is the point (−u, v, w) of the frame with its x
|
||||
// axis turned round: that frame's normal is the mirrored normal, so the material
|
||||
// stays on the same side of the mirrored base face.
|
||||
out.frame = Plane::new(origin, fx * -1.0, fy)?;
|
||||
out.flip_flat();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Everything in flat coordinates mirrored across the flat's y axis (x → −x).
|
||||
fn flip_flat(&mut self) {
|
||||
self.base = flip_x_region(&self.base);
|
||||
for f in &mut self.flanges {
|
||||
// The bend line runs the other way, from its far end, so the flange still lies on
|
||||
// the same side of it (`n` turned as `d` is): its ends swap.
|
||||
f.p = flip_x(f.p + f.d * f.len);
|
||||
f.d = Vec2::new(f.d.x, -f.d.y);
|
||||
f.n = flip_x(f.n);
|
||||
f.ext = (f.ext.1, f.ext.0);
|
||||
f.slope = (f.slope.1, f.slope.0);
|
||||
f.tear = (f.tear.1, f.tear.0);
|
||||
if let Some(shape) = &mut f.shape {
|
||||
*shape = shape.iter().rev().map(|p| flip_x(*p)).collect();
|
||||
}
|
||||
if let Some(exact) = &mut f.exact {
|
||||
*exact = flip_x_loop(exact);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
for b in &mut self.bores {
|
||||
b.base.x = -b.base.x;
|
||||
b.axis.x = -b.axis.x;
|
||||
}
|
||||
for p in &mut self.perforations {
|
||||
p.from_right = !p.from_right;
|
||||
}
|
||||
for (_, a, b) in &mut self.base_curves {
|
||||
(*a, *b) = (flip_x(*b), flip_x(*a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
|
||||
@@ -103,6 +103,28 @@ fn set_back_reports_restored_features() {
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
|
||||
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
|
||||
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
|
||||
#[test]
|
||||
fn results_kept_ahead_outlive_the_cache() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
let kept = std::sync::Arc::new(KeptResults::default());
|
||||
let mut ahead = m.clone();
|
||||
ahead.keep(Some(kept.clone()));
|
||||
assert!(ahead.clone().kept().is_none(), "a copy keeps nothing");
|
||||
ahead.evaluate(&doc);
|
||||
assert_eq!((ahead.last_recomputed, kept.len()), (2, 2));
|
||||
*m.cache.lock().unwrap() = Default::default();
|
||||
m.keep(Some(kept));
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 2), "nothing computed again");
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
|
||||
@@ -559,18 +559,24 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
|
||||
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
|
||||
};
|
||||
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
|
||||
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
|
||||
// Bodies later features name after this one (`Top (1)`, a split's other piece) keep their
|
||||
// names too: they would otherwise follow it, and break what names them (#3510).
|
||||
let derived = format!("{old} (");
|
||||
let at = s.doc.features.iter().position(|f| f.id == fid);
|
||||
let counts = at.and_then(|at| s.doc.features.get(at)).is_some_and(|f| counted(f, k));
|
||||
let later: Vec<(u64, Vec<String>)> = match at {
|
||||
Some(at) => s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.filter_map(|f| {
|
||||
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
|
||||
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
|
||||
let keep = (counts && (0..made.len()).any(|i| counted(f, i))) || made.iter().any(|n| n.starts_with(&derived));
|
||||
keep.then_some((f.id, made))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let d = s.doc_mut();
|
||||
for (id, made) in &later {
|
||||
@@ -771,6 +777,26 @@ mod tests {
|
||||
assert_eq!(names(&s)[1], b);
|
||||
}
|
||||
|
||||
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
|
||||
/// other piece, and the features that use it, as they were.
|
||||
#[test]
|
||||
fn split_pieces_are_renamed_on_their_own() {
|
||||
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5], "body_name": "Top"}));
|
||||
run(&mut s, "solid.split_body", json!({"body": "Top", "plane": "XY"}));
|
||||
assert_eq!(names(&s), vec!["Top", "Top (1)"]);
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Top (1)"], "translate": [0, 0, -5]}));
|
||||
run(&mut s, "body.rename", json!({"body": "Top", "name": "Upper"}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
|
||||
assert!(ok(&s), "the move still finds the other piece");
|
||||
run(&mut s, "body.rename", json!({"body": "Top (1)", "name": "Lower"}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Lower"]);
|
||||
assert!(ok(&s), "the move follows the renamed piece");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
|
||||
}
|
||||
|
||||
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
|
||||
/// follows them, as it follows the names inside the kind (a combine's target and tools).
|
||||
#[test]
|
||||
|
||||
@@ -111,8 +111,9 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solidcraft/9");
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
|
||||
let anchored = solidcraft_doc::Feature { shell_piece_order: true, ..anchored };
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order,
|
||||
// and numbering the pieces of its faces as that build did (#3902).
|
||||
let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, ..anchored };
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
// Names saved before format 14 keep naming the face they named, marked so they are found as
|
||||
// the build that saved them found them.
|
||||
|
||||
@@ -63,14 +63,30 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
/// The point is in the frame of the command's other picks: the component's own when
|
||||
/// they were mapped into it, else the world's.
|
||||
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
|
||||
match face_ref_form(v).map_err(|why| bad(cmd, why))? {
|
||||
FaceRefForm::Point(p) => Ok(p),
|
||||
FaceRefForm::Name(name) => {
|
||||
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
|
||||
solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face reference as given, before it is looked up: see [`face_ref`].
|
||||
pub(crate) enum FaceRefForm<'a> {
|
||||
Point(Vec3),
|
||||
Name(&'a str),
|
||||
}
|
||||
|
||||
/// What [`face_ref`] reads `v` as, without the design: a point, or a name of 1…512 bytes.
|
||||
pub(crate) fn face_ref_form(v: &Value) -> std::result::Result<FaceRefForm<'_>, &'static str> {
|
||||
if let Some(name) = v.as_str() {
|
||||
if name.is_empty() || name.len() > 512 {
|
||||
return Err(bad(cmd, "a face name is 1…512 characters"));
|
||||
return Err("a face name is 1…512 characters");
|
||||
}
|
||||
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
|
||||
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
|
||||
return Ok(FaceRefForm::Name(name));
|
||||
}
|
||||
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
|
||||
vec3(v).or_else(|| v.get("point").and_then(vec3)).map(FaceRefForm::Point).ok_or("a face is a point [x, y, z] on it, or its name from model.faces")
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance, body).
|
||||
|
||||
@@ -11,7 +11,8 @@ use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
|
||||
use crate::schema::{BoxParams, ExtrudeParams, FeatureResult, TypedContract, output_schema_for};
|
||||
use crate::schema::features::{BoxParams, ExtrudeParams, FeatureResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
|
||||
@@ -67,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -91,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"),
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -204,13 +205,16 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
|
||||
// Match the evaluator's implicit participants: a grouped cut from an empty
|
||||
// assembly component considers every body; otherwise keep the active scope.
|
||||
// Explicit participants override either default.
|
||||
// A component with no body of its own works on those of the components inside it (#3223).
|
||||
let parts = string_list(p, "participants");
|
||||
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
let bodies: Vec<_> = if !parts.is_empty() {
|
||||
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
|
||||
} else if implicit_all {
|
||||
st.bodies.iter().collect()
|
||||
} else {
|
||||
active_scope(s, &st)
|
||||
};
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
}
|
||||
@@ -241,11 +245,7 @@ pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
|
||||
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
|
||||
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
if let Some(v) = p.get("sketch") {
|
||||
let key = match v {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
|
||||
};
|
||||
let key = sketch_key(Some(v)).ok_or_else(|| bad(cmd, "`sketch` must be an id or name"))?;
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
return match f.kind {
|
||||
FeatureKind::Sketch { .. } => Ok(f.id),
|
||||
@@ -497,11 +497,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
// A cut or intersect made where the active component has no body of its own (a sketch in
|
||||
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
|
||||
// takes part in every body instead, which it changes only where it meets them.
|
||||
// takes part in every body instead, which it changes only where it meets them. An extrude's
|
||||
// own operation counts too: an automatic one that came out as a cut (#3223).
|
||||
let participants = if participants.is_empty()
|
||||
&& !primitive
|
||||
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
|| matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
&& {
|
||||
let st = s.model.state();
|
||||
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
|
||||
@@ -655,14 +656,40 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
|
||||
/// it is a body of the active component.
|
||||
/// it is one the active component works on ([`active_scope`]).
|
||||
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
|
||||
return None;
|
||||
};
|
||||
let st = s.model.state();
|
||||
let body = st.body(&anchor.target.body)?;
|
||||
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
|
||||
active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// The bodies a feature of the active component works on when it names none: the component's
|
||||
/// own. A component with no body of its own (the root of an opened STEP assembly, whose parts
|
||||
/// are components inside it) works on the bodies of the components inside it whose geometry is
|
||||
/// in its own frame, as an import places it: a profile sketched on such a part's face meets the
|
||||
/// part where it is shown (#3223).
|
||||
fn active_scope<'a>(s: &Session, st: &'a solidcraft_doc::ModelState) -> Vec<&'a solidcraft_doc::ModelBody> {
|
||||
let active = s.active_component;
|
||||
let own: Vec<_> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == active).collect();
|
||||
if !own.is_empty() {
|
||||
return own;
|
||||
}
|
||||
let Some(into_active) = solidcraft_doc::mat_inverse(&s.doc.component_transform(active)) else { return own };
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
let comp = s.doc.model_body_component(b);
|
||||
if !s.doc.component_within(comp, active) {
|
||||
return false;
|
||||
}
|
||||
let frame = s.doc.component_transform(comp);
|
||||
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
|
||||
let rel = solidcraft_doc::mat_mul(&into_active, &world);
|
||||
rel.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-9)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
|
||||
@@ -970,45 +997,64 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
|
||||
let mut out = Vec::new();
|
||||
for e in a {
|
||||
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut nearest = f64::INFINITY;
|
||||
let on_edge = bodies.iter().any(|(size, edges)| {
|
||||
let d = edges
|
||||
.iter()
|
||||
.flat_map(|e| e.points.windows(2))
|
||||
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
d <= size * 0.05 + 1e-3
|
||||
});
|
||||
if !on_edge && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
EdgeRefForm::Point(pt) => pt,
|
||||
EdgeRefForm::Index { body, index } => {
|
||||
let idx = index as usize;
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
continue;
|
||||
}
|
||||
out.push(pt);
|
||||
continue;
|
||||
};
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut nearest = f64::INFINITY;
|
||||
let on_edge = bodies.iter().any(|(size, edges)| {
|
||||
let d = edges
|
||||
.iter()
|
||||
.flat_map(|e| e.points.windows(2))
|
||||
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
d <= size * 0.05 + 1e-3
|
||||
});
|
||||
if !on_edge && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
|
||||
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
out.push(pt);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An edge reference as given, before it is looked up: see [`edge_points`].
|
||||
pub(crate) enum EdgeRefForm<'a> {
|
||||
Point(Vec3),
|
||||
Index { body: &'a str, index: u64 },
|
||||
}
|
||||
|
||||
/// What [`edge_points`] reads one edge as, without the design: a point on it (`[x, y, z]` or
|
||||
/// `{point}`), else `{body, index}`.
|
||||
pub(crate) fn edge_ref_form(e: &Value) -> std::result::Result<EdgeRefForm<'_>, &'static str> {
|
||||
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
|
||||
return Ok(EdgeRefForm::Point(pt));
|
||||
}
|
||||
let body = e.get("body").and_then(Value::as_str).ok_or("an edge is a point [x,y,z], {point} or {body, index}")?;
|
||||
let index = e.get("index").and_then(Value::as_u64).ok_or("edge `index` must be a number")?;
|
||||
Ok(EdgeRefForm::Index { body, index })
|
||||
}
|
||||
|
||||
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
|
||||
/// edge is rounded (a fillet).
|
||||
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1807,15 +1853,25 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
};
|
||||
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
|
||||
// that body: the pick is already in that body's frame, and the component's own bodies alone
|
||||
// would leave nothing to cut.
|
||||
let mut p = p.clone();
|
||||
if points.is_none()
|
||||
&& p.get("participants").is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
p["participants"] = json!([body]);
|
||||
let targets = string_list(&p, "targets");
|
||||
if p.get("participants").is_none() {
|
||||
// The bodies to drill, as `targets` (like an extrude's): its participants.
|
||||
if !targets.is_empty() {
|
||||
p["participants"] = json!(targets);
|
||||
} else if let Some(body) = points.as_ref().filter(|(sp, _, _)| !sp.single).and_then(|(sp, _, _)| sketch_face_body(s, sp.sketch.0)) {
|
||||
// Holes at the points of a sketch on a body's face drill that body (#2882): a body
|
||||
// further along, past the face, doesn't take them. Stored, so recomputes and
|
||||
// reopening keep the same scope.
|
||||
p["participants"] = json!([body]);
|
||||
} else if points.is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
// A hole picked on a body of another component (a moved one, shown where it is
|
||||
// placed) cuts that body: the pick is already in that body's frame, and the
|
||||
// component's own bodies alone would leave nothing to cut.
|
||||
p["participants"] = json!([body]);
|
||||
}
|
||||
}
|
||||
add_feature(
|
||||
s,
|
||||
@@ -1920,17 +1976,22 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))),
|
||||
};
|
||||
let key = sketch_key(v).ok_or_else(|| bad(cmd, format!("`{what}` must be a sketch id or name")))?;
|
||||
match s.doc.find_feature(&key) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id),
|
||||
_ => Err(bad(cmd, format!("no sketch `{key}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The key [`sketch_id`] and [`feature_sketch`] look a sketch up by: an id (a number) or a name.
|
||||
pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(Value::String(x)) => Some(x.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sweep";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.align", "Align", align)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("align")
|
||||
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
|
||||
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"),
|
||||
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
|
||||
];
|
||||
|
||||
@@ -385,23 +385,46 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
|
||||
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
|
||||
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
|
||||
// a face, its normal.
|
||||
let plane = |s: &Session, key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
|
||||
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
|
||||
};
|
||||
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
|
||||
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if plane.is_some() {
|
||||
if p.get(pk).is_some() || p.get(fk).is_some() {
|
||||
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
|
||||
}
|
||||
// Worked out from the plane when the feature is evaluated.
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
if let Some(fp) = p.get(fk).and_then(vec3) {
|
||||
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
|
||||
return Ok((fp, Some(n)));
|
||||
}
|
||||
// The origin point.
|
||||
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
|
||||
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
|
||||
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
|
||||
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
|
||||
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
|
||||
}
|
||||
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
|
||||
p.get(pk)
|
||||
.and_then(vec3)
|
||||
.map(|v| (v, None))
|
||||
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
|
||||
};
|
||||
let (from, from_normal) = pick(s, "from", "from_face")?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face")?;
|
||||
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
|
||||
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
|
||||
let (from_normal, to_normal) = match (from_normal, to_normal) {
|
||||
(Some(a), Some(b)) => (Some(a), Some(b)),
|
||||
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
|
||||
_ => (None, None),
|
||||
};
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
@@ -415,6 +438,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
from_normal: from_normal.map(Dir),
|
||||
to_normal: to_normal.map(Dir),
|
||||
flip,
|
||||
from_plane,
|
||||
to_plane,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -540,6 +540,57 @@ fn replace_face_align_remove() {
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
|
||||
/// The z range and the x minimum of body `name`.
|
||||
fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) {
|
||||
let m = measure(s);
|
||||
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap().clone();
|
||||
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
|
||||
(g("min", 2), g("max", 2), g("min", 0))
|
||||
}
|
||||
|
||||
/// Align onto planes and the origin: a face goes flush onto an origin plane (turning to meet it),
|
||||
/// onto an offset plane, a vertex onto the origin point, and a plane the bodies are aligned to
|
||||
/// is followed when it moves.
|
||||
#[test]
|
||||
fn align_takes_planes_and_the_origin() {
|
||||
let boxed = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 20], "body_name": "C"}));
|
||||
s
|
||||
};
|
||||
// The bottom face onto XY: it already faces down, so it only drops; nothing slides across.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "XY"}));
|
||||
let (lo, hi, x) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6 && (x - 50.0).abs() < 1e-6, "{lo} {hi} {x}");
|
||||
// The top face onto XY: the body turns over (the face now faces down) and sits on the plane.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 30], "to_plane": "XY"}));
|
||||
let (lo, hi, _) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{lo} {hi}");
|
||||
assert!(measure(&mut s)["bodies"][0]["bbox"]["max"][2].as_f64().is_some());
|
||||
// A vertex onto the origin point.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from": {"snap": "vertex", "at": [50, 0, 20]}, "to": "origin"}));
|
||||
let (lo, _, x) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && x.abs() < 1e-6, "{lo} {x}");
|
||||
// Onto an offset plane, and the bodies follow it when its distance changes.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "parameters.add", json!({"name": "lift", "expression": "30 mm"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "lift", "name": "Shelf"}));
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "Shelf"}));
|
||||
assert!((z_x(&mut s, "C").0 - 30.0).abs() < 1e-6);
|
||||
run(&mut s, "parameters.change", json!({"name": "lift", "expression": "45 mm"}));
|
||||
assert!((z_x(&mut s, "C").0 - 45.0).abs() < 1e-6, "the bodies follow the plane");
|
||||
// A plane as the source: the bodies move with it onto a point.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_plane": "XY", "to": [0, 0, 7]}));
|
||||
assert!((z_x(&mut s, "C").0 - 27.0).abs() < 1e-6);
|
||||
// One side is a plane or a point, not both; an unknown plane is refused.
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 7]})).is_err());
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coil_and_loft_to_a_point() {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&DESCRIBE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::DescribeResult>)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::inspect::DescribeResult>)
|
||||
.params("command: a command id. Returns {id, label, params, input, output} (and param_warnings in the warn parameter-check mode): the command's parameter documentation and, once the command has a typed contract, the JSON Schemas (2020-12) of its parameters and of a successful result (null otherwise). Read-only."),
|
||||
];
|
||||
|
||||
@@ -326,13 +326,13 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::DescribeParams> = crate::schema::TypedContract::new();
|
||||
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::inspect::DescribeParams> = crate::schema::TypedContract::new();
|
||||
|
||||
/// A command's prose documentation and, for converted commands, its schemas.
|
||||
fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = p.get("command").and_then(Value::as_str).ok_or_else(|| crate::params::bad("engine.describe", "`command` must be a command id"))?;
|
||||
let spec = super::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.to_string()))?;
|
||||
let reply = crate::schema::DescribeResult {
|
||||
let reply = crate::schema::inspect::DescribeResult {
|
||||
id: spec.id.to_string(),
|
||||
label: spec.label.to_string(),
|
||||
params: spec.params.to_string(),
|
||||
|
||||
@@ -35,7 +35,9 @@ mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach};
|
||||
pub use sketch_modify::trim_piece;
|
||||
mod component_link;
|
||||
|
||||
@@ -616,6 +616,61 @@ fn matrix_move_turns_a_quarter_and_moves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
|
||||
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
|
||||
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
|
||||
/// one piece instead of starting a body of the root's own.
|
||||
#[test]
|
||||
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("nested.step");
|
||||
let mut src = Session::default();
|
||||
let (_, carrier) = created(&mut src, "Carrier");
|
||||
let (part, nested) = created(&mut src, "Nested Part");
|
||||
run(&mut src, "solid.box", json!({"length": 40, "width": 24, "height": 10, "body_name": "NestedBlock"}));
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": carrier, "translate": [12, 7, 5], "axis": [0, 0, 1], "angle": "30 deg"}));
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": nested, "translate": [3, 11, 2], "axis": [0, 1, 0], "angle": "20 deg"}));
|
||||
// The middle of the top face, in the file's frame: the imported root's.
|
||||
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
|
||||
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
|
||||
let centre = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
|
||||
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let block = 40.0 * 24.0 * 10.0;
|
||||
let hole = std::f64::consts::PI * 3.0 * 3.0;
|
||||
|
||||
// The circle is measured as faceted: within a thousandth of what it removes or adds.
|
||||
for operation in ["cut", "auto"] {
|
||||
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
|
||||
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
|
||||
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
|
||||
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
|
||||
/// is refused with the reason, and the occurrence stays where it was.
|
||||
#[test]
|
||||
@@ -662,3 +717,75 @@ fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
|
||||
}
|
||||
|
||||
/// #3383: a combine across components meets the tool where it is shown, not where its numbers
|
||||
/// would be in the target's own frame. The plate in a component turned a quarter about X sits at
|
||||
/// y -10..0, z -20..20; the boss in the root overlaps it by 10 x 5 x 5. The reverse (a peg in the
|
||||
/// turned component cut from a root plate) removes 10 x 10 x 5 of it, not 10 x 10 x 10.
|
||||
#[test]
|
||||
fn combine_across_a_turned_occurrence_meets_the_tool_where_it_is_shown() {
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
|
||||
let turned = |s: &mut Session, body: Value| {
|
||||
created(s, "Part");
|
||||
run(s, "solid.box", body);
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
run(s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [1, 0, 0], "angle": "90 deg"}));
|
||||
run(s, "component.capture_position", json!({}));
|
||||
};
|
||||
let mut s = Session::default();
|
||||
turned(&mut s, json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "body_name": "Plate"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, -25], "operation": "new", "body_name": "Boss"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Boss"]}));
|
||||
assert_eq!(s.model.state().body("Plate").map(|b| b.body.shell_count()), Some(1));
|
||||
let want = 40.0 * 40.0 * 10.0 + 10.0 * 10.0 * 10.0 - 10.0 * 5.0 * 5.0;
|
||||
assert!((volume(&s, "Plate") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Plate"));
|
||||
|
||||
let mut s = Session::default();
|
||||
turned(&mut s, json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, 0], "body_name": "Peg"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "operation": "new", "body_name": "Base"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Peg"], "operation": "cut"}));
|
||||
let want = 40.0 * 40.0 * 10.0 - 10.0 * 10.0 * 5.0;
|
||||
assert!((volume(&s, "Base") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Base"));
|
||||
}
|
||||
|
||||
/// #3383: a feature joining into a body of another, placed component draws its tool where the
|
||||
/// tool is shown, as Combine does. Part is moved 100 mm along x, so its 10 mm cubes sit at x 100..110
|
||||
/// and 120..130; a box and an extrude drawn from the root over each add 4 x 4 x 5 to it in one
|
||||
/// piece, not a separate lump 100 mm further on. A through-all cut is as long as the target is
|
||||
/// far where it is shown.
|
||||
#[test]
|
||||
fn joins_into_a_moved_occurrence_meet_the_tool_where_it_is_shown() {
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
|
||||
let mut s = Session::default();
|
||||
created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 15, "corner": [103, 3, 0], "operation": "join", "targets": ["A"]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [123, 3], "p1": [127, 7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 15, "operation": "join", "targets": ["B"]}));
|
||||
let want = 10.0 * 10.0 * 10.0 + 4.0 * 4.0 * 5.0;
|
||||
for b in ["A", "B"] {
|
||||
assert_eq!(s.model.state().body(b).map(|b| b.body.shell_count()), Some(1), "{b}");
|
||||
assert!((volume(&s, b) - want).abs() < 1e-6 * want, "{b}: {} vs {want}", volume(&s, b));
|
||||
}
|
||||
|
||||
// Through all reaches the target where it is shown: Part moved 100 mm along x and up, a cut
|
||||
// drawn on the root's XY plane goes 100 mm up through its cube, not 10 mm.
|
||||
let mut s = Session::default();
|
||||
created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "C"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 100]}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [103, 3], "p1": [107, 7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["C"]}));
|
||||
let want = 10.0 * 10.0 * 10.0 - 4.0 * 4.0 * 10.0;
|
||||
assert!((volume(&s, "C") - want).abs() < 1e-6 * want, "C: {} vs {want}", volume(&s, "C"));
|
||||
}
|
||||
|
||||
@@ -359,7 +359,10 @@ fn designs_from_before_bend_edges_open_unchanged() {
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
// An older design keeps the numbering of the pieces it was saved with (#3902).
|
||||
let mut same = solidcraft_doc::Document::clone(&s.doc);
|
||||
same.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
assert_eq!(doc, same);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
|
||||
@@ -4878,3 +4878,108 @@ fn a_sheet_made_of_sheet_features_and_flat_cuts_still_exports_its_flat_pattern()
|
||||
}
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// An L-bracket with a hole off its centre and two flanges, so that its mirror image is the
|
||||
/// opposite hand: a 100 x 60 base on XY with a 10 mm hole at (25, 40), a 20 mm flange on the
|
||||
/// front edge and a 15 mm one on the right edge.
|
||||
fn asymmetric_bracket(s: &mut Session) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "S", "thickness": "2 mm", "bend_radius": "2 mm", "k_factor": 0.4, "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"type": "base"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "name": "F1"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[100, 30, 2]], "height": 15, "name": "F2"}));
|
||||
circle_sketch(s, "Hole", json!("XY"), [25.0, 40.0], 10.0);
|
||||
run(s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
}
|
||||
|
||||
/// The points of a flat pattern's loops, `x` scaled by `sx`, sorted, rounded to 1e-6 mm.
|
||||
fn flat_points(v: &Value, sx: f64) -> Vec<(i64, i64)> {
|
||||
let mut out: Vec<(i64, i64)> = v
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
|
||||
.map(|p| ((p[0].as_f64().unwrap_or(f64::NAN) * sx * 1e6).round() as i64, (p[1].as_f64().unwrap_or(f64::NAN) * 1e6).round() as i64))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
fn body_volume(s: &mut Session, name: &str) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
m["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == name).and_then(|b| b["volume_mm3"].as_f64()).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirrored_sheet_copy_is_the_opposite_hand_with_the_mirror_image_flat_pattern() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
let mirrored = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1 (2)"}));
|
||||
// The same blank, the other way round: K-factor flat length, bends and thickness are kept.
|
||||
assert_eq!(mirrored["flat_size_mm"], original["flat_size_mm"], "{mirrored}");
|
||||
assert_eq!(mirrored["thickness"], original["thickness"]);
|
||||
assert_eq!(flat_points(&mirrored["outline"], 1.0), flat_points(&original["outline"], -1.0));
|
||||
assert_eq!(original["cutouts"].as_array().map(Vec::len), Some(1), "{original}");
|
||||
assert_eq!(flat_points(&mirrored["cutouts"], 1.0), flat_points(&original["cutouts"], -1.0));
|
||||
let bends = |f: &Value| -> Vec<(String, i64, i64)> {
|
||||
let mut b: Vec<_> = f["bends"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| {
|
||||
(
|
||||
b["direction"].to_string(),
|
||||
(b["allowance"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
(b["length"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
b.sort();
|
||||
b
|
||||
};
|
||||
assert_eq!(bends(&mirrored), bends(&original));
|
||||
assert_eq!(bends(&mirrored).len(), 2);
|
||||
// The hole is where the mirror put it, on the copy and in its flat pattern.
|
||||
let in_copy = |p: [f64; 3]| {
|
||||
s.model.state().bodies.iter().find(|b| b.name == "Body1 (2)").is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||||
};
|
||||
assert!(!in_copy([-25.0, 40.0, 1.0]) && in_copy([-35.0, 40.0, 1.0]));
|
||||
let hole = flat_points(&mirrored["cutouts"], 1.0);
|
||||
let cx = hole.iter().map(|p| p.0 as f64).sum::<f64>() / hole.len().max(1) as f64 * 1e-6;
|
||||
assert!((cx + 25.0).abs() < 1e-3, "hole centre x {cx}");
|
||||
// The sheet model is the mirrored body: a flange added to the copy grows it from its own
|
||||
// back edge, and its DXF is that of a sheet.
|
||||
let before = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(rel(before, body_volume(&mut s, "Body1")) < 1e-9);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[-50, 60, 2]], "height": 10, "body": "Body1 (2)"}));
|
||||
let after = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(after > before + 100.0, "{before} -> {after}");
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({"body": "Body1 (2)"}));
|
||||
assert_eq!(dxf["bends"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_or_copying_a_sheet_body_leaves_the_original_sheet_as_it_was() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
let volume = body_volume(&mut s, "Body1");
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 200, 0], "copy": true}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Body1"], "dir1": [0, 0, 1], "count1": 2, "spacing1": 100}));
|
||||
assert_eq!(run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"})), original);
|
||||
assert!(rel(body_volume(&mut s, "Body1"), volume) < 1e-9);
|
||||
// A moved or patterned copy is the same hand: the same flat pattern, as a sheet of its own.
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
for name in names.iter().filter(|n| *n != "Body1" && *n != "Body1 (2)") {
|
||||
let mut copy = run(&mut s, "sheet.flat_pattern", json!({"body": name}));
|
||||
copy["body"] = original["body"].clone();
|
||||
assert_eq!(flat_points(©["outline"], 1.0), flat_points(&original["outline"], 1.0), "{name}");
|
||||
assert_eq!(copy["bends"], original["bends"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,3 +723,17 @@ fn a_custom_plane_origin_that_is_not_numbers_is_refused_not_zeroed() {
|
||||
assert_eq!(origin_of(&mut s, json!({"origin": [20, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([20.0, 0.0, 0.0]));
|
||||
assert_eq!(origin_of(&mut s, json!({"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([0.0, 0.0, 0.0]));
|
||||
}
|
||||
/// A sketch on a body's face projects that face's edges from the body's display mesh. The body
|
||||
/// it projects from is the model's own, not a copy that meshes it again: on a body whose curved
|
||||
/// edges are slow to mesh, the copy's mesh was thrown away and the next feature meshed the body
|
||||
/// a second time, which made a sketch on a face as slow as a cut.
|
||||
#[test]
|
||||
fn a_sketch_on_a_face_meshes_its_body_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_none());
|
||||
let pick = top_pick(&s, "Base");
|
||||
run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_some(), "the sketch's projection meshed a copy of the body");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,62 @@ fn body(s: &mut Session, b: &str) -> Value {
|
||||
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
|
||||
}
|
||||
|
||||
fn stepped_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "step_height", "expression": "10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "step_height", "operation": "join"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A column beside a step ends at the step's top, including when the To point lies on an
|
||||
/// edge or corner shared with a vertical wall (#3729).
|
||||
#[test]
|
||||
fn extrude_to_a_planar_face_boundary_ends_at_its_plane() {
|
||||
for to in [[5, 0, 20], [5, 5, 20], [0, 0, 20], [1000, 1000, 20]] {
|
||||
let mut s = stepped_block();
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"to": to, "operation": "join"}));
|
||||
let b = body(&mut s, "Block");
|
||||
let top = b["bbox"]["max"][2].as_f64().unwrap();
|
||||
let volume = b["volume_mm3"].as_f64().unwrap();
|
||||
assert!((top - 20.0).abs() < 1e-6, "To {to:?}: z max {top}");
|
||||
assert!((volume - (5000.0 + 10.0 * PI * 1.5 * 1.5)).abs() < 0.1, "To {to:?}: volume {volume}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The face chosen at an edge must also be the stored reference: raising the step must raise
|
||||
/// the end of the extrusion, including after a save/reload.
|
||||
#[test]
|
||||
fn extrude_to_a_planar_face_boundary_follows_it_after_reload() {
|
||||
for to in [[5, 0, 20], [5, 5, 20]] {
|
||||
let mut s = stepped_block();
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// A negative reach with an offset past the target, away from the start.
|
||||
run(&mut s, "solid.extrude", json!({"to": to, "to_offset": 2, "operation": "new", "body_name": "Post"}));
|
||||
let b = body(&mut s, "Post");
|
||||
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 18.0).abs() < 1e-6, "{b}");
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut cold = Session::new(doc);
|
||||
for current in [&mut s, &mut cold] {
|
||||
run(current, "parameters.change", json!({"name": "step_height", "expression": "15 mm"}));
|
||||
let b = body(current, "Post");
|
||||
let volume = b["volume_mm3"].as_f64().unwrap();
|
||||
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 23.0).abs() < 1e-6, "{b}");
|
||||
assert!((b["bbox"]["max"][2].as_f64().unwrap() - 30.0).abs() < 1e-6, "{b}");
|
||||
assert!((volume - 7.0 * PI * 1.5 * 1.5).abs() < 0.1, "{b}");
|
||||
assert!(current.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", current.model.results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
|
||||
/// top takes the cylinder's shape, so the post is shortest in the middle.
|
||||
#[test]
|
||||
|
||||
@@ -374,3 +374,44 @@ fn drilled_sketch_points_break_through_a_sloping_back_face() {
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
near(volume(&mut reopened), left);
|
||||
}
|
||||
|
||||
/// Back (z 0…6) under a smaller Front (z 14…20), and a sketch on Front's top face with points
|
||||
/// over Front (`in`) and beyond its edges, over Back (`out1`, `out2`).
|
||||
fn front_and_back(s: &mut Session) {
|
||||
run(s, "solid.box", json!({"length": 40, "width": 40, "height": 6, "body_name": "Back"}));
|
||||
run(s, "solid.box", json!({"corner": [10, 10, 14], "length": 20, "width": 20, "height": 6, "body_name": "Front"}));
|
||||
run(s, "sketch.create", json!({"plane": {"face": [20, 20, 20]}, "name": "Pts", "project_edges": false}));
|
||||
for (id, p) in [("in", [20, 20]), ("out1", [5, 5]), ("out2", [35, 35])] {
|
||||
run(s, "sketch.point", json!({"point": p, "id": id}));
|
||||
}
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// #2882: holes at the points of a sketch on a body's face drill that body, never one behind it.
|
||||
#[test]
|
||||
fn holes_from_a_sketch_on_a_face_drill_that_body_only() {
|
||||
let mut s = Session::default();
|
||||
front_and_back(&mut s);
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in", "out1", "out2"], "diameter": 5, "depth": 4, "name": "H"}));
|
||||
assert_eq!(s.doc.find_feature("H").unwrap().participants, vec!["Front".to_string()]);
|
||||
near(base - volume(&mut s), pi() * 6.25 * 4.0);
|
||||
assert!(!solid_at(&s, 20.0, 20.0, 18.0), "drilled into Front");
|
||||
for (x, y) in [(5.0, 5.0), (35.0, 35.0), (20.0, 20.0)] {
|
||||
assert!(solid_at(&s, x, y, 5.5) && solid_at(&s, x, y, 3.0), "Back is not drilled at ({x}, {y})");
|
||||
}
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
near(base - volume(&mut reopened), pi() * 6.25 * 4.0);
|
||||
}
|
||||
|
||||
/// Through all from Front's face stops at Front; `targets` picks another body to drill instead.
|
||||
#[test]
|
||||
fn face_sketch_holes_through_all_stay_in_their_body_unless_targets_say_otherwise() {
|
||||
let mut s = Session::default();
|
||||
front_and_back(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "name": "H"}));
|
||||
assert!(!solid_at(&s, 20.0, 20.0, 15.0) && solid_at(&s, 20.0, 20.0, 3.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "targets": ["Back"], "name": "H"}));
|
||||
assert!(solid_at(&s, 20.0, 20.0, 15.0) && !solid_at(&s, 20.0, 20.0, 3.0));
|
||||
}
|
||||
|
||||
@@ -170,9 +170,19 @@ pub struct Snapshot {
|
||||
pub struct Ahead(Session);
|
||||
|
||||
impl Ahead {
|
||||
/// Run `commands` (their results stay in the shared evaluation cache).
|
||||
/// What the evaluation computes, kept for the run that follows here whatever the evaluation
|
||||
/// cache drops meanwhile: hand it to [`solidcraft_doc::Model::keep`] for that run.
|
||||
pub fn kept(&self) -> Arc<solidcraft_doc::KeptResults> {
|
||||
self.0.model.kept().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run `commands` (their results stay in the shared evaluation cache, and in [`Ahead::kept`]),
|
||||
/// then make the display meshes and plans of the bodies they end with: the bodies the run
|
||||
/// here finds share them, so the window draws, fits and picks without tessellating anything.
|
||||
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
|
||||
self.0.preview_in_place(commands).map(|_| ())
|
||||
let (s, _) = self.0.preview_in_place(commands)?;
|
||||
s.world_state().warm_display();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
|
||||
@@ -1030,6 +1040,7 @@ impl Session {
|
||||
let mut copy = self.scratch();
|
||||
copy.undo.clone_from(&self.undo);
|
||||
copy.redo.clone_from(&self.redo);
|
||||
copy.model.keep(Some(Arc::default()));
|
||||
Ahead(copy)
|
||||
}
|
||||
|
||||
|
||||
@@ -278,3 +278,53 @@ fn step_round_trip_of_a_plate_with_holes() {
|
||||
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// A part ring cut by `solid.combine` (two prisms through its ends, a notch through its chamfer)
|
||||
// measures what it encloses, wherever it is moved. The reference mesher left a crack where the
|
||||
// notch meets the chamfer's cone; the volume beside it was left out (0.2 %), and integrated
|
||||
// about the world origin the loss grew with the distance (7 % moved 1000 mm away).
|
||||
|
||||
fn volume(s: &mut Session, body: &str) -> f64 {
|
||||
run(s, "inspect.measure", json!({"bodies": [body]}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn prism(s: &mut Session, offset: f64, points: Value, distance: f64, name: &str) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": offset}));
|
||||
run(s, "sketch.line", json!({"points": points, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": distance, "operation": "new", "body_name": name}));
|
||||
}
|
||||
|
||||
/// A part ring (210° of a chamfered section) less two oblique prisms and a notch.
|
||||
fn cut_ring(s: &mut Session) {
|
||||
let (c, si) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
|
||||
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [si, c, 0], "y_dir": [0, 0, -1]}}));
|
||||
let section = json!([[16.6, 0], [17.5, 0], [18.5, -1.0], [18.5, -7.0], [17.5, -8.0], [16.6, -8.0], [16.4, -7.8], [16.4, -0.2]]);
|
||||
run(s, "sketch.line", json!({"points": section, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.revolve", json!({"axis": "Z", "angle": "210 deg", "body_name": "Ring"}));
|
||||
let quad = |sy: f64| json!([[4.644832, 15.101839 * sy], [5.17399, 16.822301 * sy], [4.555215, 17.000295 * sy], [2.187187, 15.574386 * sy]]);
|
||||
prism(s, -1.0, quad(1.0), 10.0, "T0");
|
||||
prism(s, -1.0, quad(-1.0), 10.0, "T1");
|
||||
prism(s, 7.1, json!([[-17.6, 0.45], [-19.5, 0.45], [-19.5, -0.45], [-17.6, -0.45]]), 1.9, "N0");
|
||||
run(s, "solid.combine", json!({"target": "Ring", "tools": ["T0", "T1", "N0"], "operation": "cut"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_cut_volume_does_not_depend_on_placement() {
|
||||
let mut s = Session::default();
|
||||
cut_ring(&mut s);
|
||||
let here = volume(&mut s, "Ring");
|
||||
// The 210° ring by Pappus less what the prisms and the notch take (a fine grid quadrature
|
||||
// of each, independent of the kernel's meshes): 1005.489 − 2 × 5.639 − 0.287.
|
||||
let want = 993.924;
|
||||
assert!((here - want).abs() <= 2e-4 * want, "at the origin: {here} vs {want}");
|
||||
let mut moved = Vec::new();
|
||||
for by in [[100.0, 0.0, 0.0], [900.0, 0.0, 0.0], [-1000.0, 100.0, 0.0], [0.0, 0.0, 10000.0], [0.0, -10000.0, 0.0]] {
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Ring"], "translate": by}));
|
||||
moved.push((by, volume(&mut s, "Ring")));
|
||||
}
|
||||
for (by, v) in moved {
|
||||
assert!((v - here).abs() <= 1e-6 * here, "after a move by {by:?}: {v} vs {here} at the origin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,3 +401,63 @@ fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
|
||||
assert_eq!(blends, 4, "{filleted:?}");
|
||||
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
|
||||
}
|
||||
|
||||
/// #3902: a torus with a closed, twisted square sweep cut inside it splits the sweep's walls into
|
||||
/// pieces the kernel returns in a different order on each evaluation. The pieces are numbered by
|
||||
/// where they lie, so every fresh evaluation gives each piece the same name.
|
||||
#[test]
|
||||
fn split_pieces_keep_their_numbers_across_evaluations() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json")).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).expect("a body");
|
||||
let named = |s: &mut Session| -> Vec<(String, [i64; 3])> {
|
||||
let mut faces: Vec<(String, [i64; 3])> = listed(s, &body).into_iter().map(|(n, _, c)| (n, c.map(|x| x.round() as i64))).collect();
|
||||
faces.sort();
|
||||
faces
|
||||
};
|
||||
let first = named(&mut s);
|
||||
assert!(first.iter().any(|(n, _)| n.contains('#')), "no split pieces: {first:?}");
|
||||
for run in 0..10 {
|
||||
let mut fresh = Session::new(solidcraft_doc::Document::clone(&s.doc));
|
||||
fresh.refresh();
|
||||
assert_eq!(named(&mut fresh), first, "evaluation {run}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3902: a design saved before the pieces of a face were numbered by place opens with the
|
||||
/// numbers it was saved with, so a reference to `…#1` stays on the piece it was made on. The
|
||||
/// block cut by the second block here has its side split in two, numbered the other way round
|
||||
/// by place.
|
||||
#[test]
|
||||
fn a_design_saved_before_pieces_were_numbered_by_place_keeps_their_numbers() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../tests/action_matrix/designs/multi-body-join-cut.json")).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
let by_place = listed(&mut s, "Block");
|
||||
// As a build before the change saved it: its features carry no mark, and the file does not
|
||||
// list the change among those it has.
|
||||
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
for f in file["features"].as_array_mut().unwrap() {
|
||||
f.as_object_mut().unwrap().remove("kernel_piece_order");
|
||||
}
|
||||
if let Some(Value::Array(pending)) = file.get_mut("format_pending") {
|
||||
pending.retain(|c| c != "face-pieces-by-place");
|
||||
}
|
||||
// 0.18.3 folded the change into format 29, so a build before it wrote 28.
|
||||
file["format"] = Value::String(solidcraft_doc::format::format_string(28));
|
||||
let old = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
|
||||
assert!(old.features.iter().all(|f| f.kernel_piece_order), "the features of an older design keep the old numbering");
|
||||
let mut reopened = Session::new(old);
|
||||
reopened.refresh();
|
||||
let kept = listed(&mut reopened, "Block");
|
||||
let at = |faces: &[(String, bool, [f64; 3])], name: &str| faces.iter().find(|(n, _, _)| n == name).map(|(_, _, c)| *c);
|
||||
// The pieces are numbered differently by place, and the older design keeps its own numbers.
|
||||
let (old1, new1) = (at(&kept, "F2:side#1").unwrap(), at(&by_place, "F2:side#1").unwrap());
|
||||
assert!((0..3).any(|k| (old1[k] - new1[k]).abs() > 1.0), "{kept:?} {by_place:?}");
|
||||
let mut legacy = solidcraft_doc::Document::clone(&s.doc);
|
||||
legacy.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
let mut saved_with = Session::new(legacy);
|
||||
saved_with.refresh();
|
||||
assert_eq!(listed(&mut saved_with, "Block"), kept);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,23 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String {
|
||||
format!("{WHOLE}{message}")
|
||||
}
|
||||
|
||||
/// Marks a deserializer's message that carries its own path: an error from a flattened
|
||||
/// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error
|
||||
/// follow as JSON.
|
||||
const LOCATED: char = '\u{2}';
|
||||
|
||||
/// `e`, an error deserializing a flattened `FeatureCommon`, as a deserializer's message that
|
||||
/// keeps its path, for [`parse_issue`].
|
||||
pub(crate) fn located(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
|
||||
let located = serde_json::to_string(&(error_path(e), e.inner().to_string())).unwrap_or_default();
|
||||
format!("{LOCATED}{located}")
|
||||
}
|
||||
|
||||
fn error_path(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
|
||||
let path = e.path().to_string();
|
||||
if path == "." { String::new() } else { path }
|
||||
}
|
||||
|
||||
/// The top-level property names of a schema, following `allOf`/`anyOf`/`oneOf` and `$ref`s
|
||||
/// into the root's `$defs`.
|
||||
pub(crate) fn schema_keys(root: &Value) -> BTreeSet<String> {
|
||||
@@ -207,10 +224,15 @@ fn nearest(key: &str, allowed: &BTreeSet<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
/// A deserialization failure as an issue, in the engine's style.
|
||||
fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
let path = e.path().to_string();
|
||||
let path = if path == "." { String::new() } else { path };
|
||||
pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
let path = error_path(e);
|
||||
let inner = e.inner().to_string();
|
||||
// An error from a flattened `FeatureCommon`: its field continues the path.
|
||||
let (path, inner) = match inner.strip_prefix(LOCATED).and_then(|r| serde_json::from_str::<(String, String)>(r).ok()) {
|
||||
Some((field, inner)) if path.is_empty() => (field, inner),
|
||||
Some((field, inner)) => (format!("{path}.{field}"), inner),
|
||||
None => (path, inner),
|
||||
};
|
||||
if let Some(complete) = inner.strip_prefix(WHOLE) {
|
||||
// The handler's own message, which names the caller's value: in full up to a generous
|
||||
// cap, since the contract also checks keys the handler ignores (`side` without `thin`).
|
||||
@@ -218,7 +240,7 @@ fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
}
|
||||
let message = if let Some(field) = inner.strip_prefix("missing field `").and_then(|r| r.split('`').next()) {
|
||||
let at = if path.is_empty() { field.to_string() } else { format!("{path}.{field}") };
|
||||
return Issue { path: at.clone(), message: format!("`{at}` is required"), suggestion: None };
|
||||
return Issue { path: at.clone(), message: truncate(&format!("`{at}` is required"), MAX_MESSAGE_LEN), suggestion: None };
|
||||
} else if path.is_empty() {
|
||||
inner
|
||||
} else if inner.starts_with("must ") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The converted commands' parameter contracts and result types.
|
||||
//! Contracts and result types for the commands in `cmd/features.rs`.
|
||||
//!
|
||||
//! Field doc comments are the schemas' descriptions. Keys the handlers read only in some
|
||||
//! situations (`sketch` without `face`, the thin-wall keys with `thin: true`) are optional
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{
|
||||
AngleExpr, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FromPoint, IdOrName, LengthExpr, OperationParam, Point3,
|
||||
@@ -161,39 +160,6 @@ pub struct ExtrudeParams {
|
||||
pub thickness2: Option<LengthExpr>,
|
||||
}
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DescribeParams {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
#[serde(deserialize_with = "command_id")]
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// A command id, with the `engine.describe` handler's message for anything else.
|
||||
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(serde::de::Error::custom("must be a command id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `engine.describe` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DescribeResult {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The command's parameter documentation, as prose.
|
||||
pub params: String,
|
||||
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
|
||||
pub input: Option<Value>,
|
||||
/// The successful result's JSON Schema, or `null` when not described yet.
|
||||
pub output: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a feature command returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FeatureResult {
|
||||
@@ -218,3 +184,7 @@ pub struct FeatureResult {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "features_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,127 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BoxParams, ExtrudeParams, FeatureResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<BoxParams>(),
|
||||
&["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<ExtrudeParams>(),
|
||||
&[
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"convention",
|
||||
"curves",
|
||||
"direction",
|
||||
"distance",
|
||||
"distance2",
|
||||
"extent",
|
||||
"face",
|
||||
"face_body",
|
||||
"faces",
|
||||
"from",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"profiles",
|
||||
"side",
|
||||
"sketch",
|
||||
"start_offset",
|
||||
"taper",
|
||||
"targets",
|
||||
"thickness",
|
||||
"thickness2",
|
||||
"thin",
|
||||
"through_all",
|
||||
"to",
|
||||
"to_offset",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
|
||||
("solid.box", json!({"length": true, "width": 5, "height": 5})),
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": []})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": []})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": null})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": "true"})),
|
||||
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "from": "x"})),
|
||||
("solid.extrude", json!({"distance": 5, "sketch": true})),
|
||||
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
|
||||
("solid.extrude", json!({"distance": " "})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
|
||||
"removed_bodies": ["C"], "warning": warning, "ms": ms});
|
||||
let new = FeatureResult {
|
||||
feature: 7,
|
||||
name: "Box1".into(),
|
||||
bodies: vec!["A".into(), "B".into()],
|
||||
created_bodies: vec!["B".into()],
|
||||
changed_bodies: vec![],
|
||||
removed_bodies: vec!["C".into()],
|
||||
warning: warning.clone(),
|
||||
ms,
|
||||
param_warnings: vec![],
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert!(!required.contains(&"param_warnings"));
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let replies = [
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
|
||||
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
|
||||
];
|
||||
for r in replies {
|
||||
let r = r.unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
}
|
||||
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
|
||||
// schema leaves other keys open, so a new result key doesn't break clients.)
|
||||
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
|
||||
assert!(conforms(schema, schema, &ok));
|
||||
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
|
||||
let mut bad = ok.clone();
|
||||
bad[k] = v;
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
let mut bad = ok.clone();
|
||||
bad.as_object_mut().map(|o| o.remove("name"));
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Contracts and result types for the commands in `cmd/inspect.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DescribeParams {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
#[serde(deserialize_with = "command_id")]
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// A command id, with the `engine.describe` handler's message for anything else.
|
||||
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(serde::de::Error::custom("must be a command id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `engine.describe` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DescribeResult {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The command's parameter documentation, as prose.
|
||||
pub params: String,
|
||||
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
|
||||
pub input: Option<Value>,
|
||||
/// The successful result's JSON Schema, or `null` when not described yet.
|
||||
pub output: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inspect_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::DescribeParams;
|
||||
use crate::Session;
|
||||
use crate::schema::input_schema_for;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("engine.describe", json!({"command": 5})),
|
||||
("engine.describe", json!({"command": null})),
|
||||
("engine.describe", json!({"command": "solid.extrud"})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_returns_both_schemas() {
|
||||
let mut s = Session::default();
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(d["id"], "solid.box");
|
||||
assert!(d["input"]["properties"].get("length").is_some());
|
||||
assert!(d["output"]["properties"].get("created_bodies").is_some());
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
@@ -22,15 +22,22 @@
|
||||
//! more lenient (a value of the wrong type read as if the key were absent), the contract is the
|
||||
//! stricter of the two; that difference is what warn and strict surface.
|
||||
//!
|
||||
//! To convert a command: declare its parameters type in `commands.rs` (and its reply type, for
|
||||
//! an output schema), give its `CommandSpec` `checked!(…)`, `.contract(…)` and `.output(…)` as
|
||||
//! `solid.extrude` does, and add it to `schemas_and_the_key_check_agree` and, for messages it
|
||||
//! already gives, `strict_keeps_todays_errors`. Then run the whole test suite with the audit
|
||||
//! recorder (see `record.rs`): a call that succeeds today but breaks the contract is either a
|
||||
//! form the contract is missing or a caller's mistake.
|
||||
//! Contracts live in one module per command file: `features.rs` for `cmd/features.rs`,
|
||||
//! `inspect.rs` for `cmd/inspect.rs`, and so on, each with its own tests (`features_tests.rs`),
|
||||
//! so conversions of different command files touch different files (apart from a new
|
||||
//! module's line below).
|
||||
//!
|
||||
//! To convert a command: declare its parameters type in the module for its command file (and
|
||||
//! its reply type, for an output schema), creating the module if it is the file's first
|
||||
//! contract, from the shared types in [`types`] (the keys every feature takes come from
|
||||
//! [`FeatureCommon`], which `solid.box` and `solid.extrude` predate); give its `CommandSpec`
|
||||
//! `checked!(…)`, `.contract(…)` and `.output(…)` as `solid.extrude` does; and add it to that
|
||||
//! module's `schemas_and_the_key_check_agree` and, for messages it already gives,
|
||||
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
|
||||
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
|
||||
//! contract is missing or a caller's mistake.
|
||||
|
||||
mod check;
|
||||
mod commands;
|
||||
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
|
||||
mod record;
|
||||
mod types;
|
||||
@@ -38,8 +45,11 @@ mod types;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// The contracts, by command file.
|
||||
pub mod features;
|
||||
pub mod inspect;
|
||||
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
|
||||
pub use commands::{BoxParams, DescribeParams, DescribeResult, ExtrudeParams, FeatureResult};
|
||||
pub use types::*;
|
||||
|
||||
/// A command's `run` that checks the call against `$contract` before running `$handler`
|
||||
|
||||
+260
-150
@@ -1,7 +1,12 @@
|
||||
//! Tests of the shared types and the checking machinery, and helpers for the per-module tests
|
||||
//! (`features_tests.rs`, …).
|
||||
|
||||
use schemars::Schema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::check::schema_keys;
|
||||
use super::features::{BoxParams, ExtrudeParams};
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
@@ -159,54 +164,6 @@ fn shape_problems_are_issues() {
|
||||
assert!(messages(&c.check(&Value::Object(many))).iter().any(|m| m.starts_with("too many parameters")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
for (schema, fields) in [
|
||||
(
|
||||
input_schema_for::<BoxParams>(),
|
||||
vec!["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
|
||||
),
|
||||
(
|
||||
input_schema_for::<ExtrudeParams>(),
|
||||
vec![
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"convention",
|
||||
"curves",
|
||||
"direction",
|
||||
"distance",
|
||||
"distance2",
|
||||
"extent",
|
||||
"face",
|
||||
"face_body",
|
||||
"faces",
|
||||
"from",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"profiles",
|
||||
"side",
|
||||
"sketch",
|
||||
"start_offset",
|
||||
"taper",
|
||||
"targets",
|
||||
"thickness",
|
||||
"thickness2",
|
||||
"thin",
|
||||
"through_all",
|
||||
"to",
|
||||
"to_offset",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let v = schema.as_value();
|
||||
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
|
||||
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
|
||||
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_null_is_refused_but_absent_is_fine() {
|
||||
let base = json!({"length": 5, "width": 5, "height": 5});
|
||||
@@ -228,7 +185,7 @@ fn present_null_is_refused_but_absent_is_fine() {
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch_rectangle(s: &mut Session) {
|
||||
pub(super) fn sketch_rectangle(s: &mut Session) {
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [10, 10]})),
|
||||
@@ -302,32 +259,12 @@ fn the_level_survives_a_new_design() {
|
||||
assert_eq!(s.scratch().param_check, ParamCheck::Strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
|
||||
"removed_bodies": ["C"], "warning": warning, "ms": ms});
|
||||
let new = FeatureResult {
|
||||
feature: 7,
|
||||
name: "Box1".into(),
|
||||
bodies: vec!["A".into(), "B".into()],
|
||||
created_bodies: vec!["B".into()],
|
||||
changed_bodies: vec![],
|
||||
removed_bodies: vec!["C".into()],
|
||||
warning: warning.clone(),
|
||||
ms,
|
||||
param_warnings: vec![],
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` conforms to `node` of the schema `root`, for the keywords these schemas use
|
||||
/// apart from `pattern`: `type`, `properties`, `required`, `additionalProperties: false`,
|
||||
/// `items`, `minItems`, `maxItems`, `uniqueItems`, `enum`, `minLength`, `maxLength`, `minimum`,
|
||||
/// `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `anyOf`, `not` and `$ref`. (`integer`
|
||||
/// here is what serde reads as one, so `1.0` is not.)
|
||||
fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
pub(super) fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
if let Some(name) = node.get("$ref").and_then(Value::as_str).and_then(|r| r.strip_prefix("#/$defs/")) {
|
||||
return root.get("$defs").and_then(|d| d.get(name)).is_some_and(|t| conforms(root, t, v));
|
||||
}
|
||||
@@ -410,38 +347,6 @@ fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert!(!required.contains(&"param_warnings"));
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let replies = [
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
|
||||
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
|
||||
];
|
||||
for r in replies {
|
||||
let r = r.unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
}
|
||||
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
|
||||
// schema leaves other keys open, so a new result key doesn't break clients.)
|
||||
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
|
||||
assert!(conforms(schema, schema, &ok));
|
||||
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
|
||||
let mut bad = ok.clone();
|
||||
bad[k] = v;
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
let mut bad = ok.clone();
|
||||
bad.as_object_mut().map(|o| o.remove("name"));
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_recorder_keeps_a_bounded_number_of_signatures() {
|
||||
@@ -459,22 +364,6 @@ fn the_recorder_keeps_a_bounded_number_of_signatures() {
|
||||
assert_eq!(seen.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_returns_both_schemas() {
|
||||
let mut s = Session::default();
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(d["id"], "solid.box");
|
||||
assert!(d["input"]["properties"].get("length").is_some());
|
||||
assert!(d["output"]["properties"].get("created_bodies").is_some());
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
|
||||
/// The profiles schema and the contract agree on every listed case; the schema is deliberately
|
||||
/// narrower on non-text curve ids and loops past the outer one (and describes the app's
|
||||
/// `whole_boundaries` selection only by its `type`).
|
||||
@@ -554,43 +443,29 @@ fn recorder_lines_have_the_documented_shape() {
|
||||
assert_eq!(super::record::enabled(), cfg!(feature = "param-audit") && set);
|
||||
}
|
||||
|
||||
/// A contract's input schema lists exactly `fields` (sorted) as its keys, refuses other keys
|
||||
/// and declares draft 2020-12. Each module's `schemas_and_the_key_check_agree` calls this for
|
||||
/// its contracts, so their keys change only on purpose. (Whether the keys are the ones the
|
||||
/// handler reads is what the audit run checks.)
|
||||
pub(super) fn assert_keys(schema: Schema, fields: &[&str]) {
|
||||
let v = schema.as_value();
|
||||
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
|
||||
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
|
||||
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
|
||||
}
|
||||
|
||||
/// A call that already fails gets the same error in strict as with the check off, apart from
|
||||
/// the missing-key, missing-instead-of-wrong-type, non-object, profile-index, 10,000-item and
|
||||
/// over-4-KB messages the report lists.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let cases = [
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
|
||||
("solid.box", json!({"length": true, "width": 5, "height": 5})),
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": []})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": []})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": null})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": "true"})),
|
||||
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "from": "x"})),
|
||||
("solid.extrude", json!({"distance": 5, "sketch": true})),
|
||||
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
|
||||
("solid.extrude", json!({"distance": " "})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
|
||||
("engine.describe", json!({"command": 5})),
|
||||
("engine.describe", json!({"command": null})),
|
||||
("engine.describe", json!({"command": "solid.extrud"})),
|
||||
];
|
||||
/// over-4-KB messages the report lists, and a list of picks where one finds nothing in the
|
||||
/// design and a later one is malformed (the handler fails on the first, strict on the second).
|
||||
/// Each module's `strict_keeps_todays_errors` calls this with its commands' cases; each runs
|
||||
/// after `sketch_rectangle`.
|
||||
pub(super) fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
|
||||
for (id, p) in cases {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
sketch_rectangle(&mut s);
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
s.execute(id, p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
@@ -601,7 +476,10 @@ fn strict_keeps_todays_errors() {
|
||||
/// Every `default` in the input schemas is a value its own property schema allows.
|
||||
#[test]
|
||||
fn schema_defaults_are_valid() {
|
||||
for root in [input_schema_for::<BoxParams>(), input_schema_for::<ExtrudeParams>()] {
|
||||
let contracts: Vec<_> = crate::cmd::command_specs().into_iter().filter_map(|c| c.contract).collect();
|
||||
assert!(contracts.len() >= 3);
|
||||
for c in contracts {
|
||||
let root = c.input_schema();
|
||||
let root = root.as_value();
|
||||
for (key, property) in root["properties"].as_object().into_iter().flatten() {
|
||||
if let Some(d) = property.get("default") {
|
||||
@@ -631,3 +509,235 @@ fn long_values_in_handler_messages_are_capped() {
|
||||
let w = r["param_warnings"][0].as_str().unwrap_or_default();
|
||||
assert!(w.starts_with("unknown side `xxx") && w.len() <= 4096 + "…".len(), "{} bytes", w.len());
|
||||
}
|
||||
|
||||
/// The pick types accept what the pick readers accept. (Comparing a type with the helper it
|
||||
/// calls only guards against the two drifting apart; the schema comparisons and the handler
|
||||
/// tests below are the real checks.)
|
||||
#[test]
|
||||
fn face_and_edge_refs_match_the_pick_readers() {
|
||||
let long = "f".repeat(513);
|
||||
// 171 three-byte characters are 513 bytes, too long for the reader; the schema's maxLength
|
||||
// counts characters, so it can't say this.
|
||||
let wide = "面".repeat(171);
|
||||
let face_schema = input_schema_for::<FaceRef>();
|
||||
let face_schema = face_schema.as_value();
|
||||
let edge_schema = input_schema_for::<EdgeRef>();
|
||||
let edge_schema = edge_schema.as_value();
|
||||
for v in [
|
||||
json!([1, 2, 3]),
|
||||
json!({"x": 1, "y": 2, "z": 3}),
|
||||
json!({"point": [1, 2, 3]}),
|
||||
json!({"point": {"x": 1, "y": 2, "z": 3}}),
|
||||
json!({"x": "a", "point": [1, 2, 3]}),
|
||||
json!("F3:1"),
|
||||
json!(""),
|
||||
json!(long),
|
||||
json!([1, 2]),
|
||||
json!([1e9, 0, 0]),
|
||||
json!({"point": [1, 2]}),
|
||||
json!({"body": "A", "index": 0}),
|
||||
json!({"body": "A", "index": u64::MAX}),
|
||||
json!({"body": "A", "index": -1}),
|
||||
json!({"body": 5, "index": 0}),
|
||||
json!({"body": "A"}),
|
||||
json!({"point": "x", "body": "A", "index": 2}),
|
||||
json!(null),
|
||||
json!(5),
|
||||
json!(true),
|
||||
] {
|
||||
let face = crate::cmd::face_ref_form(&v).is_ok();
|
||||
assert_eq!(parse::<FaceRef>(v.clone()).is_some(), face, "{v}");
|
||||
assert_eq!(conforms(face_schema, face_schema, &v), face, "face {v}");
|
||||
let edge = crate::cmd::edge_ref_form(&v).is_ok();
|
||||
assert_eq!(parse::<EdgeRef>(v.clone()).is_some(), edge, "{v}");
|
||||
assert_eq!(conforms(edge_schema, edge_schema, &v), edge, "edge {v}");
|
||||
}
|
||||
// Where the schemas can't follow the readers: a name's cap is in bytes, and an index of
|
||||
// `1.0`, which the reader refuses, is an integer to JSON Schema (`conforms` reads integers
|
||||
// as serde does, so it agrees with the reader here; a standard validator would not).
|
||||
assert!(parse::<FaceRef>(json!(wide)).is_none() && conforms(face_schema, face_schema, &json!(wide)));
|
||||
assert!(parse::<EdgeRef>(json!({"body": "A", "index": 1.0})).is_none());
|
||||
assert_eq!(edge_schema["anyOf"][2]["properties"]["index"]["type"], "integer");
|
||||
assert_eq!(parse::<EdgeRef>(json!({"body": "A", "index": 4})), Some(EdgeRef::Index { body: "A".into(), index: 4 }));
|
||||
assert_eq!(parse::<FaceRef>(json!({"point": [1, 2, 3]})), Some(FaceRef::Point(solidcraft_geom::Vec3::new(1.0, 2.0, 3.0))));
|
||||
}
|
||||
|
||||
/// The pick lists take 1 to 1000 picks, as `face_points` and `edge_points` do, and so do their
|
||||
/// schemas.
|
||||
#[test]
|
||||
fn pick_lists_match_the_list_readers() {
|
||||
let faces = input_schema_for::<FaceRefs>();
|
||||
let faces = faces.as_value();
|
||||
let edges = input_schema_for::<EdgeRefs>();
|
||||
let edges = edges.as_value();
|
||||
let point = json!([1, 2, 3]);
|
||||
for (v, accepted) in [
|
||||
(json!([point]), true),
|
||||
(json!(vec![point.clone(); 1000]), true),
|
||||
(json!([point, {"point": [4, 5, 6]}]), true),
|
||||
(json!([]), false),
|
||||
(json!(vec![point.clone(); 1001]), false),
|
||||
(json!([point, [1, 2]]), false),
|
||||
(json!(point), false),
|
||||
(json!(null), false),
|
||||
(json!("F1"), false),
|
||||
] {
|
||||
assert_eq!(parse::<FaceRefs>(v.clone()).is_some(), accepted, "{v}");
|
||||
assert_eq!(conforms(faces, faces, &v), accepted, "faces {v}");
|
||||
assert_eq!(parse::<EdgeRefs>(v.clone()).is_some(), accepted, "{v}");
|
||||
assert_eq!(conforms(edges, edges, &v), accepted, "edges {v}");
|
||||
}
|
||||
// Names are faces, not edges; `{body, index}` is an edge, not a face.
|
||||
let mixed = json!(["F1", {"body": "A", "index": 0}]);
|
||||
assert!(parse::<FaceRefs>(json!(["F1"])).is_some() && parse::<EdgeRefs>(json!(["F1"])).is_none());
|
||||
assert!(parse::<FaceRefs>(mixed.clone()).is_none() && !conforms(faces, faces, &mixed));
|
||||
assert_eq!(parse::<EdgeRefs>(json!([{"body": "A", "index": 0}])).map(|e| e.0.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_refs_match_sketch_id() {
|
||||
let schema = input_schema_for::<SketchRef>();
|
||||
let schema = schema.as_value();
|
||||
for v in [json!(3), json!(2.5), json!(-1), json!("Profile"), json!(""), json!(null), json!(true), json!([1]), json!({"id": 1})] {
|
||||
let sketch = crate::cmd::sketch_key(Some(&v));
|
||||
assert_eq!(parse::<SketchRef>(v.clone()).map(|s| s.0), sketch, "{v}");
|
||||
assert_eq!(conforms(schema, schema, &v), sketch.is_some(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A contract built from the shared types, as a converted feature command's would be: the pick
|
||||
/// keys refuse `null`, as their readers do.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[allow(dead_code)]
|
||||
struct Probe {
|
||||
radius: LengthExpr,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "FaceRefs")]
|
||||
faces: Option<FaceRefs>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "EdgeRefs")]
|
||||
edges: Option<EdgeRefs>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
path_sketch: Option<SketchRef>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
common: FeatureCommon,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_common_flattens_into_a_contract() {
|
||||
let c = TypedContract::<Probe>::new();
|
||||
assert_keys(input_schema_for::<Probe>(), &["body_name", "body_names", "edges", "faces", "name", "participants", "path_sketch", "radius"]);
|
||||
let ok = c.check(&json!({"radius": 1, "name": "Round", "body_name": "B", "body_names": ["B", "C"], "participants": "A"}));
|
||||
assert!(ok.is_empty(), "{ok:?}");
|
||||
assert!(c.check(&json!({"radius": 1, "name": null, "participants": null})).is_empty(), "null is absent, as for `str_`");
|
||||
let parsed: Option<Probe> = parse(json!({"radius": 1, "name": "Round", "participants": ["A", "B"]}));
|
||||
assert_eq!(parsed.map(|p| p.common.participants), Some(Some(StringList(vec!["A".into(), "B".into()]))));
|
||||
// Messages and paths name the key, as for the parent's own fields.
|
||||
for (p, path, message) in [
|
||||
(json!({"radius": 1, "name": 5}), "name", "`name` must be text"),
|
||||
(json!({"radius": 1, "body_name": [1]}), "body_name", "`body_name` must be text"),
|
||||
(json!({"radius": 1, "participants": 5}), "participants", "`participants` must be a name or a list of names"),
|
||||
(json!({"radius": 1, "body_names": vec!["x"; 10_001]}), "body_names", "`body_names` must have at most 10000 items"),
|
||||
(json!({"radius": 1, "bodyname": "x"}), "bodyname", "unknown key `bodyname`; did you mean `body_name`?"),
|
||||
(json!({"radius": 1, "faces": null}), "faces", "`faces` must be a list of points or face names"),
|
||||
(json!({"radius": 1, "edges": null}), "edges", "`edges` must be a list"),
|
||||
(json!({"radius": 1, "path_sketch": null}), "path_sketch", "`path_sketch` must be a sketch id or name"),
|
||||
(json!({"name": "x"}), "radius", "`radius` is required"),
|
||||
// The flattened keys are read after the parent's own, whatever their order.
|
||||
(json!({"name": 5}), "radius", "`radius` is required"),
|
||||
(json!({"body_name": 5, "radius": true}), "radius", "`radius` must be a number or an expression"),
|
||||
] {
|
||||
let issues = c.check(&p);
|
||||
assert_eq!(messages(&issues), vec![message], "{p}");
|
||||
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A flattened `FeatureCommon`'s errors are located below wherever its parent is.
|
||||
#[test]
|
||||
fn feature_common_errors_keep_their_place() {
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct Outer {
|
||||
options: Probe,
|
||||
#[serde(default)]
|
||||
list: Vec<Probe>,
|
||||
}
|
||||
let c = TypedContract::<Outer>::new();
|
||||
let probe = json!({"radius": 1});
|
||||
for (p, path, message) in [
|
||||
(json!({"options": {"radius": 1, "name": 5}}), "options.name", "`options.name` must be text"),
|
||||
(
|
||||
json!({"options": probe, "list": [probe, {"radius": 1, "participants": 5}]}),
|
||||
"list[1].participants",
|
||||
"`list[1].participants` must be a name or a list of names",
|
||||
),
|
||||
(json!({"options": probe, "list": [{"radius": 1, "faces": []}]}), "list[0].faces", "select 1…1000 faces"),
|
||||
] {
|
||||
let issues = c.check(&p);
|
||||
assert_eq!(messages(&issues), vec![message], "{p}");
|
||||
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick types refuse what the handlers refuse, with the handlers' messages.
|
||||
#[test]
|
||||
fn shared_types_keep_the_handlers_messages() {
|
||||
let c = TypedContract::<Probe>::new();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
sketch_rectangle(&mut s);
|
||||
for (id, p, probe) in [
|
||||
("solid.shell", json!({"thickness": 1, "faces": 5}), json!({"faces": 5})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": null}), json!({"faces": null})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": []}), json!({"faces": []})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": [true]}), json!({"faces": [true]})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": [""]}), json!({"faces": [""]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": {}}), json!({"edges": {}})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": vec![[0, 0, 0]; 1001]}), json!({"edges": vec![[0, 0, 0]; 1001]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": [[1, 2]]}), json!({"edges": [[1, 2]]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": [{"body": "Body1", "index": "0"}]}), json!({"edges": [{"body": "Body1", "index": "0"}]})),
|
||||
("solid.sweep", json!({"path_sketch": true, "path": ["l1"]}), json!({"path_sketch": true})),
|
||||
("solid.sweep", json!({"path_sketch": null, "path": ["l1"]}), json!({"path_sketch": null})),
|
||||
] {
|
||||
let mut probe = probe;
|
||||
probe["radius"] = json!(1);
|
||||
let issue = messages(&c.check(&probe)).into_iter().next().unwrap_or_default();
|
||||
let today = s.execute(id, &p).err().map(|e| e.to_string());
|
||||
assert_eq!(today, Some(crate::params::bad(id, issue).to_string()), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookups that follow the shape checks the pick types share with the handlers: picks
|
||||
/// by `{body, index}` and sketches by name still find the design, and still fail in order, one
|
||||
/// pick at a time.
|
||||
#[test]
|
||||
fn picks_are_still_looked_up_one_at_a_time() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let error = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let fillet = |edges: Value| json!({"radius": 1, "edges": edges});
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.fillet", fillet(json!([{"body": "Nope", "index": 0}, [1, 2]]))),
|
||||
"invalid parameters for `solid.fillet`: no body `Nope`"
|
||||
);
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.fillet", fillet(json!([{"body": "Body1", "index": 999}]))),
|
||||
"invalid parameters for `solid.fillet`: Body1 has no edge 999"
|
||||
);
|
||||
let far = error(&mut s, "solid.fillet", fillet(json!([[100, 100, 100], [1, 2]])));
|
||||
assert!(far.starts_with("invalid parameters for `solid.fillet`: no edge at [100, 100, 100]"), "{far}");
|
||||
let unknown = error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace"]}));
|
||||
assert!(!unknown.is_empty());
|
||||
assert_eq!(error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace", true]})), unknown, "the name is looked up first");
|
||||
sketch_rectangle(&mut s);
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.sweep", json!({"path_sketch": "Nope", "path": ["l1"]})),
|
||||
"invalid parameters for `solid.sweep`: no sketch `Nope`"
|
||||
);
|
||||
assert_eq!(error(&mut s, "solid.extrude", json!({"distance": 5, "sketch": "Nope"})), "invalid parameters for `solid.extrude`: no sketch `Nope`");
|
||||
let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}");
|
||||
}
|
||||
|
||||
@@ -583,3 +583,201 @@ impl JsonSchema for ProfilesParam {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A face given to a command, as `face::face_ref` reads it: a point on the face (`[x, y, z]`,
|
||||
/// `{x, y, z}` or `{point}`) or its name as `model.faces` lists it (1 to 512 bytes; the
|
||||
/// schema's `maxLength` counts characters, so it can't say bytes). Whether it finds a face is
|
||||
/// checked by the handler, which has the design. (Not `solidcraft_doc::FacePick`, the stored
|
||||
/// pick.)
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceRef {
|
||||
Point(Vec3),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FaceRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match crate::cmd::face_ref_form(&Value::deserialize(d)?) {
|
||||
Ok(crate::cmd::FaceRefForm::Point(p)) => Ok(FaceRef::Point(p)),
|
||||
Ok(crate::cmd::FaceRefForm::Name(n)) => Ok(FaceRef::Name(n.to_string())),
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FaceRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FaceRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A face: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or its name as model.faces lists it (at most 512 bytes of UTF-8).",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "object", "properties": {"point": point}, "required": ["point"]},
|
||||
{"type": "string", "minLength": 1, "maxLength": 512}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge given to a fillet or chamfer, as `features::edge_points` reads it: a point on the
|
||||
/// edge (`[x, y, z]`, `{x, y, z}` or `{point}`), else `{body, index}` in the body's edge order.
|
||||
/// The index is whole-number JSON, as `Value::as_u64` reads it: JSON Schema also counts `1.0`
|
||||
/// as an integer, which the reader refuses. Whether it finds an edge is checked by the handler,
|
||||
/// which has the design. (Not `solidcraft_doc::EdgeRef`, the stored edge position.)
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EdgeRef {
|
||||
Point(Vec3),
|
||||
Index { body: String, index: u64 },
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EdgeRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match crate::cmd::edge_ref_form(&Value::deserialize(d)?) {
|
||||
Ok(crate::cmd::EdgeRefForm::Point(p)) => Ok(EdgeRef::Point(p)),
|
||||
Ok(crate::cmd::EdgeRefForm::Index { body, index }) => Ok(EdgeRef::Index { body: body.to_string(), index }),
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for EdgeRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EdgeRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "An edge: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or {\"body\", \"index\"} in the body's edge order (an index written without a fraction).",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "object", "properties": {"point": point}, "required": ["point"]},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"body": {"type": "string"}, "index": {"type": "integer", "minimum": 0, "maximum": u64::MAX}},
|
||||
"required": ["body", "index"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of 1 to 1000 picks, with the handler's messages for the list itself. Like the handler,
|
||||
/// it refuses `null`: an optional field of this type takes `present`.
|
||||
///
|
||||
/// The handler looks each pick up in the design before reading the next, so a call with a pick
|
||||
/// that finds nothing and a later malformed one fails today on the first and in strict on the
|
||||
/// second.
|
||||
macro_rules! pick_list {
|
||||
($(#[$doc:meta])* $name:ident, $item:ty, $not_list:literal, $count:literal, $description:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct $name(pub Vec<$item>);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let items = v.as_array().ok_or_else(|| D::Error::custom(whole($not_list)))?;
|
||||
if items.is_empty() || items.len() > 1000 {
|
||||
return Err(D::Error::custom(whole($count)));
|
||||
}
|
||||
items.iter().map(|item| <$item>::deserialize(item).map_err(D::Error::custom)).collect::<Result<_, _>>().map($name)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let item = generator.subschema_for::<$item>();
|
||||
json_schema!({"description": $description, "type": "array", "items": item, "minItems": 1, "maxItems": 1000})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pick_list!(
|
||||
/// The `faces` of `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face` and
|
||||
/// `face.delete`, as `features::face_points` reads them. (Extrude's `faces` is a
|
||||
/// [`FacesParam`].)
|
||||
FaceRefs,
|
||||
FaceRef,
|
||||
"`faces` must be a list of points or face names",
|
||||
"select 1…1000 faces",
|
||||
"The faces, 1 to 1000."
|
||||
);
|
||||
pick_list!(
|
||||
/// The `edges` of `solid.fillet` and `solid.chamfer`, as `features::edge_points` reads them.
|
||||
EdgeRefs,
|
||||
EdgeRef,
|
||||
"`edges` must be a list",
|
||||
"select 1…1000 edges",
|
||||
"The edges, 1 to 1000."
|
||||
);
|
||||
|
||||
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
|
||||
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
|
||||
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
|
||||
/// where the handler says `sections[].sketch`. The `sketch` that `features::feature_sketch`
|
||||
/// reads (extrude's, revolve's…) has another message and is an [`IdOrName`]. Where the reader
|
||||
/// refuses `null`, an optional field of this type takes `present`. Whether a sketch has that id
|
||||
/// or name is checked by the handler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchRef(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::sketch_key(Some(&Value::deserialize(d)?)).map(SketchRef).ok_or_else(|| D::Error::custom("must be a sketch id or name"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys `features::add_feature` reads for every feature command, whatever else it takes:
|
||||
/// the feature's name, the new bodies' names and the bodies taking part. A feature command's
|
||||
/// parameters include it with
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[serde(flatten, deserialize_with = "feature_common")]
|
||||
/// pub common: FeatureCommon,
|
||||
/// ```
|
||||
///
|
||||
/// Two keys `add_feature` also reads aren't here. `targets` it reads only for primitives; the
|
||||
/// other commands that read it are the ones that combine bodies. `operation` it reads for every
|
||||
/// feature (a cut where the active component has no bodies takes part in all of them), but only
|
||||
/// the commands that combine bodies take one; those declare it with their own type
|
||||
/// ([`OperationParam`] or [`ExtrudeOperation`]), and for the rest it is a caller's mistake.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
|
||||
pub struct FeatureCommon {
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// The new body's name.
|
||||
#[serde(default)]
|
||||
pub body_name: Option<String>,
|
||||
/// Names for the new bodies, in order.
|
||||
#[serde(default)]
|
||||
pub body_names: Option<StringList>,
|
||||
/// The bodies taking part; overrides the default scope.
|
||||
#[serde(default)]
|
||||
pub participants: Option<StringList>,
|
||||
}
|
||||
|
||||
/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports
|
||||
/// a problem as if its fields were the parent's own, with the key in the message and in the
|
||||
/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after
|
||||
/// all the parent's own, so its problems come after theirs, including a missing key.
|
||||
pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result<FeatureCommon, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e)))
|
||||
}
|
||||
|
||||
@@ -3032,6 +3032,40 @@ fn shell_directions_and_tangent_chain() {
|
||||
assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// #209: selecting a tangent chain must explain why no shell can remain.
|
||||
#[test]
|
||||
fn shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 60}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({
|
||||
"edges": [[0,0,30], [40,0,30], [40,30,30], [0,30,30],
|
||||
[20,0,60], [20,30,60], [0,15,60], [40,15,60],
|
||||
[20,0,0], [20,30,0], [0,15,0], [40,15,0]],
|
||||
"radius": 1.5
|
||||
}),
|
||||
);
|
||||
let before = s.doc.to_json();
|
||||
for direction in ["inside", "outside", "both"] {
|
||||
let error = s
|
||||
.execute(
|
||||
"solid.shell",
|
||||
&json!({
|
||||
"faces": [[20,0,30]], "thickness": 2, "tangent_chain": true, "direction": direction
|
||||
}),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Tangent Chain") && error.contains("every face") && error.contains("turn off"), "{direction}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before, "a refused shell must preserve the design");
|
||||
}
|
||||
run(&mut s, "solid.shell", json!({"faces": [[20,0,30]], "thickness": 2, "tangent_chain": false}));
|
||||
assert!((volume(&mut s) - 15_305.28).abs() < 0.1);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// Hole To Object (a face it meets, or a point) and holes at every point of a sketch.
|
||||
#[test]
|
||||
fn hole_to_object_and_at_sketch_points() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -92,6 +93,89 @@ fn modelled_full_length_fine_threads_on_large_shafts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A modelled thread on a shaft thicker than it turns the shaft down to the major diameter
|
||||
/// over the threaded length, then cuts the thread: an M6 over the top 8 mm of an 8 mm shaft
|
||||
/// leaves a 6 mm threaded section on an 8 mm stub, its turned face named for later features.
|
||||
/// A shaft of the thread's own size is cut as it is.
|
||||
#[test]
|
||||
fn a_shaft_thicker_than_a_modelled_thread_is_turned_down_to_it() {
|
||||
let (p, len) = (1.0, 8.0);
|
||||
let root = 3.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||
let groove = per_turn(root, 3.0, |r| p / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * len / p;
|
||||
for (shaft, turned) in [(8.0, true), (6.0, false)] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": shaft / 2.0, "height": 12, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [shaft / 2.0, 0, 11], "designation": "M6", "length": len, "modeled": true}));
|
||||
let ring = PI * (shaft * shaft / 4.0 - 9.0) * len;
|
||||
let want = PI * shaft * shaft / 4.0 * 12.0 - ring - groove;
|
||||
let got = volume(&mut s, "Shaft");
|
||||
assert!((got - want).abs() < groove * 0.03, "M6 on {shaft} mm: {got} mm³, want {want}");
|
||||
let st = s.model.state();
|
||||
let Some(b) = st.body("Shaft") else { panic!("no shaft") };
|
||||
assert!(b.body.validity().is_empty(), "{shaft} mm: {:?}", b.body.validity());
|
||||
// Nothing past the major diameter over the threaded length; the stub keeps its size.
|
||||
let round = |z: f64, r: f64| {
|
||||
(0..24).filter(|i| b.body.contains(Vec3::new(r * (PI * *i as f64 / 12.0).cos(), r * (PI * *i as f64 / 12.0).sin(), z))).count()
|
||||
};
|
||||
assert_eq!(round(8.0, 3.05), 0, "{shaft} mm: the threaded part is turned to 6 mm");
|
||||
assert_eq!(round(2.0, shaft / 2.0 - 0.05), 24, "{shaft} mm: the stub is whole");
|
||||
let names = solidcraft_doc::naming::face_names(b);
|
||||
let fid = s.doc.features.last().map(|f| f.id).unwrap_or(0);
|
||||
let named = |role: &str| names.iter().any(|n| n.starts_with(&format!("F{fid}:{role}")));
|
||||
assert_eq!(named("turned"), turned, "{shaft} mm: {names:?}");
|
||||
assert_eq!(named("shoulder:"), turned, "{shaft} mm: {names:?}");
|
||||
}
|
||||
|
||||
// A 9 mm shaft is close enough to M8 to be offered it; it was refused as too thick (#2688).
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true}));
|
||||
let root = 4.0 - 5.0 * 1.25 * 3f64.sqrt() / 16.0;
|
||||
let groove = per_turn(root, 4.0, |r| 1.25 / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * 10.0 / 1.25;
|
||||
let (want, got) = (PI * 16.0 * 10.0 - groove, volume(&mut s, "Shaft"));
|
||||
assert!((got - want).abs() < groove * 0.03, "M8 on 9 mm: {got} mm³, want {want}");
|
||||
}
|
||||
|
||||
/// Issue 2705: an M14 over the top 10 mm of a 14.6 mm boss on a plate. Turned to 14 mm first:
|
||||
/// 1600 + π 7.3² 15 − π (7.3² − 7²) 10 − 5 turns of 50.189 mm³ (tests/kernel-cases/threads/2705).
|
||||
#[test]
|
||||
fn an_m14_thread_on_a_14_6_mm_boss_turns_it_to_14_mm() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 4, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 7.3, "height": 15, "base": [0, 0, 4], "operation": "join", "targets": ["Part"]}));
|
||||
run(&mut s, "solid.thread", json!({"face": [7.3, 0, 17], "designation": "M14", "length": 10, "modeled": true}));
|
||||
let got = volume(&mut s, "Part");
|
||||
assert!((got - 3725.5127).abs() < 3725.5127 * 0.01, "{got} mm³");
|
||||
}
|
||||
|
||||
/// A shaft thinner than the thread's minor diameter is still refused, and so is a cosmetic
|
||||
/// thread on a shaft thicker than it (a note changes nothing to turn). A shaft the part goes on
|
||||
/// round (standing against a wall) is not turned into the wall.
|
||||
#[test]
|
||||
fn a_thin_shaft_or_one_the_part_surrounds_is_refused() {
|
||||
let refused = |s: &mut Session, body: &str, p: Value| {
|
||||
let before = volume(s, body);
|
||||
let e = s.execute("solid.thread", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!((volume(s, body) - before).abs() < 1e-9, "{p}: {e}");
|
||||
e
|
||||
};
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 10, "body_name": "Shaft"}));
|
||||
let e = refused(&mut s, "Shaft", json!({"face": [2.4, 0, 5], "designation": "M6", "modeled": true}));
|
||||
assert!(e.contains("does not fit a 4.80 mm shaft") || e.contains("too thin"), "{e}");
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "body_name": "Shaft"}));
|
||||
let e = refused(&mut s, "Shaft", json!({"face": [4, 0, 5], "designation": "M6"}));
|
||||
assert!(e.contains("an M6 thread does not fit a 8.00 mm shaft"), "{e}");
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [2, -10, 0], "length": 10, "width": 20, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "operation": "join", "targets": ["Part"]}));
|
||||
let e = refused(&mut s, "Part", json!({"face": [-4, 0, 5], "designation": "M6", "modeled": true}));
|
||||
assert!(e.contains("this 8 mm shaft is too thick for a modelled M6 thread") && e.contains("part around it"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
|
||||
let mut s = Session::default();
|
||||
@@ -174,9 +258,9 @@ fn a_modelled_thread_that_would_cut_nothing_is_refused() {
|
||||
|
||||
/// Issue 2688: an M8 modelled thread in a 6 mm hole (narrower than its 6.65 mm minor diameter)
|
||||
/// was refused as "the face is too far from the thread's diameter". It says what is wrong and
|
||||
/// what to drill, as a hole that is too wide does; and the same for a shaft that is too thick.
|
||||
/// what to drill, as a hole that is too wide does. (A shaft too thick is turned down.)
|
||||
#[test]
|
||||
fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
|
||||
fn a_hole_too_narrow_says_so_and_what_fits() {
|
||||
let mut s = hex_nut(6.0);
|
||||
let before = volume(&mut s, "Nut");
|
||||
let e = s.execute("solid.thread", &json!({"face": [3.0, 0, 2], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
|
||||
@@ -184,13 +268,6 @@ fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
|
||||
assert!(e.contains("this 6 mm hole is too narrow for a modelled M8 thread") && e.contains("drill 6.75 mm for M8"), "{e}");
|
||||
assert!(!e.contains("too far"), "{e}");
|
||||
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
|
||||
|
||||
let mut s = Session::default();
|
||||
// 9 mm is close enough to M8 to be offered it, too thick to cut it.
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
|
||||
let e = s.execute("solid.thread", &json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
|
||||
let e = e.unwrap_or_default();
|
||||
assert!(e.contains("this 9 mm shaft is too thick for a modelled M8 thread") && e.contains("make the shaft 8 mm for M8"), "{e}");
|
||||
}
|
||||
|
||||
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
|
||||
|
||||
+13
-1
@@ -8,6 +8,9 @@ pub mod envelope;
|
||||
pub const VERSION: u32 = 3;
|
||||
pub const MAX_ITEMS: usize = 1_000_000;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// Most control points along one direction of a spline (a helical thread flank written as one
|
||||
/// spline has thousands along the helix); the item budget bounds a surface's grid as a whole.
|
||||
pub const MAX_SPLINE_CONTROLS: usize = 8192;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -195,7 +198,7 @@ fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().copied())?;
|
||||
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
|
||||
require((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require((2..=MAX_SPLINE_CONTROLS).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
|
||||
require(values.get(degree) < values.get(count), "empty spline domain")?;
|
||||
// No knot may have multiplicity greater than degree + 1.
|
||||
@@ -482,6 +485,15 @@ mod tests {
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
// Control points along one direction: up to the cap, not one more (a clamped cubic).
|
||||
let clamped = |count: usize| {
|
||||
let mut k = vec![0.0; 4];
|
||||
k.extend((1..count - 3).map(|i| i as f64));
|
||||
k.extend([(count - 3) as f64; 4]);
|
||||
k
|
||||
};
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS), MAX_SPLINE_CONTROLS, &mut Budget(0)).is_ok());
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS + 1), MAX_SPLINE_CONTROLS + 1, &mut Budget(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+33
-6
@@ -36,7 +36,10 @@ pub enum EvalSurface {
|
||||
/// `origin + u·du + v·dv`.
|
||||
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
|
||||
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
|
||||
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
|
||||
/// `closed[d]`: the surface joins itself along direction `d` (a B-spline tube's way round).
|
||||
/// Such a direction is periodic: a parameter outside the knot range is moved by whole
|
||||
/// ranges back into it, so a face whose boundary runs across the join still evaluates.
|
||||
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>, closed: [bool; 2] },
|
||||
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
|
||||
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
|
||||
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
|
||||
@@ -128,7 +131,7 @@ impl TessPlan {
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
EvalSurface::Plane { origin, du, dv } => EvalSurface::Plane { origin: point(*origin), du: vector(*du), dv: vector(*dv) },
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => EvalSurface::Nurbs {
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => EvalSurface::Nurbs {
|
||||
degree: *degree,
|
||||
knots: knots.clone(),
|
||||
count: *count,
|
||||
@@ -140,6 +143,9 @@ impl TessPlan {
|
||||
[q.x, q.y, q.z, c[3]]
|
||||
})
|
||||
.collect(),
|
||||
// Which directions the surface joins itself along is a property of its
|
||||
// parameters: moving the surface does not change it.
|
||||
closed: *closed,
|
||||
},
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
// `m` (as rows) after the surface's own affine map.
|
||||
@@ -235,7 +241,20 @@ impl EvalSurface {
|
||||
}
|
||||
match self {
|
||||
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
|
||||
// The knot range of direction `d`: the parameters the surface is defined over.
|
||||
let range = |d: usize| -> Option<(f64, f64)> {
|
||||
let k = knots.get(d)?;
|
||||
Some((*k.get(*degree.get(d)?)?, *k.get(*count.get(d)?)?))
|
||||
};
|
||||
// A direction the surface joins itself along repeats, so a parameter outside the
|
||||
// range is moved by whole ranges back into it. Any other direction is left alone.
|
||||
let wrap = |d: usize, t: f64| match range(d) {
|
||||
Some((a, b)) if closed.get(d).copied().unwrap_or(false) && b > a && !(a..=b).contains(&t) => a + (t - a).rem_euclid(b - a),
|
||||
_ => t,
|
||||
};
|
||||
nurbs_derivs(*degree, knots, *count, ctrl, wrap(0, u), wrap(1, v))
|
||||
}
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
let (s, a) = if *swap { (v, u) } else { (u, v) };
|
||||
let (c, dc) = profile.eval(s)?;
|
||||
@@ -390,6 +409,7 @@ mod tests {
|
||||
knots: [vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [3, 2],
|
||||
ctrl: vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
|
||||
closed: [false, false],
|
||||
};
|
||||
let revolved = EvalSurface::Revolved {
|
||||
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 1.0)),
|
||||
@@ -511,7 +531,13 @@ mod tests {
|
||||
fn bilinear_nurbs_patch_and_derivatives() {
|
||||
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
|
||||
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
|
||||
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
|
||||
let s = EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [2, 2],
|
||||
ctrl,
|
||||
closed: [false, false],
|
||||
};
|
||||
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
|
||||
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
|
||||
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
|
||||
@@ -533,7 +559,7 @@ mod tests {
|
||||
}
|
||||
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
|
||||
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
|
||||
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
|
||||
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl, closed: [false, false] };
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
|
||||
@@ -557,12 +583,13 @@ mod tests {
|
||||
#[test]
|
||||
fn hostile_surfaces_do_not_panic() {
|
||||
let bad = [
|
||||
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
|
||||
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![], closed: [false, false] },
|
||||
EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [2, 2],
|
||||
ctrl: vec![[0.0; 4]; 4],
|
||||
closed: [false, false],
|
||||
},
|
||||
EvalSurface::Revolved {
|
||||
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
|
||||
|
||||
@@ -466,8 +466,9 @@ fn pack(plans: &[&TessPlan]) -> Result<Packed> {
|
||||
push3(&mut data, *du);
|
||||
push3(&mut data, *dv);
|
||||
}
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
|
||||
head[0] = 1;
|
||||
head[11] = u32::from(closed[0]) | (u32::from(closed[1]) << 1);
|
||||
head[1] = word(degree[0])?;
|
||||
head[2] = word(degree[1])?;
|
||||
head[3] = word(count[0])?;
|
||||
|
||||
@@ -27,7 +27,8 @@ struct Surface {
|
||||
// Revolved: parameters exchanged; mirror transform (normal reversed).
|
||||
swap: u32,
|
||||
mirror: u32,
|
||||
_pad: u32,
|
||||
// NURBS: bit 0 the surface joins itself along u, bit 1 along v (the direction is periodic).
|
||||
closed: u32,
|
||||
};
|
||||
|
||||
struct Vertex {
|
||||
@@ -159,8 +160,20 @@ fn rational_der(a: vec4<f32>, da: vec4<f32>) -> vec3<f32> {
|
||||
return (da.xyz - rational_point(a) * da.w) / a.w;
|
||||
}
|
||||
|
||||
fn nurbs(s: Surface, u: f32, v: f32) -> Derivs {
|
||||
// A parameter outside the knot range of a direction the surface joins itself along, moved by
|
||||
// whole ranges back into it (the CPU evaluator's `EvalSurface::derivs` does the same).
|
||||
fn wrap_closed(knots: u32, p: u32, n: u32, t: f32, closed: bool) -> f32 {
|
||||
if (!closed) { return t; }
|
||||
let a = data[knots + p];
|
||||
let b = data[knots + n];
|
||||
if (!(b > a) || (t >= a && t <= b)) { return t; }
|
||||
return a + (t - a) - floor((t - a) / (b - a)) * (b - a);
|
||||
}
|
||||
|
||||
fn nurbs(s: Surface, u_in: f32, v_in: f32) -> Derivs {
|
||||
var r: Derivs;
|
||||
let u = wrap_closed(s.knots_u, s.pu, s.nu, u_in, (s.closed & 1u) != 0u);
|
||||
let v = wrap_closed(s.knots_v, s.pv, s.nv, v_in, (s.closed & 2u) != 0u);
|
||||
let su = span_of(s.knots_u, s.pu, s.nu, u);
|
||||
let sv = span_of(s.knots_v, s.pv, s.nv, v);
|
||||
let bu0 = basis_funs(s.knots_u, su, s.pu, u);
|
||||
|
||||
+216
-35
@@ -20,7 +20,15 @@ const VERSION: &str = "solidcraft-container/1";
|
||||
const LEGACY_VERSION: &str = "solvecraft-container/1";
|
||||
const PREVIEW_VERSION: &str = "solidcraft-preview/1";
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_EXPANDED: usize = 512 << 20;
|
||||
/// An embedded STEP source may be as large as an import accepts. It is always stored
|
||||
/// Deflate-compressed, so the file itself stays within [`MAX_DESIGN_BYTES`] (#3221).
|
||||
const MAX_STEP_PART: usize = crate::MAX_STEP_BYTES;
|
||||
/// Deflate level for STEP sources. Level 1 uses fixed Huffman codes: a 270 MiB STEP export
|
||||
/// shrank to 70 MB in 1.6 s, at level 2 to 47 MB in 3.1 s; higher levels gain little more.
|
||||
const STEP_LEVEL: i64 = 2;
|
||||
/// Every other part is bounded by the file size limit.
|
||||
const MAX_PART: usize = MAX_DESIGN_BYTES;
|
||||
const MAX_EXPANDED: usize = MAX_STEP_PART + MAX_DESIGN_BYTES;
|
||||
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
|
||||
const MAX_PREVIEW_BYTES: usize = 8 << 20;
|
||||
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
|
||||
@@ -65,7 +73,7 @@ struct PackageWriter {
|
||||
|
||||
impl PackageWriter {
|
||||
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
if bytes.len() > if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART } {
|
||||
return Err(invalid("part too large"));
|
||||
}
|
||||
if !self.names.insert(name.to_owned()) {
|
||||
@@ -76,10 +84,21 @@ impl PackageWriter {
|
||||
return Err(invalid("package too large"));
|
||||
}
|
||||
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(1));
|
||||
let level = if name.ends_with(".step") { STEP_LEVEL } else { 1 };
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(level));
|
||||
self.zip.start_file(name, options).map_err(invalid)?;
|
||||
self.zip.write_all(bytes).map_err(invalid)
|
||||
}
|
||||
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep
|
||||
/// the file within `budget` and its expanded size within the reader's budget?
|
||||
fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
|
||||
// The bytes written so far, plus what the open compressed part may still hold back.
|
||||
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
|
||||
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
|
||||
self.names.len().saturating_add(entries) <= MAX_ENTRIES
|
||||
&& written.saturating_add(extra).saturating_add(directory) <= budget
|
||||
&& self.expanded.saturating_add(extra) <= MAX_EXPANDED
|
||||
}
|
||||
fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value> {
|
||||
self.resolved =
|
||||
self.resolved.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?;
|
||||
@@ -89,9 +108,70 @@ impl PackageWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts what is written without keeping it.
|
||||
#[derive(Default)]
|
||||
struct Measure {
|
||||
position: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl Write for Measure {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.position = self.position.saturating_add(bytes.len() as u64);
|
||||
self.length = self.length.max(self.position);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for Measure {
|
||||
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
|
||||
let target = match position {
|
||||
SeekFrom::Start(n) => Some(n),
|
||||
SeekFrom::End(n) => self.length.checked_add_signed(n),
|
||||
SeekFrom::Current(n) => self.position.checked_add_signed(n),
|
||||
};
|
||||
self.position = target.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek out of range"))?;
|
||||
Ok(self.position)
|
||||
}
|
||||
}
|
||||
|
||||
/// Room kept in a design file for everything besides one large embedded STEP source.
|
||||
const STEP_RESERVE: usize = 4 << 20;
|
||||
|
||||
/// Can an import's STEP text be saved in a design? It must be no larger than an import
|
||||
/// accepts, and, compressed as a save stores it, leave room in a design file for the rest of
|
||||
/// the design. Checked when the file is imported, so it never imports and then fails to save.
|
||||
pub fn check_step_storable(step: &str) -> Result<()> {
|
||||
let limit = MAX_DESIGN_BYTES - STEP_RESERVE;
|
||||
if step.len() > MAX_STEP_PART {
|
||||
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", step.len() >> 20, MAX_STEP_PART >> 20)));
|
||||
}
|
||||
// Deflate adds at most a few bytes per 16 KiB block: smaller text always fits.
|
||||
if step.len() <= limit - (limit >> 10) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut zip = zip::ZipWriter::new(Measure::default());
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(STEP_LEVEL));
|
||||
zip.start_file("blobs/source.step", options).map_err(invalid)?;
|
||||
zip.write_all(step.as_bytes()).map_err(invalid)?;
|
||||
let compressed = usize::try_from(zip.finish().map_err(invalid)?.length).unwrap_or(usize::MAX);
|
||||
if compressed > limit {
|
||||
return Err(IoError::Invalid(format!(
|
||||
"STEP file too large to save in a design: {} MB of STEP text compresses to {} MB, over the {} MB a design file may hold",
|
||||
step.len() >> 20,
|
||||
compressed >> 20,
|
||||
limit >> 20
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a compact container, deduplicating payloads by SHA-256. Mesh arrays are
|
||||
/// stored directly for low save/open latency; small metadata uses level-one
|
||||
/// Deflate. Use the explicit Compact option to compress STEP and meshes too.
|
||||
/// stored directly for low save/open latency; STEP sources and small metadata use
|
||||
/// fast Deflate. Use the explicit Compact option to compress meshes too.
|
||||
pub fn write_design_container(doc: &Document) -> Result<Vec<u8>> {
|
||||
write_design_container_with_compression(doc, DesignCompression::Fast)
|
||||
}
|
||||
@@ -145,7 +225,9 @@ fn write_container(
|
||||
for feature in &doc.features {
|
||||
let replacements = match &feature.kind {
|
||||
FeatureKind::Import { step, .. } => {
|
||||
let blob = writer.blob(step.as_bytes(), "step", compression == DesignCompression::Compact)?;
|
||||
// STEP is text and shrinks several times: always compressed, so an import as large
|
||||
// as MAX_STEP_BYTES still fits in a design file.
|
||||
let blob = writer.blob(step.as_bytes(), "step", true)?;
|
||||
fields([("step", Value::String(String::new())), ("step_blob", blob)])
|
||||
}
|
||||
FeatureKind::MeshImport { meshes, .. } => {
|
||||
@@ -190,14 +272,7 @@ fn write_container(
|
||||
// The model cache is optional: one that does not fit the container is left out, never a
|
||||
// failed save.
|
||||
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata).ok()).filter(|(manifest, data)| {
|
||||
let fits = writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= budget;
|
||||
let fits = writer.room(2, manifest.len().saturating_add(data.len()), budget);
|
||||
if !fits {
|
||||
log::info!("the model cache ({} MB) does not fit the design file: saved without it", (manifest.len() + data.len()) >> 20);
|
||||
}
|
||||
@@ -211,14 +286,7 @@ fn write_container(
|
||||
writer.part(payload_entry, &data, true)?;
|
||||
}
|
||||
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= budget
|
||||
&& writer.room(2, manifest.len().saturating_add(data.len()), budget)
|
||||
{
|
||||
writer.part("cache/preview.json", &manifest, false)?;
|
||||
writer.part("cache/preview.bin", &data, false)?;
|
||||
@@ -228,7 +296,7 @@ fn write_container(
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid("compressed file too large"));
|
||||
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -345,7 +413,8 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
return Err(invalid("duplicate or invalid archive entry"));
|
||||
}
|
||||
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
|
||||
if part.size() > MAX_DESIGN_BYTES as u64 || total > MAX_EXPANDED as u64 {
|
||||
let limit = if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART };
|
||||
if part.size() > limit as u64 || total > MAX_EXPANDED as u64 {
|
||||
return Err(invalid("expanded package too large"));
|
||||
}
|
||||
}
|
||||
@@ -360,7 +429,7 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, MAX_DESIGN_BYTES)
|
||||
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
|
||||
}
|
||||
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
|
||||
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
|
||||
@@ -369,7 +438,9 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
return Err(invalid("part too large"));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(expected as usize);
|
||||
part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
// A compressed part may expand beyond its declared size (a decompression bomb): stop
|
||||
// one byte past it instead of at the limit.
|
||||
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
if bytes.len() > limit || bytes.len() as u64 != expected {
|
||||
return Err(invalid("part too large or length mismatch"));
|
||||
}
|
||||
@@ -676,7 +747,7 @@ mod tests {
|
||||
#[test]
|
||||
fn seekable_preview_defers_large_source_blob_until_document_read() {
|
||||
let mut doc = Document::new("Deferred source");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
let step = noise(2_600_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
@@ -685,12 +756,12 @@ mod tests {
|
||||
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
|
||||
// ZIP directory scans may touch a bounded suffix of a stored blob. They
|
||||
// ZIP directory scans may touch a bounded suffix of a source blob. They
|
||||
// must not load its multi-megabyte payload to obtain the saved picture.
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
|
||||
let restored = reader.read_document().unwrap();
|
||||
assert_eq!(restored.to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
assert!(count.get() >= step.len() / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,9 +769,11 @@ mod tests {
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let marker = b"ISO-10303-21;";
|
||||
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
|
||||
bytes[offset] ^= 1;
|
||||
// The first part written is the (compressed) STEP source: damage its data.
|
||||
let name = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
|
||||
let extra = u16::from_le_bytes([bytes[28], bytes[29]]) as usize;
|
||||
assert!(bytes[30..30 + name].ends_with(b".step"));
|
||||
bytes[30 + name + extra + 2] ^= 0x55;
|
||||
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
assert!(reader.read_document().is_err());
|
||||
@@ -752,7 +825,7 @@ mod tests {
|
||||
#[test]
|
||||
fn native_cache_round_trip_and_preview_defer_original_sources() {
|
||||
let mut doc = Document::new("Native cache");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
let step = noise(2_600_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let bodies = native_geometry();
|
||||
@@ -767,7 +840,7 @@ mod tests {
|
||||
assert!(!reader.preview().unwrap().meshes.is_empty());
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
assert!(count.get() >= step.len() / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1159,7 +1232,7 @@ mod tests {
|
||||
assert!(crate::read_design(&duplicate).is_err());
|
||||
let mut bomb = bytes.clone();
|
||||
let central = bomb.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
|
||||
bomb[central + 24..central + 28].copy_from_slice(&((MAX_DESIGN_BYTES + 1) as u32).to_le_bytes());
|
||||
bomb[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
|
||||
assert!(crate::read_design(&bomb).unwrap_err().to_string().contains("expanded package too large"));
|
||||
let mut excessive = bytes;
|
||||
let end = excessive.windows(4).rposition(|v| v == b"PK\x05\x06").unwrap();
|
||||
@@ -1167,6 +1240,114 @@ mod tests {
|
||||
assert!(crate::read_design(&excessive).is_err());
|
||||
}
|
||||
|
||||
/// Printable text that compresses poorly (93 characters: about 6.5 bits each). No `*`, so
|
||||
/// it never ends a STEP comment.
|
||||
fn noise(length: usize) -> String {
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
let c = b'!' + ((state >> 32) % 93) as u8;
|
||||
char::from(if c >= b'*' { c + 1 } else { c })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A valid STEP cube, padded with a comment to `length` bytes. `noisy` padding barely
|
||||
/// compresses; otherwise it is repetitive like the text of a real large export.
|
||||
fn padded_step(length: usize, noisy: bool) -> String {
|
||||
use solidcraft_geom::Vec3;
|
||||
let cube = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let step = solidcraft_kernel::step_export(&[&cube], "padded").unwrap();
|
||||
let (head, tail) = step.split_at(step.find("DATA;").unwrap() + 5);
|
||||
let end = length - tail.len() - 2;
|
||||
let mut out = String::with_capacity(length);
|
||||
out.push_str(head);
|
||||
out.push_str("\n/*");
|
||||
if noisy {
|
||||
out.push_str(&noise(end - out.len()));
|
||||
} else {
|
||||
let mut line = 0u64;
|
||||
while out.len() + 64 < end {
|
||||
line += 1;
|
||||
out.push_str(&format!("#{line}=CARTESIAN_POINT('',({}.,{}.5,-{}.25));\n", line % 997, line % 89, line % 13));
|
||||
}
|
||||
out.extend(std::iter::repeat_n(' ', end - out.len()));
|
||||
}
|
||||
out.push_str("*/");
|
||||
out.push_str(tail);
|
||||
assert_eq!(out.len(), length);
|
||||
out
|
||||
}
|
||||
|
||||
/// #3221: a STEP file larger than a design part used to import, then fail to save.
|
||||
#[test]
|
||||
fn step_over_the_part_limit_imports_saves_compressed_and_reopens() {
|
||||
let step = padded_step(MAX_DESIGN_BYTES + (1 << 20), false);
|
||||
let imported = crate::step_import_feature(step.as_bytes(), "large.step").unwrap();
|
||||
drop(step);
|
||||
let mut doc = Document::new("Large import");
|
||||
doc.add_feature(imported.kind, None).unwrap();
|
||||
let bytes = write_design_container(&doc).unwrap();
|
||||
assert!(bytes.len() < MAX_DESIGN_BYTES / 2, "saved {} MB", bytes.len() >> 20);
|
||||
let restored = crate::read_design(&bytes).unwrap();
|
||||
let (Some(FeatureKind::Import { step: before, .. }), Some(FeatureKind::Import { step: after, .. })) =
|
||||
(doc.features.first().map(|f| &f.kind), restored.features.first().map(|f| &f.kind))
|
||||
else {
|
||||
panic!("the reopened design lost its import");
|
||||
};
|
||||
assert!(before.len() > MAX_DESIGN_BYTES && before == after, "the reopened STEP text differs");
|
||||
let reopened = crate::step_import_feature(after.as_bytes(), "large.step").unwrap();
|
||||
assert_eq!(reopened.body_names, imported.body_names);
|
||||
assert_eq!(reopened.import.bodies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_that_cannot_be_saved_even_compressed_is_refused_at_import() {
|
||||
let step = padded_step(MAX_DESIGN_BYTES + (80 << 20), true);
|
||||
let message = crate::step_import_feature(step.as_bytes(), "noisy.step").err().unwrap().to_string();
|
||||
assert!(message.contains("too large to save in a design"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designs_with_uncompressed_step_sources_still_open() {
|
||||
// Saves before #3221 stored the STEP source without compression.
|
||||
let doc = document();
|
||||
let entries = parts(&write_design_container(&doc).unwrap());
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in &entries {
|
||||
let method = if name.ends_with(".step") { zip::CompressionMethod::Stored } else { zip::CompressionMethod::Deflated };
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default().compression_method(method)).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
let old = zip.finish().unwrap().into_inner();
|
||||
assert!(old.windows(13).any(|v| v == b"ISO-10303-21;"));
|
||||
assert_eq!(crate::read_design(&old).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_refused_without_expanding_it() {
|
||||
// 64 MiB that deflate to a few hundred kilobytes, declared as a 1 KiB STEP source.
|
||||
let mut doc = Document::new("Bomb");
|
||||
let step = format!("ISO-10303-21;{}", " ".repeat(64 << 20));
|
||||
doc.add_feature(FeatureKind::Import { file: "bomb.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None).unwrap();
|
||||
let honest = write_design_container(&doc).unwrap();
|
||||
assert!(honest.len() < 1 << 20);
|
||||
let central = honest.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
|
||||
let mut bomb = honest.clone();
|
||||
// The STEP source is the first part: its local header is at 0.
|
||||
bomb[22..26].copy_from_slice(&1024u32.to_le_bytes());
|
||||
bomb[central + 24..central + 28].copy_from_slice(&1024u32.to_le_bytes());
|
||||
let message = crate::read_design(&bomb).unwrap_err().to_string();
|
||||
assert!(message.contains("declared uncompressed size") || message.contains("length mismatch"), "{message}");
|
||||
// An honest declaration over the limit is refused before the part is read at all.
|
||||
let mut over = honest;
|
||||
over[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
|
||||
assert!(crate::read_design(&over).unwrap_err().to_string().contains("expanded package too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_references_and_repeated_payload_expansion() {
|
||||
let bytes = write_design_container(&document()).unwrap();
|
||||
|
||||
@@ -17,15 +17,7 @@ pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) ->
|
||||
}
|
||||
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
|
||||
.map_err(invalid)?;
|
||||
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|
||||
|| writer
|
||||
.expanded
|
||||
.saturating_add(key.len())
|
||||
.saturating_add(png.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
> MAX_DESIGN_BYTES
|
||||
{
|
||||
if !writer.room(2, key.len().saturating_add(png.len()), MAX_DESIGN_BYTES) {
|
||||
return Ok(());
|
||||
}
|
||||
writer.part("cache/thumbnail.json", &key, false)?;
|
||||
|
||||
@@ -542,8 +542,6 @@ mod tests {
|
||||
/// self-inserts fan out to 6^16 visits for no output. Both shapes must be refused quickly.
|
||||
#[test]
|
||||
fn empty_blocks_do_not_fan_out() {
|
||||
let t = std::time::Instant::now();
|
||||
|
||||
let fanout = format!(
|
||||
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n{}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
|
||||
"0\nINSERT\n2\nA\n".repeat(6)
|
||||
@@ -557,7 +555,5 @@ mod tests {
|
||||
);
|
||||
let g = read_dxf(&array).unwrap_or_default();
|
||||
assert!(g.is_empty(), "an empty block has no geometry to place");
|
||||
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "took {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,8 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
|
||||
let text = step_text(bytes);
|
||||
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }
|
||||
};
|
||||
// The feature keeps this text and a save stores it: refuse now what could not be saved.
|
||||
design::check_step_storable(&text)?;
|
||||
let imp = solidcraft_kernel::step_import_shared(&text)?;
|
||||
let stem = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
|
||||
@@ -467,6 +467,21 @@ outer
|
||||
survives("3MF (production extension)", &mutations(&production, false, 0x2089), usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
// Its model parts' XML mutated and zipped again: mutations of the zip mostly stop at its
|
||||
// checksums, these reach the `p:path` and `objectid` handling.
|
||||
let parts = crate::threemf::tests::production_parts();
|
||||
for (k, (name, xml)) in parts.iter().enumerate() {
|
||||
let rezipped: Vec<Vec<u8>> = mutations(xml.as_bytes(), true, 0x2089 + k as u64)
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, x)| (*n, if n == name { m.as_slice() } else { x.as_bytes() })).collect();
|
||||
crate::threemf::tests::package_bytes(&entries)
|
||||
})
|
||||
.collect();
|
||||
survives(&format!("3MF (production extension, {name} mutated)"), &rezipped, usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+395
-81
@@ -8,10 +8,12 @@
|
||||
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
|
||||
//! (components flattened with their transforms), converted to millimetres. Components and build
|
||||
//! items may name an object in another model part of the package (`p:path`, the 3MF Production
|
||||
//! extension, which slicers use for multi-part projects); those parts are read too. Packages are
|
||||
//! untrusted: sizes, entry counts, model parts, mesh sizes and component nesting are capped.
|
||||
//! extension, which slicers use for multi-part projects); those parts are read too. Only the root
|
||||
//! model's build counts (readers must ignore the others), and a part that can't be read fails
|
||||
//! only the placements that need it. Packages are untrusted: sizes, entry counts, model parts,
|
||||
//! mesh sizes and component nesting are capped.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
@@ -352,10 +354,30 @@ fn bad<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
|
||||
}
|
||||
|
||||
/// The name a part is looked up by: no leading `/`, in lower case (part names compare
|
||||
/// case-insensitively).
|
||||
/// The name a part is looked up by: `%XX` escapes decoded, no leading `/`, in lower case (part
|
||||
/// names are URIs and compare case-insensitively).
|
||||
fn part_key(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
let b = name.as_bytes();
|
||||
let hex = |k: usize| b.get(k).and_then(|h| char::from(*h).to_digit(16));
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
match (c, hex(i + 1), hex(i + 2)) {
|
||||
(b'%', Some(hi), Some(lo)) => {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
(b'\\', ..) => {
|
||||
out.push(b'/');
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).trim_start_matches('/').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
|
||||
@@ -363,20 +385,22 @@ fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Opt
|
||||
let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| part_key(&n) == want)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
read_entry(zip, idx, name).map(Some)
|
||||
match read_entry(zip, idx, name, MAX_PART)? {
|
||||
Some(data) => Ok(Some(data)),
|
||||
None => bad(format!("{name} expands to more than 1 GB")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str) -> Result<Vec<u8>> {
|
||||
/// Entry `idx` uncompressed, or `None` when it expands to more than `limit` bytes (zip bombs stop
|
||||
/// there).
|
||||
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
|
||||
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
if f.size() > MAX_PART {
|
||||
return bad(format!("{name} expands to {} bytes", f.size()));
|
||||
if f.size() > limit {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize);
|
||||
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
if out.len() as u64 > MAX_PART {
|
||||
return bad(format!("{name} expands to more than 1 GB"));
|
||||
}
|
||||
Ok(out)
|
||||
(&mut f).take(limit.saturating_add(1)).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
Ok((out.len() as u64 <= limit).then_some(out))
|
||||
}
|
||||
|
||||
fn attrs(e: &BytesStart) -> HashMap<String, String> {
|
||||
@@ -435,10 +459,16 @@ fn det(m: &Xf) -> f64 {
|
||||
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
|
||||
}
|
||||
|
||||
/// A component or build item: object `id` placed by `xf`, in model part `part` (its key, from
|
||||
/// `p:path`) or, without one, in the part that names it.
|
||||
/// A model part named by `p:path`: its key ([`part_key`]) and the name as written, for messages.
|
||||
struct PartName {
|
||||
key: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// A component or build item: object `id` placed by `xf`, in model part `part` or, without one,
|
||||
/// in the part that names it.
|
||||
struct Ref {
|
||||
part: Option<String>,
|
||||
part: Option<PartName>,
|
||||
id: u32,
|
||||
xf: Xf,
|
||||
}
|
||||
@@ -453,19 +483,29 @@ struct Object {
|
||||
}
|
||||
|
||||
/// One model part. Positions and translations are in millimetres (converted with the part's
|
||||
/// unit as it is read), so objects of parts in different units fit together.
|
||||
/// unit as it is read), so objects of parts in different units fit together. Objects are kept in
|
||||
/// id order, so the parts they name are read, and reported, in the same order every run.
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
unit: f64,
|
||||
objects: HashMap<u32, Object>,
|
||||
objects: BTreeMap<u32, Object>,
|
||||
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
|
||||
build: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// The model parts read from a package, by key, and the key of the root model.
|
||||
/// The model parts of a package by key: the root model's key, the parts read, the parts that are
|
||||
/// in the package but could not be read (with the error), and each part's name as first written.
|
||||
struct Package {
|
||||
root: String,
|
||||
parts: HashMap<String, Model>,
|
||||
failed: HashMap<String, String>,
|
||||
names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Part `key` as the package names it.
|
||||
fn shown<'a>(&'a self, key: &'a str) -> &'a str {
|
||||
self.names.get(key).map_or(key, String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn color(s: &str) -> Option<[f32; 3]> {
|
||||
@@ -489,11 +529,19 @@ fn unit_mm(u: &str) -> Option<f64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
/// The model part's name in `p:path`, if any.
|
||||
fn part_name(a: &HashMap<String, String>) -> Option<PartName> {
|
||||
a.get("path").map(|p| PartName { key: part_key(p), path: p.clone() })
|
||||
}
|
||||
|
||||
/// Model part `xml`. Only the `root` model's build items are read: the spec has readers ignore
|
||||
/// the build sections of the other parts (slicers leave them empty or point them anywhere).
|
||||
fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
|
||||
let mut rd = Reader::from_reader(xml);
|
||||
rd.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut m = Model { unit: 1.0, ..Default::default() };
|
||||
let mut m = Model::default();
|
||||
let mut unit = 1.0;
|
||||
let mut depth = 0usize;
|
||||
let mut cur: Option<(u32, Object)> = None;
|
||||
let mut cur_mat: Option<u32> = None;
|
||||
@@ -541,7 +589,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
b"model" => {
|
||||
seen_model = true;
|
||||
if let Some(u) = a.get("unit") {
|
||||
m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
|
||||
unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
|
||||
}
|
||||
}
|
||||
b"basematerials" => {
|
||||
@@ -573,7 +621,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
return bad("too many vertices");
|
||||
}
|
||||
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
|
||||
o.positions.push(Vec3::new(x, y, z) * m.unit);
|
||||
o.positions.push(Vec3::new(x, y, z) * unit);
|
||||
}
|
||||
b"triangle" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
|
||||
@@ -586,11 +634,13 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
b"component" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
|
||||
o.components.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
// The spec allows `p:path` only in the root model's components; a reader can still
|
||||
// follow it from any part (loops and depth are checked when placing).
|
||||
o.components.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
|
||||
}
|
||||
b"item" => {
|
||||
b"item" if root => {
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
|
||||
m.build.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
m.build.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -602,29 +652,30 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Queue the model parts `m` names that were not seen yet.
|
||||
fn queue_parts(m: &Model, seen: &mut HashSet<String>, pending: &mut Vec<String>) -> Result<()> {
|
||||
for key in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
|
||||
if !seen.contains(key) {
|
||||
/// Queue the model parts `m` names that were not seen yet, noting how each is spelled (`m.build`
|
||||
/// is empty but for the root).
|
||||
fn queue_parts(m: &Model, names: &mut HashMap<String, String>, pending: &mut Vec<String>) -> Result<()> {
|
||||
for p in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
|
||||
if !names.contains_key(&p.key) {
|
||||
// A package holds no more parts than it has entries.
|
||||
if seen.len() >= MAX_ENTRIES {
|
||||
if names.len() >= MAX_ENTRIES {
|
||||
return bad(format!("objects name more than {MAX_ENTRIES} model parts"));
|
||||
}
|
||||
seen.insert(key.clone());
|
||||
pending.push(key.clone());
|
||||
names.insert(p.key.clone(), p.path.clone());
|
||||
pending.push(p.key.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An error reading model part `key`, saying which part it was.
|
||||
fn in_part(key: &str, e: IoError) -> IoError {
|
||||
/// An error reading model part `name`, saying which part it was.
|
||||
fn in_part(name: &str, e: IoError) -> String {
|
||||
match e {
|
||||
IoError::Invalid(msg) => {
|
||||
let msg = msg.strip_prefix("3MF: ").or_else(|| msg.strip_prefix("3MF ")).unwrap_or(&msg);
|
||||
IoError::Invalid(format!("3MF model part `{key}`: {msg}"))
|
||||
format!("3MF model part `{name}`: {msg}")
|
||||
}
|
||||
e => e,
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,15 +689,22 @@ fn flatten<'a>(
|
||||
stack: &mut Vec<(&'a str, u32)>,
|
||||
total: &mut usize,
|
||||
) -> Result<()> {
|
||||
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&(part, id)) {
|
||||
return bad("components nested too deeply or recursive");
|
||||
// Loops are keyed by (part, object): parts number their objects on their own, so object 1 of
|
||||
// the root placing object 1 of another part is no loop.
|
||||
if stack.contains(&(part, id)) {
|
||||
return bad("components place each other in a loop");
|
||||
}
|
||||
let m = pkg.parts.get(part).ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{part}`")))?;
|
||||
if stack.len() > MAX_COMPONENT_DEPTH {
|
||||
return bad(format!("components nested more than {MAX_COMPONENT_DEPTH} deep"));
|
||||
}
|
||||
let Some(m) = pkg.parts.get(part) else {
|
||||
return Err(IoError::Invalid(pkg.failed.get(part).cloned().unwrap_or_else(|| format!("3MF: no model part `{}`", pkg.shown(part)))));
|
||||
};
|
||||
let o = m.objects.get(&id).ok_or_else(|| {
|
||||
IoError::Invalid(if part == pkg.root {
|
||||
format!("3MF: missing object {id}")
|
||||
} else {
|
||||
format!("3MF: missing object {id} in model part `{part}`")
|
||||
format!("3MF: missing object {id} in model part `{}`", pkg.shown(part))
|
||||
})
|
||||
})?;
|
||||
// Every placement costs at least one unit of the budget, so objects that only place other
|
||||
@@ -695,7 +753,8 @@ fn flatten<'a>(
|
||||
}
|
||||
stack.push((part, id));
|
||||
for c in &o.components {
|
||||
flatten(pkg, c.part.as_deref().unwrap_or(part), c.id, &compose(&c.xf, xf), out, stack, total)?;
|
||||
// Without `p:path` a component is in the part that names it, not in the root.
|
||||
flatten(pkg, c.part.as_ref().map_or(part, |p| p.key.as_str()), c.id, &compose(&c.xf, xf), out, stack, total)?;
|
||||
}
|
||||
stack.pop();
|
||||
Ok(())
|
||||
@@ -733,40 +792,56 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
|
||||
}
|
||||
}
|
||||
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
|
||||
let root = part_key(&root);
|
||||
let model = parse_model(&xml)?;
|
||||
let mut model_bytes = xml.len();
|
||||
let root_name = root;
|
||||
let root = part_key(&root_name);
|
||||
let model = parse_model(&xml, true)?;
|
||||
// All model parts together may expand to 1 GB; only one is held as XML at a time.
|
||||
let mut model_bytes = xml.len() as u64;
|
||||
drop(xml);
|
||||
// The model parts objects name (production extension), each read once. The entries are
|
||||
// indexed first, so a package naming thousands of parts costs no scan per name; a name
|
||||
// with no entry is left out, and the placements that need it say so.
|
||||
// indexed first, so a package naming thousands of parts costs no scan per name. A name with
|
||||
// no entry is left out and a part that can't be read is noted: either way only the
|
||||
// placements that need it fail, and say why.
|
||||
let mut entries = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(Ok(n)) = zip.name_for_index(i) {
|
||||
entries.entry(part_key(&n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::from([root.clone()]);
|
||||
let mut names = HashMap::from([(root.clone(), root_name)]);
|
||||
let mut pending = Vec::new();
|
||||
queue_parts(&model, &mut seen, &mut pending)?;
|
||||
queue_parts(&model, &mut names, &mut pending)?;
|
||||
let mut parts = HashMap::from([(root.clone(), model)]);
|
||||
let mut failed = HashMap::new();
|
||||
while let Some(key) = pending.pop() {
|
||||
let Some(&idx) = entries.get(&key) else { continue };
|
||||
let xml = read_entry(&mut zip, idx, &key)?;
|
||||
model_bytes = model_bytes.saturating_add(xml.len());
|
||||
if model_bytes as u64 > MAX_PART {
|
||||
return bad("model parts expand to more than 1 GB");
|
||||
let name = names.get(&key).cloned().unwrap_or_else(|| key.clone());
|
||||
let read = match read_entry(&mut zip, idx, &name, MAX_PART.saturating_sub(model_bytes)) {
|
||||
Ok(Some(xml)) => {
|
||||
model_bytes = model_bytes.saturating_add(xml.len() as u64);
|
||||
parse_model(&xml, false)
|
||||
}
|
||||
// The package-wide caps refuse the whole package, not just this part.
|
||||
Ok(None) => return bad("model parts expand to more than 1 GB"),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match read {
|
||||
Ok(m) => {
|
||||
queue_parts(&m, &mut names, &mut pending)?;
|
||||
parts.insert(key, m);
|
||||
}
|
||||
Err(e) => {
|
||||
failed.insert(key, in_part(&name, e));
|
||||
}
|
||||
}
|
||||
let m = parse_model(&xml).map_err(|e| in_part(&key, e))?;
|
||||
queue_parts(&m, &mut seen, &mut pending)?;
|
||||
parts.insert(key, m);
|
||||
}
|
||||
let pkg = Package { root, parts };
|
||||
let pkg = Package { root, parts, failed, names };
|
||||
let Some(model) = pkg.parts.get(&pkg.root) else { return bad("no root model") };
|
||||
let mut out = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for (k, item) in model.build.iter().enumerate() {
|
||||
let part = item.part.as_deref().unwrap_or(&pkg.root);
|
||||
let part = item.part.as_ref().map_or(pkg.root.as_str(), |p| p.key.as_str());
|
||||
let mut mo = MeshObject::default();
|
||||
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
|
||||
Ok(()) if !mo.triangles.is_empty() => {
|
||||
@@ -814,16 +889,21 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
/// A package of these parts (name, contents).
|
||||
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
pub(crate) fn package_bytes(parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (name, data) in parts {
|
||||
zip.start_file(*name, o).unwrap();
|
||||
zip.write_all(data.as_bytes()).unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A package of these text parts (name, contents).
|
||||
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
package_bytes(&parts.iter().map(|(n, d)| (*n, d.as_bytes())).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
|
||||
let mut parts: Vec<(&str, &str)> = rels.map(|r| ("_rels/.rels", r)).into_iter().collect();
|
||||
parts.push(("3D/3dmodel.model", model));
|
||||
@@ -850,9 +930,9 @@ pub(crate) mod tests {
|
||||
o.triangles.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2])))).sum::<f64>() / 6.0
|
||||
}
|
||||
|
||||
/// A multi-part project as slicers save it: the root model's object is two components in
|
||||
/// another model part, which holds the meshes.
|
||||
pub(crate) fn production_package() -> Vec<u8> {
|
||||
/// The model parts (name, XML) of a multi-part project as slicers save it: the root model's
|
||||
/// object is two components in another model part, which holds the meshes.
|
||||
pub(crate) fn production_parts() -> [(&'static str, String); 2] {
|
||||
let root = format!(
|
||||
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model" name="Pair"><components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1"/>
|
||||
@@ -860,7 +940,12 @@ pub(crate) mod tests {
|
||||
</components></object></resources><build><item objectid="10" transform="1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#
|
||||
);
|
||||
let objects = model_part("millimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
|
||||
package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])
|
||||
[("3D/3dmodel.model", root), (OBJECTS_PART, objects)]
|
||||
}
|
||||
|
||||
pub(crate) fn production_package() -> Vec<u8> {
|
||||
let [(r, root), (o, objects)] = production_parts();
|
||||
package_parts(&[(r, &root), (o, &objects)])
|
||||
}
|
||||
|
||||
/// Objects kept in a model part of their own (3MF Production extension) come in with their
|
||||
@@ -916,22 +1001,40 @@ pub(crate) mod tests {
|
||||
r#"<object id="1" type="model"><components><component p:path="/3D/3dmodel.model" objectid="10"/></components></object>"#,
|
||||
);
|
||||
let many: String = (0..=MAX_ENTRIES).map(|i| format!(r#"<component p:path="/p{i}.model" objectid="1"/>"#)).collect();
|
||||
// Each with the message it must give: before the production extension was read, every
|
||||
// one of these failed alike, on the first `p:path`.
|
||||
let cases = [
|
||||
// A part that isn't in the package, one outside it and an empty name.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/missing.model" objectid="1"/>"#))]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
// A part that isn't in the package (named as written), one outside it and an empty name.
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/Missing.model" objectid="1"/>"#))]),
|
||||
"no model part `/3D/Objects/Missing.model`",
|
||||
),
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
"no model part `/../../etc/passwd`",
|
||||
),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]), "no model part ``"),
|
||||
// An object the part doesn't have.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
(
|
||||
package_parts(&[
|
||||
("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)),
|
||||
(OBJECTS_PART, &tet),
|
||||
]),
|
||||
"missing object 7 in model part `/3D/Objects/object_1.model`",
|
||||
),
|
||||
// A part that is no model.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
|
||||
"model part `/3D/Objects/object_1.model`: no <model> element",
|
||||
),
|
||||
// Parts that place each other in a loop.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]), "components place each other in a loop"),
|
||||
// More part names than a package can hold.
|
||||
package_parts(&[("3D/3dmodel.model", &root(&many))]),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(&many))]), "objects name more than 10000 model parts"),
|
||||
];
|
||||
for (i, c) in cases.iter().enumerate() {
|
||||
assert!(read_3mf(c).is_err(), "case {i}");
|
||||
for (i, (c, msg)) in cases.iter().enumerate() {
|
||||
let err = read_3mf(c).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains(msg), "case {i}: {err}");
|
||||
}
|
||||
// A placement whose part is missing is reported; the others still come in.
|
||||
let two = format!(
|
||||
@@ -942,6 +1045,221 @@ pub(crate) mod tests {
|
||||
assert!(warnings.iter().any(|w| w.contains("build item 2") && w.contains("gone.model")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// The root model: `objects`, and a build of `items`.
|
||||
fn root_model(objects: &str, items: &str) -> String {
|
||||
format!(r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build>{items}</build></model>"#)
|
||||
}
|
||||
|
||||
/// Object `id` made of components: (`p:path`, or "" for none, and object id).
|
||||
fn assembly(id: u32, components: &[(&str, u32)]) -> String {
|
||||
let c: String = components
|
||||
.iter()
|
||||
.map(|(path, oid)| match *path {
|
||||
"" => format!(r#"<component objectid="{oid}"/>"#),
|
||||
path => format!(r#"<component p:path="{path}" objectid="{oid}"/>"#),
|
||||
})
|
||||
.collect();
|
||||
format!(r#"<object id="{id}" type="model"><components>{c}</components></object>"#)
|
||||
}
|
||||
|
||||
/// A component without `p:path` in another part is in that part, not in the root, which has
|
||||
/// an object of the same id too (T1 of the review of #2127).
|
||||
#[test]
|
||||
fn components_without_a_path_stay_in_their_part() {
|
||||
let root =
|
||||
root_model(&format!("{}{}", tet_object(2, "Root", 5.0), assembly(10, &[("/3D/Objects/object_1.model", 1)])), r#"<item objectid="10"/>"#);
|
||||
let objects = model_part("millimeter", &format!("{}{}", assembly(1, &[("", 2)]), tet_object(2, "Child", 2.0)));
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!((volume(&objs[0]) - 8.0 / 6.0).abs() < 1e-9, "the child's object 2, not the root's: {}", volume(&objs[0]));
|
||||
}
|
||||
|
||||
/// Parts number their objects on their own, so root object 1 placing object 1 of another part
|
||||
/// is no loop (T2).
|
||||
#[test]
|
||||
fn the_same_id_in_two_parts_is_no_loop() {
|
||||
let root = root_model(&assembly(1, &[("/3D/Objects/object_1.model", 1)]), r#"<item objectid="1"/>"#);
|
||||
let objects = model_part("millimeter", &tet_object(1, "A", 1.0));
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(objs.len() == 1 && (volume(&objs[0]) - 1.0 / 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A package shaped like a slicer project (Bambu Studio, OrcaSlicer): object UUIDs, printable
|
||||
/// build items, thumbnails, slicer configs, and the meshes in a part of their own with an empty
|
||||
/// build (T3). Each object on the plate is one body with all its volumes. The slicer's config
|
||||
/// (names, which volumes are modifiers) isn't read, so the bodies are named `MeshN` and the
|
||||
/// modifier comes in as solid.
|
||||
#[test]
|
||||
fn slicer_project_package() {
|
||||
let ns = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#;
|
||||
let uuid = |n: u32| format!("{n:08x}-61cb-4c03-9d28-80fed5dfa1dc");
|
||||
let component = |oid: u32| {
|
||||
format!(
|
||||
r#"<component p:path="/3D/Objects/object_1.model" objectid="{oid}" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>"#,
|
||||
uuid(100 + oid)
|
||||
)
|
||||
};
|
||||
let root = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" {ns}>
|
||||
<metadata name="Application">BambuStudio-02.03.00.70</metadata>
|
||||
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
||||
<resources>
|
||||
<object id="2" p:UUID="{}" type="model"><components>{}</components></object>
|
||||
<object id="4" p:UUID="{}" type="model"><components>{}{}</components></object>
|
||||
</resources>
|
||||
<build p:UUID="{}">
|
||||
<item objectid="2" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 128 128 0" printable="1"/>
|
||||
<item objectid="4" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 90 128 0" printable="1"/>
|
||||
</build>
|
||||
</model>"#,
|
||||
uuid(2),
|
||||
component(1),
|
||||
uuid(4),
|
||||
component(3),
|
||||
component(5),
|
||||
uuid(200),
|
||||
uuid(201),
|
||||
uuid(202)
|
||||
);
|
||||
let mesh = |id: u32, s: f64| tet_object(id, "", s).replace(r#" name="""#, &format!(r#" p:UUID="{}""#, uuid(300 + id)));
|
||||
let objects = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" {ns}>
|
||||
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
||||
<resources>{}{}{}</resources>
|
||||
<build/>
|
||||
</model>"#,
|
||||
mesh(1, 10.0),
|
||||
mesh(3, 20.0),
|
||||
mesh(5, 4.0)
|
||||
);
|
||||
let settings = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<object id="2"><metadata key="name" value="Cube"/><part id="1" subtype="normal_part"><metadata key="name" value="Cube"/></part></object>
|
||||
<object id="4"><metadata key="name" value="Bracket"/><part id="3" subtype="normal_part"/><part id="5" subtype="modifier_part"/></object>
|
||||
<plate><metadata key="plater_id" value="1"/><metadata key="thumbnail_file" value="Metadata/plate_1.png"/></plate>
|
||||
</config>"#;
|
||||
let content_types = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
let rels = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="{MODEL_REL}"/><Relationship Target="/Metadata/plate_1.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/></Relationships>"#
|
||||
);
|
||||
let model_rels = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/Objects/object_1.model" Id="rel-1" Type="{MODEL_REL}"/></Relationships>"#
|
||||
);
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
|
||||
let package = package_bytes(&[
|
||||
("[Content_Types].xml", content_types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("3D/3dmodel.model", root.as_bytes()),
|
||||
("3D/_rels/3dmodel.model.rels", model_rels.as_bytes()),
|
||||
(OBJECTS_PART, objects.as_bytes()),
|
||||
("Metadata/model_settings.config", settings.as_bytes()),
|
||||
("Metadata/project_settings.config", br#"{"layer_height": "0.2"}"#),
|
||||
("Metadata/plate_1.png", png),
|
||||
("Metadata/plate_1_small.png", png),
|
||||
]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect();
|
||||
assert_eq!(names, ["Mesh1", "Mesh2"]);
|
||||
assert_eq!((objs[0].triangles.len(), objs[1].triangles.len()), (4, 8));
|
||||
assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6, "{}", volume(&objs[0]));
|
||||
assert!((volume(&objs[1]) - (8000.0 + 64.0) / 6.0).abs() < 1e-6, "{}", volume(&objs[1]));
|
||||
assert!(objs[0].positions.iter().all(|p| p.x >= 128.0 - 1e-9 && p.y >= 128.0 - 1e-9));
|
||||
}
|
||||
|
||||
/// Placements in every part draw on one budget (T4): three parts fanning out 300 ways each
|
||||
/// (27 million placements of an empty object) stop at it, and only the build item that went
|
||||
/// over fails.
|
||||
#[test]
|
||||
fn parts_share_the_placement_budget() {
|
||||
let t = std::time::Instant::now();
|
||||
let fan = |id: u32, path: &str| assembly(id, &vec![(path, 1); 300]);
|
||||
let root = root_model(&fan(10, "/3D/Objects/a.model"), r#"<item p:path="/3D/Objects/a.model" objectid="9"/><item objectid="10"/>"#);
|
||||
let a = model_part("millimeter", &format!("{}{}", fan(1, "/3D/Objects/b.model"), tet_object(9, "Tet", 1.0)));
|
||||
let b = model_part("millimeter", &fan(1, "/3D/Objects/c.model"));
|
||||
let c = model_part("millimeter", r#"<object id="1" type="model"/>"#);
|
||||
let package =
|
||||
package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/a.model", &a), ("3D/Objects/b.model", &b), ("3D/Objects/c.model", &c)]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!(warnings.len() == 1 && warnings[0].starts_with("build item 2") && warnings[0].contains("placements"), "{warnings:?}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// A loop through three parts with different object ids is found, and a chain of parts deeper
|
||||
/// than the cap stops there while one level less still comes in (T5).
|
||||
#[test]
|
||||
fn loops_and_deep_chains_across_parts() {
|
||||
let read = |parts: &[(String, String)]| read_3mf(&package_parts(&parts.iter().map(|(n, x)| (n.as_str(), x.as_str())).collect::<Vec<_>>()));
|
||||
let part = |name: &str, objects: String| (name.to_string(), model_part("millimeter", &objects));
|
||||
let looped = [
|
||||
("3D/3dmodel.model".to_string(), root_model("", r#"<item p:path="/3D/Objects/a.model" objectid="1"/>"#)),
|
||||
part("3D/Objects/a.model", assembly(1, &[("/3D/Objects/b.model", 2)])),
|
||||
part("3D/Objects/b.model", assembly(2, &[("/3D/Objects/c.model", 3)])),
|
||||
part("3D/Objects/c.model", assembly(3, &[("/3D/Objects/a.model", 1)])),
|
||||
];
|
||||
let err = read(&looped).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains("components place each other in a loop"), "{err}");
|
||||
// Object k of part k places object k + 1 of part k + 1; the last one is a mesh.
|
||||
let chain = |n: u32| {
|
||||
let name = |k: u32| format!("3D/Objects/c{k}.model");
|
||||
let mut parts = vec![("3D/3dmodel.model".to_string(), root_model("", &format!(r#"<item p:path="/{}" objectid="1"/>"#, name(1))))];
|
||||
for k in 1..=n {
|
||||
let object = if k == n { tet_object(k, "End", 1.0) } else { assembly(k, &[(format!("/{}", name(k + 1)).as_str(), k + 1)]) };
|
||||
parts.push(part(&name(k), object));
|
||||
}
|
||||
read(&parts)
|
||||
};
|
||||
let deepest = MAX_COMPONENT_DEPTH as u32 + 1;
|
||||
let (objs, warnings) = chain(deepest).unwrap();
|
||||
assert!(objs.len() == 1 && warnings.is_empty(), "{warnings:?}");
|
||||
let err = chain(deepest + 1).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("components nested more than {MAX_COMPONENT_DEPTH} deep")), "{err}");
|
||||
}
|
||||
|
||||
/// A part that can't be read fails only the placements that need it, and none when only
|
||||
/// objects nobody places name it. The build sections of other parts are ignored, even ones
|
||||
/// pointing at a thumbnail or missing their object id (T6).
|
||||
#[test]
|
||||
fn broken_parts_and_the_builds_of_other_parts() {
|
||||
let root = root_model(
|
||||
&assembly(20, &[("/3D/Objects/unused.model", 1)]),
|
||||
r#"<item p:path="/3D/Objects/object_1.model" objectid="1"/><item p:path="/3D/Objects/Broken.model" objectid="1"/>"#,
|
||||
);
|
||||
let objects = format!(
|
||||
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{}</resources><build><item p:path="/Metadata/plate_1.png" objectid="1"/><item/></build></model>"#,
|
||||
tet_object(1, "A", 1.0)
|
||||
);
|
||||
let package = package_parts(&[
|
||||
("3D/3dmodel.model", &root),
|
||||
(OBJECTS_PART, &objects),
|
||||
("3D/Objects/broken.model", "not xml"),
|
||||
("3D/Objects/unused.model", "<model><resources><object id="),
|
||||
("Metadata/plate_1.png", "\u{89}PNG"),
|
||||
]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(
|
||||
warnings[0].starts_with("build item 2") && warnings[0].contains("model part `/3D/Objects/Broken.model`: no <model> element"),
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Part names are URIs: a `%XX` escape matches the character it stands for, either way round.
|
||||
#[test]
|
||||
fn escaped_part_names() {
|
||||
let root = root_model("", r#"<item p:path="/3D/Objects/my%20part.model" objectid="1"/><item p:path="/3D/Objects/café.model" objectid="1"/>"#);
|
||||
let tet = model_part("millimeter", &tet_object(1, "A", 1.0));
|
||||
let package = package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/my part.model", &tet), ("3D/Objects/caf%C3%A9.model", &tet)]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert!(warnings.is_empty() && objs.len() == 2, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_is_well_formed() {
|
||||
let mut t = tetra("A <&> \"b\"");
|
||||
@@ -1034,7 +1352,6 @@ pub(crate) mod tests {
|
||||
/// allocation, which no panic guard can intercept.
|
||||
#[test]
|
||||
fn many_placements_of_a_big_object_stay_bounded() {
|
||||
let t = std::time::Instant::now();
|
||||
let verts: String = (0..100_000).map(|i| format!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
|
||||
let model = format!(
|
||||
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
|
||||
@@ -1046,7 +1363,6 @@ pub(crate) mod tests {
|
||||
assert_eq!(objs.len(), 10_000);
|
||||
// Only the three vertices the one triangle reaches are copied per placement.
|
||||
assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Objects that only place other objects count toward the budget too: six levels of 300
|
||||
@@ -1054,7 +1370,6 @@ pub(crate) mod tests {
|
||||
/// budget instead of running for days.
|
||||
#[test]
|
||||
fn nested_fan_out_of_empty_objects_is_bounded() {
|
||||
let t = std::time::Instant::now();
|
||||
let components = |child: u32| (0..300).map(|_| format!(r#"<component objectid="{child}"/>"#)).collect::<String>();
|
||||
let mut objects = r#"<object id="1" type="model"/>"#.to_string();
|
||||
for id in 2..=7 {
|
||||
@@ -1063,7 +1378,6 @@ pub(crate) mod tests {
|
||||
let model = format!(r#"<model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build><item objectid="7"/></build></model>"#);
|
||||
let err = read_3mf(&package(&model, None)).unwrap_err().to_string();
|
||||
assert!(err.contains("placements"), "{err}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
|
||||
|
||||
+70
-17
@@ -23,6 +23,16 @@ pub struct Body {
|
||||
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
|
||||
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
|
||||
pub(crate) size: f64,
|
||||
/// How far this body's face boundaries may lie off their surfaces (mm) and still be taken as
|
||||
/// lying on them. `Some` for a body read from a file that declares its own numeric
|
||||
/// uncertainty (a STEP file's `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`, see
|
||||
/// `crate::step_in::Units::tol`); `None` for a body this kernel built, which has no such
|
||||
/// declaration.
|
||||
///
|
||||
/// A band face's ring edges are held to it when the importer splits the band (see
|
||||
/// `crate::step_in::split`). The display planner takes it only where it is more than its own
|
||||
/// bound, a hundredth of the body (see `accept` in [`crate::tess::plan_face`]).
|
||||
pub(crate) boundary_tol: Option<f64>,
|
||||
/// The last volume measured for this solid and the tolerance it was measured at, shared by
|
||||
/// clones because they share the solid. A boolean measures its operands, and a later boolean
|
||||
/// takes the result as an operand again, so the same body is measured twice otherwise.
|
||||
@@ -636,6 +646,7 @@ impl Body {
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: Some((tol, missing)),
|
||||
mesh: None,
|
||||
@@ -677,6 +688,7 @@ impl Body {
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse,
|
||||
mesh: None,
|
||||
@@ -706,7 +718,12 @@ impl Body {
|
||||
return Err(KernelError::Invalid("degenerate placement".into()));
|
||||
}
|
||||
let rigid = crate::ops::rigid_placement(&a);
|
||||
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
|
||||
let out = Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())?;
|
||||
// A placement that does not change distances leaves the boundaries exactly as far off
|
||||
// their surfaces as they were, so the file's own figure still describes them (see
|
||||
// [`Body::boundary_tol`]); one that scales the part does not, and the copy declares
|
||||
// nothing. The same rule as the mesh count above.
|
||||
Ok(if rigid { out.with_boundary_tol(self.boundary_tol) } else { out })
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
@@ -992,6 +1009,14 @@ impl Body {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// The same body, with how far its boundaries may lie off their surfaces (mm) as the file it
|
||||
/// was read from declares (see [`Body::boundary_tol`]). A value that is not finite and above
|
||||
/// zero clears the declaration, which puts the body back on the kernel's own bound.
|
||||
pub(crate) fn with_boundary_tol(mut self, tol: Option<f64>) -> Body {
|
||||
self.boundary_tol = tol.filter(|t| t.is_finite() && *t > 0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// The same body with a display colour.
|
||||
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
|
||||
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
|
||||
@@ -1241,15 +1266,19 @@ impl Body {
|
||||
if let Some(m) = &self.mesh {
|
||||
return Ok(m.to_mesh());
|
||||
}
|
||||
// A face trimmed by a helix (a modelled thread from another CAD system) turns more than
|
||||
// once around its cylinder; truck meshes it with triangles across the solid (#1289).
|
||||
// Such a body is meshed from its display plan, which cuts the face along a seam.
|
||||
if guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid))).unwrap_or(false) {
|
||||
// Two kinds of face truck cannot mesh in its surface's parameters: one trimmed by a
|
||||
// helix, which turns more than once around its cylinder and comes out with triangles
|
||||
// across the solid (#1289), and one that runs across the join of a B-spline surface that
|
||||
// closes on itself, which comes out folded over itself or with a hole in it.
|
||||
// Such a body is meshed from its display plan, which cuts and unwraps those faces.
|
||||
let planned =
|
||||
guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::seam::closes_on_itself(&self.solid)));
|
||||
if planned.unwrap_or(false) {
|
||||
let turn = (angle.is_finite() && angle > 0.0)
|
||||
.then(|| crate::tess::size::Turn { angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
|
||||
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn)) {
|
||||
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol)) {
|
||||
Ok((plan, _)) => return Ok(plan.evaluate()),
|
||||
Err(e) => log::debug!("helical faces: no display plan ({e}); meshing the reference way"),
|
||||
Err(e) => log::debug!("no display plan ({e}); meshing the reference way"),
|
||||
}
|
||||
}
|
||||
let deflection = if angle.is_finite() && angle > 0.0 {
|
||||
@@ -1358,7 +1387,7 @@ impl Body {
|
||||
// The same angle and floor as the reference mesher's angular display mesh.
|
||||
let turn =
|
||||
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
|
||||
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
|
||||
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol).map(|(plan, _)| plan))
|
||||
}
|
||||
|
||||
/// Planar faces whose boundary crosses itself: two of its loops crossing each other, or a
|
||||
@@ -1443,25 +1472,49 @@ impl Body {
|
||||
Ok(crossed)
|
||||
}
|
||||
|
||||
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
|
||||
/// Faces with no triangles of their own, and so not shown: none in a coarse tessellation,
|
||||
/// and none in the display plan either (the plan is what the viewport draws, and it meshes
|
||||
/// faces the reference mesher gives up on). Only faces the coarse tessellation misses are
|
||||
/// looked for in the plan, so a sound body costs no planning.
|
||||
pub fn unmeshed_faces(&self) -> usize {
|
||||
if self.is_mesh() {
|
||||
return 0;
|
||||
}
|
||||
let tol = orientation_tolerance(self.size());
|
||||
if let Some((cached_tol, missing)) = self.coarse_missing
|
||||
&& cached_tol == tol
|
||||
{
|
||||
return missing;
|
||||
// The count [`Body::new`] already took at this tolerance, else the same count made now.
|
||||
let missing = match self.coarse_missing {
|
||||
Some((cached_tol, missing)) if cached_tol == tol => missing,
|
||||
_ => {
|
||||
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
has.iter().filter(|h| !**h).count()
|
||||
}
|
||||
};
|
||||
// A body the coarse mesh covers whole is shown whole: no plan is made for it, whether the
|
||||
// count came from the cache or from the mesh above.
|
||||
if missing == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
|
||||
self.unmeshed_in_plan(tol).unwrap_or(missing)
|
||||
}
|
||||
|
||||
/// Faces the display plan leaves without triangles; `None` when the body has no plan.
|
||||
fn unmeshed_in_plan(&self, tol: f64) -> Option<usize> {
|
||||
let plan = self.display_plan(tol).ok()?;
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
for f in &plan.faces {
|
||||
if !f.triangles.is_empty()
|
||||
&& let Some(h) = has.get_mut(f.face as usize)
|
||||
{
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
has.iter().filter(|h| !**h).count()
|
||||
Some(has.iter().filter(|h| !**h).count())
|
||||
}
|
||||
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged. Meshed from the
|
||||
|
||||
@@ -228,8 +228,10 @@ fn a_small_hole_on_a_large_plate_has_a_round_rim() {
|
||||
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(segments, 18);
|
||||
for z in [0.0, 2.0] {
|
||||
// The chord tolerance alone: each half circle is one segment, the rim a line.
|
||||
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
|
||||
// The chord tolerance alone leaves each half circle one segment, the rim a line no face
|
||||
// can be bounded by: its edges are divided again, and the rim stays a coarse polygon.
|
||||
let (n, _) = rim(&coarse, centre, 2.0, z);
|
||||
assert!((3..segments).contains(&n), "{n} segments around the rim at the chord tolerance");
|
||||
let (n, widest) = rim(&round, centre, 2.0, z);
|
||||
assert!(n >= segments, "{n} segments around the rim at z = {z}");
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
|
||||
@@ -291,9 +293,12 @@ fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
|
||||
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
|
||||
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
|
||||
for z in [0.0, 2.0] {
|
||||
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
|
||||
// At the chord tolerance alone each rim is divided only as far as a face can be bounded
|
||||
// by it (two segments, a line, are divided again).
|
||||
assert!(boundary(&coarse, z) < 6 * centres.len(), "{}", boundary(&coarse, z));
|
||||
let n = boundary(&round, z);
|
||||
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
|
||||
let (rounder, chord) = (rim(&round, centres[0], 2.0, z).0, rim(&coarse, centres[0], 2.0, z).0);
|
||||
assert!(rounder >= chord, "the rims are at least as round as the chord tolerance leaves them: {rounder} < {chord}");
|
||||
assert!(n <= budget, "{n} segments bound the face at z = {z}");
|
||||
}
|
||||
// Every rim got the same share, and the walls follow their rims.
|
||||
|
||||
+135
-19
@@ -429,6 +429,8 @@ struct Tr<'a> {
|
||||
curve_work: usize,
|
||||
/// Composite curves (102) on the path being expanded.
|
||||
composites: Vec<i64>,
|
||||
/// Farthest a trim loop's end was moved to close a gap (mm; see `close_gaps`).
|
||||
snapped: f64,
|
||||
}
|
||||
|
||||
fn xf_key(x: &Xf) -> [u64; 12] {
|
||||
@@ -1034,24 +1036,35 @@ struct Sewer {
|
||||
}
|
||||
|
||||
impl Sewer {
|
||||
fn vertex(&mut self, p: V3) -> usize {
|
||||
fn key(&self, p: V3) -> [i64; 3] {
|
||||
// Neighbouring buckets are this key plus or minus one, so leave room for that: a
|
||||
// coordinate near f64's range saturates the cast and the addition would overflow.
|
||||
let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64;
|
||||
let key = [c(p[0]), c(p[1]), c(p[2])];
|
||||
[c(p[0]), c(p[1]), c(p[2])]
|
||||
}
|
||||
/// The vertex within the tolerance of `p`, if there is one.
|
||||
fn find(&self, p: V3) -> Option<usize> {
|
||||
let key = self.key(p);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) {
|
||||
for &i in list {
|
||||
if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) {
|
||||
return i;
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn vertex(&mut self, p: V3) -> usize {
|
||||
if let Some(i) = self.find(p) {
|
||||
return i;
|
||||
}
|
||||
let key = self.key(p);
|
||||
self.verts.push(p);
|
||||
self.grid.entry(key).or_default().push(self.verts.len() - 1);
|
||||
self.verts.len() - 1
|
||||
@@ -1227,12 +1240,21 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
}
|
||||
let f = parse(text)?;
|
||||
let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5);
|
||||
let mut t =
|
||||
Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() };
|
||||
let mut t = Tr {
|
||||
f: &f,
|
||||
o: Out { lines: Vec::new() },
|
||||
warnings: Vec::new(),
|
||||
tol,
|
||||
surf_cache: HashMap::new(),
|
||||
curve_work: 0,
|
||||
composites: Vec::new(),
|
||||
snapped: 0.0,
|
||||
};
|
||||
// Header: units and context.
|
||||
let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
|
||||
let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
|
||||
let unc_line = t.o.lines.len();
|
||||
let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol)));
|
||||
let ctx = t.o.add(format!(
|
||||
"(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))"
|
||||
@@ -1278,6 +1300,13 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
}
|
||||
}
|
||||
assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?;
|
||||
if t.snapped > 0.0 {
|
||||
// The STEP reader takes vertices up to ten times the stated accuracy off their curves:
|
||||
// state enough to cover the ends `close_gaps` moved, and no more.
|
||||
if let Some(l) = t.o.lines.get_mut(unc_line) {
|
||||
*l = format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol.max(t.snapped * 0.2)));
|
||||
}
|
||||
}
|
||||
for (item, c) in styles {
|
||||
let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)));
|
||||
let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
|
||||
@@ -1313,15 +1342,21 @@ fn sew_surfaces(
|
||||
sw.edges.clear();
|
||||
sw.by_ends.clear();
|
||||
let watch = crate::watch::current();
|
||||
let mut drafts = Vec::new();
|
||||
for seq in seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
match trimmed(t, sw, seq as i64) {
|
||||
Ok(face) => faces.push(face),
|
||||
match trimmed(t, sw.tol, seq as i64) {
|
||||
Ok(face) => drafts.push(face),
|
||||
Err(e) => t.warn(format!("surface {seq}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
close_gaps(t, &mut drafts, sw.tol);
|
||||
for d in drafts {
|
||||
let loops = d.loops.iter().map(|l| l.iter().map(|s| sw.edge(s)).collect()).collect();
|
||||
faces.push(FaceB { surface: d.surface, sense: true, loops, color: d.color });
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -1336,15 +1371,96 @@ fn sew_surfaces(
|
||||
}))
|
||||
}
|
||||
|
||||
/// A face read from a trimmed or bounded surface, its loops not yet sewn.
|
||||
struct Draft {
|
||||
surface: u64,
|
||||
loops: Vec<Vec<Seg>>,
|
||||
color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// Gaps between the pieces of a trim loop wider than this (mm) are left open: past it, the ends
|
||||
/// may be different corners of the model rather than one corner written loosely.
|
||||
const MAX_TRIM_GAP: f64 = 0.1;
|
||||
/// ...and a gap must also be at most this share of the shorter piece on either side.
|
||||
const MAX_TRIM_GAP_SHARE: f64 = 0.05;
|
||||
|
||||
/// Close the small gaps between consecutive pieces of the faces' trim loops. Some exporters
|
||||
/// write each piece's model-space curve separately, and consecutive pieces can then miss by a
|
||||
/// few micrometres to a tenth of a millimetre, well past the file's stated resolution: the loop
|
||||
/// does not close, its face is lost and the shell stays open. The two ends of each such gap, and
|
||||
/// every other piece end at either of them (the neighbouring faces at that corner), move to
|
||||
/// their mean, so all the faces there sew to one vertex. A gap wider than `MAX_TRIM_GAP`, or not
|
||||
/// small next to its pieces, is left open; the sewing tolerance stays the file's.
|
||||
fn close_gaps(t: &mut Tr, faces: &mut [Draft], tol: f64) {
|
||||
let length = |s: &Seg| norm(sub(s.q, s.a)) + norm(sub(s.mid, s.q)) + norm(sub(s.q3, s.mid)) + norm(sub(s.b, s.q3));
|
||||
// The gaps' ends as vertices, joined into groups (union-find).
|
||||
let mut ends = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
let mut parent: Vec<usize> = Vec::new();
|
||||
let root = |parent: &[usize], mut i: usize| {
|
||||
while let Some(&p) = parent.get(i).filter(|p| **p != i) {
|
||||
i = p;
|
||||
}
|
||||
i
|
||||
};
|
||||
for l in faces.iter().flat_map(|f| f.loops.iter()) {
|
||||
for (i, prev) in l.iter().enumerate() {
|
||||
let Some(next) = l.get((i + 1) % l.len()) else { continue };
|
||||
let gap = norm(sub(next.a, prev.b));
|
||||
if gap <= tol || gap > MAX_TRIM_GAP || gap > MAX_TRIM_GAP_SHARE * length(prev).min(length(next)) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (ends.vertex(prev.b), ends.vertex(next.a));
|
||||
while parent.len() < ends.verts.len() {
|
||||
parent.push(parent.len());
|
||||
}
|
||||
let (ra, rb) = (root(&parent, a), root(&parent, b));
|
||||
if let Some(x) = parent.get_mut(ra) {
|
||||
*x = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ends.verts.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each group's mean; a group that spreads wider than a gap may be is left alone.
|
||||
let mut sums: HashMap<usize, (V3, usize)> = HashMap::new();
|
||||
for (i, p) in ends.verts.iter().enumerate() {
|
||||
let e = sums.entry(root(&parent, i)).or_insert(([0.0; 3], 0));
|
||||
e.0 = add(e.0, *p);
|
||||
e.1 += 1;
|
||||
}
|
||||
let mean: HashMap<usize, V3> = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect();
|
||||
let mut to: Vec<Option<V3>> = ends.verts.iter().enumerate().map(|(i, _)| mean.get(&root(&parent, i)).copied()).collect();
|
||||
for (i, p) in ends.verts.iter().enumerate() {
|
||||
if to.get(i).copied().flatten().is_some_and(|m| norm(sub(m, *p)) > MAX_TRIM_GAP / 2.0) {
|
||||
let r = root(&parent, i);
|
||||
for (k, x) in to.iter_mut().enumerate() {
|
||||
if root(&parent, k) == r {
|
||||
*x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut moved = 0.0f64;
|
||||
let mut snap = |p: &mut V3| {
|
||||
if let Some(m) = ends.find(*p).and_then(|i| to.get(i).copied().flatten()) {
|
||||
moved = moved.max(norm(sub(m, *p)));
|
||||
*p = m;
|
||||
}
|
||||
};
|
||||
for s in faces.iter_mut().flat_map(|f| f.loops.iter_mut()).flat_map(|l| l.iter_mut()) {
|
||||
snap(&mut s.a);
|
||||
snap(&mut s.b);
|
||||
}
|
||||
t.snapped = t.snapped.max(moved);
|
||||
}
|
||||
|
||||
/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face.
|
||||
fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
|
||||
let de = t.de(seq)?;
|
||||
let xf = t.own_xf(seq, 0)?;
|
||||
let color = color_of(t, seq);
|
||||
let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec<Seg>| -> Vec<(usize, bool)> {
|
||||
let tol = sw.tol;
|
||||
segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect()
|
||||
};
|
||||
let loop_of = |segs: Vec<Seg>| -> Vec<Seg> { segs.into_iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).collect() };
|
||||
match de.typ {
|
||||
144 => {
|
||||
let surf_seq = t.i(seq, 1)?;
|
||||
@@ -1353,16 +1469,16 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
|
||||
let mut loops = Vec::new();
|
||||
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
|
||||
loops.push(loop_of(t, sw, outer));
|
||||
loops.push(loop_of(outer));
|
||||
for k in 0..n2 {
|
||||
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
|
||||
loops.push(loop_of(t, sw, segs));
|
||||
loops.push(loop_of(segs));
|
||||
}
|
||||
loops.retain(|l| !l.is_empty());
|
||||
if loops.is_empty() {
|
||||
return Err("no boundary".into());
|
||||
}
|
||||
Ok(FaceB { surface, sense: true, loops, color })
|
||||
Ok(Draft { surface, loops, color })
|
||||
}
|
||||
143 => {
|
||||
let surf_seq = t.i(seq, 2)?;
|
||||
@@ -1372,19 +1488,19 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
for k in 0..n {
|
||||
let b = t.i(seq, 4 + k)?;
|
||||
let segs = boundary_141(t, b, &xf)?;
|
||||
loops.push(loop_of(t, sw, segs));
|
||||
loops.push(loop_of(segs));
|
||||
}
|
||||
loops.retain(|l| !l.is_empty());
|
||||
if loops.is_empty() {
|
||||
return Err("no boundary".into());
|
||||
}
|
||||
Ok(FaceB { surface, sense: true, loops, color })
|
||||
Ok(Draft { surface, loops, color })
|
||||
}
|
||||
_ => {
|
||||
let surface = t.surface(seq, &Xf::ID)?;
|
||||
let outer = t.natural_boundary(seq, &Xf::ID)?;
|
||||
let l = loop_of(t, sw, outer);
|
||||
Ok(FaceB { surface, sense: true, loops: vec![l], color })
|
||||
let l = loop_of(outer);
|
||||
Ok(Draft { surface, loops: vec![l], color })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,30 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
|
||||
}
|
||||
|
||||
/// Trim pieces whose ends miss each other by a hundredth of a millimetre, far past the file's
|
||||
/// stated resolution: the loop is closed and the box sews into a solid. A gap of half a
|
||||
/// millimetre is no corner written loosely, and stays open.
|
||||
#[test]
|
||||
fn trim_loops_with_small_gaps_still_close() {
|
||||
let with_gap = |gap: f64| {
|
||||
let mut ents = Vec::new();
|
||||
trimmed_box(&mut ents, 0.0);
|
||||
// The edge from (0, 0, 0) towards (0, 20, 0) stops `gap` short of that corner, in both
|
||||
// faces that run along it (the first face's first line, the fifth face's last).
|
||||
ents[1].2 = format!("0.,0.,0.,0.,{:?},0.", 20.0 - gap);
|
||||
ents[36].2 = format!("0.,{:?},0.,0.,0.,0.", 20.0 - gap);
|
||||
iges_import(&iges_file(&ents))
|
||||
};
|
||||
let imp = with_gap(0.01).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert!(imp.bodies[0].closed);
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.volume, 6000.0) < 1e-3 && rel(m.area, 2200.0) < 1e-3, "{} {}", m.volume, m.area);
|
||||
if let Ok(imp) = with_gap(0.5) {
|
||||
assert!(imp.bodies.iter().all(|b| !b.closed), "a 0.5 mm gap was closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfaces_in_separate_groups_remain_separate_parts() {
|
||||
let mut ents = Vec::new();
|
||||
|
||||
@@ -22,15 +22,21 @@ pub struct BodyMeasure {
|
||||
pub merged: TopoCounts,
|
||||
}
|
||||
|
||||
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem, origin 0).
|
||||
fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> {
|
||||
let mut out = vec![(0.0, 0.0, Vec3::ZERO); nf];
|
||||
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem), taken
|
||||
/// about `o` (the moments relative to it), and the sum of the area vectors (zero over a closed
|
||||
/// surface). Over a closed surface the volume does not depend on `o`; over one that leaves a
|
||||
/// gap, the gap is closed by a cone to `o`, so `o` near the body keeps that cone small.
|
||||
fn face_sums(m: &Mesh, nf: usize, o: Vec3) -> Vec<(f64, f64, Vec3, Vec3)> {
|
||||
let mut out = vec![(0.0, 0.0, Vec3::ZERO, Vec3::ZERO); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let (Some([a, b, c]), Some(slot)) = (m.tri(t), out.get_mut(*f as usize)) else { continue };
|
||||
let n = (b - a).cross(c - a) * 0.5;
|
||||
let (a, b, c) = (a - o, b - o, c - o);
|
||||
let d = a.dot(b.cross(c));
|
||||
slot.0 += (b - a).cross(c - a).len() * 0.5;
|
||||
slot.0 += n.len();
|
||||
slot.1 += d;
|
||||
slot.2 += (a + b + c) * d;
|
||||
slot.3 += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -152,7 +158,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
None => None,
|
||||
};
|
||||
let nf = b.face_count();
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
|
||||
// About the box's centre when the meshes leave only a crack: see `face_sums`. A body that is
|
||||
// open on purpose (a surface, or a cup whose lid is still a separate sheet) has no volume of
|
||||
// its own; it keeps the origin, so such bodies still compare with each other when the centre
|
||||
// of their boxes moves (a crowned lid rises, the open cup under it doesn't change).
|
||||
let (surface, gap) = face_sums(&fine, nf, Vec3::ZERO).iter().fold((0.0, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.3));
|
||||
let o = Some(fine.bounds().center()).filter(|c| c.is_finite() && gap.len() <= 0.01 * surface).unwrap_or(Vec3::ZERO);
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf, o), face_sums(&medium, nf, o), face_sums(&coarse, nf, o));
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
|
||||
let (mut area, mut v6, mut mom) = (0.0, 0.0, Vec3::ZERO);
|
||||
for ((f, m), c) in sf.iter().zip(&sm).zip(&sc) {
|
||||
@@ -168,6 +180,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
v6 += pick.1;
|
||||
mom += pick.2;
|
||||
}
|
||||
// The moments back about the origin. A gap the meshes leave (a crack where a trimmed face
|
||||
// meets a hole cut into it) is closed by a cone to the box's centre, which keeps it small
|
||||
// (a crack of 0.3 mm² moved a part ring's volume by 7 % about the origin 1000 mm away; about
|
||||
// the centre, by 0.1 %). Every body is measured by the same meshes this way, so a body and the
|
||||
// pieces a boolean cuts it into add up: a closed display plan measured instead would mesh
|
||||
// some faces more coarsely than the sums do and break that.
|
||||
let mom = o * (4.0 * v6) + mom;
|
||||
let volume = v6 / 6.0;
|
||||
let centroid = if v6.abs() > 1e-12 { mom / (4.0 * v6) } else { fine.bounds().center() };
|
||||
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()) }, topo))
|
||||
@@ -197,4 +216,121 @@ mod tests {
|
||||
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}");
|
||||
assert!(measure(&ring).unwrap().merged.faces > 0);
|
||||
}
|
||||
|
||||
/// A 10 mm cube at `at`, each side in n × n squares of two triangles, one face per side;
|
||||
/// without its first `open` triangles.
|
||||
fn cube(at: Vec3, n: usize, open: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = 10.0;
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side * s;
|
||||
c[(axis + 1) % 3] = a as f64 * s / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 * s / n as f64;
|
||||
at + Vec3::new(c[0], c[1], c[2])
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.triangles.drain(..open);
|
||||
m.tri_face.drain(..open);
|
||||
m
|
||||
}
|
||||
|
||||
/// The summed per-face sums of `m` about `o`, with the moments about the origin (as
|
||||
/// `measure` passes them on).
|
||||
fn sums(m: &Mesh, o: Vec3) -> (f64, f64, Vec3, Vec3) {
|
||||
let (area, v6, mom, normal) =
|
||||
face_sums(m, 6, o).iter().fold((0.0, 0.0, Vec3::ZERO, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.1, s.2 + f.2, s.3 + f.3));
|
||||
(area, v6, o * (4.0 * v6) + mom, normal)
|
||||
}
|
||||
|
||||
/// A cube 1000 mm from the origin with one 0.5 mm² facet left out: about the box's centre
|
||||
/// the sums miss a cone of that facet to the centre (0.08 %), where about the origin they are
|
||||
/// 17 % over. Closed, it measures the same about either.
|
||||
#[test]
|
||||
fn a_mesh_left_open_is_measured_about_its_centre() {
|
||||
let at = Vec3::new(1000.0, 1000.0, 1000.0);
|
||||
let (closed, open) = (cube(at, 10, 0), cube(at, 10, 1));
|
||||
let centre = at + Vec3::new(5.0, 5.0, 5.0);
|
||||
for o in [Vec3::ZERO, centre] {
|
||||
let (area, v6, mom, normal) = sums(&closed, o);
|
||||
assert!((area - 600.0).abs() < 1e-9 && (v6 / 6.0 - 1000.0).abs() < 1e-6 && normal.len() < 1e-9);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 1e-6 * at.len(), "{o:?}");
|
||||
}
|
||||
let (_, v6_origin, _, normal) = sums(&open, Vec3::ZERO);
|
||||
assert!((normal.len() - 0.5).abs() < 1e-9);
|
||||
// The facet faces the origin: leaving it out adds its cone to the origin, 167 mm³.
|
||||
assert!((v6_origin / 6.0 - (1000.0 + 0.5 * 1000.0 / 3.0)).abs() < 1e-3, "{}", v6_origin / 6.0);
|
||||
let (_, v6, mom, _) = sums(&open, centre);
|
||||
assert!((v6 / 6.0 - (1000.0 - 0.5 * 5.0 / 3.0)).abs() < 1e-6, "{}", v6 / 6.0);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 0.01, "{:?}", mom / (4.0 * v6));
|
||||
}
|
||||
|
||||
// A body's measured volume does not depend on where it sits: the same revolved ring of cones
|
||||
// and cylinders, moved far from the origin, measures what it measures at the origin.
|
||||
use crate::{revolve, transform};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
/// Ring cross-section in (radius, height): cones top and bottom, cylinders inside and out.
|
||||
const PROFILE: [(f64, f64); 6] = [(5.0, 0.0), (9.0, 0.0), (11.0, 2.0), (11.0, 6.0), (8.0, 8.0), (5.0, 8.0)];
|
||||
|
||||
/// Pappus: 2π × the section's first moment about the axis.
|
||||
fn ring_volume() -> f64 {
|
||||
let mut moment6 = 0.0;
|
||||
for (i, &(x0, y0)) in PROFILE.iter().enumerate() {
|
||||
let (x1, y1) = PROFILE[(i + 1) % PROFILE.len()];
|
||||
moment6 += (x0 + x1) * (x0 * y1 - x1 * y0);
|
||||
}
|
||||
TAU * moment6 / 6.0
|
||||
}
|
||||
|
||||
fn revolved(plane: &Plane, pts: &[Vec2]) -> Body {
|
||||
let region = Region2 { outer: Loop2::polygon(pts), holes: vec![] };
|
||||
revolve(plane, &[region], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_volume_does_not_depend_on_placement() {
|
||||
let pts: Vec<Vec2> = PROFILE.iter().map(|&(r, z)| Vec2::new(r, z)).collect();
|
||||
let base = revolved(&Plane::XZ, &pts);
|
||||
let at_origin = measure(&base).unwrap();
|
||||
assert!(rel(at_origin.volume, ring_volume()) < 1e-4, "{} vs {}", at_origin.volume, ring_volume());
|
||||
for d in [100.0, 1000.0, 10000.0] {
|
||||
for axis in [Vec3::X, Vec3::Y, Vec3::Z, Vec3::new(1.0, 1.0, 1.0)] {
|
||||
let m = measure(&transform(&base, axis * d, Vec3::ZERO, Vec3::Z, 0.0).unwrap()).unwrap();
|
||||
assert!(rel(m.volume, at_origin.volume) < 1e-6, "moved {d} along {axis:?}: {} vs {}", m.volume, at_origin.volume);
|
||||
let off = m.centroid - at_origin.centroid - axis * d;
|
||||
assert!(off.len() < 1e-6 * d, "centroid moved {d} along {axis:?}: off by {off:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_and_cone_volumes_far_from_the_origin() {
|
||||
let (r, h) = (7.0, 12.0);
|
||||
// A frustum, radii 7 and 3, height 10: πh/3 (R² + Rr + r²).
|
||||
let frustum = [Vec2::new(0.0, 0.0), Vec2::new(7.0, 0.0), Vec2::new(3.0, 10.0), Vec2::new(0.0, 10.0)];
|
||||
let cone_volume = PI * 10.0 / 3.0 * (49.0 + 21.0 + 9.0);
|
||||
for at in [Vec3::ZERO, Vec3::new(5000.0, -3000.0, 8000.0)] {
|
||||
let cyl = measure(&cylinder(at, Vec3::Z, r, h).unwrap()).unwrap();
|
||||
assert!(rel(cyl.volume, PI * r * r * h) < 5e-4, "cylinder at {at:?}: {}", cyl.volume);
|
||||
let cone = measure(&revolved(&Plane { origin: at, ..Plane::XZ }, &frustum)).unwrap();
|
||||
assert!(rel(cone.volume, cone_volume) < 5e-4, "frustum at {at:?}: {} vs {cone_volume}", cone.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
Ok(Body {
|
||||
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
|
||||
size: 0.0,
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
@@ -203,6 +204,7 @@ impl Body {
|
||||
Some(Body {
|
||||
solid: self.solid.clone(),
|
||||
size: 0.0,
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
|
||||
@@ -37,6 +37,10 @@ struct Runtime {
|
||||
size: f64,
|
||||
/// The orientation mesh's missing-face count at its tolerance.
|
||||
coarse_missing: Option<(f64, u64)>,
|
||||
/// [`Body::boundary_tol`]: the accuracy the file it was read from declares. Written only
|
||||
/// when there is one, so a body without it is stored as before.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
boundary_tol: Option<f64>,
|
||||
}
|
||||
fn invalid(detail: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Invalid(format!("native B-rep: {detail}"))
|
||||
@@ -66,7 +70,8 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let runtime = Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)) };
|
||||
let runtime =
|
||||
Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)), boundary_tol: body.boundary_tol };
|
||||
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)?, runtime };
|
||||
// Structural validation does not invoke another geometric reconstruction.
|
||||
validate_topology(&saved)?;
|
||||
@@ -98,13 +103,17 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
|
||||
validate_topology(&saved)?;
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let Runtime { size, coarse_missing } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0) || coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64) {
|
||||
let Runtime { size, coarse_missing, boundary_tol } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0)
|
||||
|| coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64)
|
||||
|| boundary_tol.is_some_and(|t| !(t.is_finite() && t > 0.0))
|
||||
{
|
||||
return Err(invalid("runtime values"));
|
||||
}
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
size,
|
||||
boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse_missing.map(|(tol, n)| (tol, n as usize)),
|
||||
mesh: None,
|
||||
@@ -228,6 +237,18 @@ mod tests {
|
||||
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
/// A body read from a file keeps the accuracy the file declares through the model cache, so
|
||||
/// a reopened design plans its faces as the import did; one without it is stored as before.
|
||||
#[test]
|
||||
fn a_files_declared_accuracy_survives_the_model_cache() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
|
||||
let plain = encode_native_body(&cube).unwrap();
|
||||
assert_eq!(decode_native_body(&plain).unwrap().boundary_tol, None);
|
||||
let read = cube.with_boundary_tol(Some(1e-5));
|
||||
let bytes = encode_native_body(&read).unwrap();
|
||||
assert_ne!(bytes, plain);
|
||||
assert_eq!(decode_native_body(&bytes).unwrap().boundary_tol, Some(1e-5));
|
||||
}
|
||||
#[test]
|
||||
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
|
||||
@@ -2126,6 +2126,15 @@ fn transform_matrix_keeping(body: &Body, m: [[f64; 4]; 4], phased: bool) -> Resu
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
}
|
||||
// A perspective row or a flattening (from a design file or a command's parameters) leaves
|
||||
// no solid: refused rather than built into faces that mesh to nonsense.
|
||||
let scale = (0..3).map(|c| Vec3::new(m[c][0], m[c][1], m[c][2]).len()).fold(0.0, f64::max);
|
||||
if m[0][3].abs() > 1e-12 || m[1][3].abs() > 1e-12 || m[2][3].abs() > 1e-12 || (m[3][3] - 1.0).abs() > 1e-12 {
|
||||
return Err(KernelError::Invalid("the transform is not affine: its last row must be 0, 0, 0, 1".into()));
|
||||
}
|
||||
if det3(&m).abs() <= 1e-12 * scale.powi(3) {
|
||||
return Err(KernelError::Invalid("the transform flattens the body: its linear part is singular".into()));
|
||||
}
|
||||
if let Some(placed) = body.mesh_transformed(&m) {
|
||||
let history = crate::history::History::identity(&placed);
|
||||
return placed.with_history(history, &[body]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user