mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea32f8aac | |||
| 034aa60d47 | |||
| 971b223246 | |||
| 8c861f9f46 | |||
| e04a016e7c | |||
| 93a2922c5e | |||
| ff8b09c225 | |||
| 975a08c992 | |||
| 55587f4ac4 | |||
| e94900933d | |||
| 4927214976 | |||
| 57bcc40838 | |||
| 9974413a5b | |||
| 818af2b856 | |||
| 80238a7cfb | |||
| bd65a561c8 | |||
| 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 | |||
| bdd2fb1a5c | |||
| e64f1e0427 | |||
| ac362945cc | |||
| 382680d8e8 | |||
| 316b49cd84 | |||
| b0932a487e | |||
| edad8078ba | |||
| 26c0c19ef7 | |||
| 2f43ad5c5b | |||
| 6a8604c55d | |||
| b621819c0c | |||
| ec552a5300 | |||
| e0cc740cc3 | |||
| 5de48e916d | |||
| 80af7bbe3e | |||
| 191e983423 | |||
| 2b9445b049 | |||
| 7ddf48ca16 | |||
| d2f6848377 | |||
| 0b6d6fecd8 | |||
| fa691707c2 | |||
| b9a55c74e2 | |||
| 2bb9c81069 | |||
| cedb51b8ea | |||
| 66d13059dd | |||
| 800ea40c0d | |||
| 20442eecd3 | |||
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 41779cf7bb | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 5ead82e627 | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| 4c09fa5355 |
@@ -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);
|
||||
+1
-1
@@ -94,7 +94,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
@@ -119,6 +118,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -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.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4193,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.18.1"
|
||||
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.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4217,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4262,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4302,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4322,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4331,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4345,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4356,14 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5920,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
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.1"
|
||||
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.
|
||||
|
||||
@@ -141,18 +141,13 @@ new false
|
||||
normal false
|
||||
offset false
|
||||
offset_face false
|
||||
offset_one_side false
|
||||
offset_symmetric false
|
||||
offset_two_sides false
|
||||
open false
|
||||
orbit false
|
||||
origin false
|
||||
pan false
|
||||
params false
|
||||
paste false
|
||||
pattern_center false
|
||||
pattern_circ false
|
||||
pattern_flip false
|
||||
pattern_path false
|
||||
pattern_rect false
|
||||
perforate false
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Center X": "Centre X",
|
||||
"Center Y": "Centre Y",
|
||||
"Center of mass": "Centre of mass",
|
||||
"Customize Toolbar": "Customise Toolbar",
|
||||
"Mass Center": "Mass Centre",
|
||||
"Modeled": "Modelled",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.sketch_arc_center_point": "Centre Point Arc",
|
||||
"cmd.sketch_circle_center": "Centre Diameter Circle",
|
||||
"cmd.sketch_rectangle_center": "Centre Rectangle",
|
||||
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
|
||||
"cmd.sketch_slot_center_point": "Centre Point Slot",
|
||||
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
|
||||
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
|
||||
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"sketch_text.align_center": "Centre",
|
||||
"ui.center": "Centre",
|
||||
"ui.centerline": "Centreline",
|
||||
"ui.customizeToolbar": "Customise Toolbar…",
|
||||
"ui.design_properties.license": "Licence",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
|
||||
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
|
||||
}
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "Licences",
|
||||
"help.mit": "MIT licence",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
|
||||
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
|
||||
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
|
||||
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
|
||||
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
|
||||
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.documentation": "Documentation",
|
||||
"home.in_this_browser": "in this browser",
|
||||
@@ -1203,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",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "Licences",
|
||||
"help.mit": "Licence MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
|
||||
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
|
||||
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
|
||||
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
|
||||
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
|
||||
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
|
||||
"help.third_party": "Licences tierces",
|
||||
"home.documentation": "Documentation",
|
||||
"home.in_this_browser": "dans ce navigateur",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "ライセンス",
|
||||
"help.mit": "MITライセンス",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
|
||||
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
|
||||
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
|
||||
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
|
||||
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
|
||||
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
|
||||
"help.third_party": "サードパーティライセンス",
|
||||
"home.documentation": "ドキュメント",
|
||||
"home.in_this_browser": "このブラウザ内",
|
||||
@@ -1203,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": "エージェントを追加",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "许可证",
|
||||
"help.mit": "MIT 许可证",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
|
||||
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
|
||||
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
|
||||
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
|
||||
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
|
||||
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
|
||||
"help.third_party": "第三方许可证",
|
||||
"home.documentation": "文档",
|
||||
"home.in_this_browser": "在此浏览器中",
|
||||
@@ -1203,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);
|
||||
|
||||
+578
-65
File diff suppressed because it is too large
Load Diff
@@ -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 } => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
|
||||
//! tool `whole_face_tool` picks.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::{Plane, Vec2, Vec3};
|
||||
use solidcraft_kernel as kernel;
|
||||
use solidcraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::*;
|
||||
|
||||
thread_local! {
|
||||
static SLABS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A cut took a slab over the whole body as its tool, on this thread.
|
||||
pub(super) fn slab_taken() {
|
||||
SLABS.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
|
||||
/// A closed spline through six points round the origin, on the plane at height `z`.
|
||||
fn spline_sketch(z: f64) -> FeatureKind {
|
||||
let mut sk = Sketch::new();
|
||||
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
|
||||
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
pts.push(pts[0]);
|
||||
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
|
||||
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
|
||||
}
|
||||
|
||||
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
|
||||
let kind = FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: serde_json::from_value(extent).unwrap(),
|
||||
operation,
|
||||
targets: vec![],
|
||||
};
|
||||
doc.add_feature(kind, None).unwrap();
|
||||
}
|
||||
|
||||
fn volume(model: &Model) -> f64 {
|
||||
let state = model.state();
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
|
||||
kernel::measure(&state.bodies[0].body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
|
||||
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
|
||||
/// below the face up: it must take the face's area times 3 and leave the plate alone.
|
||||
#[test]
|
||||
fn slab_cut_into_a_spline_prism() {
|
||||
let mut doc = Document::new("spline prism");
|
||||
let plate = FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
|
||||
length: "40 mm".into(),
|
||||
width: "40 mm".into(),
|
||||
height: "5 mm".into(),
|
||||
operation: Operation::NewBody,
|
||||
};
|
||||
doc.add_feature(plate, None).unwrap();
|
||||
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
|
||||
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let joined = volume(&model);
|
||||
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
|
||||
assert!(area > 100.0, "the prism's area {area}");
|
||||
|
||||
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
|
||||
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
|
||||
SLABS.with(|n| n.set(0));
|
||||
model.evaluate(&doc);
|
||||
let cut = volume(&model);
|
||||
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
|
||||
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
|
||||
let bounds = model.state().bodies[0].mesh().bounds();
|
||||
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
|
||||
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
|
||||
}
|
||||
+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();
|
||||
|
||||
@@ -1150,3 +1150,76 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block of #2040 with `extra` run after it.
|
||||
fn rounded_block_and(extra: Value) -> Session {
|
||||
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
|
||||
script["commands"].as_array_mut().unwrap().push(extra);
|
||||
replay(&script.to_string())
|
||||
}
|
||||
|
||||
/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's
|
||||
/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the
|
||||
/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the
|
||||
/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2).
|
||||
#[test]
|
||||
fn extrude_join_from_a_face_rounded_by_fillets() {
|
||||
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
|
||||
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
|
||||
let r = 3.8_f64;
|
||||
let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt();
|
||||
let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}}));
|
||||
let bodies = &joined.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1, "the join makes no second body");
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let v = volume(&joined, "Body1");
|
||||
assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0);
|
||||
}
|
||||
|
||||
/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom
|
||||
/// edge left sharp). The walls from the bottom edge go into the body and the others only touch
|
||||
/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result".
|
||||
/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its
|
||||
/// top corners rounded) moved down the face by t: the cut takes
|
||||
/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
|
||||
#[test]
|
||||
fn extrude_cut_into_a_face_beside_rounds() {
|
||||
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
|
||||
let top_only = |extra: Option<Value>| {
|
||||
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
|
||||
let commands = script["commands"].as_array_mut().unwrap();
|
||||
let fillet = commands.last_mut().unwrap();
|
||||
fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]);
|
||||
commands.extend(extra);
|
||||
replay(&script.to_string())
|
||||
};
|
||||
let before = volume(&top_only(None), "Body1");
|
||||
let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}})));
|
||||
let bodies = &cut.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
|
||||
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
|
||||
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
|
||||
let v = before - volume(&cut, "Body1");
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
|
||||
/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide
|
||||
/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the
|
||||
/// whole body would take them too: the cut takes only what lies over the face, at height z from
|
||||
/// x = 0 to the slanted face at x = x0 + z.
|
||||
#[test]
|
||||
fn extrude_cut_into_a_rounded_block_top() {
|
||||
let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64);
|
||||
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
|
||||
let cut = rounded_block_and(
|
||||
json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}),
|
||||
);
|
||||
let bodies = &cut.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0);
|
||||
let v = before - volume(&cut, "Body1");
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -89,9 +89,10 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
};
|
||||
let names = match p.get("names") {
|
||||
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
|
||||
Some(v) => {
|
||||
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
|
||||
}
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(solidcraft_io::StepNames::parse)
|
||||
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
|
||||
};
|
||||
Ok(StepOptions { y_up, names })
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -170,8 +170,8 @@ fn wrong_up_and_names_are_refused() {
|
||||
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
|
||||
}
|
||||
|
||||
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
|
||||
/// the assembly occurrence's. Repeated names are numbered as ever.
|
||||
/// Explicit naming choices select the solid, part or occurrence; automatic naming keeps
|
||||
/// meaningful solid names.
|
||||
#[test]
|
||||
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
|
||||
let path = frame_file("names");
|
||||
@@ -185,6 +185,7 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
|
||||
// The post is placed twice: one component, two occurrences, its body built once (#1386).
|
||||
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
|
||||
assert_eq!(named(json!({"path": path})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "auto"})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
|
||||
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
|
||||
@@ -228,3 +229,29 @@ fn a_step_file_can_be_inserted_at_a_matrix() {
|
||||
assert!(e.contains("not rigid"), "{e}");
|
||||
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
|
||||
}
|
||||
|
||||
/// Generated body labels become part names, and the saved assembly retains its components
|
||||
/// and repeated placements without needing the source file.
|
||||
#[test]
|
||||
fn automatic_part_names_and_assembly_survive_reopening() {
|
||||
let path = frame_file("automatic");
|
||||
let text = std::fs::read_to_string(&path).unwrap().replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
assert_eq!(body_names(&s), ["Plate", "Post"]);
|
||||
let top = r["component"].as_u64().unwrap();
|
||||
let parts: Vec<_> = s.doc.components.iter().filter(|c| c.parent == top).collect();
|
||||
assert_eq!(parts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post"]);
|
||||
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == parts[1].id && o.parent == top).count(), 2);
|
||||
let design = std::path::Path::new(&path).with_file_name("automatic.solidcraft");
|
||||
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let mut again = Session::default();
|
||||
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
assert_eq!(body_names(&again), ["Plate", "Post"]);
|
||||
assert_eq!(again.doc.components, s.doc.components);
|
||||
assert_eq!(again.doc.occurrences, s.doc.occurrences);
|
||||
assert!(near(extent(&again).0, [0.0, 0.0, 0.0]) && near(extent(&again).1, [40.0, 34.0, 20.0]));
|
||||
assert!((total_volume(&mut again) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+33
-12
@@ -101,8 +101,10 @@ pub fn is_iges_path(path: &str) -> bool {
|
||||
/// nothing), so the caller chooses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum StepNames {
|
||||
/// The solid's own name in the file, else the name of the product that holds it.
|
||||
/// Keep meaningful solid names; replace placeholders with the nearest named product.
|
||||
#[default]
|
||||
Auto,
|
||||
/// The solid's own name in the file, else the name of the product that holds it.
|
||||
Solid,
|
||||
/// The name of the product (part) that holds the body, else the solid's name.
|
||||
Product,
|
||||
@@ -112,9 +114,10 @@ pub enum StepNames {
|
||||
}
|
||||
|
||||
impl StepNames {
|
||||
/// `solid`, `product` or `instance` (any case); `None` for anything else.
|
||||
/// `auto`, `solid`, `product` or `instance` (any case); `None` for anything else.
|
||||
pub fn parse(s: &str) -> Option<StepNames> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(StepNames::Auto),
|
||||
"solid" => Some(StepNames::Solid),
|
||||
"product" => Some(StepNames::Product),
|
||||
"instance" => Some(StepNames::Instance),
|
||||
@@ -126,7 +129,16 @@ impl StepNames {
|
||||
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
|
||||
/// gives to something it has no name for.
|
||||
fn generic_name(n: &str) -> bool {
|
||||
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
|
||||
let n = n.trim();
|
||||
n.is_empty()
|
||||
|| n.starts_with('(')
|
||||
|| n.eq_ignore_ascii_case("import")
|
||||
|| n.eq_ignore_ascii_case("unnamed")
|
||||
|| ["body", "solid", "shell"].iter().any(|prefix| {
|
||||
n.get(..prefix.len()).is_some_and(|s| s.eq_ignore_ascii_case(prefix))
|
||||
&& n.get(prefix.len()..)
|
||||
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit() || c.is_ascii_whitespace() || matches!(c, '_' | '-' | '(' | ')')))
|
||||
})
|
||||
}
|
||||
|
||||
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
|
||||
@@ -134,6 +146,8 @@ fn generic_name(n: &str) -> bool {
|
||||
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
|
||||
let usable = |n: &&String| !generic_name(n);
|
||||
let chosen = match names {
|
||||
StepNames::Auto if !generic_name(&b.name) => Some(&b.name),
|
||||
StepNames::Auto => b.path.iter().rev().find(usable).or_else(|| b.instance.as_ref().filter(usable)),
|
||||
StepNames::Solid => None,
|
||||
StepNames::Product => b.path.last().filter(usable),
|
||||
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
|
||||
@@ -146,8 +160,7 @@ pub struct StepFeature {
|
||||
/// Timeline name: the root product's name, else the file name.
|
||||
pub name: String,
|
||||
pub kind: solidcraft_doc::FeatureKind,
|
||||
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
|
||||
/// the product names).
|
||||
/// Body names from the file, as [`StepNames`] chose them (automatic by default).
|
||||
pub body_names: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
|
||||
@@ -203,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()
|
||||
@@ -748,7 +763,7 @@ mod tests {
|
||||
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
|
||||
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
|
||||
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
|
||||
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
|
||||
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["t"], "still reads back");
|
||||
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
|
||||
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
|
||||
let model_xml = |bytes: Vec<u8>| {
|
||||
@@ -812,7 +827,7 @@ mod tests {
|
||||
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
|
||||
// Named after the exported product (the export was called `t`).
|
||||
assert_eq!(f.name, "t");
|
||||
assert_eq!(f.body_names, ["Body1"]);
|
||||
assert_eq!(f.body_names, ["t"]);
|
||||
assert_eq!(f.body_names.len(), 1);
|
||||
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
|
||||
let mut imported = Document::new("Imported STEP round trip");
|
||||
@@ -862,9 +877,8 @@ mod tests {
|
||||
step_export_products(&products, 0, &StepHeader::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The bodies of a STEP file are named after the solid (the default), the product that holds
|
||||
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
|
||||
/// falls back to the next one.
|
||||
/// Automatic names keep meaningful solid labels and replace placeholders with part names.
|
||||
/// Explicit choices still select the solid, product or occurrence.
|
||||
#[test]
|
||||
fn step_import_names_follow_the_choice() {
|
||||
let text = frame_step();
|
||||
@@ -872,10 +886,15 @@ mod tests {
|
||||
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
|
||||
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
|
||||
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
|
||||
// Without a choice it is the solid's name, as before.
|
||||
// Meaningful solid names survive automatic naming, including SolidCraft exports.
|
||||
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
|
||||
let generic = text.replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
|
||||
assert_eq!(step_import_feature(generic.as_bytes(), "frame.step").unwrap().body_names, ["Plate", "Post", "Post"]);
|
||||
assert_eq!(names(&generic, StepNames::Solid), ["Body1", "Body 2 (3)", "Body 2 (3)"]);
|
||||
let named = generic.replace("'Body1'", "'Body housing'");
|
||||
assert_eq!(names(&named, StepNames::Auto), ["Body housing", "Post", "Post"]);
|
||||
// The timeline entry is the root product's name whatever the bodies are called.
|
||||
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
|
||||
for choice in [StepNames::Auto, StepNames::Solid, StepNames::Product, StepNames::Instance] {
|
||||
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
|
||||
}
|
||||
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
|
||||
@@ -887,6 +906,8 @@ mod tests {
|
||||
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
|
||||
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
|
||||
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
|
||||
assert_eq!(names(&part, StepNames::Auto), ["Bracket"]);
|
||||
assert_eq!(StepNames::parse(" AuTo "), Some(StepNames::Auto));
|
||||
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
|
||||
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
|
||||
assert_eq!(StepNames::parse("label"), None);
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
-12
@@ -1986,6 +1986,19 @@ fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
|
||||
}
|
||||
|
||||
fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>)> {
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
// Per section, the profile segments under each of its wire's edges.
|
||||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
for (p, r) in loft_section_loops(sections, reversed)? {
|
||||
wires.push(uniform_wire(&p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
}
|
||||
Ok((wires, edge_sources))
|
||||
}
|
||||
|
||||
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
|
||||
/// re-split to the same number of segments and started where they line up best.
|
||||
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
|
||||
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid("empty section".into()));
|
||||
@@ -2000,28 +2013,51 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
|
||||
// with the most segments.
|
||||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||||
let parallel = ordered.iter().all(|(p, _)| p.normal().dot(n0).abs() > 1.0 - 1e-9);
|
||||
// Whether a section faces the first one's way is followed neighbour to neighbour, not by its
|
||||
// normal against the first's: planes turning about an axis pass 90 degrees and would be
|
||||
// flipped, folding the loft (#2847).
|
||||
let mut facing = true;
|
||||
let mut before = n0;
|
||||
let oriented: Vec<(Plane, NumberedLoop)> = ordered
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let ccw = p.normal().dot(n0) >= 0.0;
|
||||
(*p, l.oriented(!ccw))
|
||||
let n = p.normal();
|
||||
if n.dot(before) < 0.0 {
|
||||
facing = !facing;
|
||||
}
|
||||
before = n;
|
||||
(*p, l.oriented(!facing))
|
||||
})
|
||||
.collect();
|
||||
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||||
let lead_c = lead.0.to_world(lead.1.lp.centroid());
|
||||
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
// Per section, the profile segments under each of its wire's edges.
|
||||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
// The share of the lead's perimeter before each of its vertices: where a circle section
|
||||
// breaks when the lead's vertex directions do not go round in order.
|
||||
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
|
||||
let fractions: Vec<f64> = lead
|
||||
.1
|
||||
.lp
|
||||
.segs
|
||||
.iter()
|
||||
.scan(0.0, |before, s| {
|
||||
let f = *before / lead_len;
|
||||
*before += s.length();
|
||||
Some(f)
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<(Plane, NumberedLoop)> = Vec::new();
|
||||
for (p, l) in &oriented {
|
||||
let c = l.lp.centroid();
|
||||
// Break angles: the lead section's vertex directions seen in this plane.
|
||||
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
|
||||
let mut r = l.resample(n, &angles);
|
||||
// Circle resampling creates increasing local angles. An antiparallel sketch
|
||||
// plane must run clockwise locally to keep the same winding in world space.
|
||||
// Curving a run through a right angle does not reverse a section's winding.
|
||||
if parallel && p.normal().dot(n0) < 0.0 {
|
||||
// plane must run clockwise locally to keep the same winding in world space (the lead's
|
||||
// directions go round clockwise there). Curving a run through a right angle does not
|
||||
// reverse a section's winding.
|
||||
let flip = parallel && p.normal().dot(n0) < 0.0;
|
||||
let mut r = l.resample(n, &angles, &fractions, flip);
|
||||
if flip {
|
||||
r = r.oriented(true);
|
||||
}
|
||||
// Start where every vertex lines up best with the lead's vertex of the same number (the
|
||||
@@ -2036,10 +2072,9 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
|
||||
if let Some(k) = (0..m).min_by(|a, b| fit(*b).total_cmp(&fit(*a))) {
|
||||
r.rotate_left(k);
|
||||
}
|
||||
wires.push(uniform_wire(p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
out.push((*p, r));
|
||||
}
|
||||
Ok((wires, edge_sources))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
|
||||
|
||||
@@ -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.
|
||||
|
||||
+185
-72
@@ -9,6 +9,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
mod assembly;
|
||||
|
||||
/// Largest IGES file we read.
|
||||
pub const MAX_IGES_BYTES: usize = 512 << 20;
|
||||
const MAX_ENTITIES: usize = 2_000_000;
|
||||
@@ -427,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] {
|
||||
@@ -1032,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
|
||||
@@ -1225,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('',''))"
|
||||
@@ -1240,77 +1264,49 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
// Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots;
|
||||
// physically dependent entities have status digits 01 in columns 3–4, but files are lax:
|
||||
// collect references instead.
|
||||
let mut solids: Vec<(u64, String)> = Vec::new();
|
||||
let assembly = assembly::Assembly::read(&t, &seqs)?;
|
||||
let mut solids = HashMap::new();
|
||||
let mut styles: Vec<(u64, [f32; 3])> = Vec::new();
|
||||
let mut n = 0usize;
|
||||
let mut fallback = String::new();
|
||||
let watch = crate::watch::current();
|
||||
for &s in &seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let Some(de) = f.des.get(&s) else { continue };
|
||||
if de.typ != 186 {
|
||||
continue;
|
||||
}
|
||||
n += 1;
|
||||
// A name property (406 form 15) among the solid's trailing pointers, else the label.
|
||||
let named = f.params.get(&s).and_then(|p| {
|
||||
p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| {
|
||||
let d = f.des.get(&usize::try_from(ptr).ok()?)?;
|
||||
(d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten()
|
||||
})
|
||||
});
|
||||
let name = match named.filter(|x| !x.trim().is_empty()) {
|
||||
Some(x) => x.trim().to_string(),
|
||||
None if de.label.is_empty() => format!("Body{n}"),
|
||||
None => de.label.clone(),
|
||||
let name = if let Some(name) = assembly::entity_name(&f, s) {
|
||||
name
|
||||
} else {
|
||||
fallback.clear();
|
||||
let _ = write!(fallback, "Body{n}");
|
||||
fallback.as_str()
|
||||
};
|
||||
match brep(&mut t, s as i64, &mut styles) {
|
||||
Ok((id, color)) => {
|
||||
let named = {
|
||||
// Re-label the solid with its name.
|
||||
let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default();
|
||||
line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1)
|
||||
};
|
||||
if let Some(l) = t.o.lines.get_mut(id as usize - 1) {
|
||||
*l = named;
|
||||
*l = l.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
styles.push((id, c));
|
||||
}
|
||||
solids.push((id, name));
|
||||
solids.insert(s, id);
|
||||
}
|
||||
Err(e) => t.warn(format!("solid {name}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
// Trimmed and bounded surfaces not used by anything else: sewn into shells.
|
||||
let mut faces = Vec::new();
|
||||
let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
if solids.is_empty() {
|
||||
for &s in &seqs {
|
||||
let Some(de) = f.des.get(&s) else { continue };
|
||||
if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) {
|
||||
continue;
|
||||
}
|
||||
match trimmed(&mut t, &mut sw, s as i64) {
|
||||
Ok(fb) => faces.push(fb),
|
||||
Err(e) => t.warn(format!("surface {s}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
if !faces.is_empty() {
|
||||
orient(&mut faces);
|
||||
let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles);
|
||||
let item = if closed {
|
||||
t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})"))
|
||||
} else {
|
||||
t.warn("the surfaces do not close into a solid: imported as an open body");
|
||||
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))"))
|
||||
};
|
||||
solids.push((item, "Body1".into()));
|
||||
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)));
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into());
|
||||
return Err(format!("IGES import: nothing to read ({why})"));
|
||||
}
|
||||
let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::<Vec<_>>().join(",");
|
||||
t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})"));
|
||||
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})"));
|
||||
@@ -1331,23 +1327,140 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
Ok((s, t.warnings))
|
||||
}
|
||||
|
||||
/// A surface entity nobody refers to (a bare B-spline patch is a face of its own).
|
||||
fn is_root(f: &Iges, seq: usize) -> bool {
|
||||
let target = seq.to_string();
|
||||
!f.params
|
||||
.iter()
|
||||
.any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target))
|
||||
/// Sew surfaces within their owning group, keeping different parts separate.
|
||||
fn sew_surfaces(
|
||||
t: &mut Tr,
|
||||
seqs: impl Iterator<Item = usize>,
|
||||
name: &str,
|
||||
styles: &mut Vec<(u64, [f32; 3])>,
|
||||
faces: &mut Vec<FaceB>,
|
||||
sw: &mut Sewer,
|
||||
) -> R<Option<u64>> {
|
||||
faces.clear();
|
||||
sw.verts.clear();
|
||||
sw.grid.clear();
|
||||
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.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);
|
||||
}
|
||||
orient(faces);
|
||||
let (shell, closed) = write_shell(&mut t.o, sw, faces, styles);
|
||||
let name = name.replace('\'', "''");
|
||||
Ok(Some(if closed {
|
||||
t.o.add(format!("MANIFOLD_SOLID_BREP('{name}',#{shell})"))
|
||||
} else {
|
||||
t.warn("the surfaces do not close into a solid: imported as an open body");
|
||||
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('{name}',(#{shell}))"))
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -1356,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)?;
|
||||
@@ -1375,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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! IGES groups (402) and subfigures (308/408), from the published IGES specification.
|
||||
//! Definitions become STEP products; instances refer to them without copying geometry.
|
||||
//! Reference: https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nbsir88-3813.pdf
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAX_GROUPS: usize = 10_000;
|
||||
const MAX_INSTANCES: usize = 100_000;
|
||||
const MAX_REFERENCES: usize = 4 * MAX_ENTITIES;
|
||||
|
||||
struct Group {
|
||||
seq: usize,
|
||||
members: Vec<usize>,
|
||||
}
|
||||
|
||||
struct Instance {
|
||||
definition: usize,
|
||||
transform: Xf,
|
||||
}
|
||||
|
||||
pub(super) struct Assembly {
|
||||
groups: Vec<Group>,
|
||||
indices: HashMap<usize, usize>,
|
||||
instances: HashMap<usize, Instance>,
|
||||
referenced: HashSet<usize>,
|
||||
surface_references: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
pub(super) fn read(t: &Tr, seqs: &[usize]) -> R<Self> {
|
||||
let mut out = Self {
|
||||
groups: Vec::new(),
|
||||
indices: HashMap::new(),
|
||||
instances: HashMap::new(),
|
||||
referenced: HashSet::new(),
|
||||
surface_references: HashSet::new(),
|
||||
};
|
||||
let mut references = 0usize;
|
||||
let watch = crate::watch::current();
|
||||
for &seq in seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let de = t.de(seq as i64)?;
|
||||
if de.typ == 308 || (de.typ == 402 && matches!(de.form, 1 | 7 | 14 | 15)) {
|
||||
if out.groups.len() >= MAX_GROUPS {
|
||||
return Err("too many IGES assembly definitions".into());
|
||||
}
|
||||
let field = if de.typ == 308 { 3 } else { 1 };
|
||||
let count = usize::try_from(t.i(seq as i64, field)?).map_err(|_| "negative IGES group size")?;
|
||||
references = references.saturating_add(count);
|
||||
if count > MAX_LIST || references > MAX_REFERENCES {
|
||||
return Err("IGES assembly references exceed the work limit".into());
|
||||
}
|
||||
let p = t.p(seq as i64)?;
|
||||
let entries = p.get(field + 1..field + 1 + count).ok_or("truncated IGES group")?;
|
||||
let mut members = Vec::with_capacity(count);
|
||||
for entry in entries {
|
||||
let child = int(entry).and_then(|x| usize::try_from(x).ok()).filter(|x| *x > 0).ok_or("invalid IGES group reference")?;
|
||||
t.de(child as i64)?;
|
||||
members.push(child);
|
||||
out.referenced.insert(child);
|
||||
}
|
||||
out.indices.insert(seq, out.groups.len());
|
||||
out.groups.push(Group { seq, members });
|
||||
} else if de.typ == 408 {
|
||||
if out.instances.len() >= MAX_INSTANCES {
|
||||
return Err("too many IGES assembly instances".into());
|
||||
}
|
||||
let definition = usize::try_from(t.i(seq as i64, 1)?).map_err(|_| "invalid IGES subfigure reference")?;
|
||||
if t.de(definition as i64)?.typ != 308 {
|
||||
return Err("IGES instance does not refer to a subfigure definition".into());
|
||||
}
|
||||
let p = t.p(seq as i64)?;
|
||||
let scale = match p.get(5).map(|s| s.trim()) {
|
||||
None | Some("") => 1.0,
|
||||
Some(s) => num(s).filter(|x| *x != 0.0).ok_or("invalid IGES instance scale")?,
|
||||
};
|
||||
let [x, y, z] = t.pt(seq as i64, 2)?;
|
||||
let local = Xf([[scale, 0.0, 0.0, x], [0.0, scale, 0.0, y], [0.0, 0.0, scale, z]]);
|
||||
let transform = t.own_xf(seq as i64, 0)?.then(&local).then(&t.own_xf(definition as i64, 0)?);
|
||||
if !transform.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err("non-finite IGES instance placement".into());
|
||||
}
|
||||
out.instances.insert(seq, Instance { definition, transform });
|
||||
out.referenced.insert(definition);
|
||||
}
|
||||
}
|
||||
// Index geometry references once instead of rescanning all parameters for each patch.
|
||||
for (&seq, params) in &t.f.params {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
if t.f.des.get(&seq).is_some_and(|d| matches!(d.typ, 308 | 402 | 406 | 408)) {
|
||||
continue;
|
||||
}
|
||||
for p in params.iter().skip(1) {
|
||||
let p = p.trim();
|
||||
if p.starts_with('0') {
|
||||
continue;
|
||||
}
|
||||
if let Ok(id) = p.parse::<usize>()
|
||||
&& id != seq
|
||||
&& t.f.des.get(&id).is_some_and(|d| d.typ == 128)
|
||||
{
|
||||
out.surface_references.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut depths = HashMap::with_capacity(out.groups.len());
|
||||
let mut placements = out.groups.iter().filter(|g| !out.referenced.contains(&g.seq)).count()
|
||||
+ out.instances.keys().filter(|s| !out.referenced.contains(*s)).count();
|
||||
for group in &out.groups {
|
||||
placements += group.members.iter().filter(|s| out.indices.contains_key(*s) || out.instances.contains_key(*s)).count();
|
||||
if placements > MAX_INSTANCES {
|
||||
return Err("too many IGES assembly placements".into());
|
||||
}
|
||||
out.depth(group.seq, 0, &mut depths)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn depth(&self, seq: usize, depth: usize, seen: &mut HashMap<usize, Option<usize>>) -> R<usize> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err("IGES assembly nesting is too deep".into());
|
||||
}
|
||||
if let Some(height) = seen.get(&seq) {
|
||||
return height.ok_or_else(|| "cyclic IGES assembly".into());
|
||||
}
|
||||
seen.insert(seq, None);
|
||||
let group = self.indices.get(&seq).and_then(|i| self.groups.get(*i)).ok_or("missing IGES assembly definition")?;
|
||||
let mut height = 1;
|
||||
for child in &group.members {
|
||||
let definition = self.instances.get(child).map(|i| i.definition).unwrap_or(*child);
|
||||
if self.indices.contains_key(&definition) {
|
||||
height = height.max(1 + self.depth(definition, depth + 1, seen)?);
|
||||
}
|
||||
}
|
||||
if height > MAX_DEPTH {
|
||||
return Err("IGES assembly nesting is too deep".into());
|
||||
}
|
||||
seen.insert(seq, Some(height));
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
pub(super) fn write(&self, t: &mut Tr, seqs: &[usize], solids: &HashMap<usize, u64>, styles: &mut Vec<(u64, [f32; 3])>, ctx: u64) -> R<()> {
|
||||
let app = t.o.add("APPLICATION_CONTEXT('mechanical design')");
|
||||
let pc = t.o.add(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
|
||||
let dc = t.o.add(format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
|
||||
let origin = t.o.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||||
let mut products = HashMap::with_capacity(self.groups.len() + 1);
|
||||
let mut items = Vec::new();
|
||||
let mut list = String::new();
|
||||
let mut fallback = String::new();
|
||||
let mut made = 0usize;
|
||||
let f = t.f;
|
||||
let mut faces = Vec::new();
|
||||
let mut sewer = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
let watch = crate::watch::current();
|
||||
// A synthetic root holds loose geometry and top-level groups/instances.
|
||||
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let seq = group.map(|g| g.seq).unwrap_or(0);
|
||||
let name = if seq == 0 {
|
||||
"Import"
|
||||
} else if let Some(name) = entity_name(f, seq) {
|
||||
name
|
||||
} else {
|
||||
fallback.clear();
|
||||
let _ = write!(fallback, "Group {seq}");
|
||||
fallback.as_str()
|
||||
};
|
||||
items.clear();
|
||||
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
|
||||
items.extend(members.iter().filter(|s| seq != 0 || !self.referenced.contains(*s)).filter_map(|s| solids.get(s).copied()));
|
||||
let surfaces = members
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| seq != 0 || !self.referenced.contains(s))
|
||||
.filter(|s| f.des.get(s).is_some_and(|d| matches!(d.typ, 143 | 144) || (d.typ == 128 && !self.surface_references.contains(s))));
|
||||
if let Some(item) = sew_surfaces(t, surfaces, if seq == 0 { "Body1" } else { name }, styles, &mut faces, &mut sewer)? {
|
||||
items.push(item);
|
||||
}
|
||||
made += items.len();
|
||||
list.clear();
|
||||
let _ = write!(list, "#{origin}");
|
||||
for item in &items {
|
||||
let _ = write!(list, ",#{item}");
|
||||
}
|
||||
let rep = t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({list}),#{ctx})"));
|
||||
let name = name.replace('\'', "''");
|
||||
let product = t.o.add(format!("PRODUCT('{name}','{name}','',(#{pc}))"));
|
||||
let formation = t.o.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
|
||||
let pd = t.o.add(format!("PRODUCT_DEFINITION('design','',#{formation},#{dc})"));
|
||||
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
|
||||
t.o.add(format!("SHAPE_DEFINITION_REPRESENTATION(#{shape},#{rep})"));
|
||||
products.insert(seq, (pd, rep));
|
||||
}
|
||||
if made == 0 {
|
||||
let why = t.warnings.first().map(String::as_str).unwrap_or("no solids or trimmed surfaces");
|
||||
return Err(format!("IGES import: nothing to read ({why})"));
|
||||
}
|
||||
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
|
||||
let parent = group.map(|g| g.seq).unwrap_or(0);
|
||||
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
|
||||
for &child in members {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
if parent == 0 && self.referenced.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
let (definition, transform) = match self.instances.get(&child) {
|
||||
Some(i) => (i.definition, i.transform),
|
||||
None if self.indices.contains_key(&child) => (child, t.own_xf(child as i64, 0)?),
|
||||
_ => continue,
|
||||
};
|
||||
let (parent_pd, parent_rep) = products.get(&parent).copied().ok_or("missing IGES assembly parent")?;
|
||||
let (child_pd, child_rep) = products.get(&definition).copied().ok_or("missing IGES assembly child")?;
|
||||
let label = entity_name(f, child).unwrap_or("").replace('\'', "''");
|
||||
let nauo = t.o.add(format!("NEXT_ASSEMBLY_USAGE_OCCURRENCE('{child}','{label}','',#{parent_pd},#{child_pd},$)"));
|
||||
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
|
||||
let op = transformation(&mut t.o, transform)?;
|
||||
let rr = t.o.add(format!("(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{op})SHAPE_REPRESENTATION_RELATIONSHIP())"));
|
||||
t.o.add(format!("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{shape})"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn transformation(out: &mut Out, xf: Xf) -> R<u64> {
|
||||
if !xf.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err("non-finite IGES assembly placement".into());
|
||||
}
|
||||
let [x, y, z] = [xf.dir([1.0, 0.0, 0.0]), xf.dir([0.0, 1.0, 0.0]), xf.dir([0.0, 0.0, 1.0])];
|
||||
let [sx, sy, sz] = [norm(x), norm(y), norm(z)];
|
||||
let scale = xf.scale();
|
||||
if [sx, sy, sz, scale].iter().any(|s| !s.is_finite() || *s <= 1e-300) {
|
||||
return Err("singular IGES assembly placement".into());
|
||||
}
|
||||
let (x, y, z, o) = (out.dir(mul(x, 1.0 / sx)), out.dir(mul(y, 1.0 / sy)), out.dir(mul(z, 1.0 / sz)), out.point(xf.point([0.0; 3])));
|
||||
Ok(out.add(format!("CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#{x},#{y},#{o},{},#{z},{},{})", r(sx), r(sy), r(sz))))
|
||||
}
|
||||
|
||||
/// Name properties follow the associativity pointers; member pointers are not properties.
|
||||
pub(super) fn entity_name(f: &Iges, seq: usize) -> Option<&str> {
|
||||
let de = f.des.get(&seq)?;
|
||||
let p = f.params.get(&seq)?;
|
||||
let count = |i: usize| p.get(i).and_then(|s| int(s)).and_then(|x| usize::try_from(x).ok()).filter(|n| *n <= MAX_LIST);
|
||||
let end = match de.typ {
|
||||
186 => count(3)?.checked_mul(2)?.checked_add(4)?,
|
||||
308 => count(3)?.checked_add(4)?,
|
||||
402 => count(1)?.checked_add(2)?,
|
||||
408 => 6,
|
||||
_ => p.len(),
|
||||
};
|
||||
let property = (|| {
|
||||
let after = end.checked_add(count(end)?)?.checked_add(1)?;
|
||||
let n = count(after)?;
|
||||
p.get(after + 1..after + 1 + n)?.iter().find_map(|s| {
|
||||
let id = int(s).and_then(|x| usize::try_from(x).ok())?;
|
||||
let d = f.des.get(&id)?;
|
||||
if d.typ != 406 || d.form != 15 {
|
||||
return None;
|
||||
}
|
||||
f.params.get(&id)?.get(2).and_then(|s| name_text(s))
|
||||
})
|
||||
})();
|
||||
property.or_else(|| if de.typ == 308 { p.get(2).and_then(|s| name_text(s)) } else { None }).or_else(|| name_text(&de.label))
|
||||
}
|
||||
|
||||
/// Match the component-name limit before copying or repeating a shared name property.
|
||||
fn name_text(s: &str) -> Option<&str> {
|
||||
let end = s.char_indices().nth(128).map(|(i, _)| i).unwrap_or(s.len());
|
||||
let name = s.get(..end)?.trim();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
@@ -156,15 +156,116 @@ fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
|
||||
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
|
||||
}
|
||||
|
||||
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
|
||||
/// boundaries are lines: sewn into a closed solid.
|
||||
/// An exported box inside a named group and nested subfigures, placed twice. The second
|
||||
/// placement composes its translation and scale with a directory transformation.
|
||||
fn assembly_file(form: i64, scale: f64) -> String {
|
||||
let body = box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
|
||||
let text = iges_export_bodies(&[ExportBody { name: "Body1".into(), body: &body, color: None }], &StepHeader::default()).unwrap();
|
||||
let f = parse(&text).unwrap();
|
||||
let mut ids: Vec<_> = f.des.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
let solid = *ids.iter().find(|&&s| f.des[&s].typ == 186).unwrap();
|
||||
let mut transforms: HashMap<_, _> = f.des.iter().map(|(seq, d)| (*seq, d.xform)).collect();
|
||||
let mut ents: Vec<_> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, seq)| {
|
||||
assert_eq!(*seq, 2 * k + 1);
|
||||
let d = &f.des[seq];
|
||||
let p = f.params[seq]
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|s| if s.is_empty() || num(s).is_some() { s.clone() } else { format!("{}H{s}", s.chars().count()) })
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
(d.typ, d.form, p)
|
||||
})
|
||||
.collect();
|
||||
let definition = 2 * ents.len() + 1;
|
||||
ents.push((308, 0, format!("0,8HLead rod,1,{solid}")));
|
||||
let first = 2 * ents.len() + 1;
|
||||
ents.push((408, 0, format!("{definition},10.,0.,0.,1.")));
|
||||
let rotation = 2 * ents.len() + 1;
|
||||
ents.push((124, 0, "0.,-1.,0.,100.,1.,0.,0.,0.,0.,0.,1.,0.".into()));
|
||||
let second = 2 * ents.len() + 1;
|
||||
ents.push((408, 0, format!("{definition},0.,20.,0.,{scale}")));
|
||||
transforms.insert(second, rotation as i64);
|
||||
let frame = 2 * ents.len() + 1;
|
||||
ents.push((308, 0, format!("1,5HFrame,2,{first},{second}")));
|
||||
let property = 2 * ents.len() + 1;
|
||||
ents.push((406, 15, "1,6HMotion".into()));
|
||||
ents.push((402, form, format!("1,{frame},0,1,{property}")));
|
||||
iges_file(&ents)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.as_bytes()[72] != b'D' {
|
||||
return line.to_string();
|
||||
}
|
||||
let seq: usize = line[73..80].trim().parse().unwrap();
|
||||
match transforms.get(&seq) {
|
||||
Some(xf) => format!("{}{:>8}{}", &line[..48], xf, &line[56..]),
|
||||
None => line.to_string(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let c =
|
||||
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
|
||||
fn groups_and_subfigure_instances_keep_names_structure_and_placements() {
|
||||
for (form, scale) in [(1, 1.0), (7, 2.0), (14, 1.0), (15, 2.0)] {
|
||||
let imp = iges_import(&assembly_file(form, scale)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies.len(), 2);
|
||||
assert_eq!(imp.tree.len(), 1);
|
||||
let motion = &imp.tree[0].children[0];
|
||||
assert_eq!(motion.name, "Motion");
|
||||
let frame = &motion.children[0];
|
||||
assert_eq!(frame.name, "Frame");
|
||||
assert_eq!(frame.children.len(), 2);
|
||||
assert!(frame.children.iter().all(|n| n.name == "Lead rod"));
|
||||
assert!(imp.bodies.iter().all(|b| b.path == ["Import", "Motion", "Frame", "Lead rod"]));
|
||||
assert_eq!(imp.bodies[0].source_item, imp.bodies[1].source_item);
|
||||
let a = measure(&imp.bodies[0].body).unwrap();
|
||||
let b = measure(&imp.bodies[1].body).unwrap();
|
||||
assert!(rel(a.volume, 24.0) < 1e-6 && rel(b.volume, 24.0 * scale.powi(3)) < 1e-6);
|
||||
assert!(a.bbox.min.dist(Vec3::new(10.0, 0.0, 0.0)) < 1e-6 && a.bbox.max.dist(Vec3::new(12.0, 3.0, 4.0)) < 1e-6);
|
||||
assert!(b.bbox.min.dist(Vec3::new(80.0 - 3.0 * scale, 0.0, 0.0)) < 1e-6);
|
||||
assert!(b.bbox.max.dist(Vec3::new(80.0, 2.0 * scale, 4.0 * scale)) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_assembly_references_and_counts_fail_before_geometry() {
|
||||
for (params, message) in [
|
||||
("0,4HLoop,1,1".into(), "cyclic"),
|
||||
("0,4HPart,1,999".into(), "missing entity"),
|
||||
("0,4HPart,-1".into(), "negative IGES group size"),
|
||||
(format!("0,4HPart,{}", MAX_LIST + 1), "work limit"),
|
||||
] {
|
||||
let error = iges_import(&iges_file(&[(308, 0, params)])).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains(message)), "{error}");
|
||||
}
|
||||
let mut chain = Vec::new();
|
||||
for k in 0..=MAX_DEPTH {
|
||||
chain.push((308, 0, if k == MAX_DEPTH { "0,4HPart,0".into() } else { format!("0,4HPart,1,{}", 2 * k + 3) }));
|
||||
}
|
||||
let error = iges_import(&iges_file(&chain)).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("nesting is too deep")), "{error}");
|
||||
let members = "1,".repeat(100_000);
|
||||
let many = [(308, 0, "0,4HPart,0".into()), (308, 0, format!("1,5HFrame,100001,{members}1"))];
|
||||
let error = iges_import(&iges_file(&many)).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("too many IGES assembly placements")), "{error}");
|
||||
}
|
||||
|
||||
/// Six trimmed bilinear patches with line boundaries, forming a 10 × 20 × 30 box.
|
||||
fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
|
||||
let c = |i: usize| -> [f64; 3] {
|
||||
[x + if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }]
|
||||
};
|
||||
// Faces as corner quads (counter-clockwise seen from outside).
|
||||
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
|
||||
let mut ents: Vec<(i64, i64, String)> = Vec::new();
|
||||
let mut faces = Vec::new();
|
||||
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
|
||||
for q in quads {
|
||||
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
|
||||
@@ -182,7 +283,15 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
|
||||
let cos = 2 * ents.len() - 1;
|
||||
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
|
||||
faces.push(2 * ents.len() - 1);
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let mut ents = Vec::new();
|
||||
trimmed_box(&mut ents, 0.0);
|
||||
let imp = iges_import(&iges_file(&ents)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert!(imp.bodies[0].closed);
|
||||
@@ -190,6 +299,49 @@ 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();
|
||||
for (name, x) in [("Left", 0.0), ("Right", 100.0)] {
|
||||
let faces = trimmed_box(&mut ents, x);
|
||||
let members = faces.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
|
||||
ents.push((308, 0, format!("0,{}H{name},{},{members}", name.len(), faces.len())));
|
||||
}
|
||||
let imp = iges_import(&iges_file(&ents)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies.len(), 2);
|
||||
assert_eq!(imp.tree[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Left", "Right"]);
|
||||
for (body, x) in imp.bodies.iter().zip([0.0, 100.0]) {
|
||||
let m = measure(&body.body).unwrap();
|
||||
assert!(body.closed && rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6);
|
||||
assert!(m.bbox.min.dist(Vec3::new(x, 0.0, 0.0)) < 1e-6 && m.bbox.max.dist(Vec3::new(x + 10.0, 20.0, 30.0)) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drill_point_round_trips_through_iges() {
|
||||
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
|
||||
|
||||
@@ -153,6 +153,7 @@ mod tspline;
|
||||
#[cfg(test)]
|
||||
mod untouched_faces_tests;
|
||||
pub mod watch;
|
||||
mod whole_face;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
|
||||
pub use blend_corner::fillet_radii;
|
||||
@@ -220,6 +221,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
|
||||
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -85,9 +85,13 @@ impl NumberedLoop {
|
||||
NumberedLoop { lp: pieces, from }
|
||||
}
|
||||
|
||||
/// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
|
||||
/// other loops split their longest segments in half.
|
||||
pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop {
|
||||
/// Re-split into exactly `n` segments, other loops split their longest segments in half. A
|
||||
/// full circle is split once round (one turn, every arc a positive sweep) to follow a lead
|
||||
/// loop: at the lead's vertex directions `angles` when they go round in order (counter-
|
||||
/// clockwise, or clockwise when `clockwise`), otherwise from the first of them at the lead's
|
||||
/// perimeter `fractions` (the share of the lead's length before each vertex; evenly when
|
||||
/// those are unusable).
|
||||
pub(crate) fn resample(&self, n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> NumberedLoop {
|
||||
let lp = &self.lp;
|
||||
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
|
||||
&& lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) {
|
||||
@@ -96,22 +100,15 @@ impl NumberedLoop {
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
|
||||
let mut a: Vec<f64> =
|
||||
if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
|
||||
// Keep the given order but make them increase.
|
||||
for i in 1..a.len() {
|
||||
let Some(prev) = a.get(i - 1).copied() else { continue };
|
||||
if let Some(x) = a.get_mut(i) {
|
||||
while *x <= prev {
|
||||
*x += std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
}
|
||||
let segs: Vec<Seg2> = (0..n)
|
||||
if circle
|
||||
&& n > 0
|
||||
&& let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied()
|
||||
{
|
||||
let a = circle_breaks(n, angles, fractions, clockwise);
|
||||
let segs: Vec<Seg2> = (0..a.len())
|
||||
.filter_map(|i| {
|
||||
let s = *a.get(i)?;
|
||||
let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
|
||||
let e = if i + 1 < a.len() { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
|
||||
Some(Seg2::Arc { center, radius, start: s, sweep: e - s })
|
||||
})
|
||||
.collect();
|
||||
@@ -190,6 +187,65 @@ impl NumberedRegion {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest arc a circle is split into (radians).
|
||||
const MIN_SWEEP: f64 = 1e-9;
|
||||
|
||||
/// `n` increasing break angles of one turn round a circle (each step and the closing step back
|
||||
/// to the first, one turn on, positive) for a circle following a lead loop: `angles` (the lead's
|
||||
/// vertex directions) when they go round once in order in the lead's direction (counter-clockwise,
|
||||
/// or clockwise when `clockwise`), otherwise from the first of them at the lead's perimeter
|
||||
/// `fractions`, or evenly. A non-star-shaped lead's directions turn back on themselves: forcing
|
||||
/// them to increase wound the circle round many times (#2570).
|
||||
fn circle_breaks(n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> Vec<f64> {
|
||||
use std::f64::consts::TAU;
|
||||
let sign = if clockwise { -1.0 } else { 1.0 };
|
||||
// Angles measured in the lead's direction of travel.
|
||||
let along: Vec<f64> = angles.iter().map(|a| sign * a).collect();
|
||||
let run = (along.len() == n).then(|| one_turn(&along)).flatten().unwrap_or_else(|| {
|
||||
let start = along.first().copied().filter(|a| a.is_finite()).unwrap_or(0.0);
|
||||
let even = || (0..n).map(|i| i as f64 / n as f64).collect::<Vec<f64>>();
|
||||
let f = if usable_fractions(n, fractions) { fractions.to_vec() } else { even() };
|
||||
f.iter().map(|f| start + TAU * f).collect()
|
||||
});
|
||||
let mut breaks: Vec<f64> = run.into_iter().map(|a| sign * a).collect();
|
||||
if clockwise {
|
||||
breaks.reverse();
|
||||
}
|
||||
breaks
|
||||
}
|
||||
|
||||
/// `along` unwrapped into one increasing turn (each step the shortest positive one), or `None`
|
||||
/// when they do not go round once in order.
|
||||
fn one_turn(along: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut out: Vec<f64> = Vec::with_capacity(along.len());
|
||||
for &x in along {
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let next = match out.last() {
|
||||
Some(&prev) => {
|
||||
let step = (x - prev).rem_euclid(std::f64::consts::TAU);
|
||||
if step <= MIN_SWEEP {
|
||||
return None;
|
||||
}
|
||||
prev + step
|
||||
}
|
||||
None => x,
|
||||
};
|
||||
out.push(next);
|
||||
}
|
||||
let (first, last) = (*out.first()?, *out.last()?);
|
||||
(last - first < std::f64::consts::TAU - MIN_SWEEP).then_some(out)
|
||||
}
|
||||
|
||||
/// `fractions` are `n` finite, strictly increasing shares of a turn from 0, all under one.
|
||||
fn usable_fractions(n: usize, fractions: &[f64]) -> bool {
|
||||
fractions.len() == n
|
||||
&& fractions.first().is_some_and(|f| f.abs() < MIN_SWEEP)
|
||||
&& fractions.windows(2).all(|w| matches!(w, [a, b] if b - a > MIN_SWEEP))
|
||||
&& fractions.last().is_some_and(|f| f.is_finite() && *f < 1.0 - MIN_SWEEP)
|
||||
}
|
||||
|
||||
/// Every number of `sets`, once each, in order.
|
||||
fn union<'a>(sets: impl IntoIterator<Item = &'a [usize]>) -> Vec<usize> {
|
||||
sets.into_iter().flatten().copied().collect::<BTreeSet<usize>>().into_iter().collect()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -796,7 +796,7 @@ fn unsupported_shell(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {m}"))
|
||||
}
|
||||
|
||||
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
pub(crate) fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
let h = (p - a).dot(nn);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -101,7 +101,7 @@ pub(crate) fn point(cx: &Ctx, id: u64) -> R<mt::Point3> {
|
||||
Ok(mt::Point3::new(g(0)?, g(1)?, g(2)?) * cx.len)
|
||||
}
|
||||
|
||||
fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
|
||||
pub(super) fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
|
||||
let e = entity(cx, id)?;
|
||||
if e.name() != "DIRECTION" {
|
||||
return Err(format!("#{id}: expected DIRECTION, found {}", e.name()));
|
||||
@@ -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,12 +584,41 @@ 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 e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
|
||||
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
|
||||
let p = e.params();
|
||||
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
|
||||
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
|
||||
let direction = |i: usize, fallback: mt::Vector3| -> R<mt::Vector3> {
|
||||
match p.get(i) {
|
||||
None | Some(Param::Null) => Ok(fallback),
|
||||
Some(Param::Ref(id)) => geom::direction(&cx, *id),
|
||||
_ => Err("invalid transformation direction".into()),
|
||||
}
|
||||
};
|
||||
let scale = |i: usize, fallback: f64| -> R<f64> {
|
||||
match p.get(i) {
|
||||
None | Some(Param::Null) => Ok(fallback),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x != 0.0).ok_or_else(|| "invalid transformation scale".into()),
|
||||
}
|
||||
};
|
||||
let sx = scale(5, 1.0)?;
|
||||
let (sy, sz) = (scale(7, sx)?, scale(8, sx)?);
|
||||
let m = mt::Matrix4::from_cols(
|
||||
(direction(2, mt::Vector3::unit_x())? * sx).extend(0.0),
|
||||
(direction(3, mt::Vector3::unit_y())? * sy).extend(0.0),
|
||||
(direction(6, mt::Vector3::unit_z())? * sz).extend(0.0),
|
||||
o.to_homogeneous(),
|
||||
);
|
||||
if !to_array(&m).iter().flatten().all(|x| x.is_finite()) {
|
||||
return Err("invalid transformation scale".into());
|
||||
}
|
||||
use mt::SquareMatrix as _;
|
||||
let det = m.determinant();
|
||||
if !det.is_finite() || det == 0.0 {
|
||||
return Err("singular transformation".into());
|
||||
}
|
||||
return Ok(m);
|
||||
}
|
||||
Ok(geom::frame(&cx, item)?.matrix())
|
||||
}
|
||||
@@ -732,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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user