mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4193,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
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.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4217,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4262,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4302,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4322,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4331,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4345,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4356,14 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5920,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.2"
|
||||
version = "0.18.3"
|
||||
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.3"
|
||||
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..]),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エージェントを追加",
|
||||
|
||||
@@ -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": "添加智能体",
|
||||
|
||||
@@ -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,61 @@ 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");
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -503,6 +503,7 @@ fn divided(body: &Body, faces: &[usize], other: &[(mt::Edge, Piece)], fr: &Frame
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
|
||||
@@ -636,6 +636,7 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
@@ -1173,6 +1174,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
|
||||
@@ -194,8 +194,9 @@ impl Builder {
|
||||
let sense = p.get(4).and_then(Param::as_bool).unwrap_or(true);
|
||||
let g = geom::curve(cx, rid(p.get(3), "edge geometry")?, 0)?;
|
||||
let closed = vs_id == ve_id;
|
||||
let pieces = guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pieces =
|
||||
guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol, cx.snap).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let n = pieces.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut cur = vs.clone();
|
||||
@@ -503,7 +504,7 @@ impl Builder {
|
||||
origin = vec![file_face; patches.len()];
|
||||
out = patches;
|
||||
}
|
||||
let out = super::split::split_bands(out, &mut origin, failed);
|
||||
let out = super::split::split_bands(out, &mut origin, failed, cx.tol);
|
||||
Ok((out.into(), closed, read, origin))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn extruded_ellipse_seam_uses_profile_angles_for_oblique_sweeps() {
|
||||
{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let origin = mt::Point3::new(10.0, -20.0, 3.0);
|
||||
let d = mt::Vector3::new(0.5, 0.25, 1.0).normalize();
|
||||
let point = |angle: f64, h: f64| origin + mt::Vector3::new(9.0 * angle.cos(), 4.0 * angle.sin(), 0.0) + d * h;
|
||||
|
||||
@@ -176,7 +176,32 @@ fn knot_vec(mults: Option<&Param>, knots: Option<&Param>, expected: usize) -> R<
|
||||
if m.len() > expected || m.iter().try_fold(0usize, |a, v| a.checked_add(*v)) != Some(expected) {
|
||||
return Err("knot multiplicities do not match the control points".into());
|
||||
}
|
||||
mt::KnotVec::from_single_multi(k, m).map_err(|e| format!("knot vector: {e}"))
|
||||
mt::KnotVec::from_single_multi(spanning_one(k), m).map_err(|e| format!("knot vector: {e}"))
|
||||
}
|
||||
|
||||
/// Shortest parameter range a spline is read with.
|
||||
const MIN_KNOT_RANGE: f64 = 1.0;
|
||||
|
||||
/// `knots` stretched, when they span less than [`MIN_KNOT_RANGE`], to span exactly that from the
|
||||
/// same first knot. A STEP writer may parameterize a spline over any range (some write a few
|
||||
/// millionths, as if in metres of a part drawn in millimetres), and the kernel tells parameters
|
||||
/// apart only to a fixed tolerance (1e-6): over such a range it took distinct knots for one, and
|
||||
/// trimming an edge to its vertices dropped a control point. Stretching the knots changes the
|
||||
/// parameters, never the curve (every point is where it was), and nothing read from the file
|
||||
/// refers to them: edges are trimmed by their vertices.
|
||||
fn spanning_one(knots: Vec<f64>) -> Vec<f64> {
|
||||
let (Some(&first), Some(&last)) = (knots.first(), knots.last()) else { return knots };
|
||||
let range = last - first;
|
||||
if !(range > 0.0 && range < MIN_KNOT_RANGE) {
|
||||
return knots;
|
||||
}
|
||||
let scale = MIN_KNOT_RANGE / range;
|
||||
let mut out: Vec<f64> = knots.iter().map(|k| first + (k - first) * scale).collect();
|
||||
// The last knot exactly at the end of the range (the product can miss it by a rounding).
|
||||
if let Some(k) = out.last_mut() {
|
||||
*k = first + MIN_KNOT_RANGE;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R<mt::KnotVec> {
|
||||
@@ -539,15 +564,16 @@ fn sub_curve<C: Cut + BoundedCurve + Clone>(c: &C, a: f64, b: f64) -> C {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge).
|
||||
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<C>>
|
||||
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge). The vertices may
|
||||
/// be up to `snap` off the curve; `tol` decides whether the curve closes on itself.
|
||||
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<C>>
|
||||
where
|
||||
C: ParametricCurve<Point = mt::Point3> + BoundedCurve + SearchNearestParameter<mt::D1, Point = mt::Point3> + Cut + Clone + Invertible,
|
||||
{
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let (ts, ds) = nearest_param(c, ps).ok_or("spline evaluation failed")?;
|
||||
let (te, de) = nearest_param(c, pe).ok_or("spline evaluation failed")?;
|
||||
if ds > tol || de > tol {
|
||||
if !(ds <= snap && de <= snap) {
|
||||
return Err(format!("edge vertices are {:.3e} mm off their curve", ds.max(de)));
|
||||
}
|
||||
let span = t1 - t0;
|
||||
@@ -589,9 +615,51 @@ where
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
/// Moves one end of a spline piece (`back`: its end, else its start) onto its vertex `p` by
|
||||
/// the control point that end sits on, so the piece meets the vertex exactly and keeps its
|
||||
/// shape away from that end. A piece whose end is not a control point (an unclamped spline) is
|
||||
/// left as it is.
|
||||
fn pin_end(c: &mut mt::Curve, p: mt::Point3, back: bool) {
|
||||
let end = if back { c.back() } else { c.front() };
|
||||
let d = p - end;
|
||||
if !(d.magnitude2() > 0.0) {
|
||||
return;
|
||||
}
|
||||
let at_end = |q: mt::Point3| q.distance(end) <= mt::TOLERANCE;
|
||||
match c {
|
||||
mt::Curve::BSplineCurve(b) => {
|
||||
let cp = if back { b.control_points_mut().last() } else { b.control_points_mut().next() };
|
||||
if let Some(q) = cp.filter(|q| at_end(**q)) {
|
||||
*q += d;
|
||||
}
|
||||
}
|
||||
mt::Curve::NurbsCurve(n) => {
|
||||
let cp = if back { n.control_points_mut().last() } else { n.control_points_mut().next() };
|
||||
if let Some(q) = cp.filter(|q| q.w != 0.0 && at_end(mt::Point3::from_homogeneous(**q))) {
|
||||
*q = (mt::Point3::from_homogeneous(*q) + d).to_homogeneous() * q.w;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kernel curves for an edge from `ps` to `pe`: one piece, or two for a closed edge (so no
|
||||
/// kernel edge starts and ends at the same vertex).
|
||||
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<mt::Curve>> {
|
||||
/// kernel edge starts and ends at the same vertex). A spline or open-conic edge's ends are
|
||||
/// moved onto vertices up to `snap` off its curve, so the pieces meet the vertices exactly.
|
||||
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
|
||||
let mut pieces = curve_pieces(g, ps, pe, sense, closed, tol, snap)?;
|
||||
if matches!(g, CurveGeo::BSpline(_) | CurveGeo::Nurbs(_) | CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. }) {
|
||||
if let Some(c) = pieces.first_mut() {
|
||||
pin_end(c, ps, false);
|
||||
}
|
||||
if let Some(c) = pieces.last_mut() {
|
||||
pin_end(c, pe, true);
|
||||
}
|
||||
}
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
fn curve_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
|
||||
match g {
|
||||
CurveGeo::Line { .. } => {
|
||||
if closed {
|
||||
@@ -628,15 +696,15 @@ pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: b
|
||||
}
|
||||
// The curve is open, so the edge is the parameters between its vertices: rising
|
||||
// for the same sense, falling for the opposite one.
|
||||
let (ts, te) = (open_conic_param(g, ps, tol)?, open_conic_param(g, pe, tol)?);
|
||||
let (ts, te) = (open_conic_param(g, ps, snap)?, open_conic_param(g, pe, snap)?);
|
||||
let scale = 1.0 + ts.abs().max(te.abs());
|
||||
if (te - ts).abs() <= 1e-9 * scale {
|
||||
return Err("degenerate conic edge".into());
|
||||
}
|
||||
Ok(vec![open_conic_curve(g, ts, te)?])
|
||||
}
|
||||
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
|
||||
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
|
||||
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
|
||||
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ pub(crate) struct Ctx<'a> {
|
||||
pub ang: f64,
|
||||
/// Vertex-on-curve tolerance (mm).
|
||||
pub tol: f64,
|
||||
/// Farthest an edge's vertex may sit off its curve and still be moved onto it (mm, at least
|
||||
/// `tol`): see [`snap_limit`].
|
||||
pub snap: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -121,9 +124,29 @@ struct Units {
|
||||
len: f64,
|
||||
ang: f64,
|
||||
tol: f64,
|
||||
snap: f64,
|
||||
}
|
||||
|
||||
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01 };
|
||||
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01, snap: 0.01 };
|
||||
|
||||
/// Largest tolerance a file can ask for (mm), however coarse the accuracy it declares.
|
||||
const MAX_TOL: f64 = 1.0;
|
||||
|
||||
/// Gap between an edge's vertex and its curve that is still read as the same point, as a share
|
||||
/// of the model's size: the chord error the kernel's own coarse meshes allow at that size
|
||||
/// (`Body::size() * 1e-3`), so moving the vertex onto the curve changes the shape by less than
|
||||
/// those meshes already do.
|
||||
const SNAP_SHARE: f64 = 1e-3;
|
||||
|
||||
/// Farthest a vertex may sit off its edge's curve and be moved onto it (mm): the file's own
|
||||
/// tolerance `tol`, or up to [`SNAP_SHARE`] of the model's size `extent` (mm) when that is
|
||||
/// larger. Some exporters write vertices further off than the accuracy they declare (a surface
|
||||
/// export may declare 0.01 mm and join two curves 0.38 mm apart at a vertex between them).
|
||||
/// Never more than [`MAX_TOL`], so a far stray point cannot turn the check off.
|
||||
fn snap_limit(tol: f64, extent: f64) -> f64 {
|
||||
let share = if extent.is_finite() { extent * SNAP_SHARE } else { 0.0 };
|
||||
tol.max(share.min(MAX_TOL))
|
||||
}
|
||||
|
||||
fn si_prefix(p: Option<&Param>) -> f64 {
|
||||
match p.and_then(Param::as_enum) {
|
||||
@@ -208,7 +231,8 @@ fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units
|
||||
}
|
||||
}
|
||||
}
|
||||
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, 1.0);
|
||||
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, MAX_TOL);
|
||||
u.snap = snap_limit(u.tol, ex.vertex_extent() * u.len);
|
||||
cache.insert(id, u);
|
||||
u
|
||||
}
|
||||
@@ -560,7 +584,7 @@ impl Reader {
|
||||
}
|
||||
|
||||
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
|
||||
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol };
|
||||
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol, snap: u.snap };
|
||||
let e = geom::entity(&cx, item)?;
|
||||
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
|
||||
let p = e.params();
|
||||
@@ -761,7 +785,7 @@ impl Reader {
|
||||
};
|
||||
let placement = self.batches.get(p.batch).map_or(mt::Matrix4::from_scale(1.0), |b| b.m);
|
||||
sources.push((i, item));
|
||||
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item) });
|
||||
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item), tol: units.tol });
|
||||
}
|
||||
// Reading and orienting new parts is shared out between the workers.
|
||||
let prepared = parallel::bodies(&ex, &jobs, self.workers);
|
||||
|
||||
@@ -125,12 +125,36 @@ pub struct Exchange {
|
||||
pub entities: HashMap<u64, Entity>,
|
||||
/// Ids defined more than once (the first definition is kept).
|
||||
pub duplicates: Vec<u64>,
|
||||
/// [`Exchange::vertex_extent`], worked out on first use.
|
||||
extent: std::sync::OnceLock<f64>,
|
||||
}
|
||||
|
||||
impl Exchange {
|
||||
pub fn get(&self, id: u64) -> Option<&Entity> {
|
||||
self.entities.get(&id)
|
||||
}
|
||||
|
||||
/// The size of the model in the file's own coordinates: the diagonal of the box around every
|
||||
/// VERTEX_POINT (0 for a file without one). Non-finite coordinates are skipped.
|
||||
pub fn vertex_extent(&self) -> f64 {
|
||||
*self.extent.get_or_init(|| {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for e in self.entities.values().filter(|e| e.name() == "VERTEX_POINT") {
|
||||
let Some(p) = e.params().get(1).and_then(Param::as_ref_id).and_then(|id| self.get(id)) else { continue };
|
||||
let Some(xyz) = p.params().get(1).and_then(Param::as_list) else { continue };
|
||||
let c: Vec<f64> = xyz.iter().take(3).filter_map(Param::as_f64).collect();
|
||||
if c.len() != 3 || !c.iter().all(|x| x.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
for ((l, h), x) in lo.iter_mut().zip(hi.iter_mut()).zip(c) {
|
||||
*l = l.min(x);
|
||||
*h = h.max(x);
|
||||
}
|
||||
}
|
||||
let d = lo.iter().zip(&hi).map(|(l, h)| (h - l).powi(2)).sum::<f64>().sqrt();
|
||||
if d.is_finite() { d } else { 0.0 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
|
||||
@@ -8,6 +8,11 @@ pub(super) struct Job {
|
||||
/// Where the body goes (its instance's placement in the assembly).
|
||||
pub placement: mt::Matrix4,
|
||||
pub check_mesh: bool,
|
||||
/// The file's point-on-surface tolerance (`super::Units::tol`) for the representation this
|
||||
/// solid was read from: how far its boundaries may lie off their surfaces (mm). The body
|
||||
/// keeps it, so the tessellator holds an imported boundary to the file's own uncertainty
|
||||
/// rather than to a fraction of the part's size (see `crate::body::Body::boundary_tol`).
|
||||
pub tol: f64,
|
||||
}
|
||||
|
||||
/// What a job places: a solid item to read from the file (and orient), or a part an earlier
|
||||
@@ -62,19 +67,23 @@ fn prepare(ex: &Exchange, job: &Job) -> Prepared {
|
||||
let (part, read) = match &job.source {
|
||||
Source::Oriented(part) => (part.clone(), None),
|
||||
Source::Item { item, units } => {
|
||||
let cx = Ctx { ex, len: units.len, ang: units.ang, tol: units.tol };
|
||||
let cx = Ctx { ex, len: units.len, ang: units.ang, tol: units.tol, snap: units.snap };
|
||||
let out = guard("step brep", || brep::read_item(&cx, *item).map_err(KernelError::Failed))
|
||||
.map_err(|e| Failure { read: true, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() })?;
|
||||
// Orient once where the file defines the solid; each placement then takes its
|
||||
// orientation from the matrix instead of meshing the whole part again.
|
||||
let body = guard("STEP body preparation", || Body::new(out.solid)).map_err(|e| Failure { read: false, msg: e.to_string() })?;
|
||||
// The file's tolerance is set on the part itself, before anything meshes it: the
|
||||
// unmeshed-face count below is the import's own warning and must judge the body by
|
||||
// the file's uncertainty too, and every placement of the part carries the figure on.
|
||||
let body = body.with_boundary_tol(Some(job.tol));
|
||||
(Arc::new(Part { body, closed: out.closed, file_faces: out.file_faces, face_origin: out.face_origin }), Some(out.failed))
|
||||
}
|
||||
};
|
||||
guard("STEP body preparation", || {
|
||||
let body = part.body.placed(&job.placement)?;
|
||||
// Counted where the orientation mesh already counted them (a placement meshes the
|
||||
// same faces).
|
||||
// same faces), and on the part, which carries the file's tolerance.
|
||||
let missing = if job.check_mesh { part.body.unmeshed_faces() } else { 0 };
|
||||
Ok((body, missing))
|
||||
})
|
||||
|
||||
@@ -13,6 +13,10 @@ use truck_modeling as mt;
|
||||
|
||||
use super::geom::{Frame, R, conic_arc};
|
||||
|
||||
/// How far a band's ring edge may lie off its surface, as a fraction of the body, for a body that
|
||||
/// declares no accuracy of its own.
|
||||
const RING_OFF_SURFACE: f64 = 1e-3;
|
||||
|
||||
/// Samples per edge when looking for the split crossing.
|
||||
const SAMPLES: usize = 48;
|
||||
|
||||
@@ -34,6 +38,14 @@ fn coord(d: Dir, uv: (f64, f64)) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction the split line runs along (the one that does not wrap).
|
||||
fn along(d: Dir) -> Dir {
|
||||
match d {
|
||||
Dir::U => Dir::V,
|
||||
Dir::V => Dir::U,
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed, non-periodic direction of a surface with its range; with `periodic`, a revolved
|
||||
/// surface's turn around its axis too.
|
||||
fn closed_dirs(s: &mt::Surface, periodic: bool) -> Vec<(Dir, f64, f64)> {
|
||||
@@ -140,8 +152,11 @@ fn crossing(s: &mt::Surface, d: Dir, chain: &[mt::Edge], mid: f64, period: f64)
|
||||
Err("no crossing with the split line".into())
|
||||
}
|
||||
|
||||
/// Iso-parameter edge curve `w = mid` from `q1` to `q2`.
|
||||
fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3, seam: &[mt::Point3]) -> R<mt::Curve> {
|
||||
/// Iso-parameter edge curve `w = mid` from `q1` to `q2`. `tol` is how far a ring edge may lie off
|
||||
/// the surface the split line lies on: a STEP file writes the surface and the curves bounding it
|
||||
/// to the file's own declared uncertainty only, so the line is matched to the surface points
|
||||
/// below `q1` and `q2`, not to them.
|
||||
fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3, seam: &[mt::Point3], tol: f64) -> R<mt::Curve> {
|
||||
match s {
|
||||
// Across the turn of a cylinder or cone: a straight line along it.
|
||||
mt::Surface::RevolutedCurve(_) if turn_dir(s).is_some_and(|t| t.0 == d) => {
|
||||
@@ -181,7 +196,7 @@ fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3,
|
||||
}
|
||||
let f = if sweep > 0.0 { f } else { Frame { o: c, x, y: -a.cross(x), z: -a } };
|
||||
let arc = conic_arc(&f, r, r, 0.0, sweep.abs());
|
||||
if arc.back().distance(q2) > 1e-6 * (1.0 + r) {
|
||||
if arc.back().distance(q2) > (1e-6 * (1.0 + r)).max(tol) {
|
||||
return Err("split line does not reach the other ring".into());
|
||||
}
|
||||
Ok(mt::Curve::NurbsCurve(arc))
|
||||
@@ -196,20 +211,27 @@ fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3,
|
||||
_ => return Err("unsupported surface".into()),
|
||||
};
|
||||
let iso = mt::Curve::NurbsCurve(iso_bsp(&hom, mid, d == Dir::V)?);
|
||||
let (t0, t1) = iso.range_tuple();
|
||||
// Where the split line comes nearest `p`: the point on the surface below `p` (the
|
||||
// line runs on the surface, `p` only within the file's tolerance of it), looked for
|
||||
// from its own surface parameter and from a scan of the whole line.
|
||||
let near = |p: mt::Point3| {
|
||||
let (t0, t1) = iso.range_tuple();
|
||||
let foot = uv_of(s, p).filter(|uv| s.subs(uv.0, uv.1).distance(p) <= tol);
|
||||
let target = foot.map_or(p, |uv| s.subs(uv.0, uv.1));
|
||||
let mut best = (t0, f64::INFINITY);
|
||||
for i in 0..=256 {
|
||||
let t = t0 + (t1 - t0) * i as f64 / 256.0;
|
||||
let dd = iso.subs(t).distance(p);
|
||||
let dd = iso.subs(t).distance(target);
|
||||
if dd < best.1 {
|
||||
best = (t, dd);
|
||||
}
|
||||
}
|
||||
if let Some(t) = iso.search_nearest_parameter(p, best.0, 64) {
|
||||
let seeds = foot.map(|uv| coord(along(d), uv)).into_iter().chain(iso.search_nearest_parameter(target, best.0, 64));
|
||||
for t in seeds {
|
||||
let t = t.clamp(t0, t1);
|
||||
if iso.subs(t).distance(p) <= best.1 {
|
||||
best = (t, iso.subs(t).distance(p));
|
||||
let dd = iso.subs(t).distance(target);
|
||||
if dd <= best.1 {
|
||||
best = (t, dd);
|
||||
}
|
||||
}
|
||||
best
|
||||
@@ -220,7 +242,6 @@ fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3,
|
||||
return Err("split line does not reach the rings".into());
|
||||
}
|
||||
let (lo, hi) = if ta < tb { (ta, tb) } else { (tb, ta) };
|
||||
let (t0, t1) = iso.range_tuple();
|
||||
let mut c = iso;
|
||||
let eps = (t1 - t0) * 1e-9;
|
||||
if lo > t0 + eps {
|
||||
@@ -292,7 +313,10 @@ struct Plan {
|
||||
faces: (mt::Face, mt::Face),
|
||||
}
|
||||
|
||||
fn plan(face: &mt::Face, periodic: bool) -> R<Option<Plan>> {
|
||||
/// How to cut `face` in two along the line opposite its seam. `periodic` also cuts a face that
|
||||
/// runs round the turn of a revolved surface (see [`closed_dirs`]). `tol` is how far a ring edge
|
||||
/// of the face may lie off the surface it bounds (see [`iso_curve`]).
|
||||
fn plan(face: &mt::Face, periodic: bool, tol: f64) -> R<Option<Plan>> {
|
||||
let s = face.surface();
|
||||
let wires = face.absolute_boundaries().clone();
|
||||
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
@@ -333,7 +357,7 @@ fn plan(face: &mt::Face, periodic: bool) -> R<Option<Plan>> {
|
||||
}
|
||||
// A cone's tip: the seam runs from the ring to the apex and straight back.
|
||||
if let [(true, sm), (false, ring)] = chains.as_slice() {
|
||||
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx).map(Some);
|
||||
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx, tol).map(Some);
|
||||
}
|
||||
let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else {
|
||||
return Err(format!("unsupported seam loop layout ({} runs)", chains.len()));
|
||||
@@ -345,7 +369,7 @@ fn plan(face: &mt::Face, periodic: bool) -> R<Option<Plan>> {
|
||||
let (k1, t1, q1) = crossing(&s, d, r1, mid, period)?;
|
||||
let (k2, t2, q2) = crossing(&s, d, r2, mid, period)?;
|
||||
let seam_line: Vec<mt::Point3> = s1.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
|
||||
let iso = iso_curve(&s, d, mid, q1, q2, &seam_line)?;
|
||||
let iso = iso_curve(&s, d, mid, q1, q2, &seam_line, tol)?;
|
||||
let (r1a, r1b, c1, v1) = split_chain(r1, k1, t1, q1)?;
|
||||
let (r2a, r2b, c2, v2) = split_chain(r2, k2, t2, q2)?;
|
||||
let e_split = mt::Edge::new_unchecked(&v1, &v2, iso);
|
||||
@@ -404,6 +428,7 @@ fn apex_plan(
|
||||
ring: &[mt::Edge],
|
||||
wires: &[mt::Wire],
|
||||
outer_idx: usize,
|
||||
tol: f64,
|
||||
) -> R<Plan> {
|
||||
let h = sm.len() / 2;
|
||||
if !sm.len().is_multiple_of(2) || h == 0 {
|
||||
@@ -416,7 +441,7 @@ fn apex_plan(
|
||||
let apex = out.last().ok_or("seam")?.back().clone();
|
||||
let (k, t, q) = crossing(s, d, ring, mid, period)?;
|
||||
let seam_line: Vec<mt::Point3> = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
|
||||
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line)?;
|
||||
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line, tol)?;
|
||||
let (ra, rb, c, vq) = split_chain(ring, k, t, q)?;
|
||||
let e = mt::Edge::new_unchecked(&vq, &apex, iso);
|
||||
// Face A: out to the apex, down the split line, the ring's second part; face B the rest.
|
||||
@@ -500,6 +525,9 @@ pub(crate) fn split_seams(body: &crate::Body) -> Option<crate::Body> {
|
||||
if !body.solid.face_iter().any(seamed) {
|
||||
return None;
|
||||
}
|
||||
// How far a ring edge of such a face may lie off the surface it bounds (see `iso_curve`):
|
||||
// the file's own declared uncertainty for a body read from one, else a thousandth of the body.
|
||||
let tol = body.boundary_tol.unwrap_or(body.size() * RING_OFF_SURFACE).max(1e-6);
|
||||
let mut shells: Vec<mt::Shell> = Vec::new();
|
||||
for sh in body.solid.boundaries() {
|
||||
let mut faces: Vec<mt::Face> = sh.face_iter().cloned().collect();
|
||||
@@ -509,7 +537,7 @@ pub(crate) fn split_seams(body: &crate::Body) -> Option<crate::Body> {
|
||||
if !seamed(&face) {
|
||||
continue;
|
||||
}
|
||||
let p = crate::guard("seam split", || plan(&face, true).map_err(crate::KernelError::Failed)).ok()??;
|
||||
let p = crate::guard("seam split", || plan(&face, true, tol).map_err(crate::KernelError::Failed)).ok()??;
|
||||
faces = faces.iter().map(|f| recut(f, &p.cuts)).collect();
|
||||
if let Some(slot) = faces.get_mut(i) {
|
||||
*slot = p.faces.0;
|
||||
@@ -525,11 +553,16 @@ pub(crate) fn split_seams(body: &crate::Body) -> Option<crate::Body> {
|
||||
|
||||
/// Split every band face of a shell (see the module docs). Faces that cannot be split stay as
|
||||
/// they are and are reported.
|
||||
pub(crate) fn split_bands(mut faces: Vec<mt::Face>, origin: &mut Vec<u64>, warnings: &mut Vec<String>) -> Vec<mt::Face> {
|
||||
///
|
||||
/// `tol` is the file's point-on-surface tolerance (`step_in::mod::Units::tol`): 10x the file's
|
||||
/// declared uncertainty, floored at 1 µm and clamped to [1e-4, 1] mm. A ring edge is written to
|
||||
/// within this distance of the surface it bounds, so it is how far `iso_curve` may accept a
|
||||
/// crossing point as lying on the surface.
|
||||
pub(crate) fn split_bands(mut faces: Vec<mt::Face>, origin: &mut Vec<u64>, warnings: &mut Vec<String>, tol: f64) -> Vec<mt::Face> {
|
||||
let n = faces.len();
|
||||
for i in 0..n {
|
||||
let Some(face) = faces.get(i).cloned() else { continue };
|
||||
match crate::guard("step band split", || plan(&face, false).map_err(crate::KernelError::Failed)) {
|
||||
match crate::guard("step band split", || plan(&face, false, tol).map_err(crate::KernelError::Failed)) {
|
||||
Ok(Some(p)) => {
|
||||
faces = faces.iter().map(|f| recut(f, &p.cuts)).collect();
|
||||
if let Some(slot) = faces.get_mut(i) {
|
||||
|
||||
@@ -164,7 +164,7 @@ fn benchmark_independent_step_solids() {
|
||||
let start = std::time::Instant::now();
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let parse = start.elapsed();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 0.01 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 0.01, snap: 0.01 };
|
||||
let mut items: Vec<_> = ex.entities.iter().filter(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| *id).collect();
|
||||
items.sort();
|
||||
let start = std::time::Instant::now();
|
||||
@@ -271,7 +271,7 @@ fn parallel_body_preparation_reports_errors_in_place_and_preserves_placement() {
|
||||
// The second item is not in the file.
|
||||
let jobs: Vec<_> = [solid, 999_999_999, solid, solid]
|
||||
.into_iter()
|
||||
.map(|item| parallel::Job { source: parallel::Source::Item { item, units }, placement, check_mesh: true })
|
||||
.map(|item| parallel::Job { source: parallel::Source::Item { item, units }, placement, check_mesh: true, tol: units.tol })
|
||||
.collect();
|
||||
let serial = parallel::bodies(&ex, &jobs, 1);
|
||||
let threaded = parallel::bodies(&ex, &jobs, 4);
|
||||
@@ -433,7 +433,7 @@ fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points
|
||||
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
|
||||
}
|
||||
|
||||
@@ -483,13 +483,13 @@ fn offset_surfaces_nested_and_hostile() {
|
||||
}
|
||||
let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
|
||||
let text = format!(
|
||||
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
|
||||
}
|
||||
|
||||
@@ -903,6 +903,75 @@ fn unsupported_face_is_left_out_and_reported() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("open body")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
/// A flat square sheet `size` mm across in millimetres (declared accuracy 1e-6 mm) whose right
|
||||
/// edge is a straight spline ending `gap` mm beside the corner vertex it runs to, as exporters
|
||||
/// write two curves meeting at a vertex between them. `stray` adds an unused vertex that far
|
||||
/// out, which makes the model look that large.
|
||||
fn gapped_sheet(size: f64, gap: f64, stray: Option<f64>) -> String {
|
||||
let mut t = String::from(HEAD);
|
||||
for (k, (x, y)) in [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)].iter().enumerate() {
|
||||
t += &format!("#{}=CARTESIAN_POINT('',({x:.6},{y:.6},0.));\n#{}=VERTEX_POINT('',#{});\n", 20 + k, 30 + k, 20 + k);
|
||||
}
|
||||
if let Some(far) = stray {
|
||||
t += &format!("#38=CARTESIAN_POINT('',({far:.6},0.,0.));\n#39=VERTEX_POINT('',#38);\n");
|
||||
}
|
||||
t += &format!(
|
||||
"#46=CARTESIAN_POINT('',({:.6},{size:.6},0.));\n#47=B_SPLINE_CURVE_WITH_KNOTS('',1,(#21,#46),.UNSPECIFIED.,.F.,.F.,(2,2),(0.,1.),.UNSPECIFIED.);\n",
|
||||
size + gap
|
||||
);
|
||||
t += "#40=DIRECTION('',(1.,0.,0.));\n#41=VECTOR('',#40,1.);\n#50=LINE('',#20,#41);\n#52=LINE('',#22,#41);\n#53=LINE('',#23,#41);\n";
|
||||
for (k, curve) in [50, 47, 52, 53].iter().enumerate() {
|
||||
t += &format!(
|
||||
"#{}=EDGE_CURVE('',#{},#{},#{curve},.T.);\n#{}=ORIENTED_EDGE('',*,*,#{},.T.);\n",
|
||||
60 + k,
|
||||
30 + k,
|
||||
30 + (k + 1) % 4,
|
||||
70 + k,
|
||||
60 + k
|
||||
);
|
||||
}
|
||||
t += "#80=EDGE_LOOP('',(#70,#71,#72,#73));\n#81=FACE_OUTER_BOUND('',#80,.T.);\n#90=CARTESIAN_POINT('',(0.,0.,0.));\n#91=DIRECTION('',(0.,0.,1.));\n";
|
||||
t += "#92=AXIS2_PLACEMENT_3D('',#90,#91,#40);\n#93=PLANE('',#92);\n#82=ADVANCED_FACE('',(#81),#93,.T.);\n";
|
||||
t += "#110=OPEN_SHELL('',(#82));\n#111=SHELL_BASED_SURFACE_MODEL('Sheet',(#110));\n#112=SHAPE_REPRESENTATION('',(#111),#10);\n";
|
||||
t += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
t += TAIL;
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertex_off_its_curve_by_a_small_share_of_the_model_is_moved_onto_it() {
|
||||
// 0.19 mm on a 1 m sheet: 190 000 times the file's declared accuracy, 1.3e-4 of its size.
|
||||
let imp = step_import(&gapped_sheet(1000.0, 0.19, None)).unwrap();
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("off their curve")), "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let body = &imp.bodies[0].body;
|
||||
assert_eq!(body.face_count(), 1);
|
||||
// Every edge meets its vertices exactly: the spline's end was moved onto the corner.
|
||||
use mt::{BoundedCurve as _, MetricSpace as _};
|
||||
for e in body.solid.edge_iter() {
|
||||
let c = e.curve();
|
||||
assert!(c.front().distance(e.front().point()) < 1e-9 && c.back().distance(e.back().point()) < 1e-9);
|
||||
}
|
||||
let m = measure(body).unwrap();
|
||||
assert!(rel(m.area, 1e6) < 1e-4, "area {}", m.area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertex_further_off_its_curve_than_the_model_allows_leaves_the_face_out() {
|
||||
// 0.19 mm on a 10 mm sheet is 1.3 % of the part; and a stray point a kilometre out cannot
|
||||
// stretch the limit past 1 mm.
|
||||
for text in [gapped_sheet(10.0, 0.19, None), gapped_sheet(1000.0, 2.0, Some(1e6))] {
|
||||
let warnings = match step_import(&text) {
|
||||
Ok(imp) => {
|
||||
assert!(imp.bodies.is_empty());
|
||||
imp.warnings.join("\n")
|
||||
}
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
assert!(warnings.contains("off their curve"), "{warnings}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_files_never_panic() {
|
||||
let good = step_export(&[&box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap()], "t").unwrap();
|
||||
@@ -1020,6 +1089,206 @@ fn split_band_edges_are_seams() {
|
||||
assert!(m.seams.iter().filter(|s| **s).count() >= 2, "{:?}", m.seams);
|
||||
}
|
||||
|
||||
/// A five-sided tube on a B-spline surface closed around (radius `r`, height `h`), as one band
|
||||
/// face bounded by ring edges `off` mm outside the surface, with a seam edge up one corner used
|
||||
/// both ways; plus the two flat ends on the planes the rings lie in. The file declares
|
||||
/// `uncertainty` mm of uncertainty of its own, which is what tells a reader how far the rings
|
||||
/// may lie off the surface.
|
||||
///
|
||||
/// The part's size, the rings' off-surface distance and the declared uncertainty are separate on
|
||||
/// purpose: a real file's slop stays a few micrometres whether the part is 20 mm or 2 m across
|
||||
/// (see `off_surface_budget_follows_the_file_not_the_part_size`).
|
||||
#[derive(Clone, Copy)]
|
||||
struct RingEdgeTube {
|
||||
/// Radius of the surface (mm).
|
||||
r: f64,
|
||||
/// Height of the tube (mm).
|
||||
h: f64,
|
||||
/// How far outside the surface the ring edges run (mm).
|
||||
off: f64,
|
||||
/// The uncertainty the file declares for itself (mm).
|
||||
uncertainty: f64,
|
||||
}
|
||||
|
||||
impl RingEdgeTube {
|
||||
/// A 20 mm part with a few micrometres of slop and a micrometre of declared uncertainty:
|
||||
/// what a well-behaved writer puts out.
|
||||
fn small() -> RingEdgeTube {
|
||||
RingEdgeTube { r: 10.0, h: 8.0, off: 0.004, uncertainty: 1e-3 }
|
||||
}
|
||||
|
||||
/// The same part, and the same slop, scaled to about 2 m across.
|
||||
fn large() -> RingEdgeTube {
|
||||
RingEdgeTube { r: 1000.0, h: 800.0, ..RingEdgeTube::small() }
|
||||
}
|
||||
|
||||
/// The volume the closed pentagon prism encloses (mm³).
|
||||
fn volume(&self) -> f64 {
|
||||
2.5 * self.r * self.r * (std::f64::consts::TAU / 5.0).sin() * self.h
|
||||
}
|
||||
|
||||
/// The area of the five sides and the two five-sided ends together (mm²).
|
||||
fn area(&self) -> f64 {
|
||||
let side = 2.0 * self.r * (std::f64::consts::TAU / 10.0).sin();
|
||||
5.0 * side * self.h + 5.0 * side * self.r * (std::f64::consts::TAU / 10.0).cos()
|
||||
}
|
||||
|
||||
fn step(&self) -> String {
|
||||
ring_edge_tube_step(self.r, self.h, self.off, self.uncertainty)
|
||||
}
|
||||
}
|
||||
|
||||
fn ring_edge_tube_step(r: f64, h: f64, off: f64, uncertainty: f64) -> String {
|
||||
let corner = |k: usize, radius: f64| {
|
||||
let a = std::f64::consts::TAU * (k % 5) as f64 / 5.0;
|
||||
(radius * a.cos(), radius * a.sin())
|
||||
};
|
||||
let mut t = String::from(HEAD);
|
||||
// Control net: the corners around (the first repeated to close it) x the two ends.
|
||||
let mut rows = Vec::new();
|
||||
for k in 0..=5 {
|
||||
let (x, y) = corner(k, r);
|
||||
let id = 100 + 2 * k as u64;
|
||||
t += &format!("#{id}=CARTESIAN_POINT('',({x:.9},{y:.9},0.));\n#{}=CARTESIAN_POINT('',({x:.9},{y:.9},{h:.9}));\n", id + 1);
|
||||
rows.push(format!("(#{id},#{})", id + 1));
|
||||
}
|
||||
t += &format!(
|
||||
"#200=B_SPLINE_SURFACE_WITH_KNOTS('',1,1,({}),.UNSPECIFIED.,.T.,.F.,.F.,(2,1,1,1,1,2),(2,2),(0.,1.,2.,3.,4.,5.),(0.,1.),.UNSPECIFIED.);\n",
|
||||
rows.join(",")
|
||||
);
|
||||
// The rings, `off` mm outside the surface: each is one closed polyline edge.
|
||||
for k in 0..5u64 {
|
||||
let (x, y) = corner(k as usize, r + off);
|
||||
t += &format!("#{}=CARTESIAN_POINT('',({x:.9},{y:.9},0.));\n#{}=CARTESIAN_POINT('',({x:.9},{y:.9},{h:.9}));\n", 300 + k, 310 + k);
|
||||
}
|
||||
t += "#320=VERTEX_POINT('',#300);\n#321=VERTEX_POINT('',#310);\n";
|
||||
t += "#330=POLYLINE('',(#300,#301,#302,#303,#304,#300));\n#331=POLYLINE('',(#310,#311,#312,#313,#314,#310));\n";
|
||||
t += "#340=DIRECTION('',(0.,0.,1.));\n#341=VECTOR('',#340,1.);\n#342=LINE('',#300,#341);\n";
|
||||
t += "#350=EDGE_CURVE('',#320,#320,#330,.T.);\n#351=EDGE_CURVE('',#321,#321,#331,.T.);\n#352=EDGE_CURVE('',#320,#321,#342,.T.);\n";
|
||||
// The band's loop: around the bottom ring, up the seam, back around the top, down the seam.
|
||||
t += "#360=ORIENTED_EDGE('',*,*,#350,.T.);\n#361=ORIENTED_EDGE('',*,*,#352,.T.);\n#362=ORIENTED_EDGE('',*,*,#351,.F.);\n#363=ORIENTED_EDGE('',*,*,#352,.F.);\n";
|
||||
t += "#364=EDGE_LOOP('',(#360,#361,#362,#363));\n#365=FACE_OUTER_BOUND('',#364,.T.);\n#366=ADVANCED_FACE('',(#365),#200,.T.);\n";
|
||||
// The two ends, on the planes the rings lie in.
|
||||
t += "#370=CARTESIAN_POINT('',(0.,0.,0.));\n#371=DIRECTION('',(1.,0.,0.));\n#372=AXIS2_PLACEMENT_3D('',#370,#340,#371);\n#373=PLANE('',#372);\n";
|
||||
t += "#374=ORIENTED_EDGE('',*,*,#350,.F.);\n#375=EDGE_LOOP('',(#374));\n#376=FACE_OUTER_BOUND('',#375,.T.);\n#377=ADVANCED_FACE('',(#376),#373,.F.);\n";
|
||||
t += &format!("#380=CARTESIAN_POINT('',(0.,0.,{h:.9}));\n#381=AXIS2_PLACEMENT_3D('',#380,#340,#371);\n#382=PLANE('',#381);\n");
|
||||
t += "#383=ORIENTED_EDGE('',*,*,#351,.T.);\n#384=EDGE_LOOP('',(#383));\n#385=FACE_OUTER_BOUND('',#384,.T.);\n#386=ADVANCED_FACE('',(#385),#382,.T.);\n";
|
||||
t += "#390=CLOSED_SHELL('',(#366,#377,#386));\n#391=MANIFOLD_SOLID_BREP('Tube',#390);\n#392=SHAPE_REPRESENTATION('',(#391),#20);\n";
|
||||
// What the file declares about its own numbers. This, and not the part's size, is how far a
|
||||
// reader may let the ring edges lie off the surface.
|
||||
t += "#20=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#21))GLOBAL_UNIT_ASSIGNED_CONTEXT((#22,#23))REPRESENTATION_CONTEXT('',''));\n";
|
||||
t += &format!("#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({uncertainty:.9}),#22,'','');\n");
|
||||
t += "#22=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#23=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n";
|
||||
t += TAIL;
|
||||
t
|
||||
}
|
||||
|
||||
/// Every check the split band must pass, at whatever size the part is: the band comes in as two
|
||||
/// faces, every face has triangles at every chord tolerance from coarse to fine, and the four
|
||||
/// faces together cover the tube once and close it.
|
||||
///
|
||||
/// Every tolerance here is a fraction of the body, so the same mesh is asked for at every scale;
|
||||
/// the rings' off-surface distance is the one thing that stays absolute.
|
||||
fn assert_ring_edge_tube_meshes(tube: &RingEdgeTube) {
|
||||
let imp = step_import(&tube.step()).unwrap();
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("kept whole")), "{:?}", imp.warnings);
|
||||
let ib = &imp.bodies[0];
|
||||
assert_eq!(ib.file_faces, 3);
|
||||
assert_eq!(ib.body.face_count(), 4, "the band face is split in two: {:?}", imp.warnings);
|
||||
assert_eq!(ib.body.unmeshed_faces(), 0, "{:?}", imp.warnings);
|
||||
let size = ib.body.size();
|
||||
// Every face is meshed, and the four together cover the tube once: five sides of
|
||||
// 2*R*sin(36 deg) by the height, plus the two five-sided ends.
|
||||
let m = ib.body.display_mesh(size * 2e-3).unwrap();
|
||||
let mut area = vec![0.0f64; ib.body.face_count()];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let (a, b, c) = (m.positions[t[0] as usize], m.positions[t[1] as usize], m.positions[t[2] as usize]);
|
||||
if let Some(sum) = area.get_mut(*f as usize) {
|
||||
*sum += 0.5 * (b - a).cross(c - a).len();
|
||||
}
|
||||
}
|
||||
let side = 2.0 * tube.r * (std::f64::consts::TAU / 10.0).sin();
|
||||
let want = tube.area();
|
||||
assert!(rel(area.iter().sum::<f64>(), want) < 5e-3, "{area:?} sums to {} not {want}", area.iter().sum::<f64>());
|
||||
// Neither half of the split is a sliver: the split line runs from ring to ring.
|
||||
assert!(area.iter().all(|a| *a > 0.2 * side * tube.h), "a face of the split tube is nearly empty: {area:?}");
|
||||
// The body closes: its volume is the pentagon prism's. `measure` tessellates far finer than
|
||||
// any display does, and the planner used to give up on a boundary running off its surface
|
||||
// once the chord tolerance came near the gap. One face without a plan costs the whole body
|
||||
// its plan, and the reference mesher cannot mesh a face on a closed B-spline surface at all,
|
||||
// so both halves of the band came out with no triangles: the volume was the top cap's alone.
|
||||
let vol = measure(&ib.body).unwrap().volume;
|
||||
let want_vol = tube.volume();
|
||||
assert!(rel(vol, want_vol) < 1e-3, "the split band does not close the body: {vol} not {want_vol}");
|
||||
// Every face keeps its triangles at every tolerance, coarse to fine.
|
||||
for frac in [2e-3, 4e-4, 4e-5, 1.1e-5, 4e-6] {
|
||||
let t = size * frac;
|
||||
let plan = ib.body.display_plan(t).unwrap_or_else(|e| panic!("no display plan at tol {t}: {e}"));
|
||||
let bare: Vec<u32> = plan.faces.iter().filter(|f| f.triangles.is_empty()).map(|f| f.face).collect();
|
||||
assert!(bare.is_empty(), "faces {bare:?} have no triangles at tol {t}");
|
||||
}
|
||||
// What the viewport and a snapshot actually draw: the display tolerance with a facet angle.
|
||||
// The angle divides an edge wherever it kinks, far below the chord tolerance, which is what
|
||||
// used to crowd the boundary against a corner of the surface and leave the band undrawn.
|
||||
let tol = (size * 1e-3).max(1e-3);
|
||||
let shown = ib.body.reference_display_mesh_angular(tol, crate::DISPLAY_ANGLE).unwrap();
|
||||
let mut drawn = vec![0.0f64; ib.body.face_count()];
|
||||
for (t, f) in shown.triangles.iter().zip(&shown.tri_face) {
|
||||
let (a, b, c) = (shown.positions[t[0] as usize], shown.positions[t[1] as usize], shown.positions[t[2] as usize]);
|
||||
if let Some(sum) = drawn.get_mut(*f as usize) {
|
||||
*sum += 0.5 * (b - a).cross(c - a).len();
|
||||
}
|
||||
}
|
||||
assert!(drawn.iter().all(|a| *a > 0.2 * side * tube.h), "a face of the split tube is not drawn: {drawn:?}");
|
||||
assert!(rel(drawn.iter().sum::<f64>(), want) < 5e-3, "{drawn:?} sums to {} not {want}", drawn.iter().sum::<f64>());
|
||||
}
|
||||
|
||||
/// A band face is split although its ring edges run a little off its surface. A file writes
|
||||
/// edges and surfaces to its own uncertainty, so the two disagree by a few micrometres; the
|
||||
/// split line lies on the surface and can only reach the surface points below the rings.
|
||||
#[test]
|
||||
fn band_faces_split_when_ring_edges_run_off_their_surface() {
|
||||
assert_ring_edge_tube_meshes(&RingEdgeTube::small());
|
||||
}
|
||||
|
||||
/// The very same fixture, the very same few micrometres of slop in the file, scaled to a part
|
||||
/// about 2 m across: it meshes the same way (#1721).
|
||||
#[test]
|
||||
fn band_faces_split_when_a_scaled_copy_runs_off_its_surface() {
|
||||
assert_ring_edge_tube_meshes(&RingEdgeTube::large());
|
||||
}
|
||||
|
||||
/// How far a boundary may lie off its surface is a hundredth of the part, at every chord
|
||||
/// tolerance, and more only where the file declares more. A writer's declared accuracy is what it
|
||||
/// aimed for, not a bound its edges keep (a robot assembly declaring 0.01 µm has edges 10 to
|
||||
/// 30 µm off), so it never tightens the bound; and it must not depend on the mesh asked for, or a
|
||||
/// face accepted while the model is far away would drop out when the user zooms in (#1721).
|
||||
#[test]
|
||||
fn off_surface_budget_is_a_hundredth_of_the_part_at_every_chord_tolerance() {
|
||||
let accepted = |tube: &RingEdgeTube| -> bool {
|
||||
let imp = step_import(&tube.step()).unwrap();
|
||||
let ib = &imp.bodies[0];
|
||||
let size = ib.body.size();
|
||||
[2e-3, 4e-6].iter().all(|fraction| ib.body.display_plan(size * fraction).is_ok())
|
||||
};
|
||||
// A few micrometres, whatever the file declares, at either size.
|
||||
for uncertainty in [1e-3, 1e-5] {
|
||||
for tube in [RingEdgeTube { uncertainty, ..RingEdgeTube::small() }, RingEdgeTube { uncertainty, ..RingEdgeTube::large() }] {
|
||||
assert!(accepted(&tube), "a part {} mm across, {} mm off on {uncertainty} declared", 2.0 * tube.r, tube.off);
|
||||
}
|
||||
}
|
||||
// Within a hundredth of the part: a 20 mm part takes 0.15 mm, a 2 m part 2 mm; past it, no.
|
||||
assert!(accepted(&RingEdgeTube { off: 0.15, ..RingEdgeTube::small() }));
|
||||
assert!(accepted(&RingEdgeTube { off: 2.0, ..RingEdgeTube::large() }));
|
||||
assert!(!accepted(&RingEdgeTube { off: 2.0, ..RingEdgeTube::small() }), "a 20 mm part accepts a boundary 2 mm off its surface");
|
||||
assert!(!accepted(&RingEdgeTube { off: 100.0, ..RingEdgeTube::large() }), "a 2 m part accepts a boundary 100 mm off its surface");
|
||||
// A file declaring more raises it: half a millimetre on a 20 mm part, refused on a declared
|
||||
// micrometre, accepted on a declared 60 µm (ten times which is 0.6 mm).
|
||||
let tight = RingEdgeTube { off: 0.5, ..RingEdgeTube::small() };
|
||||
assert!(!accepted(&tight));
|
||||
assert!(accepted(&RingEdgeTube { uncertainty: 0.06, ..tight }));
|
||||
}
|
||||
|
||||
/// Revolved faces of a mirrored tool keep their orientation through STEP (the writer used to
|
||||
/// drop an inverted or mirrored revolution's sense).
|
||||
#[test]
|
||||
@@ -1310,7 +1579,7 @@ fn knot_multiplicities_are_checked_before_they_are_expanded() {
|
||||
};
|
||||
let read = |text: &str| {
|
||||
let ex = p21::parse(text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
geom::curve(&cx, 3, 0)
|
||||
};
|
||||
let error = read(&curve(1_000_000, 64)).unwrap_err();
|
||||
@@ -1322,6 +1591,50 @@ fn knot_multiplicities_are_checked_before_they_are_expanded() {
|
||||
assert!(read(&curve(2, 3)).is_err());
|
||||
}
|
||||
|
||||
/// A spline written over a parameter range of a few millionths (as if in metres of a part drawn
|
||||
/// in millimetres: piecewise cubic Bézier over 5.4e-6, as a STEP part of a humanoid robot
|
||||
/// writes its edges) is read over a range of 1. Over the file's own range the kernel took knots
|
||||
/// a millionth apart for one, so trimming an edge to its vertices dropped a control point and
|
||||
/// left a curve no exact store could keep (every reopen of that design imported it again).
|
||||
#[test]
|
||||
fn a_spline_over_a_tiny_parameter_range_is_read_over_one() {
|
||||
use mt::{BoundedCurve, Cut, MetricSpace, ParametricCurve};
|
||||
let a = 1.8024e-6;
|
||||
let knots = [0.0, a, 3.0304e-6, 3.8749e-6, 4.7103e-6, 5.0653e-6, 5.4274e-6];
|
||||
let n = 3 * (knots.len() - 1) + 1;
|
||||
let points: String = (0..n).map(|i| format!("#{}=CARTESIAN_POINT('',({}.,{}.,0.));\n", 10 + i, i, (i * i) % 7)).collect();
|
||||
let refs: Vec<String> = (0..n).map(|i| format!("#{}", 10 + i)).collect();
|
||||
let mults: Vec<String> = (0..knots.len()).map(|i| if i == 0 || i + 1 == knots.len() { "4".into() } else { "3".into() }).collect();
|
||||
let text = format!(
|
||||
"{HEAD}{points}#3=B_SPLINE_CURVE_WITH_KNOTS('',3,({}),.UNSPECIFIED.,.F.,.F.,({}),({}),.UNSPECIFIED.);\n{TAIL}",
|
||||
refs.join(","),
|
||||
mults.join(","),
|
||||
knots.iter().map(|k| format!("{k:e}")).collect::<Vec<_>>().join(",")
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let Ok(geom::CurveGeo::BSpline(c)) = geom::curve(&cx, 3, 0) else { panic!("a B-spline") };
|
||||
let (t0, t1) = c.range_tuple();
|
||||
assert_eq!((t0, t1), (0.0, 1.0));
|
||||
// The same curve: its ends are the first and last control points.
|
||||
assert!(c.subs(t0).distance(mt::Point3::new(0.0, 0.0, 0.0)) < 1e-12);
|
||||
assert!(c.subs(t1).distance(mt::Point3::new((n - 1) as f64, ((n - 1) * (n - 1) % 7) as f64, 0.0)) < 1e-12);
|
||||
// Cut between knots, as an edge is trimmed to its vertices: both pieces keep clamped ends
|
||||
// and every control point, and meet where the curve was cut.
|
||||
for t in [0.3, 0.33, 0.9, 0.95] {
|
||||
let mut head = c.clone();
|
||||
let tail = head.cut(t);
|
||||
for piece in [&head, &tail] {
|
||||
let k: Vec<f64> = piece.knot_vec().iter().copied().collect();
|
||||
let d = piece.degree();
|
||||
assert_eq!(piece.control_points().len() + d + 1, k.len());
|
||||
assert!(k.iter().take(d + 1).all(|x| *x == k[0]) && k.iter().rev().take(d + 1).all(|x| Some(x) == k.last()), "{t}: {k:?}");
|
||||
}
|
||||
assert!(head.subs(head.range_tuple().1).distance(c.subs(t)) < 1e-9);
|
||||
assert!(tail.subs(tail.range_tuple().0).distance(c.subs(t)) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// One `ADVANCED_FACE` may list any number of bounds, and each one clones its edge handles into
|
||||
/// a new wire that the face extent then samples. A face naming the same loop a million times
|
||||
/// held 1e11 handles.
|
||||
@@ -1461,6 +1774,189 @@ fn large_imports_are_freed_with_their_last_user() {
|
||||
assert!(weak.upgrade().is_none(), "the cache must not keep a large import alive after its design is gone");
|
||||
}
|
||||
|
||||
/// A face that runs all the way around a closed (but not periodic) B-spline surface and carries
|
||||
/// a zero-width slit of its own: a pair of curves a hundredth of a micrometre apart between the
|
||||
/// same two vertices, which the loop walks up and straight back down. STEP writers leave such a
|
||||
/// slit where two surfaces were trimmed apart. It stands away from the surface's join, so the
|
||||
/// band splitter leaves the face whole and the mesher has to draw it; the boundary then runs
|
||||
/// along itself in (u, v). That used to fail the planner, and with it the whole body's plan, so
|
||||
/// the body fell back to the reference mesher, which drew bands folded over themselves and left
|
||||
/// holes in others.
|
||||
#[test]
|
||||
fn band_face_with_a_zero_width_slit_is_still_meshed() {
|
||||
const R: f64 = 10.0;
|
||||
const H: f64 = 8.0;
|
||||
/// How far the slit's two sides stand apart (a hundredth of a micrometre).
|
||||
const LENS: f64 = 1.0e-5;
|
||||
let corner = |k: usize, r: f64| {
|
||||
let a = std::f64::consts::TAU * (k % 5) as f64 / 5.0;
|
||||
(r * a.cos(), r * a.sin())
|
||||
};
|
||||
let mut t = String::from(HEAD);
|
||||
// Control net: the five corners around (the first repeated to close it) × the two ends.
|
||||
let mut rows = Vec::new();
|
||||
for k in 0..=5 {
|
||||
let (x, y) = corner(k, R);
|
||||
let id = 100 + 2 * k as u64;
|
||||
t += &format!("#{id}=CARTESIAN_POINT('',({x:.12},{y:.12},0.));\n#{}=CARTESIAN_POINT('',({x:.12},{y:.12},{H:.1}));\n", id + 1);
|
||||
rows.push(format!("(#{id},#{})", id + 1));
|
||||
}
|
||||
t += &format!(
|
||||
"#200=B_SPLINE_SURFACE_WITH_KNOTS('',1,1,({}),.UNSPECIFIED.,.T.,.F.,.F.,(2,1,1,1,1,2),(2,2),(0.,1.,2.,3.,4.,5.),(0.,1.),.UNSPECIFIED.);\n",
|
||||
rows.join(",")
|
||||
);
|
||||
// The seam runs up corner 0; the slit stands at corner 2, where each ring is cut in two.
|
||||
let (mx, my) = corner(2, R + LENS);
|
||||
t += &format!("#210=CARTESIAN_POINT('',({mx:.12},{my:.12},{:.1}));\n", H * 0.5);
|
||||
t += "#300=VERTEX_POINT('',#100);\n#301=VERTEX_POINT('',#101);\n#302=VERTEX_POINT('',#104);\n#303=VERTEX_POINT('',#105);\n";
|
||||
t += "#310=DIRECTION('',(0.,0.,1.));\n#311=VECTOR('',#310,1.);\n#312=LINE('',#100,#311);\n";
|
||||
// The slit's two sides: straight up corner 2, and back down a hair outside it.
|
||||
t += "#313=POLYLINE('',(#104,#105));\n#314=POLYLINE('',(#105,#210,#104));\n";
|
||||
t += "#320=POLYLINE('',(#100,#102,#104));\n#321=POLYLINE('',(#104,#106,#108,#100));\n";
|
||||
t += "#322=POLYLINE('',(#101,#103,#105));\n#323=POLYLINE('',(#105,#107,#109,#101));\n";
|
||||
t += "#330=EDGE_CURVE('',#300,#301,#312,.T.);\n";
|
||||
t += "#331=EDGE_CURVE('',#302,#303,#313,.T.);\n#332=EDGE_CURVE('',#303,#302,#314,.T.);\n";
|
||||
t += "#333=EDGE_CURVE('',#300,#302,#320,.T.);\n#334=EDGE_CURVE('',#302,#300,#321,.T.);\n";
|
||||
t += "#335=EDGE_CURVE('',#301,#303,#322,.T.);\n#336=EDGE_CURVE('',#303,#301,#323,.T.);\n";
|
||||
// The band: round the bottom to the slit, up one side of it and down the other, on round the
|
||||
// bottom, up the seam, back along the top to the slit, down it and up again, on to the seam.
|
||||
t += "#340=ORIENTED_EDGE('',*,*,#333,.T.);\n#341=ORIENTED_EDGE('',*,*,#331,.T.);\n#342=ORIENTED_EDGE('',*,*,#332,.T.);\n";
|
||||
t += "#343=ORIENTED_EDGE('',*,*,#334,.T.);\n#344=ORIENTED_EDGE('',*,*,#330,.T.);\n#345=ORIENTED_EDGE('',*,*,#336,.F.);\n";
|
||||
t += "#346=ORIENTED_EDGE('',*,*,#331,.F.);\n#347=ORIENTED_EDGE('',*,*,#332,.F.);\n#348=ORIENTED_EDGE('',*,*,#335,.F.);\n";
|
||||
t += "#349=ORIENTED_EDGE('',*,*,#330,.F.);\n";
|
||||
t += "#350=EDGE_LOOP('',(#340,#341,#342,#343,#344,#345,#346,#347,#348,#349));\n#351=FACE_OUTER_BOUND('',#350,.T.);\n";
|
||||
t += "#352=ADVANCED_FACE('',(#351),#200,.T.);\n";
|
||||
// The two ends, on the planes the rings lie in.
|
||||
t += "#360=CARTESIAN_POINT('',(0.,0.,0.));\n#361=DIRECTION('',(1.,0.,0.));\n#362=AXIS2_PLACEMENT_3D('',#360,#310,#361);\n#363=PLANE('',#362);\n";
|
||||
t += "#364=ORIENTED_EDGE('',*,*,#334,.F.);\n#365=ORIENTED_EDGE('',*,*,#333,.F.);\n#366=EDGE_LOOP('',(#364,#365));\n";
|
||||
t += "#367=FACE_OUTER_BOUND('',#366,.T.);\n#368=ADVANCED_FACE('',(#367),#363,.F.);\n";
|
||||
t += &format!("#370=CARTESIAN_POINT('',(0.,0.,{H:.1}));\n#371=AXIS2_PLACEMENT_3D('',#370,#310,#361);\n#372=PLANE('',#371);\n");
|
||||
t += "#373=ORIENTED_EDGE('',*,*,#335,.T.);\n#374=ORIENTED_EDGE('',*,*,#336,.T.);\n#375=EDGE_LOOP('',(#373,#374));\n";
|
||||
t += "#376=FACE_OUTER_BOUND('',#375,.T.);\n#377=ADVANCED_FACE('',(#376),#372,.T.);\n";
|
||||
t += "#390=CLOSED_SHELL('',(#352,#368,#377));\n#391=MANIFOLD_SOLID_BREP('Slit tube',#390);\n#392=SHAPE_REPRESENTATION('',(#391),#20);\n";
|
||||
t += "#20=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#21))GLOBAL_UNIT_ASSIGNED_CONTEXT((#22,#23))REPRESENTATION_CONTEXT('',''));\n";
|
||||
t += "#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-3),#22,'','');\n";
|
||||
t += "#22=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#23=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n";
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
let ib = &imp.bodies[0];
|
||||
assert_eq!(ib.file_faces, 3);
|
||||
assert_eq!(ib.body.unmeshed_faces(), 0);
|
||||
// Every face is planned: one face the planner gives up on costs the whole body its plan.
|
||||
let tol = 0.05;
|
||||
assert!(ib.body.display_plan(tol).is_ok(), "{:?}", ib.body.display_plan(tol).err());
|
||||
// The mesh covers the tube once: five sides of 2·R·sin(36°) by the height, plus the ends.
|
||||
let m = ib.body.display_mesh(tol).unwrap();
|
||||
let mut area = vec![0.0f64; ib.body.face_count()];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let (a, b, c) = (m.positions[t[0] as usize], m.positions[t[1] as usize], m.positions[t[2] as usize]);
|
||||
if let Some(sum) = area.get_mut(*f as usize) {
|
||||
*sum += 0.5 * (b - a).cross(c - a).len();
|
||||
}
|
||||
}
|
||||
let side = 2.0 * R * (std::f64::consts::TAU / 10.0).sin();
|
||||
let want = 5.0 * side * H + 5.0 * side * R * (std::f64::consts::TAU / 10.0).cos();
|
||||
assert!(rel(area.iter().sum::<f64>(), want) < 5e-3, "{area:?} sums to {} not {want}", area.iter().sum::<f64>());
|
||||
// The band itself is drawn once over: not folded over itself, and with no gap left in it.
|
||||
let band: f64 = (0..ib.body.face_count()).filter(|i| ib.face_origin.get(*i) == Some(&352)).map(|i| area[i]).sum();
|
||||
assert!(rel(band, 5.0 * side * H) < 5e-3, "the band covers {band} not {}", 5.0 * side * H);
|
||||
}
|
||||
|
||||
/// A face so thin that its whole boundary is two chords: a lens between a straight edge and an
|
||||
/// arc that bows away from it by a ten-thousandth of a millimetre. The edge sampler rightly
|
||||
/// leaves each side as one segment, so the boundary comes to two nodes — too few to hold a
|
||||
/// triangle. The planner used to give up on such a face, and one face it gives up on costs the
|
||||
/// whole body its plan, so every other face fell back to the reference mesher. STEP writers
|
||||
/// leave these slivers where two surfaces were trimmed apart.
|
||||
#[test]
|
||||
fn a_sliver_face_too_thin_to_triangulate_does_not_cost_the_body_its_plan() {
|
||||
/// The lens is shorter than one facet of the body it sits on: its whole boundary is then
|
||||
/// two chords, however finely the rest of the body is meshed.
|
||||
const L: f64 = 0.05;
|
||||
const H: f64 = 60.0;
|
||||
/// How far the arc bows away from the straight side, at its middle (above the file's own
|
||||
/// uncertainty, so the two sides are not the same curve).
|
||||
const BOW: f64 = 5.0e-3;
|
||||
let mut t = String::from(HEAD);
|
||||
// The lens lies in the xy plane: straight along y = 0, the arc bowing to y = BOW.
|
||||
t += &format!("#100=CARTESIAN_POINT('',(0.,0.,0.));\n#101=CARTESIAN_POINT('',(0.,0.,{H:.1}));\n");
|
||||
t += &format!("#102=CARTESIAN_POINT('',({L:.6},0.,0.));\n#103=CARTESIAN_POINT('',({L:.6},0.,{H:.1}));\n");
|
||||
// A degree-2 spline through the ends with this middle control point bows by BOW (half of it).
|
||||
t += &format!(
|
||||
"#104=CARTESIAN_POINT('',({:.6},{:.9},0.));\n#105=CARTESIAN_POINT('',({:.6},{:.9},{H:.1}));\n",
|
||||
L * 0.5,
|
||||
2.0 * BOW,
|
||||
L * 0.5,
|
||||
2.0 * BOW
|
||||
);
|
||||
t += "#110=VERTEX_POINT('',#100);\n#111=VERTEX_POINT('',#101);\n#112=VERTEX_POINT('',#102);\n#113=VERTEX_POINT('',#103);\n";
|
||||
t += "#114=DIRECTION('',(1.,0.,0.));\n#115=DIRECTION('',(0.,0.,1.));\n#116=DIRECTION('',(0.,1.,0.));\n";
|
||||
t += "#120=VECTOR('',#114,1.);\n#121=LINE('',#100,#120);\n#122=LINE('',#101,#120);\n";
|
||||
t += "#123=VECTOR('',#115,1.);\n#124=LINE('',#100,#123);\n#125=LINE('',#102,#123);\n";
|
||||
t += "#126=B_SPLINE_CURVE_WITH_KNOTS('',2,(#100,#104,#102),.UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.UNSPECIFIED.);\n";
|
||||
t += "#127=B_SPLINE_CURVE_WITH_KNOTS('',2,(#101,#105,#103),.UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.UNSPECIFIED.);\n";
|
||||
t += "#130=EDGE_CURVE('',#110,#112,#121,.T.);\n#131=EDGE_CURVE('',#111,#113,#122,.T.);\n";
|
||||
t += "#132=EDGE_CURVE('',#110,#112,#126,.T.);\n#133=EDGE_CURVE('',#111,#113,#127,.T.);\n";
|
||||
t += "#134=EDGE_CURVE('',#110,#111,#124,.T.);\n#135=EDGE_CURVE('',#112,#113,#125,.T.);\n";
|
||||
// The four surfaces: the two lens ends, the flat side and the bowed side.
|
||||
t += "#140=AXIS2_PLACEMENT_3D('',#100,#115,#114);\n#141=PLANE('',#140);\n";
|
||||
t += &format!("#142=CARTESIAN_POINT('',(0.,0.,{H:.1}));\n#143=AXIS2_PLACEMENT_3D('',#142,#115,#114);\n#144=PLANE('',#143);\n");
|
||||
t += "#145=AXIS2_PLACEMENT_3D('',#100,#116,#114);\n#146=PLANE('',#145);\n";
|
||||
t += "#147=B_SPLINE_SURFACE_WITH_KNOTS('',2,1,((#100,#101),(#104,#105),(#102,#103)),.UNSPECIFIED.,.F.,.F.,.F.,(3,3),(2,2),(0.,1.),(0.,1.),.UNSPECIFIED.);\n";
|
||||
// The lens ends: two edges each, and so two nodes. Every edge of the shell is walked once
|
||||
// each way, and each face's ring turns anticlockwise seen from outside the solid.
|
||||
t += "#150=ORIENTED_EDGE('',*,*,#132,.T.);\n#151=ORIENTED_EDGE('',*,*,#130,.F.);\n#152=EDGE_LOOP('',(#150,#151));\n";
|
||||
t += "#153=FACE_OUTER_BOUND('',#152,.T.);\n#154=ADVANCED_FACE('',(#153),#141,.F.);\n";
|
||||
t += "#155=ORIENTED_EDGE('',*,*,#131,.T.);\n#156=ORIENTED_EDGE('',*,*,#133,.F.);\n#157=EDGE_LOOP('',(#155,#156));\n";
|
||||
t += "#158=FACE_OUTER_BOUND('',#157,.T.);\n#159=ADVANCED_FACE('',(#158),#144,.T.);\n";
|
||||
// The flat side and the bowed side, each a four-edge ring.
|
||||
t += "#160=ORIENTED_EDGE('',*,*,#130,.T.);\n#161=ORIENTED_EDGE('',*,*,#135,.T.);\n#162=ORIENTED_EDGE('',*,*,#131,.F.);\n#163=ORIENTED_EDGE('',*,*,#134,.F.);\n";
|
||||
t += "#164=EDGE_LOOP('',(#160,#161,#162,#163));\n#165=FACE_OUTER_BOUND('',#164,.T.);\n#166=ADVANCED_FACE('',(#165),#146,.F.);\n";
|
||||
t += "#170=ORIENTED_EDGE('',*,*,#134,.T.);\n#171=ORIENTED_EDGE('',*,*,#133,.T.);\n#172=ORIENTED_EDGE('',*,*,#135,.F.);\n#173=ORIENTED_EDGE('',*,*,#132,.F.);\n";
|
||||
t += "#174=EDGE_LOOP('',(#170,#171,#172,#173));\n#175=FACE_OUTER_BOUND('',#174,.T.);\n#176=ADVANCED_FACE('',(#175),#147,.F.);\n";
|
||||
t += "#180=CLOSED_SHELL('',(#154,#159,#166,#176));\n#181=MANIFOLD_SOLID_BREP('Sliver lens',#180);\n#182=SHAPE_REPRESENTATION('',(#181),#20);\n";
|
||||
t += "#20=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#21))GLOBAL_UNIT_ASSIGNED_CONTEXT((#22,#23))REPRESENTATION_CONTEXT('',''));\n";
|
||||
t += "#21=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-3),#22,'','');\n";
|
||||
t += "#22=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#23=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n";
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
let ib = &imp.bodies[0];
|
||||
assert_eq!(ib.file_faces, 4);
|
||||
// Nothing is left unshown, and the body keeps its plan although one face cannot hold a
|
||||
// triangle until its boundary is sampled again.
|
||||
assert_eq!(ib.body.unmeshed_faces(), 0);
|
||||
let tol = 0.05;
|
||||
let plan = ib.body.display_plan(tol).expect("a sliver face must not cost the body its plan");
|
||||
for f in &plan.faces {
|
||||
assert!(!f.triangles.is_empty(), "face {} has no triangles", f.face);
|
||||
}
|
||||
// The sides carry the area: L by H flat, a shade more over the bow. The lens ends are
|
||||
// slivers, so what matters is that they are drawn at all, not how wide they come out.
|
||||
let m = ib.body.display_mesh(tol).unwrap();
|
||||
let mut area = vec![0.0f64; ib.body.face_count()];
|
||||
for (tri, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let Some([p, q, r]) = m.tri(tri)
|
||||
&& let Some(sum) = area.get_mut(*f as usize)
|
||||
{
|
||||
*sum += 0.5 * (q - p).cross(r - p).len();
|
||||
}
|
||||
}
|
||||
let mut sides: Vec<f64> = area.iter().copied().filter(|a| *a > 0.1).collect();
|
||||
sides.sort_by(|a, b| a.total_cmp(b));
|
||||
assert_eq!(sides.len(), 2, "two sides carry the area: {area:?}");
|
||||
// The flat side is exactly L by H; the bowed one is longer by the bow, within a tenth.
|
||||
assert!(rel(sides[0], L * H) < 1e-6, "the flat side covers {} not {}", sides[0], L * H);
|
||||
assert!((1.0..1.1).contains(&(sides[1] / (L * H))), "the bowed side covers {}", sides[1]);
|
||||
// Both lens ends are drawn, each a sliver no wider than its own bow.
|
||||
let ends: Vec<f64> = area.iter().copied().filter(|a| *a <= 0.1).collect();
|
||||
assert_eq!(ends.len(), 2, "two lens ends: {area:?}");
|
||||
for e in ends {
|
||||
assert!(e > 0.0 && e < L * BOW, "a lens end covers {e}, not a sliver under {}", L * BOW);
|
||||
}
|
||||
}
|
||||
|
||||
/// A three-quarter turn of a rectangle about Z: planar end caps, revolved cylinders and annuli.
|
||||
fn three_quarter_ring() -> Body {
|
||||
let rect =
|
||||
@@ -1571,14 +2067,15 @@ fn periodic_knot_bsplines_are_read_on_their_domain() {
|
||||
row.join(",")
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let pi = std::f64::consts::PI;
|
||||
let on_tube = |p: mt::Point3| (p.x.hypot(p.y) - r).abs() < 1e-9 && p.z > -1e-9 && p.z < h + 1e-9;
|
||||
|
||||
let surface_id = ex.get(face).and_then(|e| e.params().get(2)).and_then(Param::as_ref_id).unwrap();
|
||||
let surface = geom::bspline_surface(&cx, ex.get(surface_id).unwrap()).unwrap();
|
||||
let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { panic!("unbounded surface") };
|
||||
assert!(u0.abs() < 1e-12 && (u1 - h / r).abs() < 1e-12, "u range {u0} .. {u1}");
|
||||
// Along the tube the file's range (h / r) is shorter than one: it is read over one.
|
||||
assert!(h / r < 1.0 && u0.abs() < 1e-12 && (u1 - 1.0).abs() < 1e-12, "u range {u0} .. {u1}");
|
||||
assert!((v0 + pi).abs() < 1e-9 && (v1 - pi).abs() < 1e-9, "v range {v0} .. {v1}");
|
||||
for i in 0..=8 {
|
||||
for j in 0..=16 {
|
||||
|
||||
+101
-11
@@ -500,7 +500,7 @@ fn circle3(a: V3, b: V3, c: V3) -> Option<(V3, f64, V3)> {
|
||||
fn analytic_revolution(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
|
||||
use crate::step_in::geom::{self, CurveGeo};
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve};
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let p = e.params();
|
||||
let (ao, ad) = geom::axis1(&cx, p.get(2)?.as_ref_id()?).ok()?;
|
||||
let (oa, a) = ([ao.x, ao.y, ao.z], [ad.x, ad.y, ad.z]);
|
||||
@@ -641,7 +641,7 @@ fn planar_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<AnalyticKind> {
|
||||
if !(e.has("B_SPLINE_SURFACE") || e.name() == "B_SPLINE_SURFACE_WITH_KNOTS") {
|
||||
return None;
|
||||
}
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let s = crate::step_in::geom::bspline_surface(&cx, e).ok()?;
|
||||
let (ctrl, (u0, u1), (v0, v1)) = match &s {
|
||||
truck_modeling::Surface::BSplineSurface(b) => {
|
||||
@@ -678,7 +678,7 @@ fn ruled_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
|
||||
if !(e.has("B_SPLINE_SURFACE") || e.name() == "B_SPLINE_SURFACE_WITH_KNOTS") {
|
||||
return None;
|
||||
}
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let s = crate::step_in::geom::bspline_surface(&cx, e).ok()?;
|
||||
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { return None };
|
||||
let at = |u: f64, v: f64| {
|
||||
@@ -790,7 +790,7 @@ fn edge_points(ex: &p21::Exchange, edge: u64, n: usize) -> Option<Vec<V3>> {
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve};
|
||||
let p = ex.get(edge)?.params();
|
||||
let ends = [vertex_point(ex, p.get(1)?.as_ref_id()?)?, vertex_point(ex, p.get(2)?.as_ref_id()?)?];
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let sample = |f: &dyn Fn(f64) -> truck_modeling::Point3, (t0, t1): (f64, f64)| {
|
||||
(0..=n).map(|k| f(t0 + (t1 - t0) * k as f64 / n.max(1) as f64)).map(|q| [q.x, q.y, q.z]).collect::<Vec<V3>>()
|
||||
};
|
||||
@@ -859,7 +859,7 @@ fn collapse_short_edges(ex: &mut p21::Exchange) {
|
||||
// The curve itself must be short too (not a whole circle between two coincident vertices).
|
||||
let short = e.params().get(3).and_then(Param::as_ref_id).and_then(|c| {
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve};
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let mid = match crate::step_in::geom::curve(&cx, c, 0).ok()? {
|
||||
crate::step_in::geom::CurveGeo::Line { .. } => return Some(true),
|
||||
crate::step_in::geom::CurveGeo::BSpline(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0),
|
||||
@@ -996,7 +996,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
|
||||
"SURFACE_OF_REVOLUTION" => analytic_revolution(ex, se),
|
||||
// Coplanar pieces (a face split where a mirrored half was glued on).
|
||||
"PLANE" => {
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let f = crate::step_in::geom::frame(&cx, se.params().get(1)?.as_ref_id()?).ok()?;
|
||||
let v = |a: truck_modeling::Vector3| [a.x, a.y, a.z];
|
||||
Some(Analytic { kind: AnalyticKind::Plane { o: [f.o.x, f.o.y, f.o.z], z: v(f.z), x: v(f.x) }, flip: false })
|
||||
@@ -1092,6 +1092,26 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
|
||||
};
|
||||
let rotated =
|
||||
|ring: &Vec<(u64, bool)>, k: usize| ring.iter().skip(k).chain(ring.iter().take(k)).copied().collect::<Vec<_>>();
|
||||
// Whether a ring leaves its first vertex turning anticlockwise about a
|
||||
// cylinder's or cone's axis (the way its angle parameter grows).
|
||||
let turns_with_angle = |ring: &[(u64, bool)]| -> Option<bool> {
|
||||
let (o, z) = match ka {
|
||||
AnalyticKind::Cylinder { o, z, .. } | AnalyticKind::Cone { o, z, .. } => (*o, *z),
|
||||
_ => return None,
|
||||
};
|
||||
let &(edge, dir) = ring.first()?;
|
||||
let start = vertex_point(ex, ends((edge, dir))?.0)?;
|
||||
let mut pts = edge_points(ex, edge, 4)?;
|
||||
if ex.get(edge)?.params().get(4)?.as_bool()? != dir {
|
||||
pts.reverse();
|
||||
}
|
||||
let radial = |q: V3| {
|
||||
let v = sub(q, o);
|
||||
sub(v, mul(z, dot(v, z)))
|
||||
};
|
||||
let turn = dot(cross(radial(start), radial(*pts.get(1)?)), z);
|
||||
(turn != 0.0).then_some(turn > 0.0)
|
||||
};
|
||||
let mut cands: Vec<u64> = shared.iter().copied().collect();
|
||||
cands.sort();
|
||||
'seam: for keep in cands {
|
||||
@@ -1099,18 +1119,31 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
|
||||
let Some((a, b)) = ends((e, d)) else { continue };
|
||||
let (Some(ra), Some(ka0)) = (rings.first(), rings.first().and_then(|r| starts_at(r, a))) else { continue };
|
||||
let mut lp = rotated(ra, ka0);
|
||||
lp.push((e, d));
|
||||
let mut back = (e, !d);
|
||||
match rings.get(1) {
|
||||
Some(rb) => {
|
||||
let Some(kb) = starts_at(rb, b) else { continue };
|
||||
lp.extend(rotated(rb, kb));
|
||||
let ring_b = rotated(rb, kb);
|
||||
// Readers put a seam's first use on the side where the
|
||||
// angle starts, so the ring after it must turn the way
|
||||
// the angle grows; else that ring and the seam's other
|
||||
// use go first (#3394).
|
||||
if turns_with_angle(&ring_b) == Some(false) {
|
||||
let ring_a = std::mem::replace(&mut lp, ring_b);
|
||||
lp.push((e, !d));
|
||||
lp.extend(ring_a);
|
||||
back = (e, d);
|
||||
} else {
|
||||
lp.push((e, d));
|
||||
lp.extend(ring_b);
|
||||
}
|
||||
}
|
||||
// A cone tip (or a sphere's pole cap): the seam's far end is
|
||||
// the apex on the axis, off the ring.
|
||||
None if starts_at(ra, b).is_none() && on_axis(b) => {}
|
||||
None if starts_at(ra, b).is_none() && on_axis(b) => lp.push((e, d)),
|
||||
None => continue,
|
||||
}
|
||||
lp.push((e, !d));
|
||||
lp.push(back);
|
||||
loops = Some(vec![lp]);
|
||||
break 'seam;
|
||||
}
|
||||
@@ -1405,7 +1438,7 @@ fn trimmed_sheets(ex: &p21::Exchange, analytic: &HashMap<u64, V3>) -> HashMap<u6
|
||||
continue;
|
||||
};
|
||||
let pts: Vec<V3> = pts.into_iter().flatten().collect();
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let Ok(sheet) = crate::step_in::geom::bspline_surface(&cx, se) else { continue };
|
||||
if let Ok(Some(cut)) = crate::guard("STEP sheet trim", || Ok(trim_sheet(&sheet, &pts))) {
|
||||
out.insert(sid, cut);
|
||||
@@ -1842,6 +1875,63 @@ mod tests {
|
||||
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
/// #3394: a plate on four tapered legs and a straight peg. Each leg's cone and the peg's
|
||||
/// cylinder are written as one face whose loop runs ring, seam, ring, seam. Readers put the
|
||||
/// seam's first use on the side where the surface's angle starts and lay the next ring out
|
||||
/// from there, so that ring must turn the way the angle grows (anticlockwise about the
|
||||
/// written axis); three of the legs came out the other way and other readers dropped their
|
||||
/// sides.
|
||||
#[test]
|
||||
fn seam_loops_turn_the_way_the_angle_grows() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let plate = crate::box_solid(Vec3::new(-60.0, -40.0, 0.0), Vec3::new(60.0, 40.0, 10.0)).unwrap();
|
||||
let mut b = plate;
|
||||
for (x, y) in [(-45.0, -30.0), (45.0, -30.0), (45.0, 30.0), (-45.0, 30.0)] {
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::new(x, y), 5.0), holes: vec![] };
|
||||
let leg = crate::extrude_tapered(&Plane::XY, &disc, 20.0, -1.0, 2f64.to_radians()).unwrap();
|
||||
b = crate::boolean(&b, &leg, crate::BoolOp::Union).unwrap().unwrap();
|
||||
}
|
||||
let peg = crate::cylinder(Vec3::new(0.0, 0.0, 10.0), Vec3::Z, 4.0, 8.0).unwrap();
|
||||
b = crate::boolean(&b, &peg, crate::BoolOp::Union).unwrap().unwrap();
|
||||
let text = crate::step_export(&[&b], "test").unwrap();
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let mut seamed = 0;
|
||||
for face in ex.entities.values().filter(|e| e.name() == "ADVANCED_FACE") {
|
||||
let surface = ex.get(face.params().get(2).unwrap().as_ref_id().unwrap()).unwrap();
|
||||
if !matches!(surface.name(), "CONICAL_SURFACE" | "CYLINDRICAL_SURFACE") {
|
||||
continue;
|
||||
}
|
||||
let frame = ex.get(surface.params().get(1).unwrap().as_ref_id().unwrap()).unwrap();
|
||||
let o = triple(&ex, frame.params().get(1).unwrap().as_ref_id().unwrap());
|
||||
let z = triple(&ex, frame.params().get(2).unwrap().as_ref_id().unwrap());
|
||||
for lp in face_edges(&ex, face).unwrap() {
|
||||
let Some(i) = lp.iter().position(|(e, _)| lp.iter().filter(|(f, _)| f == e).count() == 2) else { continue };
|
||||
let &(edge, dir) = lp.get((i + 1) % lp.len()).unwrap();
|
||||
let p = ex.get(edge).unwrap().params();
|
||||
let (v0, v1) = (p.get(1).unwrap().as_ref_id().unwrap(), p.get(2).unwrap().as_ref_id().unwrap());
|
||||
let mut pts = edge_points(&ex, edge, 4).unwrap();
|
||||
if p.get(4).unwrap().as_bool().unwrap() != dir {
|
||||
pts.reverse();
|
||||
}
|
||||
let start = vertex_point(&ex, if dir { v0 } else { v1 }).unwrap();
|
||||
let radial = |q: V3| {
|
||||
let v = sub(q, o);
|
||||
sub(v, mul(z, dot(v, z)))
|
||||
};
|
||||
let turn = dot(cross(radial(start), radial(pts[1])), z);
|
||||
assert!(turn > 0.0, "face #{:?}: the ring after the seam turns against the angle", face.params().first());
|
||||
seamed += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(seamed, 5, "{text}");
|
||||
}
|
||||
|
||||
/// A CARTESIAN_POINT's or DIRECTION's three numbers.
|
||||
fn triple(ex: &p21::Exchange, id: u64) -> V3 {
|
||||
let c = ex.get(id).unwrap().params().get(1).unwrap().as_list().unwrap();
|
||||
[c[0].as_f64().unwrap(), c[1].as_f64().unwrap(), c[2].as_f64().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_strings() {
|
||||
assert_eq!(real(1.0), "1.0");
|
||||
|
||||
@@ -1133,7 +1133,8 @@ fn continued(surface: &mt::Surface, eval: &EvalSurface, need: &[(f64, f64); 2])
|
||||
match surface {
|
||||
mt::Surface::Plane(_) => Ok(Continued { surface: surface.clone(), map: identity }),
|
||||
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
|
||||
let (Some(EvalSurface::Nurbs { degree, knots, count, ctrl }), rational) = (Some(eval), matches!(surface, mt::Surface::NurbsSurface(_)))
|
||||
let (Some(EvalSurface::Nurbs { degree, knots, count, ctrl, .. }), rational) =
|
||||
(Some(eval), matches!(surface, mt::Surface::NurbsSurface(_)))
|
||||
else {
|
||||
return Err(unsupported());
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ fn surface_of(shell: mt::Shell) -> Body {
|
||||
Body {
|
||||
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
|
||||
size: 0.0,
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
|
||||
@@ -50,6 +50,9 @@ pub(crate) struct Fill<'a> {
|
||||
/// varies: a plane is shaded the same by any triangles, so planar faces skip it (around many
|
||||
/// small holes it grades out from each rim and multiplies the triangles).
|
||||
pub shape: bool,
|
||||
/// A side crossing one already kept is left out instead of refusing the loops (the last
|
||||
/// resort for a boundary that crosses itself, see `fallback`).
|
||||
pub lenient: bool,
|
||||
}
|
||||
|
||||
/// Triangulate the region bounded by `loops` (ranges of `boundary`, each closed). Boundary
|
||||
@@ -83,6 +86,9 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
|
||||
continue;
|
||||
}
|
||||
if !cdt.can_add_constraint(a, b) {
|
||||
if fill.lenient {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
cdt.add_constraint(a, b);
|
||||
@@ -267,7 +273,7 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
if added >= budget {
|
||||
return added;
|
||||
}
|
||||
if cdt.insert(spade::mitigate_underflow(p)).is_ok() {
|
||||
if insert_off_boundary(cdt, p) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
@@ -275,6 +281,22 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
added
|
||||
}
|
||||
|
||||
/// Insert `p` unless it lies exactly on a side of the boundary (the middle of a sliver along
|
||||
/// it, at a cone's apex): that would split the side, while the face's boundary nodes are shared
|
||||
/// with its neighbours, which would not have the new one, and the face lost its plan.
|
||||
fn insert_off_boundary(cdt: &mut Cdt, p: Point2<f64>) -> bool {
|
||||
let p = spade::mitigate_underflow(p);
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
if let spade::PositionInTriangulation::OnEdge(e) = cdt.locate(p)
|
||||
&& cdt.is_constraint_edge(e.as_undirected())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
cdt.insert(p).is_ok()
|
||||
}
|
||||
|
||||
/// Per face (by index): inside the region the constraint loops bound. A flood fill from the
|
||||
/// outer face that flips parity each time it crosses a constraint edge (holes are out), linear
|
||||
/// in the number of triangles.
|
||||
@@ -431,7 +453,7 @@ mod tests {
|
||||
for refine in [false, true] {
|
||||
let refine = refine.then_some((Sag { eval: &flat, tol: 1e-3, h: 0.1, turn: None, scale: [2.0, 1.0] }, 2000));
|
||||
let label = refine.is_some();
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine, shape: true }) else {
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine, shape: true, lenient: false }) else {
|
||||
panic!("meshes")
|
||||
};
|
||||
let mut uv = b.clone();
|
||||
@@ -450,11 +472,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_on_a_boundary_side_is_not_inserted() {
|
||||
let mut cdt = Cdt::new();
|
||||
let corners = [[0.0, 0.0], [4.0, 0.0], [4.0, 1.0], [0.0, 1.0]];
|
||||
let Ok(handles) = corners.iter().map(|p| cdt.insert(Point2::new(p[0], p[1]))).collect::<Result<Vec<_>, _>>() else { panic!("insert") };
|
||||
for k in 0..4 {
|
||||
cdt.add_constraint(handles[k], handles[(k + 1) % 4]);
|
||||
}
|
||||
assert!(!insert_off_boundary(&mut cdt, Point2::new(2.0, 0.0)));
|
||||
assert!(!insert_off_boundary(&mut cdt, Point2::new(f64::NAN, 0.5)));
|
||||
assert_eq!((cdt.num_constraints(), cdt.num_vertices()), (4, 4));
|
||||
assert!(insert_off_boundary(&mut cdt, Point2::new(2.0, 0.5)));
|
||||
assert_eq!((cdt.num_constraints(), cdt.num_vertices()), (4, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_loops_are_refused() {
|
||||
let b = [[0.0, 0.0], [1.0, 1.0], [1.0, 0.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }).is_none());
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false, lenient: false }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -478,7 +515,7 @@ mod tests {
|
||||
// A parameter that is zero in exact arithmetic, left at 1e-300 by projection.
|
||||
let b = [[1e-300, 0.0], [1.0, -1e-310], [1.0, 1.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
let Some((extra, tris)) = triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }) else { panic!("meshes") };
|
||||
let Some((extra, tris)) = triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false, lenient: false }) else { panic!("meshes") };
|
||||
assert!(extra.is_empty());
|
||||
assert_eq!(tris.len(), 2);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
let Ok(cyl) = crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0) else { panic!("cylinder") };
|
||||
for tol in [0.1, 0.01, 0.001] {
|
||||
for e in cyl.solid.edge_iter() {
|
||||
let pts = sample_edge(&e, &Rules { tol, max_len: f64::INFINITY, turn: None }, f64::INFINITY).points;
|
||||
let pts = sample_edge(&e, &Rules { tol, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None }, f64::INFINITY).points;
|
||||
assert!(pts.len() >= 2);
|
||||
assert_eq!(pts.first().copied(), Some(from_p3(e.absolute_front().point())));
|
||||
assert_eq!(pts.last().copied(), Some(from_p3(e.absolute_back().point())));
|
||||
@@ -184,7 +184,7 @@ mod tests {
|
||||
fn edges_are_split_to_the_side_length_and_turn() {
|
||||
let Ok(cyl) = crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0) else { panic!("cylinder") };
|
||||
for e in cyl.solid.edge_iter() {
|
||||
let pts = sample_edge(&e, &Rules { tol: 1.0, max_len: f64::INFINITY, turn: None }, 1.2).points;
|
||||
let pts = sample_edge(&e, &Rules { tol: 1.0, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None }, 1.2).points;
|
||||
for w in pts.windows(2) {
|
||||
assert!(w[0].dist(w[1]) <= 1.2 + 1e-9, "side {}", w[0].dist(w[1]));
|
||||
}
|
||||
@@ -198,8 +198,13 @@ mod tests {
|
||||
}
|
||||
// A coarse tolerance alone would leave a few long chords; a 30° facet angle keeps every
|
||||
// side within 30° of arc: at least 12 around each of the two circles.
|
||||
let thirty =
|
||||
Rules { tol: 5.0, max_len: f64::INFINITY, turn: Some(crate::tess::size::Turn { angle: std::f64::consts::PI / 6.0, floor: 0.0 }) };
|
||||
let thirty = Rules {
|
||||
tol: 5.0,
|
||||
max_len: f64::INFINITY,
|
||||
extent: 0.0,
|
||||
turn: Some(crate::tess::size::Turn { angle: std::f64::consts::PI / 6.0, floor: 0.0 }),
|
||||
boundary_tol: None,
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let sides: usize = cyl
|
||||
.solid
|
||||
@@ -217,7 +222,7 @@ mod tests {
|
||||
let Ok(pin) = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 6.0) else { panic!("pin") };
|
||||
let Ok(cross) = crate::cylinder(Vec3::new(5.0, -1.0, 2.0), Vec3::Y, 1.0, 12.0) else { panic!("cross") };
|
||||
let Ok(Some(body)) = crate::boolean(&pin, &cross, crate::BoolOp::Cut) else { panic!("cut") };
|
||||
let rules = Rules { tol: 0.01, max_len: f64::INFINITY, turn: None };
|
||||
let rules = Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None };
|
||||
let surfaces: Vec<EvalSurface> = body.solid.face_iter().filter_map(|f| super::super::surface::eval_surface(&f.surface())).collect();
|
||||
let (mut edges, mut checked) = (0, 0);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! The last resort for a face the planner cannot plan however finely its edges are divided: a
|
||||
//! leftover of the file's own modelling, such as a cusp where two edges leave a point at a zero
|
||||
//! angle, a sliver whose two sides sag across each other, or a small cap round a pole. Its
|
||||
//! boundary is filled flat, so the rest of the body keeps its plan rather than the whole body
|
||||
//! falling back to the reference mesh.
|
||||
//!
|
||||
//! The boundary nodes (the shared edge nodes, so the face still meets its neighbours exactly)
|
||||
//! are laid onto the plane that best fits them and triangulated there (see `cdt::triangulate`):
|
||||
//! every boundary side is kept as a constraint unless it crosses one already kept (the defect
|
||||
//! itself), and a triangle is in the face when it lies inside an odd number of loops. The fill is
|
||||
//! kept only where it stays within the chord tolerance of the surface, so a real curved face is
|
||||
//! left to the body's reference mesh. The triangles turn the way the surface does, and each node
|
||||
//! keeps its parameters on the surface for its normal.
|
||||
|
||||
use solidcraft_geom::tess::{EvalSurface, FacePlan};
|
||||
use solidcraft_geom::{Plane, Vec3};
|
||||
|
||||
use super::{cdt, uv};
|
||||
|
||||
/// Most boundary nodes a face is filled at.
|
||||
const MAX_NODES: usize = 50_000;
|
||||
/// Nodes the surface's normal is sampled at to find which way it faces.
|
||||
const NORMAL_SAMPLES: usize = 16;
|
||||
|
||||
/// The face `fi` of surface `s`, bounded by the loops `loops` (in space), filled flat; `None`
|
||||
/// when its nodes do not lie on the surface within `accept`, span no plane, enclose nothing, or
|
||||
/// the flat fill strays from the surface by more than the chord tolerance `tol` (a real curved
|
||||
/// face, which the body's reference mesh draws better).
|
||||
pub(crate) fn fill(fi: usize, s: &EvalSurface, loops: &[Vec<Vec3>], accept: f64, tol: f64) -> Option<FacePlan> {
|
||||
let fixed: Vec<Vec3> = loops.iter().flatten().copied().collect();
|
||||
if fixed.len() < 3 || fixed.len() > MAX_NODES {
|
||||
return None;
|
||||
}
|
||||
// Each node's parameters, for its normal (a projection from the previous node's).
|
||||
let dom = uv::domain(s);
|
||||
let mut params: Vec<[f64; 2]> = Vec::with_capacity(fixed.len());
|
||||
let mut ranges = Vec::with_capacity(loops.len());
|
||||
for l in loops {
|
||||
let start = params.len();
|
||||
let mut prev = None;
|
||||
for p in l {
|
||||
let q = uv::project(s, &dom, *p, prev, accept)?;
|
||||
params.push(q);
|
||||
prev = Some(q);
|
||||
}
|
||||
ranges.push(start..params.len());
|
||||
}
|
||||
// The plane: the loops' area vector (holes count against the outer loop) through their
|
||||
// centroid.
|
||||
let normal = loops.iter().fold(Vec3::ZERO, |n, l| n + crate::plane_fit::loop_area(l));
|
||||
let centre = fixed.iter().fold(Vec3::ZERO, |c, p| c + *p) * (1.0 / fixed.len() as f64);
|
||||
let plane = Plane::from_normal(centre, normal)?;
|
||||
let flat: Vec<[f64; 2]> = fixed.iter().map(|p| plane.to_local(*p)).map(|q| [q.x, q.y]).collect();
|
||||
let fill = cdt::Fill { seeds: &[], refine: None, shape: false, lenient: true };
|
||||
let (_, mut triangles) = cdt::triangulate(&flat, &ranges, fill)?;
|
||||
if triangles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Flat only where flat is close enough: each triangle's middle within the chord tolerance of
|
||||
// the surface (found from its first corner's parameters).
|
||||
let off = |t: &[u32; 3]| -> Option<f64> {
|
||||
let [a, b, c] = t.map(|i| fixed.get(i as usize).copied());
|
||||
let mid = (a? + b? + c?) * (1.0 / 3.0);
|
||||
let q = uv::project(s, &dom, mid, params.get(t[0] as usize).copied(), f64::INFINITY)?;
|
||||
Some(s.derivs(q[0], q[1])?.0.dist(mid))
|
||||
};
|
||||
if !triangles.iter().all(|t| off(t).is_some_and(|d| d <= tol)) {
|
||||
return None;
|
||||
}
|
||||
// Counter-clockwise about the plane's normal; turned over where the surface faces the other way.
|
||||
let step = (params.len() / NORMAL_SAMPLES).max(1);
|
||||
let along: f64 = params.iter().step_by(step).filter_map(|q| s.derivs(q[0], q[1])).map(|(_, su, sv)| su.cross(sv).dot(plane.normal())).sum();
|
||||
if along < 0.0 {
|
||||
for t in &mut triangles {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), surface: None, uv: params, fixed, normals: Vec::new(), flip: false, triangles })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A plane face whose right side folds back over itself at a zero angle (a cusp): no side
|
||||
/// crossing another may be kept, yet the face is filled and turns the plane's way.
|
||||
#[test]
|
||||
fn a_cusp_is_filled_the_surfaces_way() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let corners = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [3.0, 4.2], [3.8, 3.6], [0.0, 4.0]];
|
||||
let l: Vec<Vec3> = corners.iter().map(|q| Vec3::new(q[0], q[1], 0.0)).collect();
|
||||
let Some(f) = fill(0, &s, &[l], 1e-9, 1e-9) else { panic!("filled") };
|
||||
assert!(!f.triangles.is_empty());
|
||||
let area: f64 = f
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let [a, b, c] = t.map(|i| f.fixed[i as usize]);
|
||||
(b - a).cross(c - a).z * 0.5
|
||||
})
|
||||
.sum();
|
||||
assert!(area > 14.0 && area < 16.5, "area {area}");
|
||||
assert_eq!(f.uv.len(), f.fixed.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_enclosing_nothing_is_not_filled() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let l = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
|
||||
assert!(fill(0, &s, &[l], 1e-3, 1e-3).is_none());
|
||||
}
|
||||
}
|
||||
+288
-31
@@ -21,6 +21,7 @@ pub(crate) mod collar;
|
||||
pub(crate) mod coons;
|
||||
pub(crate) mod cutgrid;
|
||||
pub(crate) mod edges;
|
||||
pub(crate) mod fallback;
|
||||
pub(crate) mod grid;
|
||||
pub(crate) mod motorcycle;
|
||||
pub(crate) mod pieces;
|
||||
@@ -45,6 +46,8 @@ pub struct PlanStats {
|
||||
pub planar: usize,
|
||||
pub structured: usize,
|
||||
pub delaunay: usize,
|
||||
/// Faces no method could plan, filled flat from their boundary nodes.
|
||||
pub filled: usize,
|
||||
}
|
||||
|
||||
/// Curved faces from which their sizes are worked out on the worker pool.
|
||||
@@ -61,6 +64,15 @@ const PARALLEL_EDGES: usize = 256;
|
||||
const MAX_LINES: usize = 1024;
|
||||
/// Most points Ruppert refinement may add to one face, beyond its seeds.
|
||||
const MAX_REFINE: usize = 20_000;
|
||||
/// Two boundary nodes this close together on the surface (mm) are one node. Far below any
|
||||
/// spacing a mesh can carry, and far above the parameters a projection tells apart. See
|
||||
/// [`drop_collapsed`].
|
||||
const SAME_NODE: f64 = 1e-6;
|
||||
/// How far a face's boundary may lie off its surface and still be planned on it, as a fraction of
|
||||
/// the body's extent, at every display quality (ten chord tolerances at Normal).
|
||||
const ON_SURFACE: f64 = 1e-2;
|
||||
/// How much finer a refused face's edges are divided, retry by retry.
|
||||
const REFINE_STEPS: [f64; 2] = [3.0, 10.0];
|
||||
|
||||
fn unsupported(what: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: tessellation: {what}"))
|
||||
@@ -68,10 +80,13 @@ fn unsupported(what: impl std::fmt::Display) -> KernelError {
|
||||
|
||||
/// Plan the display tessellation of `solid` with chord tolerance `tol` and, when given, a
|
||||
/// facet angle no facet may turn more than (where it sags above the angle's floor).
|
||||
pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>) -> Result<(TessPlan, PlanStats)> {
|
||||
/// `boundary_tol`: how far the body's boundaries may lie off their surfaces (mm) as the file it
|
||||
/// was read from declares, `None` for a body this kernel built (see
|
||||
/// [`crate::body::Body::boundary_tol`]).
|
||||
pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>, boundary_tol: Option<f64>) -> Result<(TessPlan, PlanStats)> {
|
||||
let faces: Vec<&mt::Face> = solid.face_iter().collect();
|
||||
let (edges, edge_faces) = edge_order(faces.iter().copied());
|
||||
let rules = size::Rules::new(tol, size::extent(solid.vertex_iter().map(|v| crate::body::from_p3(v.point()))), turn);
|
||||
let rules = size::Rules::new(tol, size::extent(solid.vertex_iter().map(|v| crate::body::from_p3(v.point()))), turn, boundary_tol);
|
||||
// Each face's surface and target side length, then every edge once at the smaller size of
|
||||
// its faces; faces then read their nodes. A planar face costs microseconds and a curved
|
||||
// one about a hundred, so the pool is used only where curved faces (or intersection
|
||||
@@ -94,30 +109,20 @@ pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>) -> Result<
|
||||
nodes.insert(e.id(), n?);
|
||||
}
|
||||
relax_crowded_faces(&faces, &jobs, &rules, &mut nodes);
|
||||
let oriented = |e: &mt::Edge| -> Option<Vec<Vec3>> {
|
||||
let mut pts = nodes.get(&e.id())?.points.clone();
|
||||
if !e.orientation() {
|
||||
pts.reverse();
|
||||
}
|
||||
Some(pts)
|
||||
};
|
||||
divide_collapsed_faces(&faces, &jobs, &rules, &mut nodes);
|
||||
let indexed: Vec<(usize, &mt::Face)> = faces.iter().copied().enumerate().collect();
|
||||
let planned = crate::par::map("face tessellation", &indexed, curved >= PARALLEL_FACES, |(fi, face)| {
|
||||
let fi = *fi;
|
||||
let (eval, size) = match sized.get(fi) {
|
||||
Some((Some(eval), Some(size))) => (eval.clone(), *size),
|
||||
_ => return Err(unsupported(format!("face {fi}: surface not evaluable"))),
|
||||
};
|
||||
// Boundary nodes, loop by loop (each edge's last node is the next edge's first), with
|
||||
// their parameters on this face where the edge knows them (an intersection edge on
|
||||
// this face's surface).
|
||||
// A face's boundary nodes, loop by loop (each edge's last node is the next edge's first),
|
||||
// with their parameters on the face's surface where the edge knows them (an intersection
|
||||
// edge on this face's surface).
|
||||
type Boundary = (Vec<Vec<Vec3>>, Vec<Vec<Option<[f64; 2]>>>);
|
||||
let boundary = |face: &mt::Face, eval: &EvalSurface, nodes: &HashMap<mt::EdgeID, edges::Nodes>| -> Result<Boundary> {
|
||||
let mut loops3: Vec<Vec<Vec3>> = Vec::new();
|
||||
let mut known: Vec<Vec<Option<[f64; 2]>>> = Vec::new();
|
||||
for wire in face.absolute_boundaries() {
|
||||
let (mut pts, mut uvs) = (Vec::new(), Vec::new());
|
||||
for e in wire.edge_iter() {
|
||||
let n = nodes.get(&e.id()).ok_or_else(|| unsupported("edge without nodes"))?;
|
||||
let (mut ep, mut eq) = (n.points.clone(), n.params_on(&eval).unwrap_or_else(|| vec![None; n.points.len()]));
|
||||
let (mut ep, mut eq) = (n.points.clone(), n.params_on(eval).unwrap_or_else(|| vec![None; n.points.len()]));
|
||||
if !e.orientation() {
|
||||
ep.reverse();
|
||||
eq.reverse();
|
||||
@@ -132,15 +137,108 @@ pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>) -> Result<
|
||||
known.push(uvs);
|
||||
}
|
||||
}
|
||||
Ok((loops3, known))
|
||||
};
|
||||
// A plan in the surface's orientation turned the face's way.
|
||||
let oriented_face = |face: &mt::Face, fp: FacePlan| FacePlan {
|
||||
flip: !face.orientation(),
|
||||
triangles: if face.orientation() { fp.triangles } else { fp.triangles.into_iter().map(|[a, b, c]| [a, c, b]).collect() },
|
||||
..fp
|
||||
};
|
||||
let surface_of = |fi: usize| match sized.get(fi) {
|
||||
Some((Some(eval), Some(size))) => Ok((eval.clone(), *size)),
|
||||
_ => Err(unsupported(format!("face {fi}: surface not evaluable"))),
|
||||
};
|
||||
// One face, from the edge nodes as they are and with the rules for its level of detail.
|
||||
let plan_one = |fi: usize, face: &mt::Face, nodes: &HashMap<mt::EdgeID, edges::Nodes>, rules: &size::Rules| {
|
||||
let (eval, size) = surface_of(fi)?;
|
||||
let (loops3, known) = boundary(face, &eval, nodes)?;
|
||||
let mut stats = PlanStats::default();
|
||||
let fp = plan_face(fi, &eval, &loops3, &known, &rules, size, &mut stats)?;
|
||||
let fp = FacePlan {
|
||||
flip: !face.orientation(),
|
||||
triangles: if face.orientation() { fp.triangles } else { fp.triangles.into_iter().map(|[a, b, c]| [a, c, b]).collect() },
|
||||
..fp
|
||||
let replan = |stats: &mut PlanStats| match plan_face(fi, &eval, &loops3, &known, rules, size, stats) {
|
||||
Ok(fp) => Ok(fp),
|
||||
// A boundary that doubles straight back on itself (a zero-width slit in the face,
|
||||
// which some STEP writers leave where two surfaces were trimmed apart) encloses no
|
||||
// area but crosses itself in (u, v). Dropping the slits leaves the same face.
|
||||
Err(first) => {
|
||||
let (slit3, slitq) = drop_slits(&loops3, &known, rules.tol * 1e-2);
|
||||
if slit3 == loops3 {
|
||||
return Err(first);
|
||||
}
|
||||
// Nothing left but slits: a sliver face (two edges between the same two vertices,
|
||||
// as STEP files write one) encloses no area, and the faces on either side already
|
||||
// meet along its nodes.
|
||||
if slit3.iter().all(|l| l.len() < 3) {
|
||||
return Ok(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), ..FacePlan::default() });
|
||||
}
|
||||
*stats = PlanStats::default();
|
||||
plan_face(fi, &eval, &slit3, &slitq, rules, size, stats).map_err(|_| first)
|
||||
}
|
||||
};
|
||||
Ok((fp, eval, stats))
|
||||
});
|
||||
let fp = replan(&mut stats)?;
|
||||
Ok((oriented_face(face, fp), eval, stats))
|
||||
};
|
||||
let mut planned = crate::par::map("face tessellation", &indexed, curved >= PARALLEL_FACES, |(fi, face)| plan_one(*fi, face, &nodes, &rules));
|
||||
// A face refused at this level of detail (a boundary that comes out degenerate, or that a
|
||||
// triangulation cannot follow, between nodes too far apart) has its edges divided again,
|
||||
// finer, and it and the faces beside those edges, which share their nodes, are planned
|
||||
// again from them.
|
||||
if planned.iter().any(Result::is_err) {
|
||||
let index_of: HashMap<mt::EdgeID, usize> = edges.iter().enumerate().map(|(i, e)| (e.id(), i)).collect();
|
||||
let face_edges: Vec<Vec<usize>> = faces
|
||||
.iter()
|
||||
.map(|f| f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().filter_map(|e| index_of.get(&e.id()).copied())).collect())
|
||||
.collect();
|
||||
let mut finer: HashMap<usize, size::Rules> = HashMap::new();
|
||||
for factor in REFINE_STEPS {
|
||||
let refused: Vec<usize> = planned.iter().enumerate().filter(|(_, r)| r.is_err()).map(|(i, _)| i).collect();
|
||||
if refused.is_empty() {
|
||||
break;
|
||||
}
|
||||
let fine = rules.finer(factor);
|
||||
let mut touched: Vec<usize> = refused.iter().filter_map(|f| face_edges.get(*f)).flatten().copied().collect();
|
||||
touched.sort_unstable();
|
||||
touched.dedup();
|
||||
let resampled = crate::par::map("edge nodes", &touched, touched.len() >= PARALLEL_EDGES, |i| {
|
||||
Ok(jobs.get(*i).map(|(e, h)| (e.id(), edges::sample_edge(e, &fine, *h / factor))))
|
||||
});
|
||||
for (id, n) in resampled.into_iter().flatten().flatten() {
|
||||
nodes.insert(id, n);
|
||||
}
|
||||
finer.extend(refused.iter().map(|f| (*f, fine)));
|
||||
// The faces sharing a divided edge read its new nodes.
|
||||
let mut again: Vec<usize> = touched.iter().filter_map(|i| edge_faces.get(*i)).flatten().copied().collect();
|
||||
again.sort_unstable();
|
||||
again.dedup();
|
||||
let again: Vec<(usize, &mt::Face)> = again.into_iter().filter_map(|f| faces.get(f).map(|face| (f, *face))).collect();
|
||||
let results = crate::par::map("face tessellation", &again, again.len() >= PARALLEL_FACES, |(fi, face)| {
|
||||
plan_one(*fi, face, &nodes, finer.get(fi).unwrap_or(&rules))
|
||||
});
|
||||
for ((f, _), r) in again.iter().zip(results) {
|
||||
if let Some(slot) = planned.get_mut(*f) {
|
||||
*slot = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A face refused however finely its edges are divided is filled flat from its boundary
|
||||
// nodes (see `fallback`), so the rest of the body keeps its plan.
|
||||
for (fi, slot) in planned.iter_mut().enumerate() {
|
||||
if slot.is_ok() {
|
||||
continue;
|
||||
}
|
||||
let (Some(face), Ok((eval, _))) = (faces.get(fi), surface_of(fi)) else { continue };
|
||||
let Ok((loops3, _)) = boundary(face, &eval, &nodes) else { continue };
|
||||
if let Some(fp) = fallback::fill(fi, &eval, &loops3, off_surface(&rules), rules.tol) {
|
||||
*slot = Ok((oriented_face(face, fp), eval, PlanStats { filled: 1, ..PlanStats::default() }));
|
||||
}
|
||||
}
|
||||
let oriented = |e: &mt::Edge| -> Option<Vec<Vec3>> {
|
||||
let mut pts = nodes.get(&e.id())?.points.clone();
|
||||
if !e.orientation() {
|
||||
pts.reverse();
|
||||
}
|
||||
Some(pts)
|
||||
};
|
||||
let mut plan = TessPlan::default();
|
||||
let mut stats = PlanStats::default();
|
||||
for r in planned {
|
||||
@@ -151,6 +249,7 @@ pub(crate) fn plan(solid: &Solid, tol: f64, turn: Option<size::Turn>) -> Result<
|
||||
stats.planar += s.planar;
|
||||
stats.structured += s.structured;
|
||||
stats.delaunay += s.delaunay;
|
||||
stats.filled += s.filled;
|
||||
}
|
||||
plan.edges = edges.iter().filter_map(oriented).collect();
|
||||
plan.edge_faces = edge_faces.into_iter().map(|fs| fs.into_iter().map(|f| u32::try_from(f).unwrap_or(u32::MAX)).collect()).collect();
|
||||
@@ -222,6 +321,151 @@ fn relax_crowded_faces(faces: &[&mt::Face], jobs: &[(&mt::Edge, f64)], rules: &s
|
||||
}
|
||||
}
|
||||
|
||||
/// Every loop with its zero-width slits removed: wherever the boundary steps out to a node and
|
||||
/// straight back to where it came from, the pair is dropped. A slit encloses no area, so the
|
||||
/// face is the same without it, and without it the boundary no longer runs along itself. The
|
||||
/// two sides need only agree within `eps` (a hundredth of the chord tolerance): a STEP writer
|
||||
/// may draw them as two curves a few nanometres apart, which no mesh could tell apart anyway.
|
||||
fn drop_slits(loops3: &[Vec<Vec3>], known: &[Vec<Option<[f64; 2]>>], eps: f64) -> (Vec<Vec<Vec3>>, Vec<Vec<Option<[f64; 2]>>>) {
|
||||
/// Slits dropped from one loop before giving up (the search is a scan per slit).
|
||||
const MAX_SLITS: usize = 4096;
|
||||
let mut out3: Vec<Vec<Vec3>> = Vec::with_capacity(loops3.len());
|
||||
let mut outq: Vec<Vec<Option<[f64; 2]>>> = Vec::with_capacity(loops3.len());
|
||||
for (li, pts) in loops3.iter().enumerate() {
|
||||
let mut pts = pts.clone();
|
||||
let mut qs = known.get(li).cloned().unwrap_or_else(|| vec![None; pts.len()]);
|
||||
qs.resize(pts.len(), None);
|
||||
for _ in 0..MAX_SLITS {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
break;
|
||||
}
|
||||
let same = |a: usize, b: usize| pts.get(a).zip(pts.get(b)).is_some_and(|(p, q)| p.dist(*q) <= eps);
|
||||
let tip = (0..n).find(|&k| same((k + n - 1) % n, (k + 1) % n));
|
||||
let Some(k) = tip else { break };
|
||||
// Drop the tip and the node that repeats the one before it.
|
||||
let mut drop = [k, (k + 1) % n];
|
||||
drop.sort_unstable();
|
||||
for &i in drop.iter().rev() {
|
||||
if i < pts.len() {
|
||||
pts.remove(i);
|
||||
}
|
||||
if i < qs.len() {
|
||||
qs.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if pts.len() >= 2 {
|
||||
out3.push(pts);
|
||||
outq.push(qs);
|
||||
}
|
||||
}
|
||||
(out3, outq)
|
||||
}
|
||||
|
||||
/// Boundary nodes a loop keeps only one of: nodes whose parameters land on the node before them.
|
||||
///
|
||||
/// A boundary that runs a little off its surface (a STEP file writes its curves and surfaces to
|
||||
/// its own uncertainty, so an edge may lie micrometres off the face it bounds) projects onto the
|
||||
/// nearest surface point. Around a convex corner of the surface every point of such an edge is
|
||||
/// nearest that corner, so a whole run of nodes comes out with the corner's parameters. The finer
|
||||
/// the chord tolerance, the more nodes the run holds, and a boundary that repeats a point in
|
||||
/// (u, v) encloses no area there and cannot be triangulated (#1721).
|
||||
///
|
||||
/// One node stands for the run. The nodes dropped are nearer to it than `SAME_NODE` on the
|
||||
/// surface, so the face is the same without them.
|
||||
fn drop_collapsed(l: &mut seam::Loop, speed: [f64; 2]) {
|
||||
let eps = |d: usize| {
|
||||
let s = speed.get(d).copied().unwrap_or(1.0);
|
||||
if s.is_finite() && s > 0.0 { SAME_NODE / s } else { 0.0 }
|
||||
};
|
||||
let (eu, ev) = (eps(0), eps(1));
|
||||
let same = |a: &[f64; 2], b: &[f64; 2]| (a[0] - b[0]).abs() <= eu && (a[1] - b[1]).abs() <= ev;
|
||||
let (mut uv, mut pts) = (Vec::with_capacity(l.uv.len()), Vec::with_capacity(l.pts.len()));
|
||||
for (q, p) in l.uv.iter().zip(&l.pts) {
|
||||
if uv.last().is_some_and(|last| same(last, q)) {
|
||||
continue;
|
||||
}
|
||||
uv.push(*q);
|
||||
pts.push(*p);
|
||||
}
|
||||
// The loop is cyclic: its last node may land on its first.
|
||||
while uv.len() > 2 && uv.last().zip(uv.first()).is_some_and(|(a, b)| same(a, b)) {
|
||||
uv.pop();
|
||||
pts.pop();
|
||||
}
|
||||
l.uv = uv;
|
||||
l.pts = pts;
|
||||
}
|
||||
|
||||
/// Bring every loop of a face into the same period as its outer boundary. A loop is projected
|
||||
/// on its own, and a periodic surface repeats, so a hole can come out a whole turn away from the
|
||||
/// boundary it lies in — where it no longer cuts the face, and the face is drawn over its hole.
|
||||
fn align_periods(loops: &mut [seam::Loop], dom: &uv::Domain) {
|
||||
for d in 0..2 {
|
||||
let Some(per) = dom.period.get(d).copied().flatten().filter(|p| *p > 0.0) else { continue };
|
||||
let middle = |l: &seam::Loop| {
|
||||
let (lo, hi) = l.uv.iter().filter_map(|q| q.get(d)).fold((f64::MAX, f64::MIN), |(a, b), x| (a.min(*x), b.max(*x)));
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
let Some(outer) = loops.first().map(middle) else { continue };
|
||||
if !outer.is_finite() {
|
||||
continue;
|
||||
}
|
||||
for l in loops.iter_mut().skip(1) {
|
||||
let shift = ((outer - middle(l)) / per).round() * per;
|
||||
if shift != 0.0 && shift.is_finite() {
|
||||
for q in l.uv.iter_mut() {
|
||||
if let Some(x) = q.get_mut(d) {
|
||||
*x += shift;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes a face's boundary needs before it can hold a triangle.
|
||||
const MIN_BOUNDARY_NODES: usize = 3;
|
||||
|
||||
/// A face whose whole boundary comes to fewer than three nodes has no triangle to stand on: it is
|
||||
/// a sliver no wider than the tolerance (a chord with the shallow arc beside it, which the edge
|
||||
/// sampler rightly leaves as one segment each). Its edges are sampled again, halved until the
|
||||
/// boundary has nodes enough, so the sliver is drawn instead of failing the whole body's plan.
|
||||
/// The nodes stay shared, so the faces beside it follow the same points.
|
||||
fn divide_collapsed_faces(faces: &[&mt::Face], jobs: &[(&mt::Edge, f64)], rules: &size::Rules, nodes: &mut HashMap<mt::EdgeID, edges::Nodes>) {
|
||||
/// Halvings before giving up (a boundary of two edges reaches four nodes in one).
|
||||
const MAX_HALVINGS: usize = 8;
|
||||
let h_of: HashMap<mt::EdgeID, (&mt::Edge, f64)> = jobs.iter().map(|(e, h)| (e.id(), (*e, *h))).collect();
|
||||
for f in faces {
|
||||
let ids: Vec<mt::EdgeID> = f.absolute_boundaries().iter().flat_map(|w| w.edge_iter().map(|e| e.id())).collect();
|
||||
let count = |nodes: &HashMap<mt::EdgeID, edges::Nodes>| {
|
||||
ids.iter().map(|id| nodes.get(id).map_or(0, |n| n.points.len().saturating_sub(1))).sum::<usize>()
|
||||
};
|
||||
if count(nodes) >= MIN_BOUNDARY_NODES {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..MAX_HALVINGS {
|
||||
for id in &ids {
|
||||
let Some((e, h)) = h_of.get(id) else { continue };
|
||||
let longest =
|
||||
nodes.get(id).map_or(0.0, |n| n.points.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).fold(0.0, f64::max));
|
||||
let finer = if longest > 0.0 { (longest * 0.5).min(*h) } else { *h };
|
||||
nodes.insert(*id, edges::sample_edge(e, rules, finer));
|
||||
}
|
||||
if count(nodes) >= MIN_BOUNDARY_NODES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a boundary node may lie off its surface and still be projected onto it (see the note
|
||||
/// in [`plan_face`]).
|
||||
fn off_surface(rules: &size::Rules) -> f64 {
|
||||
(rules.extent * ON_SURFACE).max(rules.boundary_tol.unwrap_or(0.0)).max(1e-6)
|
||||
}
|
||||
|
||||
/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
|
||||
/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
|
||||
fn plan_face(
|
||||
@@ -236,7 +480,12 @@ fn plan_face(
|
||||
let tol = rules.tol;
|
||||
let (h, speed) = size;
|
||||
let dom = uv::domain(s);
|
||||
let accept = (tol * 10.0).max(1e-6);
|
||||
// How far a boundary node may lie off its surface and still be projected onto it: a property
|
||||
// of the body, not of the mesh asked for, so a finer chord tolerance never refuses a boundary
|
||||
// a coarser one accepted, and zooming in never drops a face (#1721). A hundredth of the body,
|
||||
// or more where the file it was read from declares more; a file's declared accuracy never
|
||||
// lowers it, since writers' edges stray farther from their surfaces than they declare.
|
||||
let accept = off_surface(rules);
|
||||
let fail = |why: &str| unsupported(format!("face {fi}: {why}"));
|
||||
// Project every loop, unwrapping periodic parameters along it.
|
||||
let mut projected: Vec<seam::Loop> = Vec::with_capacity(loops3.len());
|
||||
@@ -259,8 +508,16 @@ fn plan_face(
|
||||
luv.push(q);
|
||||
prev = Some(q);
|
||||
}
|
||||
projected.push(seam::Loop { uv: luv, pts: pts.clone() });
|
||||
let mut l = seam::Loop { uv: luv, pts: pts.clone() };
|
||||
drop_collapsed(&mut l, speed);
|
||||
for (d, per) in dom.period.iter().enumerate() {
|
||||
if let Some(per) = per {
|
||||
seam::close_through_pole(s, &mut l, d, *per);
|
||||
}
|
||||
}
|
||||
projected.push(l);
|
||||
}
|
||||
align_periods(&mut projected, &dom);
|
||||
// A loop that winds around a periodic direction does not close in (u, v): the face is cut
|
||||
// along a parameter line into one period (a seamless cylinder face, a helical trim).
|
||||
let wound: Vec<usize> = (0..2).filter(|&d| dom.period[d].is_some_and(|per| seam::winds(&projected, d, per))).collect();
|
||||
@@ -309,7 +566,7 @@ fn plan_face(
|
||||
}
|
||||
ranges.push(start..uvs.len());
|
||||
}
|
||||
if uvs.len() < 3 {
|
||||
if uvs.len() < MIN_BOUNDARY_NODES {
|
||||
return Err(fail("degenerate boundary"));
|
||||
}
|
||||
let mut bbox = [(f64::MAX, f64::MIN); 2];
|
||||
@@ -326,7 +583,7 @@ fn plan_face(
|
||||
let seeds = cdt::lattice(bbox, [h / speed[0], h / speed[1]], &polys, speed);
|
||||
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
|
||||
let sag = cdt::Sag { eval: &eval, tol, h, turn: rules.turn, scale: speed };
|
||||
let fill = cdt::Fill { seeds: &seeds, refine: Some((sag, MAX_REFINE)), shape: !matches!(s, EvalSurface::Plane { .. }) };
|
||||
let fill = cdt::Fill { seeds: &seeds, refine: Some((sag, MAX_REFINE)), shape: !matches!(s, EvalSurface::Plane { .. }), lenient: false };
|
||||
cdt::triangulate(&uvs, &ranges, fill).ok_or_else(|| fail("boundary crosses itself"))
|
||||
};
|
||||
// Planar and curved faces alike: a structured grid where the boundary runs along the
|
||||
@@ -547,7 +804,7 @@ fn plan_pieces(f: &Region) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
|
||||
let seeds = cdt::lattice(bbox, [f.h / f.speed[0], f.h / f.speed[1]], &polys, f.speed);
|
||||
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
|
||||
let sag = cdt::Sag { eval: &eval, tol: f.rules.tol, h: f.h, turn: f.rules.turn, scale: f.speed };
|
||||
cdt::triangulate(&luv, &ranges, cdt::Fill { seeds: &seeds, refine: Some((sag, MAX_REFINE)), shape: true })
|
||||
cdt::triangulate(&luv, &ranges, cdt::Fill { seeds: &seeds, refine: Some((sag, MAX_REFINE)), shape: true, lenient: false })
|
||||
})?;
|
||||
let (lextra, ltris) = local;
|
||||
let base = u32::try_from(n + extra.len()).ok()?;
|
||||
|
||||
@@ -37,6 +37,41 @@ pub(crate) fn winds(loops: &[Loop], d: usize, per: f64) -> bool {
|
||||
loops.iter().any(|l| drift(l, d, per).abs() > per * 0.5)
|
||||
}
|
||||
|
||||
/// A loop through a pole of the closed direction `d` (a cone's apex or a sphere's pole on the
|
||||
/// boundary): the parameter along `d` is free there, so the step into or out of the pole may be
|
||||
/// any length. Unwrapped to the nearest copy at every step, a sector wider than half a turn, or
|
||||
/// a cone face written as its rim, the seam up to the apex and the seam back (as STEP files
|
||||
/// write a pointed end, a screw's tip), seems to wind around the surface, and the face cannot
|
||||
/// be cut along a seam it does not have. The step out of the pole is made the one that closes
|
||||
/// the loop. A loop that closes already, has no pole or passes more than one stays as it is.
|
||||
pub(crate) fn close_through_pole(s: &EvalSurface, l: &mut Loop, d: usize, per: f64) {
|
||||
let w = drift(l, d, per);
|
||||
let n = l.uv.len();
|
||||
if w.abs() <= per * 0.5 || n < 3 || l.pts.len() != n {
|
||||
return;
|
||||
}
|
||||
let pole: Vec<bool> = l.uv.iter().map(|q| super::uv::degenerate(s, *q, d)).collect();
|
||||
let Some(k) = pole.iter().position(|p| *p) else { return };
|
||||
// The node the loop leaves the pole for: the loop is turned to end there, so the step that
|
||||
// closes it is an ordinary one, and the step from the pole takes what is left. A loop
|
||||
// through a pole twice is left alone.
|
||||
let Some(out) = (1..n).map(|i| (k + i) % n).find(|&i| !pole.get(i).copied().unwrap_or(true)) else { return };
|
||||
if pole.iter().enumerate().any(|(i, p)| *p && (i + n - out) % n < (k + n - out) % n) {
|
||||
return;
|
||||
}
|
||||
let start = (out + 1) % n;
|
||||
l.uv.rotate_left(start);
|
||||
l.pts.rotate_left(start);
|
||||
// Unwrapped again from the new start; the last node, where the loop leaves the pole, is
|
||||
// taken next to the first instead of next to the pole.
|
||||
for i in 1..n {
|
||||
let near = if i + 1 == n { l.uv.first() } else { l.uv.get(i - 1) }.map(|q| q[d]);
|
||||
if let (Some(near), Some(q)) = (near, l.uv.get_mut(i)) {
|
||||
q[d] = super::uv::unwrap(q[d], near, Some(per));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point where the boundary crosses a cut line: its place along the line, its 3D point, and
|
||||
/// whether the face lies above it (towards larger parameters across the line) or below.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -350,6 +385,15 @@ fn seam_nodes(s: &EvalSurface, swap: bool, x: f64, y0: f64, y1: f64, rules: &Rul
|
||||
Some(inner.iter().map(|&y| (y, s.eval(uv(y)[0], uv(y)[1]).0)).collect())
|
||||
}
|
||||
|
||||
/// Whether a face of `solid` lies on a B-spline surface that joins itself (a lofted tube).
|
||||
/// Truck meshes a face in its surface's parameters and treats only a revolved surface's
|
||||
/// rotation as periodic, so a face that runs across such a join comes out folded over itself or
|
||||
/// with a hole in it. The reference mesh of such a body is made from the display plan,
|
||||
/// which knows the join is periodic.
|
||||
pub(crate) fn closes_on_itself(solid: &crate::body::Solid) -> bool {
|
||||
solid.face_iter().any(|f| matches!(super::surface::eval_surface(&f.surface()), Some(EvalSurface::Nurbs { closed, .. }) if closed[0] || closed[1]))
|
||||
}
|
||||
|
||||
/// Whether a face of `solid` lies on a surface of revolution and has a boundary loop turning
|
||||
/// more than once around its axis (a helical trim). Truck's mesher cannot mesh such a face (its
|
||||
/// triangles cut across the solid), so the reference mesh is made from the display plan instead.
|
||||
|
||||
@@ -55,14 +55,35 @@ impl Turn {
|
||||
pub(crate) struct Rules {
|
||||
pub tol: f64,
|
||||
pub max_len: f64,
|
||||
/// The body's extent (see [`extent`]); 0 when it has none. Tolerances that measure the
|
||||
/// body's own geometry rather than the mesh are fractions of it.
|
||||
pub extent: f64,
|
||||
pub turn: Option<Turn>,
|
||||
/// How far the body's boundaries may lie off their surfaces (mm), as the file the body was
|
||||
/// read from declares; `None` for a body this kernel built (see
|
||||
/// [`crate::body::Body::boundary_tol`]). It raises the planner's bound (a hundredth of the
|
||||
/// extent) where it is larger, never lowers it.
|
||||
pub boundary_tol: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
/// Rules for a body of extent `extent` (see [`extent`]).
|
||||
pub(crate) fn new(tol: f64, extent: f64, turn: Option<Turn>) -> Rules {
|
||||
let max_len = if extent.is_finite() && extent > 0.0 { extent * MAX_LEN_FRACTION } else { f64::INFINITY };
|
||||
Rules { tol, max_len: max_len.max(tol), turn }
|
||||
/// The same rules with a chord tolerance `k` times finer.
|
||||
pub(crate) fn finer(self, k: f64) -> Rules {
|
||||
Rules { tol: self.tol / k, ..self }
|
||||
}
|
||||
|
||||
/// Rules for a body of extent `extent` (see [`extent`]). `boundary_tol`: the body's declared
|
||||
/// off-surface budget (mm), `None` for a body this kernel built.
|
||||
pub(crate) fn new(tol: f64, extent: f64, turn: Option<Turn>, boundary_tol: Option<f64>) -> Rules {
|
||||
let sound = extent.is_finite() && extent > 0.0;
|
||||
let max_len = if sound { extent * MAX_LEN_FRACTION } else { f64::INFINITY };
|
||||
Rules {
|
||||
tol,
|
||||
max_len: max_len.max(tol),
|
||||
extent: if sound { extent } else { 0.0 },
|
||||
turn,
|
||||
boundary_tol: boundary_tol.filter(|t| t.is_finite() && *t > 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The side length for a curvature `k` (1/mm): the chord sags at most the tolerance across
|
||||
@@ -172,21 +193,21 @@ mod tests {
|
||||
swap: false,
|
||||
};
|
||||
// A 20 mm body: sides from 1 mm (the floor, a quarter of the longest) to 4 mm.
|
||||
let rules = Rules::new(0.01, 20.0, THIRTY);
|
||||
let rules = Rules::new(0.01, 20.0, THIRTY, None);
|
||||
let (h, speed) = face_size(&s, &super::super::uv::domain(&s), &rules);
|
||||
// r = 3: 30° of turn is 1.57 mm; the 0.01 mm sag chord is √(8·3·0.01) = 0.49 mm, times the
|
||||
// aspect, unless that is under the floor.
|
||||
assert!((h - (ASPECT * (8.0 * 3.0 * 0.01f64).sqrt()).max(20.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
|
||||
assert!((speed[0] - 40.0).abs() < 1e-6 && (speed[1] - 3.0).abs() < 1e-6, "{speed:?}");
|
||||
// Coarser: the turn limit governs.
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, THIRTY));
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, THIRTY, None));
|
||||
assert!((h - (ASPECT * 3.0 * std::f64::consts::PI / 6.0).max(100.0 * MAX_LEN_FRACTION * MIN_LEN_FRACTION)).abs() < 0.01, "{h}");
|
||||
// Without a facet angle the sag alone: √(8·3·0.5) = 3.46 mm, four times along.
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, None));
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, None, None));
|
||||
assert!((h - ASPECT * (8.0 * 3.0 * 0.5f64).sqrt()).abs() < 0.01, "{h}");
|
||||
// A turn that sags below its floor does not divide.
|
||||
let floored = Some(Turn { angle: std::f64::consts::PI / 6.0, floor: 1.0 });
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, floored));
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &Rules::new(0.5, 100.0, floored, None));
|
||||
assert!((h - ASPECT * (8.0 * 3.0 * 0.5f64).sqrt()).abs() < 0.01, "{h}");
|
||||
}
|
||||
|
||||
@@ -197,10 +218,11 @@ mod tests {
|
||||
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, 0.0, 0.0, 1.0], [4.0, 0.0, 0.0, 1.0], [0.0, 3.0, 0.0, 1.0], [4.0, 3.0, z, 1.0]],
|
||||
closed: [false, false],
|
||||
};
|
||||
assert!(planar(&patch(0.0)));
|
||||
assert!(!planar(&patch(0.5)));
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY, None);
|
||||
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
|
||||
assert_eq!(h, rules.max_len);
|
||||
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
|
||||
@@ -217,7 +239,7 @@ mod tests {
|
||||
transform: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]],
|
||||
swap: false,
|
||||
};
|
||||
let rules = Rules::new(0.1, 100.0, THIRTY);
|
||||
let rules = Rules::new(0.1, 100.0, THIRTY, None);
|
||||
let (h, _) = face_size(&s, &super::super::uv::domain(&s), &rules);
|
||||
assert!((h - rules.max_len * MIN_LEN_FRACTION).abs() < 1e-9, "{h}");
|
||||
}
|
||||
@@ -225,7 +247,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_plane_takes_the_longest_side() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY, None);
|
||||
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,39 @@ fn nurbs((pu, pv): (usize, usize), kv: &(mt::KnotVec, mt::KnotVec), rows: Vec<Ve
|
||||
return None;
|
||||
}
|
||||
let knots = [knots(&kv.0)?, knots(&kv.1)?];
|
||||
(knots[0].len() == nu + pu + 1 && knots[1].len() == nv + pv + 1).then_some(EvalSurface::Nurbs { degree: [pu, pv], knots, count: [nu, nv], ctrl })
|
||||
if !(knots[0].len() == nu + pu + 1 && knots[1].len() == nv + pv + 1) {
|
||||
return None;
|
||||
}
|
||||
let closed = [closed_along(0, &ctrl, [nu, nv], [pu, pv], &knots), closed_along(1, &ctrl, [nu, nv], [pu, pv], &knots)];
|
||||
Some(EvalSurface::Nurbs { degree: [pu, pv], knots, count: [nu, nv], ctrl, closed })
|
||||
}
|
||||
|
||||
/// Does the surface join itself along direction `d` (a tube's way round)? Its knots there must
|
||||
/// be clamped, so the ends of the range are the first and last rows of control points, and
|
||||
/// those two rows must be the same points: the join is then the parameter line `d = end`, and
|
||||
/// the direction is periodic.
|
||||
fn closed_along(d: usize, ctrl: &[[f64; 4]], count: [usize; 2], degree: [usize; 2], knots: &[Vec<f64>; 2]) -> bool {
|
||||
let (n, p) = (count[d], degree[d]);
|
||||
if n < 2 {
|
||||
return false;
|
||||
}
|
||||
let Some(k) = knots.get(d) else { return false };
|
||||
let Some((&a, &b)) = k.first().zip(k.last()) else { return false };
|
||||
// Clamped: degree + 1 knots equal at each end, and a range to speak of.
|
||||
if !(b > a && k.iter().take(p + 1).all(|x| *x == a) && k.iter().rev().take(p + 1).all(|x| *x == b)) {
|
||||
return false;
|
||||
}
|
||||
// Control point `(i, j)` with `i` along u, as a point.
|
||||
let at =
|
||||
|i: usize, j: usize| ctrl.get(j * count[0] + i).and_then(|h| (h[3].abs() > 1e-300).then(|| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])));
|
||||
let along_d = |x: usize, t: usize| if d == 0 { at(x, t) } else { at(t, x) };
|
||||
let (mut extent, mut gap) = (0.0f64, 0.0f64);
|
||||
for t in 0..count[1 - d] {
|
||||
let (Some(first), Some(last)) = (along_d(0, t), along_d(n - 1, t)) else { return false };
|
||||
extent = extent.max(first.len()).max(last.len());
|
||||
gap = gap.max(first.dist(last));
|
||||
}
|
||||
gap <= 1e-9 * extent.max(1.0)
|
||||
}
|
||||
|
||||
/// A profile curve in the evaluator's form (a line extends for ever, as truck's does).
|
||||
|
||||
@@ -27,7 +27,7 @@ fn assert_watertight(m: &Mesh, name: &str) {
|
||||
}
|
||||
|
||||
fn plan_body(b: &Body, tol: f64) -> (Mesh, PlanStats) {
|
||||
match plan(&b.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 })) {
|
||||
match plan(&b.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 }), b.boundary_tol) {
|
||||
Ok((p, stats)) => (p.evaluate(), stats),
|
||||
Err(e) => panic!("plan: {e}"),
|
||||
}
|
||||
@@ -94,7 +94,7 @@ fn a_box_is_a_regular_grid_on_every_face() {
|
||||
fn primitives_are_watertight_with_the_right_volume() {
|
||||
for tol in [0.05, 0.005] {
|
||||
let s = check("box", &ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 6.0, 4.0))), tol, 240.0);
|
||||
assert_eq!(s, PlanStats { planar: 6, structured: 0, delaunay: 0 });
|
||||
assert_eq!(s, PlanStats { planar: 6, structured: 0, delaunay: 0, filled: 0 });
|
||||
let s = check("cylinder", &ok(crate::cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 2.5, 7.0)), tol, PI * 2.5 * 2.5 * 7.0);
|
||||
assert_eq!(s.delaunay, 0, "cylinder walls are structured");
|
||||
let s = check("torus", &ok(crate::torus(Vec3::ZERO, 6.0, 1.5)), tol, 2.0 * PI * PI * 6.0 * 1.5 * 1.5);
|
||||
@@ -176,7 +176,7 @@ fn render_wireframes() {
|
||||
let Ok(old) = body.tessellate(tol) else { panic!("reference") };
|
||||
let t_old = t0.elapsed().as_secs_f64() * 1e3;
|
||||
let t0 = std::time::Instant::now();
|
||||
let new = match plan(&body.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 })) {
|
||||
let new = match plan(&body.solid, tol, Some(crate::tess::size::Turn { angle: crate::DISPLAY_ANGLE, floor: tol * 1e-3 }), body.boundary_tol) {
|
||||
Ok((p, st)) => Some((p.evaluate(), st)),
|
||||
Err(e) => {
|
||||
println!("{name}: no plan ({e})");
|
||||
@@ -296,3 +296,156 @@ fn trace_seeds_grade_from_one_end_to_the_other() {
|
||||
assert_eq!(even.len(), 3);
|
||||
assert!(graded([0.0, 0.0], [0.5, 0.0], 1.0, 1.0, 10.0, [1.0, 1.0], &|_, _| true).is_empty());
|
||||
}
|
||||
|
||||
/// A surface that joins itself along a direction is periodic there: its parameters repeat, so
|
||||
/// the evaluator takes a parameter outside the range back into it. A boundary that runs across
|
||||
/// the join is unwrapped past the range's end and must still land on the surface.
|
||||
#[test]
|
||||
fn a_closed_spline_surface_is_periodic_and_evaluates_outside_its_range() {
|
||||
use solidcraft_geom::tess::EvalSurface;
|
||||
// A five-sided tube: the control net's last column repeats its first.
|
||||
let corner = |k: usize| {
|
||||
let a = std::f64::consts::TAU * (k % 5) as f64 / 5.0;
|
||||
(10.0 * a.cos(), 10.0 * a.sin())
|
||||
};
|
||||
let mut ctrl = Vec::new();
|
||||
for j in 0..2 {
|
||||
for k in 0..=5 {
|
||||
let (x, y) = corner(k);
|
||||
ctrl.push([x, y, f64::from(j) * 8.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let s = EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 5.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [6, 2],
|
||||
ctrl,
|
||||
closed: [true, false],
|
||||
};
|
||||
let dom = uv::domain(&s);
|
||||
assert_eq!(dom.period, [Some(5.0), None]);
|
||||
assert_eq!(dom.range, [Some((0.0, 5.0)), Some((0.0, 1.0))]);
|
||||
// A parameter a whole range below the start is the same point as one inside it.
|
||||
for (a, b) in [(-0.5, 4.5), (-2.0, 3.0), (5.5, 0.5), (7.0, 2.0)] {
|
||||
let (Some(out), Some(inside)) = (s.derivs(a, 0.25), s.derivs(b, 0.25)) else { panic!("evaluates") };
|
||||
assert!(out.0.dist(inside.0) < 1e-12, "u = {a} and u = {b}: {:?} vs {:?}", out.0, inside.0);
|
||||
}
|
||||
// The same net left open is not periodic, and keeps truck's end-span extension outside it.
|
||||
let EvalSurface::Nurbs { degree, knots, count, ctrl, .. } = s else { panic!("nurbs") };
|
||||
let open = EvalSurface::Nurbs { degree, knots, count, ctrl, closed: [false, false] };
|
||||
assert_eq!(uv::domain(&open).period, [None, None]);
|
||||
let (Some(out), Some(inside)) = (open.derivs(-0.5, 0.25), open.derivs(4.5, 0.25)) else { panic!("evaluates") };
|
||||
assert!(out.0.dist(inside.0) > 1.0, "an open surface does not wrap");
|
||||
}
|
||||
|
||||
/// Each loop of a face is projected on its own, so on a periodic surface a hole can come out a
|
||||
/// whole turn away from the boundary that holds it. Every loop is brought into the outer
|
||||
/// boundary's period, where the hole cuts the face again.
|
||||
#[test]
|
||||
fn holes_are_brought_into_the_outer_boundarys_period() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let at = |u: f64, v: f64| seam::Loop { uv: vec![[u, v], [u + 0.2, v], [u + 0.2, v + 0.2], [u, v + 0.2]], pts: vec![Vec3::ZERO; 4] };
|
||||
let dom = uv::Domain { range: [Some((0.0, 1.0)), Some((0.0, tau))], period: [None, Some(tau)] };
|
||||
let mut loops = vec![at(0.0, -tau), at(0.4, 1.5), at(0.6, 1.5 - tau)];
|
||||
align_periods(&mut loops, &dom);
|
||||
// The outer loop stays where it is; both holes land beside it, a whole turn moved or not.
|
||||
assert_eq!(loops[0].uv[0], [0.0, -tau]);
|
||||
assert!((loops[1].uv[0][1] - (1.5 - tau)).abs() < 1e-12, "{:?}", loops[1].uv[0]);
|
||||
assert!((loops[2].uv[0][1] - (1.5 - tau)).abs() < 1e-12, "{:?}", loops[2].uv[0]);
|
||||
// Nothing moves along a direction that does not repeat.
|
||||
assert_eq!(loops[1].uv[0][0], 0.4);
|
||||
}
|
||||
|
||||
/// A boundary that steps out to a node and straight back encloses no area: the pair is dropped,
|
||||
/// so what is left no longer runs along itself. The two sides need only agree within the given
|
||||
/// distance — a STEP writer may draw them as two curves a hair apart.
|
||||
#[test]
|
||||
fn zero_width_slits_are_dropped_from_a_boundary() {
|
||||
let p = |x: f64| Vec3::new(x, 0.0, 0.0);
|
||||
// A square with a slit poked into it from the corner (2, 1): out to (2, 2) and back.
|
||||
let slit = vec![
|
||||
p(0.0),
|
||||
p(1.0),
|
||||
Vec3::new(2.0, 1.0, 0.0),
|
||||
Vec3::new(2.0, 2.0, 0.0),
|
||||
Vec3::new(2.0 + 1e-9, 1.0, 0.0),
|
||||
p(3.0),
|
||||
Vec3::new(3.0, 3.0, 0.0),
|
||||
Vec3::new(0.0, 3.0, 0.0),
|
||||
];
|
||||
let (out, known) = drop_slits(&[slit], &[Vec::new()], 1e-6);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0], vec![p(0.0), p(1.0), Vec3::new(2.0, 1.0, 0.0), p(3.0), Vec3::new(3.0, 3.0, 0.0), Vec3::new(0.0, 3.0, 0.0)]);
|
||||
assert_eq!(known[0].len(), out[0].len());
|
||||
// Too far apart to be one slit: the boundary is left as it is.
|
||||
let wide = vec![p(0.0), p(1.0), Vec3::new(2.0, 1.0, 0.0), Vec3::new(2.0, 2.0, 0.0), Vec3::new(2.5, 1.0, 0.0), p(3.0)];
|
||||
let kept = drop_slits(std::slice::from_ref(&wide), &[Vec::new()], 1e-6).0;
|
||||
assert_eq!(kept, vec![wide]);
|
||||
}
|
||||
|
||||
/// A pointed end read back from STEP (a screw's tip, a cone): its cone face is written as the
|
||||
/// rim, the seam up to the apex and the seam back, and a sector of one as its rim and two
|
||||
/// sides meeting at the apex. Unwrapped to the nearest copy at every step, the jump at the
|
||||
/// apex made such a loop seem to wind around the cone; the face could not be cut along a seam
|
||||
/// it did not have, and the whole body lost its display plan.
|
||||
#[test]
|
||||
fn a_pointed_end_read_back_from_step_is_planned() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let (r, tip, len) = (3.0, 4.0, 20.0);
|
||||
let Some(axis) = Plane::new(Vec3::ZERO, Vec3::X, Vec3::Z) else { panic!("plane") };
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(r, tip), Vec2::new(r, len), Vec2::new(0.0, len)];
|
||||
for turn in [360.0f64, 300.0, 270.0, 200.0] {
|
||||
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
|
||||
let Some(body) = ok(crate::revolve(&axis, &[region], Vec2::ZERO, Vec2::Y, turn.to_radians())).pop() else { panic!("revolve") };
|
||||
let imported = ok(crate::step_import(&ok(crate::step_export(&[&body], "pointed end"))));
|
||||
let Some(copy) = imported.bodies.first() else { panic!("no body") };
|
||||
let volume = std::f64::consts::PI * r * r * (len - tip + tip / 3.0) * turn / 360.0;
|
||||
check(&format!("pointed end turned {turn} deg"), ©.body, 0.02, volume);
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on a spline that turns many times (a thread's helical flank, as other CAD systems
|
||||
/// write it to STEP, has hundreds of spans along the helix) is found from a hint a few turns
|
||||
/// away: between 16 starts across the whole spline Newton settled on another turn, the point
|
||||
/// was taken as off the surface and the body lost its display plan.
|
||||
#[test]
|
||||
fn a_point_on_a_long_helical_spline_projects() {
|
||||
let (turns, per_turn, pitch) = (40usize, 24usize, 0.5);
|
||||
let n = turns * per_turn + 1;
|
||||
let at = |i: usize, r: f64| {
|
||||
let t = std::f64::consts::TAU * i as f64 / per_turn as f64;
|
||||
[r * t.cos(), r * t.sin(), pitch * t / std::f64::consts::TAU, 1.0]
|
||||
};
|
||||
let mut ctrl: Vec<[f64; 4]> = Vec::with_capacity(2 * n);
|
||||
for i in 0..n {
|
||||
ctrl.push(at(i, 0.7));
|
||||
ctrl.push(at(i, 0.55));
|
||||
}
|
||||
let mut ku = vec![0.0; 3];
|
||||
ku.extend((1..n - 2).map(|i| i as f64 / (n - 2) as f64));
|
||||
ku.extend([1.0; 3]);
|
||||
let s = EvalSurface::Nurbs { degree: [2, 1], knots: [ku, vec![0.0, 0.0, 1.0, 1.0]], count: [n, 2], ctrl, closed: [false, false] };
|
||||
let dom = uv::domain(&s);
|
||||
for (target, hint) in [([0.5, 1.0], [0.488, 1.0]), ([0.2, 0.0], [0.23, 1.0]), ([0.9, 0.5], [0.1, 0.5])] {
|
||||
let Some((p, _, _)) = s.derivs(target[0], target[1]) else { panic!("evaluate") };
|
||||
let Some(q) = uv::project(&s, &dom, p, Some(hint), 1e-4) else { panic!("{target:?} from {hint:?}: not found") };
|
||||
let Some((back, _, _)) = s.derivs(q[0], q[1]) else { panic!("evaluate") };
|
||||
assert!(back.dist(p) <= 1e-4, "{target:?} from {hint:?}: {q:?} is {} away", back.dist(p));
|
||||
}
|
||||
}
|
||||
|
||||
/// A sliver face as STEP files write one: two edges between the same two vertices, a rounding
|
||||
/// apart, so its boundary runs out and back along itself. Its slits dropped, nothing is left to
|
||||
/// bound a face (it is planned empty); a boundary enclosing anything keeps its loop.
|
||||
#[test]
|
||||
fn a_boundary_running_back_along_itself_is_all_slit() {
|
||||
let out: Vec<Vec3> = (0..5).map(|i| Vec3::new(f64::from(i), 0.5 * f64::from(i), 0.0)).collect();
|
||||
let mut sliver = out.clone();
|
||||
sliver.extend(out.iter().rev().skip(1).take(3).map(|p| *p + Vec3::new(0.0, 1e-14, 0.0)));
|
||||
let (left, _) = drop_slits(&[sliver], &[Vec::new()], 1e-9);
|
||||
assert!(left.iter().all(|l| l.len() < 3), "{left:?}");
|
||||
let mut lens = out.clone();
|
||||
lens.extend(out.iter().rev().skip(1).take(3).map(|p| *p + Vec3::new(0.0, 1e-3, 0.0)));
|
||||
let (kept, _) = drop_slits(std::slice::from_ref(&lens), &[Vec::new()], 1e-9);
|
||||
assert_eq!(kept, vec![lens]);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,12 @@ pub(crate) fn domain(s: &EvalSurface) -> Domain {
|
||||
let tau = std::f64::consts::TAU;
|
||||
match s {
|
||||
EvalSurface::Plane { .. } => Domain { range: [None, None], period: [None, None] },
|
||||
EvalSurface::Nurbs { degree, knots, count, .. } => {
|
||||
let r = |d: usize| Some((*knots[d].get(degree[d])?, *knots[d].get(count[d])?));
|
||||
Domain { range: [r(0), r(1)], period: [None, None] }
|
||||
EvalSurface::Nurbs { degree, knots, count, closed, .. } => {
|
||||
let r = |d: usize| Some((*knots.get(d)?.get(*degree.get(d)?)?, *knots.get(d)?.get(*count.get(d)?)?));
|
||||
// A direction the surface joins itself along is periodic: a boundary that runs
|
||||
// across the join is unwrapped past the range's end, and the evaluator wraps it back.
|
||||
let p = |d: usize| closed.get(d).copied().unwrap_or(false).then(|| r(d).map(|(a, b)| b - a)).flatten().filter(|x| *x > 0.0);
|
||||
Domain { range: [r(0), r(1)], period: [p(0), p(1)] }
|
||||
}
|
||||
EvalSurface::Revolved { profile, swap, .. } => {
|
||||
let r = (|| Some((*profile.knots.get(profile.degree)?, *profile.knots.get(profile.ctrl.len())?)))();
|
||||
@@ -109,11 +112,11 @@ pub(crate) fn project(s: &EvalSurface, dom: &Domain, p: Vec3, hint: Option<[f64;
|
||||
best = Some((uv, d));
|
||||
}
|
||||
let (ur, vr) = (dom.range[0]?, dom.range[1]?);
|
||||
const N: usize = 16;
|
||||
let mut starts: Vec<(f64, [f64; 2])> = Vec::with_capacity((N + 1) * (N + 1));
|
||||
for i in 0..=N {
|
||||
for j in 0..=N {
|
||||
let uv = [ur.0 + (ur.1 - ur.0) * i as f64 / N as f64, vr.0 + (vr.1 - vr.0) * j as f64 / N as f64];
|
||||
let [nu, nv] = start_lines(s);
|
||||
let mut starts: Vec<(f64, [f64; 2])> = Vec::with_capacity((nu + 1) * (nv + 1));
|
||||
for i in 0..=nu {
|
||||
for j in 0..=nv {
|
||||
let uv = [ur.0 + (ur.1 - ur.0) * i as f64 / nu as f64, vr.0 + (vr.1 - vr.0) * j as f64 / nv as f64];
|
||||
if let Some((q, _, _)) = s.derivs(uv[0], uv[1]) {
|
||||
starts.push(((q - p).len2(), uv));
|
||||
}
|
||||
@@ -130,6 +133,28 @@ pub(crate) fn project(s: &EvalSurface, dom: &Domain, p: Vec3, hint: Option<[f64;
|
||||
best.filter(|(_, d)| *d <= accept).map(|(uv, _)| uv)
|
||||
}
|
||||
|
||||
/// Most starts [`project`] tries along one direction, and over the whole domain.
|
||||
const MAX_START_LINES: usize = 4096;
|
||||
const MAX_STARTS: usize = 16_384;
|
||||
|
||||
/// How many divisions of each direction [`project`] starts from: 16, or two per span of a
|
||||
/// spline with more. A long spline (a thread's helical flank, as other CAD systems write it to
|
||||
/// STEP: hundreds of spans along the helix) turns many times between 16 starts, and from the
|
||||
/// nearest of them Newton settles on another turn, so a point of its boundary was taken as off
|
||||
/// the surface and the body lost its display plan.
|
||||
fn start_lines(s: &EvalSurface) -> [usize; 2] {
|
||||
const N: usize = 16;
|
||||
let spans = |d: usize| match s {
|
||||
EvalSurface::Nurbs { degree, count, .. } => count.get(d).zip(degree.get(d)).map_or(0, |(c, g)| c.saturating_sub(*g)),
|
||||
_ => 0,
|
||||
};
|
||||
let mut lines = [0, 1].map(|d| (2 * spans(d)).clamp(N, MAX_START_LINES));
|
||||
// The longer direction keeps its spans; the other gives way to the cap on starts.
|
||||
let (long, short) = if lines[0] >= lines[1] { (0, 1) } else { (1, 0) };
|
||||
lines[short] = lines[short].min((MAX_STARTS / (lines[long] + 1)).max(N));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `x` moved by whole periods to lie within half a period of `near`.
|
||||
pub(crate) fn unwrap(x: f64, near: f64, period: Option<f64>) -> f64 {
|
||||
match period {
|
||||
@@ -288,15 +313,37 @@ mod tests {
|
||||
#[test]
|
||||
fn divides_the_turn_but_not_the_straight_direction() {
|
||||
let s = cylinder();
|
||||
let turn =
|
||||
divide(&s, 1, (0.0, std::f64::consts::TAU), (0.0, 1.0), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
let along =
|
||||
divide(&s, 0, (0.0, 1.0), (0.0, std::f64::consts::TAU), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
let turn = divide(
|
||||
&s,
|
||||
1,
|
||||
(0.0, std::f64::consts::TAU),
|
||||
(0.0, 1.0),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
let along = divide(
|
||||
&s,
|
||||
0,
|
||||
(0.0, 1.0),
|
||||
(0.0, std::f64::consts::TAU),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
assert_eq!(along, vec![0.0, 1.0]);
|
||||
// Chord sag r(1 − cos(θ/2)) ≤ 0.01 with r = 3 needs θ ≤ 0.163: at least 39 segments.
|
||||
assert!(turn.len() >= 40 && turn.len() < 80, "{}", turn.len());
|
||||
// With a longest side the straight direction is divided too: 4 mm in sides ≤ 0.5.
|
||||
let along = divide(&s, 0, (0.0, 1.0), (0.0, std::f64::consts::TAU), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, 0.5, 10_000);
|
||||
let along = divide(
|
||||
&s,
|
||||
0,
|
||||
(0.0, 1.0),
|
||||
(0.0, std::f64::consts::TAU),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
0.5,
|
||||
10_000,
|
||||
);
|
||||
assert_eq!(along.len(), 9);
|
||||
}
|
||||
|
||||
@@ -306,7 +353,15 @@ mod tests {
|
||||
#[test]
|
||||
fn divides_with_the_ends_exactly() {
|
||||
let (a0, a1) = (-1.010485317689877, 1.083909784703316);
|
||||
let lines = divide(&cylinder(), 1, (a0, a1), (0.0, 1.0), &Rules { tol: 0.01, max_len: f64::INFINITY, turn: None }, f64::INFINITY, 10_000);
|
||||
let lines = divide(
|
||||
&cylinder(),
|
||||
1,
|
||||
(a0, a1),
|
||||
(0.0, 1.0),
|
||||
&Rules { tol: 0.01, max_len: f64::INFINITY, extent: 0.0, turn: None, boundary_tol: None },
|
||||
f64::INFINITY,
|
||||
10_000,
|
||||
);
|
||||
assert_eq!(lines.first().copied(), Some(a0));
|
||||
assert_eq!(lines.last().copied(), Some(a1));
|
||||
}
|
||||
|
||||
@@ -3132,3 +3132,23 @@ fn a_box_cut_round_a_small_hole_of_a_long_plate_keeps_the_hole() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transform with a perspective term or a singular linear part is refused: placed into the
|
||||
/// body, it built a revolved ring whose mesh measured hundreds of millions of mm³, and cutting
|
||||
/// a pocket into it ran for over half an hour before failing. A translation still places it.
|
||||
#[test]
|
||||
fn transform_refuses_a_matrix_that_is_not_an_affine_placement() {
|
||||
let rect = Loop2::polygon(&[Vec2::new(20.0, 0.0), Vec2::new(60.0, 0.0), Vec2::new(60.0, 10.0), Vec2::new(20.0, 10.0)]);
|
||||
let ring = revolve(&Plane::XZ, &[Region2 { outer: rect, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().remove(0);
|
||||
// Column-major: the last entry of the third column is a perspective term.
|
||||
let perspective = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 10.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
let flat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
for m in [perspective, flat] {
|
||||
assert!(matches!(crate::transform_matrix(&ring, m), Err(KernelError::Invalid(_))), "{m:?}");
|
||||
}
|
||||
let lifted = crate::transform_matrix(&ring, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 10.0, 1.0]]).unwrap();
|
||||
let pocket = box_solid(Vec3::new(37.0, -3.0, 7.0), Vec3::new(43.0, 3.0, 12.0)).unwrap();
|
||||
let cut = boolean(&lifted, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let removed = measure(&lifted).unwrap().volume - measure(&cut).unwrap().volume;
|
||||
assert!((removed - 6.0 * 6.0 * 3.0).abs() < 1.0, "{removed}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use solidcraft_engine::render::StandardView;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
|
||||
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`.
|
||||
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`, `control.running`.
|
||||
pub trait Backend {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// True when a real window is attached (`ui.screenshot` can capture it).
|
||||
@@ -301,6 +301,8 @@ impl Backend for Headless {
|
||||
}))
|
||||
}
|
||||
"ui.screenshot" => Err("no window in headless mode; use ui.render".into()),
|
||||
// Calls run here one at a time: none is running while this one is asked.
|
||||
"control.running" => Ok(json!({"running": []})),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -100,6 +100,7 @@ fn handshake_and_tools_list() {
|
||||
"undo",
|
||||
"redo",
|
||||
"crash_reports",
|
||||
"running",
|
||||
"new_design",
|
||||
"open",
|
||||
"save",
|
||||
@@ -114,6 +115,8 @@ fn handshake_and_tools_list() {
|
||||
let cmds = tool(&mut s, "list_commands", json!({"filter": "fillet"}));
|
||||
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "solid.fillet"), "{cmds}");
|
||||
assert!(cmds.as_array().unwrap().len() < 5);
|
||||
// Headless, calls run one at a time: none is running while `running` is asked (#3654).
|
||||
assert_eq!(tool(&mut s, "running", json!({})), json!({"running": []}));
|
||||
|
||||
let res = call(&mut s, 5, "resources/list", json!({}));
|
||||
assert_eq!(res["result"]["resources"].as_array().unwrap().len(), 2);
|
||||
@@ -1348,6 +1351,17 @@ fn methods(b: &Recorder) -> Vec<&str> {
|
||||
b.calls.iter().map(|(m, _)| m.as_str()).collect()
|
||||
}
|
||||
|
||||
/// #3654: bridged, `running` asks the app's `control.running`; an older app's refusal is the
|
||||
/// tool's error.
|
||||
#[test]
|
||||
fn running_asks_the_app() {
|
||||
let mut b = Recorder { calls: Vec::new(), batch: Ok(json!({})) };
|
||||
let r = crate::tools::call_tool(&mut b, "running", &json!({}));
|
||||
assert_eq!(methods(&b), ["control.running"]);
|
||||
assert_eq!(r["isError"], json!(true), "{r}");
|
||||
assert!(r["content"][0]["text"].as_str().is_some_and(|t| t.starts_with("unknown method")), "{r}");
|
||||
}
|
||||
|
||||
/// #743: bridged to an app that has `engine.batch`, a batch is one control request.
|
||||
#[test]
|
||||
fn batch_is_one_engine_batch_call() {
|
||||
@@ -1555,7 +1569,7 @@ fn tool_annotations_tell_the_whole_truth() {
|
||||
for name in ["execute", "batch", "save", "export", "open", "screenshot"] {
|
||||
assert_eq!(hints(name), (false, true, true), "{name}");
|
||||
}
|
||||
for name in ["list_commands", "describe_command", "inspect_design", "measure", "body_topology", "crash_reports"] {
|
||||
for name in ["list_commands", "describe_command", "inspect_design", "measure", "body_topology", "crash_reports", "running"] {
|
||||
assert_eq!(hints(name), (true, false, false), "{name}");
|
||||
}
|
||||
assert_eq!(hints("new_design"), (false, true, false));
|
||||
|
||||
@@ -255,6 +255,16 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"running",
|
||||
"What the app is running",
|
||||
"What the app is running or has queued right now, for every client: each request's connection and id, method, \
|
||||
command id (a batch's step count), seconds since it arrived (running_s) and whether it still waits its turn \
|
||||
(queued); `self` marks a request sent on the connection that asks. Answers at once, even while the app is busy, \
|
||||
and never shows parameters. Ask it when a call timed out or the app seems slow, before sending a change again.",
|
||||
obj(json!({}), &[]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool(
|
||||
@@ -454,6 +464,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
Some(_) => exec(b, "diagnostics.crash_report", pick(a, &["id"])),
|
||||
None => exec(b, "diagnostics.crash_reports", pick(a, &["limit"])),
|
||||
},
|
||||
"running" => b.call("control.running", json!({})),
|
||||
"undo" => exec(b, "edit.undo", json!({})),
|
||||
"redo" => exec(b, "edit.redo", json!({})),
|
||||
"new_design" => exec(b, "file.new", pick(a, &["name"])),
|
||||
|
||||
@@ -75,7 +75,17 @@ impl ApiBackend {
|
||||
let stop = cancel.clone();
|
||||
let wake: Arc<dyn Fn() + Send + Sync> = Arc::from(ctx.wake);
|
||||
std::thread::spawn(move || {
|
||||
let mut w = Worker { cfg, mcp, next: 0, tools: Vec::new(), history: Vec::new(), events: ev_tx, wake, cancel: stop };
|
||||
let mut w = Worker {
|
||||
cfg,
|
||||
mcp,
|
||||
next: 0,
|
||||
tools: Vec::new(),
|
||||
history: Vec::new(),
|
||||
events: ev_tx,
|
||||
wake,
|
||||
cancel: stop,
|
||||
no_reasoning: Default::default(),
|
||||
};
|
||||
w.run(cmd_rx);
|
||||
});
|
||||
Ok(ApiBackend { cmds: Some(cmd_tx), events, cancel, mcp: handle })
|
||||
@@ -137,6 +147,8 @@ struct Worker {
|
||||
events: Sender<Event>,
|
||||
wake: Arc<dyn Fn() + Send + Sync>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
/// Set once the endpoint refused tools together with a reasoning effort: later requests ask for none.
|
||||
no_reasoning: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -322,7 +334,28 @@ impl Worker {
|
||||
let mut body = json!({"model": self.cfg.model, "messages": self.history});
|
||||
if !self.tools.is_empty() {
|
||||
body["tools"] = Value::Array(self.tools.clone());
|
||||
if self.no_reasoning.get() {
|
||||
body["reasoning_effort"] = json!("none");
|
||||
}
|
||||
}
|
||||
let (status, v) = self.post(&body)?;
|
||||
// Some OpenAI models refuse function tools on this endpoint unless the reasoning effort is
|
||||
// "none" (HTTP 400 naming `reasoning_effort`): ask again once with it, and keep it for the
|
||||
// next requests.
|
||||
let refused = status == 400 && !self.tools.is_empty() && !self.no_reasoning.get() && http_error(status, &v).contains("reasoning_effort");
|
||||
if refused {
|
||||
self.no_reasoning.set(true);
|
||||
body["reasoning_effort"] = json!("none");
|
||||
let (status, v) = self.post(&body)?;
|
||||
return if status == 200 { Ok(v) } else { Err(http_error(status, &v)) };
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn post(&self, body: &Value) -> Result<(u16, Value), String> {
|
||||
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
|
||||
if let Some(k) = &self.cfg.key {
|
||||
headers.push(("Authorization".into(), format!("Bearer {k}")));
|
||||
@@ -351,11 +384,7 @@ impl Worker {
|
||||
Err(_) => return Err("the request ended without an answer".into()),
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &v));
|
||||
}
|
||||
Ok(v)
|
||||
Ok((status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,4 +805,63 @@ mod tests {
|
||||
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
|
||||
assert!(ApiBackend::start(cfg, ctx).is_err());
|
||||
}
|
||||
|
||||
/// A worker with one function tool whose endpoint answers each POST with the next scripted
|
||||
/// (status, body); the requests it received are returned too.
|
||||
fn scripted_worker(answers: Vec<(u16, Value)>) -> (Worker, Arc<std::sync::Mutex<Vec<Value>>>) {
|
||||
let answers = std::sync::Mutex::new(answers.into_iter().rev().collect::<Vec<_>>());
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
|
||||
let log = seen.clone();
|
||||
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
|
||||
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap()).unwrap());
|
||||
let (status, v) = answers.lock().unwrap().pop().expect("no request beyond the script");
|
||||
Ok((status, v.to_string().into_bytes()))
|
||||
});
|
||||
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: Some("sk-secret".into()), http };
|
||||
let (events, _rx) = channel();
|
||||
let w = Worker {
|
||||
cfg,
|
||||
mcp: None,
|
||||
next: 0,
|
||||
tools: vec![json!({"type": "function", "function": {"name": "measure"}})],
|
||||
history: vec![json!({"role": "user", "content": "hi"})],
|
||||
events,
|
||||
wake: Arc::new(|| {}),
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
no_reasoning: Default::default(),
|
||||
};
|
||||
(w, seen)
|
||||
}
|
||||
|
||||
fn refusal() -> (u16, Value) {
|
||||
(400, json!({"error": {"message": "Function tools with reasoning_effort are not supported for gpt-6.1-sol in /v1/chat/completions."}}))
|
||||
}
|
||||
|
||||
/// Newer OpenAI models answer 400 to function tools without `reasoning_effort: none`: the
|
||||
/// request is replayed once with it, and the next ones carry it at once.
|
||||
#[test]
|
||||
fn a_refusal_of_tools_with_reasoning_effort_is_replayed_once_with_none() {
|
||||
let ok = json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]});
|
||||
let (w, seen) = scripted_worker(vec![refusal(), (200, ok.clone()), (200, ok.clone())]);
|
||||
assert_eq!(w.request().unwrap(), ok);
|
||||
assert_eq!(w.request().unwrap(), ok);
|
||||
let sent = seen.lock().unwrap();
|
||||
assert_eq!(sent.len(), 3);
|
||||
assert!(sent[0].get("reasoning_effort").is_none());
|
||||
assert_eq!(sent[1]["reasoning_effort"], "none");
|
||||
assert_eq!(sent[2]["reasoning_effort"], "none");
|
||||
}
|
||||
|
||||
/// No loop: a second refusal is the error, after exactly two requests; the message shows the
|
||||
/// endpoint's words but never the key; an unrelated 400 is not replayed at all.
|
||||
#[test]
|
||||
fn the_replay_happens_once_and_only_for_that_refusal() {
|
||||
let (w, seen) = scripted_worker(vec![refusal(), refusal()]);
|
||||
let e = w.request().unwrap_err();
|
||||
assert!(e.starts_with("HTTP 400") && e.contains("reasoning_effort") && !e.contains("sk-secret"), "{e}");
|
||||
assert_eq!(seen.lock().unwrap().len(), 2);
|
||||
let (w, seen) = scripted_worker(vec![(400, json!({"error": {"message": "context length exceeded"}}))]);
|
||||
assert!(w.request().unwrap_err().contains("context length"));
|
||||
assert_eq!(seen.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !shown(app) {
|
||||
return;
|
||||
}
|
||||
super::settings::dialog(app, &ctx);
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user