mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-12 00:45:16 -06:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe4646477 | ||
|
|
dc7f382cec | ||
|
|
e5021f0175 | ||
|
|
2d25308cfc | ||
|
|
20b4472f0b | ||
|
|
ecb362aa55 | ||
|
|
dea9f2d048 | ||
|
|
d6d16bd474 | ||
|
|
2410a1fd07 | ||
|
|
f132d81609 | ||
|
|
a1574780c2 | ||
|
|
ea2637ae73 | ||
|
|
7e780548be | ||
|
|
6d744887f4 | ||
|
|
2c25c2d700 | ||
|
|
95fdf8d11a | ||
|
|
0843770389 | ||
|
|
36f5ad64f9 | ||
|
|
7804e94ee0 | ||
|
|
bc9ad6b059 | ||
|
|
650ae383f2 | ||
|
|
2f8f186188 | ||
|
|
6f0f1fefa8 |
@@ -27,6 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,6 +30,10 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
Generated
+20
-17
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -631,6 +631,7 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -644,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -660,14 +661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -677,7 +678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -688,7 +689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
@@ -696,7 +697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -705,7 +706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -726,7 +727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
@@ -739,14 +740,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -768,13 +769,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
@@ -782,7 +784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -796,7 +798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -823,7 +825,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
@@ -1212,7 +1214,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1222,6 +1224,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5991,7 +5994,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.20.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -71,7 +71,7 @@ egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ muda = { version = "0.21", default-features = false }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
|
||||
# in Cargo.lock through eframe.
|
||||
pollster = "1"
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -250,17 +250,18 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
let Value::Object(mut obj) = msg else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
let Some(Value::String(method)) = obj.remove("method") else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
// Moved out of the parsed object, not cloned: `params` can be most of the line.
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
id: obj.remove("id").unwrap_or(Value::Null),
|
||||
method,
|
||||
params: obj.remove("params").unwrap_or(json!({})),
|
||||
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,20 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// A message can carry text from a file (a code page name, a path), so it must not be able to
|
||||
// start what looks like another record: control characters other than tab and line feed are
|
||||
// shown escaped, and every continuation line is indented, so only this function's own
|
||||
// timestamp is ever at the start of a line.
|
||||
let mut safe = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => safe.push_str("\n "),
|
||||
'\t' => safe.push('\t'),
|
||||
c if c.is_control() => safe.extend(c.escape_default()),
|
||||
c => safe.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -441,8 +454,14 @@ mod tests {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A multi-line message keeps its lines, indented so they can't pass for records; the
|
||||
// record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
|
||||
// Control characters in a message (from a file) can't forge a record.
|
||||
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
|
||||
assert_eq!(forged.lines().count(), 2);
|
||||
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
|
||||
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+40
-27
@@ -23,13 +23,15 @@ mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod sheet;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -159,39 +161,44 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| {
|
||||
filters_for_save(name)
|
||||
.iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
|
||||
start_pick: None,
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
|
||||
let dialog = match dir {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
|
||||
/// macOS sheets.
|
||||
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
match pick {
|
||||
Pick::Open => vec![
|
||||
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
|
||||
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
|
||||
(i18n::t("All files"), vec!["*"]),
|
||||
],
|
||||
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
|
||||
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
|
||||
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
|
||||
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
|
||||
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
match pick {
|
||||
Pick::Save(name) => dialog.set_file_name(name),
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
|
||||
_ => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// File types for the Save dialog, the suggested name's type first: Windows adds the first
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
@@ -361,6 +368,12 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
|
||||
// app when the dialog is resized (#1527).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = cc.winit_window().cloned() {
|
||||
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
|
||||
}
|
||||
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
|
||||
if let Some(ui) = restored {
|
||||
app.ui = ui;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
|
||||
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
|
||||
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
|
||||
//! the window keeps running; a thread of its own waits for the answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_ui_egui::{Pick, StartPick};
|
||||
|
||||
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
|
||||
|
||||
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
|
||||
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
|
||||
Box::new(move |pick, done| {
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
|
||||
let ui = ui.clone();
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
|
||||
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
|
||||
// shown.
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
done(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("file dialog: {e}");
|
||||
return;
|
||||
}
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog =
|
||||
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: Answer = match pick {
|
||||
Pick::Save(name) => {
|
||||
let picked = dialog.set_file_name(name).save_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) => {
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Open => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
};
|
||||
let _ = sheet_tx.send(answer);
|
||||
})
|
||||
}
|
||||
@@ -232,6 +232,10 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
let mut extra = Vec::new();
|
||||
let mut on = Vec::new();
|
||||
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
|
||||
// Nothing is added once the candidates are full, so the points left need no search.
|
||||
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
|
||||
continue;
|
||||
}
|
||||
@@ -267,20 +271,22 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
|
||||
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
|
||||
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
|
||||
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
|
||||
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
|
||||
break 'per;
|
||||
}
|
||||
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
|
||||
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
|
||||
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
|
||||
let ang = ang_between(*b1 - *a1, *b2 - *a2);
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
|
||||
let selected: HashSet<Handle> = handles.iter().copied().collect();
|
||||
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
|
||||
+31
-1
@@ -204,6 +204,23 @@ impl ArrayParams {
|
||||
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
|
||||
}
|
||||
|
||||
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
|
||||
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
|
||||
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
|
||||
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
|
||||
let (cols, rows) = match &self.kind {
|
||||
ArrayKind::Rect(r) => (r.cols, r.rows),
|
||||
ArrayKind::Polar(p) => (p.items, p.rows),
|
||||
};
|
||||
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
|
||||
.replaced
|
||||
.iter()
|
||||
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
|
||||
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
|
||||
.collect();
|
||||
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
|
||||
}
|
||||
|
||||
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
|
||||
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
|
||||
/// parameters are not [valid](Self::is_valid).
|
||||
@@ -217,9 +234,10 @@ impl ArrayParams {
|
||||
..Common::default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let block_for = self.block_lookup();
|
||||
for it in items {
|
||||
let ins = Insert {
|
||||
block: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
block: block_for(it.i, it.j, it.level).to_string(),
|
||||
insert: it.insert,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: it.rotation,
|
||||
@@ -301,6 +319,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
|
||||
let mut p = rect(3, 2);
|
||||
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
|
||||
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
|
||||
let look = p.block_lookup();
|
||||
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
|
||||
assert_eq!(look(i, j, level), p.block_for(i, j, level));
|
||||
}
|
||||
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_items_go_column_row_level() {
|
||||
let items = rect(3, 2).items().unwrap_or_default();
|
||||
|
||||
@@ -674,6 +674,10 @@ fn apply(
|
||||
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
|
||||
/// outline again (it may have been stretched).
|
||||
fn refill_hatches(b: &mut Block) {
|
||||
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
|
||||
// past this many vertices in all, the remaining loops keep the outline they have.
|
||||
const MAX_REFILLED_VERTICES: usize = 4_000_000;
|
||||
let mut budget = MAX_REFILLED_VERTICES;
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
@@ -689,7 +693,9 @@ fn refill_hatches(b: &mut Block) {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
&& let Some(left) = budget.checked_sub(vs.len())
|
||||
{
|
||||
budget = left;
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,16 +486,24 @@ impl DimStyle {
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values.
|
||||
/// had unusable values. When several spellings name one field, the last one wins and the
|
||||
/// superseded ones are not validated.
|
||||
pub fn apply_fields(&mut self, src: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
|
||||
use serde_json::Value;
|
||||
let mut rejected = Vec::new();
|
||||
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
|
||||
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
|
||||
// the style is checked once per field, not once per spelling (each check copies its strings).
|
||||
let last_spelling: std::collections::HashMap<&str, &String> =
|
||||
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
|
||||
for (k, v) in src {
|
||||
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
if last_spelling.get(field) != Some(&k) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = cur.as_object_mut() else { break };
|
||||
let Some(old) = obj.get(field).cloned() else {
|
||||
rejected.push(k.clone());
|
||||
|
||||
@@ -714,3 +714,14 @@ fn space_names_its_tab() {
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
|
||||
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
|
||||
#[test]
|
||||
fn dim_style_spellings_of_one_field_apply_once() {
|
||||
let mut st = DimStyle::default();
|
||||
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
|
||||
let m = m.as_object().unwrap();
|
||||
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
|
||||
assert!(st.apply_fields(m).is_empty());
|
||||
assert_eq!(st.text_height, want);
|
||||
}
|
||||
|
||||
@@ -144,10 +144,10 @@ impl BlockEdit {
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
// When the block is saved under another name, the edited block keeps the drawing's
|
||||
// description and block `name` gets the description changed in the editor.
|
||||
// When saving a copy, the source keeps its last saved definition. The editor's own
|
||||
// table entry can still be the one from before BSAVE; the copy gets the edited data.
|
||||
if !name.eq_ignore_ascii_case(&self.name) {
|
||||
keep_description(&mut d, host, &self.name);
|
||||
restore_saved_definition(&mut d, host, &self.name);
|
||||
}
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
@@ -176,16 +176,13 @@ impl BlockEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
|
||||
/// drop the empty definition a description gives a block the editor started and never saved.
|
||||
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
|
||||
/// Without a saved source, drop the empty definition an editor description may have created.
|
||||
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
match host.block(name) {
|
||||
Some(h) => {
|
||||
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
&& b.description != h.description
|
||||
{
|
||||
Arc::make_mut(b).description = h.description.clone();
|
||||
}
|
||||
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
|
||||
d.blocks.insert(key, h.clone());
|
||||
}
|
||||
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
|
||||
}
|
||||
@@ -302,7 +299,7 @@ impl DocState {
|
||||
let was = be.name.clone();
|
||||
let reconcile = |doc: &mut Arc<Drawing>| {
|
||||
let editor = Arc::make_mut(doc);
|
||||
keep_description(editor, &d, &was);
|
||||
restore_saved_definition(editor, &d, &was);
|
||||
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
|
||||
editor.blocks.insert(name.clone(), b.clone());
|
||||
};
|
||||
|
||||
@@ -178,14 +178,34 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
|
||||
/// default MAXARRAY).
|
||||
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
|
||||
|
||||
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
|
||||
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
|
||||
///
|
||||
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
|
||||
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
|
||||
/// takes the array block's own items.
|
||||
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let p = d.array_params(e)?;
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let mut out = Vec::new();
|
||||
// Count first: a hostile array must be refused before any entity is cloned.
|
||||
let total = blk
|
||||
.entities
|
||||
.iter()
|
||||
.map(|it| match &it.kind {
|
||||
EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map_or(1, |b| b.entities.len()),
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0usize, usize::saturating_add);
|
||||
if total > MAX_EXPLODED_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(total);
|
||||
for item in blk.entities.iter() {
|
||||
let item = placed(e, item, &m);
|
||||
let source = match &item.kind {
|
||||
@@ -193,7 +213,9 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
_ => None,
|
||||
};
|
||||
match source {
|
||||
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
|
||||
Some((im, b)) => {
|
||||
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
|
||||
}
|
||||
None => out.push(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +188,9 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
|
||||
let mut best: Option<((u32, u32, u32), f64)> = None;
|
||||
// Each block's own extents, measured once.
|
||||
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
|
||||
let block_for = p.block_lookup();
|
||||
for it in p.items()? {
|
||||
let name = p.block_for(it.i, it.j, it.level);
|
||||
let name = block_for(it.i, it.j, it.level);
|
||||
let (base, local) = match extents.get(name) {
|
||||
Some(x) => *x,
|
||||
None => {
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! block name and values, or one row per reference with its handle, layer, position and
|
||||
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use cadcraft_doc::{EntityKind, Insert};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -88,22 +88,75 @@ fn matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| wildcard(p.trim(), text))
|
||||
}
|
||||
|
||||
/// The attribute values of `ins`: its own, then the constant ones of its block.
|
||||
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
|
||||
if let Some(b) = d.block(&ins.block) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
/// The constant attribute values of a block definition whose tags are selected for output.
|
||||
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& selected.iter().any(|tag| tag == &a.tag.to_ascii_uppercase())
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
|
||||
/// block (`consts`, read once per block). Values no column shows are not copied.
|
||||
fn values(ins: &Insert, consts: &[(String, String)], selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins
|
||||
.attribs
|
||||
.iter()
|
||||
.map(|a| (a.tag.to_ascii_uppercase(), a))
|
||||
.filter(|(t, _)| selected.contains(t))
|
||||
.map(|(t, a)| (t, a.text.value.clone()))
|
||||
.collect();
|
||||
for (t, val) in consts {
|
||||
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
|
||||
v.push((t.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Select the output columns before copying any attribute text. A block definition can contain
|
||||
/// many large constants that do not survive the table's 190-column limit.
|
||||
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
|
||||
let mut selected = Vec::new();
|
||||
let mut add = |tag: &str| {
|
||||
let tag = tag.to_ascii_uppercase();
|
||||
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
|
||||
selected.push(tag);
|
||||
}
|
||||
};
|
||||
let mut scanned = std::collections::HashSet::new();
|
||||
for entity in ents {
|
||||
let EntityKind::Insert(ins) = &entity.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
if anonymous || !matches(&o.blocks, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for attribute in &ins.attribs {
|
||||
add(&attribute.tag);
|
||||
}
|
||||
// A block's constants are read once, however many inserts reference it.
|
||||
if scanned.insert(ins.block.as_str())
|
||||
&& let Some(block) = d.block(&ins.block)
|
||||
{
|
||||
for entity in block.entities.iter() {
|
||||
if let EntityKind::AttDef(attribute) = &entity.kind
|
||||
&& attribute.constant
|
||||
{
|
||||
add(&attribute.tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
||||
fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
let d = s.doc()?;
|
||||
@@ -112,7 +165,9 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
} else {
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
|
||||
};
|
||||
let selected = selected_tags(&ents, d, o);
|
||||
let mut out = Vec::new();
|
||||
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for e in &ents {
|
||||
let EntityKind::Insert(ins) = &e.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
@@ -121,12 +176,17 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
out.push(Found {
|
||||
block,
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
values: values(d, ins),
|
||||
block: block.clone(),
|
||||
values: values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -85,7 +85,8 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
|
||||
@@ -453,14 +453,32 @@ impl Chain {
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let mut acc = 0.0;
|
||||
for s in &self.segs {
|
||||
let l = s.len();
|
||||
if d <= acc + l + 1e-12 && l > 0.0 {
|
||||
let t = ((d - acc) / l).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
acc += l;
|
||||
self.at_length_in(&self.end_lengths(), d)
|
||||
}
|
||||
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
|
||||
pub fn end_lengths(&self) -> Vec<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.scan(0.0, |acc, s| {
|
||||
*acc += s.len();
|
||||
Some(*acc)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
|
||||
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
|
||||
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let first = ends.partition_point(|e| d > e + 1e-12);
|
||||
// A segment of no length can't hold the point; the next one that has length does.
|
||||
// `ends` never decreases, so the first segment past `first` with length is found by search
|
||||
// (a run of zero-length segments can be long) instead of a scan.
|
||||
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
|
||||
let i = (i < self.segs.len()).then_some(i);
|
||||
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
|
||||
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
@@ -640,6 +658,34 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
|
||||
/// ends and over a segment of no length.
|
||||
#[test]
|
||||
fn at_length_in_matches_a_scan() {
|
||||
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
|
||||
let c = Chain {
|
||||
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
|
||||
closed: false,
|
||||
};
|
||||
let ends = c.end_lengths();
|
||||
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
|
||||
// The scan this replaced.
|
||||
let mut acc = 0.0;
|
||||
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
|
||||
for s in &c.segs {
|
||||
let len = s.len();
|
||||
if d <= acc + len + 1e-12 && len > 0.0 {
|
||||
let t = ((d - acc) / len).clamp(0.0, 1.0);
|
||||
want = Some((s.at(t), s.tangent(t)));
|
||||
break;
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
|
||||
assert_eq!(c.at_length(d), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_builders() {
|
||||
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
|
||||
@@ -36,16 +36,18 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
|
||||
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let range = if c.closed { 0..n } else { 1..n };
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
let ends = c.end_lengths();
|
||||
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let ends = c.end_lengths();
|
||||
let mut out = Vec::new();
|
||||
let mut k = 1usize;
|
||||
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
|
||||
let d = k as f64 * step;
|
||||
out.extend(c.at_length(d));
|
||||
out.extend(c.at_length_in(&ends, d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -58,8 +58,9 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
|
||||
closed: true,
|
||||
};
|
||||
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
|
||||
let ends = chain.end_lengths();
|
||||
for j in 0..k {
|
||||
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
|
||||
@@ -204,7 +204,8 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
||||
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
@@ -375,16 +375,20 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
|
||||
let mut boxes = Vec::new();
|
||||
let mut bound = |d: &Drawing, h: Handle| {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
||||
// One block-bounds cache for each drawing, so a block many differing entities refer to is
|
||||
// measured once, not once per entity.
|
||||
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
|
||||
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
|
||||
{
|
||||
boxes.push(b);
|
||||
}
|
||||
};
|
||||
added.iter().for_each(|h| bound(&cur, *h));
|
||||
removed.iter().for_each(|h| bound(&other, *h));
|
||||
}
|
||||
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
|
||||
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
|
||||
for (a, b, _, _) in &changed {
|
||||
bound(&cur, *a);
|
||||
bound(&other, *b);
|
||||
bound(&mut boxes, &cur, *a, &mut cur_cache);
|
||||
bound(&mut boxes, &other, *b, &mut other_cache);
|
||||
}
|
||||
let regions = regions(&boxes);
|
||||
let ch: Vec<Value> = changed
|
||||
|
||||
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -303,11 +303,9 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -117,6 +117,12 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
|
||||
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
|
||||
let live = counts(s, &objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
@@ -150,6 +156,12 @@ pub(super) struct MocoroM {
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
if matches!(op, Op::Copy(_)) {
|
||||
let live = counts(s, &self.objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, 1, live)?;
|
||||
}
|
||||
}
|
||||
let before = s.state()?.doc.clone();
|
||||
let base = self.base;
|
||||
apply(s, &self.objs, &mut self.base, op)?;
|
||||
|
||||
@@ -34,10 +34,12 @@ pub(crate) fn arraypath(
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
|
||||
let ends = c.end_lengths();
|
||||
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
|
||||
let mut mats = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
|
||||
let mut m = Mat3::translate(p - p0);
|
||||
if align {
|
||||
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
|
||||
|
||||
@@ -175,8 +175,14 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
|
||||
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
|
||||
return true;
|
||||
}
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
|
||||
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
|
||||
// long text (a layer name from a file) isn't parsed, copied and split per entity.
|
||||
const MAX_COLOR_TEXT: usize = 64;
|
||||
let (pc, vc) = (ptxt.trim(), vtxt.trim());
|
||||
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
|
||||
return false;
|
||||
}
|
||||
match (Color::parse(pc), Color::parse(vc)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
@@ -400,3 +406,26 @@ mod tests {
|
||||
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod long_text_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long string property is compared as text only, and a colour name still equals its index.
|
||||
#[test]
|
||||
fn long_values_skip_colour_parsing() {
|
||||
let long = Value::String("x".repeat(100_000));
|
||||
assert!(compare(&long, Op::Eq, &long));
|
||||
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
|
||||
}
|
||||
|
||||
/// Padding is trimmed before the length limit, so a padded colour still matches.
|
||||
#[test]
|
||||
fn padded_colours_still_match() {
|
||||
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
|
||||
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
|
||||
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,11 +238,9 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -370,6 +368,16 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -123,6 +123,12 @@ pub struct LispState {
|
||||
acad_loaded: bool,
|
||||
/// Memory caps (lowered by tests).
|
||||
pub(crate) limits: Limits,
|
||||
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
|
||||
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
|
||||
pub(crate) callback_used: u64,
|
||||
/// The `callback_used` value the callback running now (and so every one it calls) must not
|
||||
/// pass: the lowest of its own and its callers' allowances.
|
||||
pub(crate) callback_ceiling: Option<u64>,
|
||||
}
|
||||
|
||||
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
|
||||
@@ -415,9 +421,12 @@ impl Session {
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// One pass over the buffer, dropping the printed lines once at the end: draining each line
|
||||
// from the front shifted the rest every time, quadratic in newline-dense output.
|
||||
let mut done = 0;
|
||||
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
|
||||
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
|
||||
done += i + 1;
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
@@ -427,6 +436,7 @@ impl Session {
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
env.out.drain(..done);
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
|
||||
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
@@ -114,7 +114,13 @@ pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
Some(v) => {
|
||||
// The strings in the list it makes are new, each as long as its text at most: the
|
||||
// text is charged, though a list result isn't inspected by `charge_result`.
|
||||
let text = string(v)?;
|
||||
charge_bytes(text.len())?;
|
||||
read::read_one(&text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,12 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Str(s) => {
|
||||
// A string is copied whole each time it occurs: it spends the allowance by its length
|
||||
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
|
||||
*budget = budget.checked_sub(s.len() as u64 / 16)?;
|
||||
serde_json::json!(&**s)
|
||||
}
|
||||
Value::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
@@ -296,12 +301,16 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
|
||||
const MAX_GROUP_TEXT: usize = 32 << 20;
|
||||
|
||||
/// An association list back to DXF groups (points split into x, y, z groups). The type
|
||||
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
|
||||
/// left out: they aren't the entity's to set.
|
||||
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
let mut kind = None;
|
||||
let mut tags = Vec::new();
|
||||
let mut text_left = MAX_GROUP_TEXT;
|
||||
for item in list.iter() {
|
||||
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
|
||||
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
|
||||
@@ -323,7 +332,11 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
_ => tags.push(match &v {
|
||||
Value::Int(i) => Tag::i(code, i64::from(*i)),
|
||||
Value::Real(r) => Tag::f(code, *r),
|
||||
Value::Str(s) => Tag::s(code, &**s),
|
||||
Value::Str(s) => {
|
||||
// Each occurrence of a shared string becomes its own copy in the tag.
|
||||
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
|
||||
Tag::s(code, &**s)
|
||||
}
|
||||
Value::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
@@ -1125,3 +1138,27 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
/// One 64 KB string occurring many times is charged by its length each time.
|
||||
#[test]
|
||||
fn shared_strings_spend_the_conversion_budgets() {
|
||||
let big = Value::str(&"x".repeat(64 << 10));
|
||||
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
|
||||
let list = |n: usize| {
|
||||
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
|
||||
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
|
||||
Value::list(items)
|
||||
};
|
||||
assert!(list_to_groups(&list(600)).is_err());
|
||||
assert!(list_to_groups(&list(100)).is_ok());
|
||||
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,10 @@ impl Machine {
|
||||
if n > budget {
|
||||
return Outcome::Wait(Need::Yield);
|
||||
}
|
||||
// Charge a callback's step as it runs, so callbacks it calls see what it already spent.
|
||||
if ctx.depth > 0 {
|
||||
ctx.s.lisp.callback_used = ctx.s.lisp.callback_used.saturating_add(1);
|
||||
}
|
||||
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
|
||||
State::Eval(expr) => self.eval(ctx, expr),
|
||||
State::Call(f, args) => self.call(ctx, f, args),
|
||||
@@ -646,29 +650,44 @@ impl Ctx<'_> {
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let start = self.s.lisp.callback_used;
|
||||
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
|
||||
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
|
||||
// allowance instead of each taking a fresh one (an exponential tree otherwise).
|
||||
let outer = self.s.lisp.callback_ceiling;
|
||||
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
|
||||
self.s.lisp.callback_ceiling = Some(ceiling);
|
||||
let r = self.drive_callback(f, args, ceiling);
|
||||
self.s.lisp.callback_ceiling = outer;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
|
||||
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
|
||||
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||
// thread and bypassing the blocking execution cap (CWE-400).
|
||||
let mut spent: u64 = 0;
|
||||
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
|
||||
// one calls are counted in `callback_used` too.
|
||||
loop {
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
match m.run(&mut sub, slice) {
|
||||
let outcome = m.run(&mut sub, slice);
|
||||
match outcome {
|
||||
// The steps were charged as they ran; finishing past the shared allowance is a failure too.
|
||||
Outcome::Done(_) if sub.s.lisp.callback_used > ceiling => {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(Need::Yield) => {}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,35 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
|
||||
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
|
||||
}
|
||||
|
||||
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
|
||||
#[test]
|
||||
fn nested_callbacks_share_one_step_allowance() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000_000;
|
||||
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
|
||||
let started = std::time::Instant::now();
|
||||
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
|
||||
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(60));
|
||||
// The next run starts afresh.
|
||||
assert_eq!(ok(&mut s, "(f 1)"), "T");
|
||||
}
|
||||
|
||||
/// An outer callback's own steps count against the allowance before the callbacks it calls run.
|
||||
#[test]
|
||||
fn an_outer_callbacks_steps_are_charged_before_a_nested_one() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
ok(&mut s, "(setq inner-steps 0)");
|
||||
// The outer callback spends ~600 steps, then a nested one tries to spend ~600 more.
|
||||
let r = eval(
|
||||
&mut s,
|
||||
"(vl-remove-if '(lambda (x) (repeat 150 (setq a 1)) (vl-remove-if '(lambda (y) (repeat 150 (setq inner-steps (1+ inner-steps)))) '(1)) nil) '(1))",
|
||||
);
|
||||
assert_eq!(r, Err("callback stopped after 1000 steps".into()));
|
||||
assert!(ok(&mut s, "inner-steps").parse::<i64>().unwrap() < 150);
|
||||
}
|
||||
|
||||
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
|
||||
#[test]
|
||||
fn setvar_rejects_an_oversized_shared_structure() {
|
||||
@@ -307,6 +336,16 @@ fn printing() {
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
|
||||
#[test]
|
||||
fn printing_many_lines_is_linear() {
|
||||
let mut s = session();
|
||||
let started = std::time::Instant::now();
|
||||
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
@@ -977,6 +1016,9 @@ fn strings_are_capped() {
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// READ makes new strings inside the list it returns: they count, though the result is a list.
|
||||
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// The budget is per run: the next one starts afresh.
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
|
||||
}
|
||||
|
||||
@@ -346,7 +346,11 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
|
||||
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
|
||||
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
equal_within(a, b, fuzz, EQUAL_BUDGET)
|
||||
}
|
||||
|
||||
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
|
||||
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
@@ -373,7 +377,19 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
_ => {
|
||||
// Comparing two strings costs their length, not one visit: a shared list of long
|
||||
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
|
||||
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
|
||||
let cost = (x.len().min(y.len()) / 64) as u64;
|
||||
if cost >= *budget {
|
||||
*budget = 0;
|
||||
return false;
|
||||
}
|
||||
*budget -= cost;
|
||||
}
|
||||
return atom_equal(&a, &b, fuzz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,6 +556,20 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
|
||||
/// big allowance but not a small one, though it has few nodes.
|
||||
#[test]
|
||||
fn equal_spends_its_allowance_on_string_bytes() {
|
||||
let long = Value::str(&"x".repeat(6_400));
|
||||
let other = Value::str(&"x".repeat(6_400));
|
||||
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
|
||||
let (a, b) = (list(&long), list(&other));
|
||||
// 100 strings x 100 units each, plus about 100 node visits.
|
||||
assert!(equal_within(&a, &b, 0.0, 20_000));
|
||||
assert!(!equal_within(&a, &b, 0.0, 5_000));
|
||||
assert!(equal(&a, &b, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_print_with_six_significant_digits() {
|
||||
assert_eq!(real_text(1.0), "1.0");
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Saving an edited block as a copy must retain its source's last saved definition.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
|
||||
/// source block keeps the definition of its own last save.
|
||||
#[test]
|
||||
fn save_as_preserves_the_last_saved_source_definition() {
|
||||
let mut s = Session::new();
|
||||
let mut handles = Vec::new();
|
||||
for y in [5, 9] {
|
||||
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
|
||||
handles.push(line["handles"][0].clone());
|
||||
}
|
||||
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
|
||||
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
|
||||
assert_eq!(saved_source.entities.len(), 3);
|
||||
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
|
||||
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
|
||||
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
|
||||
|
||||
// A later save of Copy must not reintroduce the editor table's older source definition.
|
||||
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
|
||||
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
@@ -233,3 +233,17 @@ fn options_on_objects_that_are_gone_record_nothing() {
|
||||
assert!(lines(&s).is_empty());
|
||||
assert_eq!(undo_len(&s), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_copies_stay_within_maxarray() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
|
||||
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
|
||||
let mut hs = vec![l];
|
||||
hs.extend(others);
|
||||
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
|
||||
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert_eq!(lines(&s).len(), 30, "nothing was copied");
|
||||
}
|
||||
|
||||
@@ -904,7 +904,7 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
|
||||
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
|
||||
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
assert_eq!(plain_mtext("\\U+12"), "+12", "a short escape loses only the backslash and the code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -928,6 +928,8 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
|
||||
// Malformed escapes lose only the backslash and the code.
|
||||
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
|
||||
assert_eq!(lay("a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(lay("\\pxql;a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,11 @@ impl Face {
|
||||
let Some(f) = self.font() else { return Vec::new() };
|
||||
[skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
|
||||
.into_iter()
|
||||
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
|
||||
// A name table can hold many records that point at one big string: at most
|
||||
// MAX_NAME_RECORDS per name id, each decoded only up to MAX_NAME_CHARS characters.
|
||||
.flat_map(|id| {
|
||||
f.localized_strings(id).take(MAX_NAME_RECORDS).map(|s| norm(&s.chars().take(MAX_NAME_CHARS).collect::<String>())).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -118,6 +122,10 @@ pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
|
||||
}
|
||||
|
||||
const MAX_FACES_PER_FILE: u32 = 32;
|
||||
/// Name records read per name id of a face, and characters read of each: real fonts have a few
|
||||
/// dozen short ones.
|
||||
const MAX_NAME_RECORDS: usize = 64;
|
||||
const MAX_NAME_CHARS: usize = 256;
|
||||
/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character.
|
||||
const MAX_FALLBACK_FILES: usize = 24;
|
||||
const MAX_FALLBACK_BYTES: usize = 160 << 20;
|
||||
|
||||
@@ -484,6 +484,9 @@ pub struct Polyline {
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// Sampled points one [`Polyline::tessellate`] call produces before arcs become chords.
|
||||
pub const MAX_TESSELLATED_POINTS: usize = 4_000_000;
|
||||
|
||||
impl Polyline {
|
||||
pub fn from_points(pts: &[Vec2], closed: bool) -> Self {
|
||||
Polyline { vertices: pts.iter().map(|p| PolyVertex::new(*p)).collect(), closed }
|
||||
@@ -516,10 +519,24 @@ impl Polyline {
|
||||
b
|
||||
}
|
||||
pub fn tessellate(&self, tol: f64) -> Vec<Vec2> {
|
||||
let mut budget = MAX_TESSELLATED_POINTS;
|
||||
self.tessellate_within(tol, &mut budget)
|
||||
}
|
||||
/// [`Polyline::tessellate`] spending about `budget` sampled points (shared by the caller
|
||||
/// across many polylines). The budget is checked before each segment, so the last segment
|
||||
/// started may overshoot it by that one segment's samples. Once it is spent, the remaining arcs are drawn as chords, so the
|
||||
/// output stays proportional to the input vertices. Each arc can sample thousands of points,
|
||||
/// so a compact polyline could otherwise expand into gigabytes (CWE-400).
|
||||
pub fn tessellate_within(&self, tol: f64, budget: &mut usize) -> Vec<Vec2> {
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
for s in self.segments() {
|
||||
let mut pts = Vec::new();
|
||||
s.tessellate(tol, &mut pts);
|
||||
if *budget > 0 {
|
||||
s.tessellate(tol, &mut pts);
|
||||
*budget = budget.saturating_sub(pts.len());
|
||||
} else {
|
||||
pts.extend([s.start(), s.end()]);
|
||||
}
|
||||
let skip = usize::from(out.last().is_some_and(|l| pts.first().is_some_and(|f| l.near(*f, 1e-9))));
|
||||
out.extend(pts.into_iter().skip(skip));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ mod vec;
|
||||
|
||||
pub use bounds::Bounds2;
|
||||
pub use curve::{
|
||||
Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, polygon_centroid, shoelace,
|
||||
Arc, Circle, Ellipse, Line, MAX_TESSELLATED_POINTS, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon,
|
||||
polygon_centroid, shoelace,
|
||||
};
|
||||
pub use fillet::{CornerFit, TanCurve, tangent_circles};
|
||||
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
||||
|
||||
@@ -431,10 +431,10 @@ fn outside(core: &Graph, u: usize, steps: usize) -> Option<Vec<Vec2>> {
|
||||
}
|
||||
|
||||
/// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`).
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
|
||||
let comp = components(g);
|
||||
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
|
||||
let outer_pts = points(g, outer)?;
|
||||
let ob = Bounds2::from_points(outer_pts.iter().copied());
|
||||
let core = prune(g);
|
||||
// Leftmost (then lowest) vertex still on a cycle, per component.
|
||||
@@ -464,19 +464,32 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
let mut starts: Vec<(usize, usize)> = start.into_iter().collect();
|
||||
starts.sort_unstable();
|
||||
let mut out = Vec::new();
|
||||
// Each containment test scans a whole polygon, accepted or not: they share one allowance of
|
||||
// polygon edges, and the search ends when it is spent.
|
||||
let mut edges_left: usize = 100_000_000;
|
||||
for (c, u) in starts {
|
||||
if out.len() >= MAX_ISLANDS {
|
||||
break;
|
||||
}
|
||||
let Some(&pu) = core.pts.get(u) else { continue };
|
||||
if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) {
|
||||
if !ob.contains(pu) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(outer_pts.len())?;
|
||||
edges_left = left;
|
||||
if !point_in_polygon(&outer_pts, pu) {
|
||||
continue;
|
||||
}
|
||||
let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4);
|
||||
let Some(lp) = outside(&core, u, steps) else { continue };
|
||||
let area = shoelace(&lp);
|
||||
let ib = Bounds2::from_points(lp.iter().copied());
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) {
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(lp.len())?;
|
||||
edges_left = left;
|
||||
if point_in_polygon(&lp, p) {
|
||||
continue;
|
||||
}
|
||||
let mut src = sources.remove(&c).unwrap_or_default();
|
||||
@@ -484,7 +497,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
src.dedup();
|
||||
out.push(Island { outline: lp, sources: src });
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
|
||||
@@ -497,6 +510,10 @@ fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, bu
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
// A curve with no segment costs a visit too, so many of them can't multiply the searches.
|
||||
if other.len() < 2 {
|
||||
*budget = budget.checked_sub(1).ok_or(())?;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
@@ -598,7 +615,7 @@ pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Opti
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
let ids = outer_face(&g, p)?;
|
||||
let outer = points(&g, &ids)?;
|
||||
let islands = islands(&g, &ids, p);
|
||||
let islands = islands(&g, &ids, p)?;
|
||||
let mut outer_sources: Vec<usize> =
|
||||
ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect();
|
||||
outer_sources.sort_unstable();
|
||||
|
||||
@@ -221,6 +221,18 @@ fn segment_offset() {
|
||||
assert!(s.offset(6.0).is_none());
|
||||
}
|
||||
|
||||
/// A polyline of many fine arcs samples at most the point budget, then draws chords.
|
||||
#[test]
|
||||
fn polyline_tessellation_is_bounded() {
|
||||
let vertices: Vec<PolyVertex> = (0..3000).map(|i| PolyVertex { bulge: 1.0, ..PolyVertex::new(Vec2::new(f64::from(i) * 1e6, 0.0)) }).collect();
|
||||
let pl = Polyline { vertices, closed: false };
|
||||
let pts = pl.tessellate(1e-9);
|
||||
assert!(pts.len() <= MAX_TESSELLATED_POINTS + 2 * 3000 + 4096, "{}", pts.len());
|
||||
assert!(pts.len() > 3000);
|
||||
let mut budget = 10;
|
||||
assert!(pl.tessellate_within(1e-9, &mut budget).len() <= 4096 + 2 * 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_segments_bounded() {
|
||||
assert_eq!(arc_segments(0.0, 1.0, 0.1), 1);
|
||||
|
||||
@@ -844,15 +844,33 @@ pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
|
||||
std::sync::Arc::make_mut(b).annotative = true;
|
||||
}
|
||||
}
|
||||
// Which layout holds each paper space object, found once: `modify_entity` searches every
|
||||
// layout for a handle model space lacks, which a long list of missing handles made quadratic.
|
||||
let mut in_layout: std::collections::HashMap<Handle, usize> = Default::default();
|
||||
if !p.objects.is_empty() {
|
||||
// First layout wins, as `modify_entity` did.
|
||||
for (i, l) in d.layouts.iter().enumerate() {
|
||||
for h in l.entities.handles() {
|
||||
in_layout.entry(h).or_insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in p.objects {
|
||||
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
|
||||
let store = if d.model.contains(h) {
|
||||
&mut d.model
|
||||
} else if let Some(l) = in_layout.get(&h).and_then(|&i| d.layouts.get_mut(i)) {
|
||||
&mut l.entities
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
|
||||
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
|
||||
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
|
||||
scales.push(s);
|
||||
}
|
||||
}
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
store.modify(h, |e| {
|
||||
if cadcraft_doc::can_be_annotative(&e.kind) {
|
||||
e.common.anno_scales = scales;
|
||||
}
|
||||
|
||||
@@ -339,7 +339,8 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle);
|
||||
let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default();
|
||||
let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone());
|
||||
let root_keys = root_key_index(&root_entries);
|
||||
let root_key = |h: &str| root_keys.get(h).map(|k| (*k).to_string());
|
||||
// An extension dictionary of an entity or record (not of another object).
|
||||
let is_xdict = |k: usize| {
|
||||
let Some((_, _, t)) = objects.get(k) else { return false };
|
||||
@@ -494,6 +495,16 @@ fn extension_dictionaries(
|
||||
dictionaries
|
||||
}
|
||||
|
||||
/// The root dictionary's key for each entry handle (the first key when several name one handle),
|
||||
/// built once: a lookup per object scanning the entries made a large dictionary quadratic.
|
||||
fn root_key_index(root_entries: &[(String, String)]) -> HashMap<&str, &str> {
|
||||
let mut index = HashMap::with_capacity(root_entries.len());
|
||||
for (key, handle) in root_entries {
|
||||
index.entry(handle.as_str()).or_insert(key.as_str());
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Determine which objects belong to unmodelled dictionary entries or owners.
|
||||
fn retained_objects(
|
||||
objects: &[(String, usize, &[Tag])],
|
||||
@@ -504,7 +515,8 @@ fn retained_objects(
|
||||
root_entries: &[(String, String)],
|
||||
xdict_of: &HashMap<String, String>,
|
||||
) -> HashMap<usize, bool> {
|
||||
let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key);
|
||||
let root_keys = root_key_index(root_entries);
|
||||
let root_key = |handle: &str| root_keys.get(handle).copied();
|
||||
let is_xdict = |index: usize| {
|
||||
let Some((_, _, tags)) = objects.get(index) else { return false };
|
||||
let header = head(tags);
|
||||
|
||||
@@ -1204,33 +1204,56 @@ fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
|
||||
return;
|
||||
}
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
let mut classes = 0;
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
let kind = t.str();
|
||||
if kind != "CLASS" {
|
||||
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
|
||||
} else {
|
||||
classes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < tags.len() {
|
||||
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
|
||||
start += 1;
|
||||
// One pass into a new list: inserting each missing count in place shifted every later tag,
|
||||
// quadratic in the number of CLASS records.
|
||||
// Tags move by value (no clone); the old and new buffers coexist while it runs. At most one
|
||||
// group 91 is added per CLASS record, so that is all the room the new list needs beyond the old one.
|
||||
let old = std::mem::take(tags);
|
||||
tags.reserve(old.len() + classes);
|
||||
let mut it = old.into_iter();
|
||||
let mut pending = it.next();
|
||||
while let Some(t) = pending.take() {
|
||||
if t.code != 0 || t.str() != "CLASS" {
|
||||
tags.push(t);
|
||||
pending = it.next();
|
||||
continue;
|
||||
}
|
||||
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
|
||||
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let mut record = vec![t];
|
||||
for n in it.by_ref() {
|
||||
if n.code == 0 {
|
||||
pending = Some(n);
|
||||
break;
|
||||
}
|
||||
record.push(n);
|
||||
}
|
||||
let name = record.iter().skip(1).find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let count = counts.get(&name).copied().unwrap_or(0);
|
||||
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
|
||||
tags[start + 1 + i] = Tag::i(91, count);
|
||||
false
|
||||
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
|
||||
tags.insert(start + 2 + i, Tag::i(91, count));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Inserting the group shifts the next record's start by one.
|
||||
start = end + usize::from(inserted);
|
||||
// The first 91 gets the count; without one, a 91 goes in after the first 90.
|
||||
let mut added = record.iter().skip(1).any(|t| t.code == 91);
|
||||
let mut replaced = false;
|
||||
for (i, t) in record.into_iter().enumerate() {
|
||||
let code = t.code;
|
||||
if i > 0 && code == 91 && !replaced {
|
||||
tags.push(Tag::i(91, count));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
tags.push(t);
|
||||
if i > 0 && code == 90 && !added {
|
||||
tags.push(Tag::i(91, count));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,3 +1281,31 @@ fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod class_count_tests {
|
||||
use super::*;
|
||||
|
||||
fn record(kind: &str, name: &str, extra: &[Tag]) -> Vec<Tag> {
|
||||
let mut r = vec![Tag::s(0, kind), Tag::s(1, name), Tag::i(90, 0)];
|
||||
r.extend_from_slice(extra);
|
||||
r
|
||||
}
|
||||
|
||||
/// The first 91 gets the count, a CLASS without one gets it after its 90, in one pass over many records.
|
||||
#[test]
|
||||
fn counts_are_set_and_added_in_one_pass() {
|
||||
let mut tags = record("CLASS", "FOO", &[]);
|
||||
tags.extend(record("CLASS", "BAR", &[Tag::i(91, 7), Tag::i(91, 8)]));
|
||||
for i in 0..50_000 {
|
||||
tags.extend(record("CLASS", &format!("X{i}"), &[]));
|
||||
}
|
||||
tags.push(Tag::s(0, "FOO"));
|
||||
let started = std::time::Instant::now();
|
||||
add_class_instance_counts(&mut tags, Version::R2018);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
assert_eq!(&tags[..4], &[Tag::s(0, "CLASS"), Tag::s(1, "FOO"), Tag::i(90, 0), Tag::i(91, 1)]);
|
||||
assert_eq!(&tags[4..9], &[Tag::s(0, "CLASS"), Tag::s(1, "BAR"), Tag::i(90, 0), Tag::i(91, 0), Tag::i(91, 8)]);
|
||||
assert_eq!(tags.iter().filter(|t| t.code == 91).count(), 50_003);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -50,6 +50,8 @@ const MAX_LAYER_NAME: usize = 1000;
|
||||
/// Most link annotations in one file, and the longest link target (bytes).
|
||||
const MAX_LINKS: usize = 10_000;
|
||||
const MAX_URL: usize = 2048;
|
||||
/// The most link annotations in one PDF, over all pages (a page holds at most [`MAX_LINKS`]).
|
||||
const MAX_FILE_ANNOTATIONS: usize = 100_000;
|
||||
/// Longest title and page label written (characters).
|
||||
const MAX_TEXT: usize = 1000;
|
||||
|
||||
@@ -357,7 +359,10 @@ fn sheet_name(space: &Space) -> String {
|
||||
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
|
||||
/// layout name. The options apply to every sheet; layouts use their own page setups.
|
||||
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
|
||||
// Links are file-wide (`pdf_items` reads them from `o`), so a sheet's own options carry none:
|
||||
// a copy of the list per sheet multiplied it by the sheet count.
|
||||
let sheet = PdfOptions { links: Vec::new(), ..o.clone() };
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), sheet.clone())).collect();
|
||||
pdf_items(d, &items, o)
|
||||
}
|
||||
|
||||
@@ -453,8 +458,16 @@ fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut fonts = fonts::Fonts::new(!text_as_geometry);
|
||||
let mut groups = Groups::default();
|
||||
// Link annotations of the whole file: each page keeps its own copy of the targets, so the
|
||||
// pages times the links must not multiply without a bound.
|
||||
let mut annotations_left = MAX_FILE_ANNOTATIONS;
|
||||
for (s, po) in items {
|
||||
let page = sheet(drawn, s, po, &links)?;
|
||||
// Once the budget is spent no link is built (scanned and cloned) for later pages.
|
||||
let mut page = sheet(drawn, s, po, if annotations_left == 0 { &links[..0] } else { &links })?;
|
||||
// Only targets `write_page` accepts count against the budget.
|
||||
page.links.retain(|(_, url)| uri(url).is_some());
|
||||
page.links.truncate(annotations_left);
|
||||
annotations_left -= page.links.len();
|
||||
let doc = writer.document_mut();
|
||||
// Groups are added to the document, so they must exist before the page starts.
|
||||
groups.add(doc, d, &page.layers);
|
||||
@@ -625,7 +638,8 @@ pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Shee
|
||||
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
|
||||
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
|
||||
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
|
||||
// A target past MAX_URL is refused at the annotation anyway; it is left out before it is copied.
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().filter(|(_, u)| u.len() <= MAX_URL).take(MAX_LINKS).cloned().collect();
|
||||
if !o.hyperlinks {
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -590,7 +590,10 @@ fn hidden(e: &BytesStart) -> bool {
|
||||
}
|
||||
|
||||
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
|
||||
for a in e.attributes().flatten() {
|
||||
// Stop at the first malformed attribute: skipping errors resumed the parser's duplicate check,
|
||||
// which rescans the attributes before it each time, so many duplicates were quadratic.
|
||||
for a in e.attributes() {
|
||||
let Ok(a) = a else { return None };
|
||||
if a.key.into_inner().as_bytes() == name {
|
||||
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
|
||||
}
|
||||
|
||||
@@ -396,6 +396,16 @@ fn many_distinct_symbol_names_read_quickly() {
|
||||
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
|
||||
}
|
||||
|
||||
/// Elements with many duplicate attributes are read in bounded time.
|
||||
#[test]
|
||||
fn svg_duplicate_attributes_are_not_rescanned() {
|
||||
let dup = " x=\"1\"".repeat(20_000);
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", format!("<rect{dup} width=\"1\" height=\"1\"/>").repeat(20));
|
||||
let started = std::time::Instant::now();
|
||||
let _ = read(svg.as_bytes(), "dup.svg");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dxf_does_not_panic() {
|
||||
for t in [
|
||||
|
||||
@@ -21,3 +21,6 @@ getrandom = "0.4"
|
||||
[dev-dependencies]
|
||||
# Reads the pixels of rendered PNGs in the tests.
|
||||
tiny-skia = { workspace = true }
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# O_NOFOLLOW when reading the token file (MIT OR Apache-2.0).
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -76,9 +76,17 @@ impl Remote {
|
||||
/// The per-session token for this port, read (and cached) from the owner-only token file. A
|
||||
/// missing file is not fatal here: the request is sent without a token and the server replies
|
||||
/// with an authentication error the user can act on.
|
||||
///
|
||||
/// The desktop app listens only on `127.0.0.1:PORT`, so the token goes only to that exact
|
||||
/// peer: a name that also resolves elsewhere (`localhost` → `::1`, where another local account
|
||||
/// may listen on the same port) must not be handed the desktop's credential (CWE-522).
|
||||
fn token(&mut self) -> Option<&str> {
|
||||
let port = port_of(&self.addr)?;
|
||||
let peer = self.conn.as_ref().and_then(|(_, s)| s.peer_addr().ok());
|
||||
if peer != Some(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) {
|
||||
return None;
|
||||
}
|
||||
if self.token.is_none()
|
||||
&& let Some(port) = port_of(&self.addr)
|
||||
&& let Ok(t) = crate::control_token::read(port)
|
||||
{
|
||||
self.token = Some(t);
|
||||
@@ -207,6 +215,14 @@ fn is_read_only(method: &str) -> bool {
|
||||
|
||||
impl Remote {
|
||||
/// Put the current token (if any) on `req`, replacing the one it carries.
|
||||
/// Connect if needed, put the token for the peer actually connected to on `req`, and send it.
|
||||
fn sign_and_send(&mut self, req: &mut Value) -> Result<String, (std::io::Error, bool)> {
|
||||
if self.conn.is_none() {
|
||||
self.reach().map_err(|e| (e, false))?;
|
||||
}
|
||||
self.set_token(req);
|
||||
self.roundtrip(&req.to_string())
|
||||
}
|
||||
fn set_token(&mut self, req: &mut Value) {
|
||||
let token = self.token().map(str::to_string);
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
@@ -219,7 +235,7 @@ impl Remote {
|
||||
/// Send `req` and parse the reply, refreshing the token before a safe retry after a transport
|
||||
/// error. A mutation that may have reached the server is never resent automatically.
|
||||
fn exchange(&mut self, method: &str, req: &mut Value) -> Result<Value, String> {
|
||||
let reply = match self.roundtrip(&req.to_string()) {
|
||||
let reply = match self.sign_and_send(req) {
|
||||
Ok(r) => r,
|
||||
Err((e, sent)) => {
|
||||
self.token = None;
|
||||
@@ -232,8 +248,7 @@ impl Remote {
|
||||
));
|
||||
}
|
||||
// A restarted server may have rotated its token before dropping the old socket.
|
||||
self.set_token(req);
|
||||
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
|
||||
self.sign_and_send(req).map_err(|(e, _)| {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
format!("CADCraft at {}: {e}", self.addr)
|
||||
@@ -256,14 +271,12 @@ impl Backend for Remote {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut req = json!({"id": id, "method": method, "params": params});
|
||||
self.set_token(&mut req);
|
||||
let mut v = self.exchange(method, &mut req)?;
|
||||
// The token is read before `--launch` opens the app, which then writes a new one (#2087); the
|
||||
// file can also be left over from an earlier session. Once, so a wrong token can't loop.
|
||||
if unauthorized(&v) && v.get("state").and_then(Value::as_str) == Some("not-run") {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
self.set_token(&mut req);
|
||||
v = self.exchange(method, &mut req)?;
|
||||
}
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -595,6 +608,24 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// A listener on another loopback address with the desktop's port (here `::1`) never receives
|
||||
/// the desktop's token.
|
||||
#[test]
|
||||
fn token_is_not_sent_to_another_loopback_listener() {
|
||||
let Ok(listener) = TcpListener::bind("[::1]:0") else { return };
|
||||
let addr = listener.local_addr().unwrap();
|
||||
crate::control_token::issue(addr.port()).unwrap();
|
||||
let server = fake_app(listener, move |req, _| {
|
||||
assert!(req.get("token").is_none(), "token leaked to {addr}");
|
||||
json!({"ok": true, "result": "x"})
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr.to_string(), false);
|
||||
let got = remote.call("ping", json!({}));
|
||||
crate::control_token::revoke(addr.port());
|
||||
assert_eq!(got, Ok(json!("x")));
|
||||
assert_eq!(server.join().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// `--launch` reads the token before it opens the app, so the first request carries the token
|
||||
/// of an earlier session (or none); the app refuses it without running it, and the call is
|
||||
/// resent once with the token the app has just written (#2087).
|
||||
|
||||
@@ -32,7 +32,9 @@ fn private_dir() -> io::Result<PathBuf> {
|
||||
if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(h).join(".cache"));
|
||||
}
|
||||
Ok(std::env::temp_dir())
|
||||
// The shared temporary directory would put the credential at a path any local account can
|
||||
// predict and plant a link at: refuse instead.
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR or HOME is required for a private control token"))
|
||||
}
|
||||
|
||||
/// Require the Windows per-user local app-data directory for session token storage.
|
||||
@@ -75,7 +77,31 @@ fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
|
||||
|
||||
/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge).
|
||||
pub fn read(port: u16) -> io::Result<String> {
|
||||
let raw = std::fs::read_to_string(token_path(port)?)?;
|
||||
read_at(&token_path(port)?)
|
||||
}
|
||||
|
||||
/// Read a token file the issuer wrote: not through a link, a plain file, and (Unix) readable by
|
||||
/// its owner only, so a file another account planted or left open is not taken as the credential.
|
||||
fn read_at(path: &std::path::Path) -> io::Result<String> {
|
||||
use std::io::Read;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "the control token is not a plain file"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if std::os::unix::fs::PermissionsExt::mode(&meta.permissions()) & 0o077 != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the control token file is readable by other users"));
|
||||
}
|
||||
let mut raw = String::new();
|
||||
file.take(1024).read_to_string(&mut raw)?;
|
||||
let token = raw.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token"));
|
||||
@@ -181,6 +207,24 @@ mod tests {
|
||||
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
|
||||
}
|
||||
|
||||
/// A link, or a file other users can read, is not accepted as the token.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_refuses_links_and_shared_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-token-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let secret = dir.join("secret");
|
||||
issue_at(&secret, b"abc").unwrap();
|
||||
assert_eq!(read_at(&secret).unwrap(), "abc");
|
||||
let link = dir.join("link");
|
||||
std::os::unix::fs::symlink(&secret, &link).unwrap();
|
||||
assert!(read_at(&link).is_err());
|
||||
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(read_at(&secret).is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Token issuance creates its missing directory hierarchy before writing the credential.
|
||||
#[test]
|
||||
fn issuance_creates_missing_parent() {
|
||||
|
||||
+100
-9
@@ -12,7 +12,7 @@ use super::{Builder, Ctx, Ink, fill, ink, max_scale};
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::library::{PatternLine, pattern_for};
|
||||
use cadcraft_doc::{Drawing, Entity, Gradient, Hatch};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance.
|
||||
@@ -128,7 +128,17 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
|
||||
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
|
||||
// One point budget for all the loops: many short arcs across loops must not add up unbounded.
|
||||
let mut budget = cadcraft_geom::MAX_TESSELLATED_POINTS;
|
||||
// Source loops that are the same boundary up to start vertex and winding are tessellated once, so a
|
||||
// duplicate cannot be sampled coarser than its twin by the shared budget and then escape `dedup_polygons`.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut loops: Vec<Vec<Vec2>> = h
|
||||
.loops
|
||||
.iter()
|
||||
.filter(|l| seen.insert(canonical_key(&l.vertices)))
|
||||
.map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget))
|
||||
.collect();
|
||||
dedup_polygons(&mut loops);
|
||||
let max_depth = match h.style {
|
||||
1 => 1,
|
||||
@@ -146,6 +156,50 @@ pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
loops.iter().enumerate().filter(|(i, l)| depth(*i, l) <= max_depth).map(|(_, l)| l.clone()).collect()
|
||||
}
|
||||
|
||||
/// Start index of the least rotation of `s` (two-pointer scan, O(n)).
|
||||
fn least_rotation(s: &[[u64; 3]]) -> usize {
|
||||
let n = s.len();
|
||||
let (mut i, mut j, mut k) = (0, 1, 0);
|
||||
while i < n && j < n && k < n {
|
||||
let (a, b) = (s.get((i + k) % n), s.get((j + k) % n));
|
||||
if a == b {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
if a > b {
|
||||
i += k + 1;
|
||||
} else {
|
||||
j += k + 1;
|
||||
}
|
||||
if i == j {
|
||||
j += 1;
|
||||
}
|
||||
k = 0;
|
||||
}
|
||||
i.min(j)
|
||||
}
|
||||
|
||||
/// A key equal for loops that are the same boundary starting at another vertex or wound the other way
|
||||
/// (reversing moves each bulge to the other end of its edge and negates it). Exact bits only; the
|
||||
/// tolerance-based `dedup_polygons` still catches near matches.
|
||||
fn canonical_key(v: &[PolyVertex]) -> Vec<[u64; 3]> {
|
||||
let bits = |x: f64| (x + 0.0).to_bits(); // -0.0 and 0.0 match
|
||||
let n = v.len();
|
||||
let fwd: Vec<[u64; 3]> = v.iter().map(|q| [bits(q.p.x), bits(q.p.y), bits(q.bulge)]).collect();
|
||||
let rev: Vec<[u64; 3]> = (0..n)
|
||||
.map(|j| {
|
||||
let i = n - 1 - j;
|
||||
let edge = v.get((i + n - 1) % n).map_or(0.0, |q| -q.bulge);
|
||||
v.get(i).map_or([0; 3], |q| [bits(q.p.x), bits(q.p.y), bits(edge)])
|
||||
})
|
||||
.collect();
|
||||
let rotated = |s: &[[u64; 3]]| {
|
||||
let k = least_rotation(s);
|
||||
s.iter().cycle().skip(k).take(s.len()).copied().collect::<Vec<_>>()
|
||||
};
|
||||
rotated(&fwd).min(rotated(&rev))
|
||||
}
|
||||
|
||||
/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge
|
||||
/// (vertices may touch an enclosing loop; edge midpoints rarely do).
|
||||
fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
@@ -367,6 +421,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
|
||||
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
|
||||
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
|
||||
/// the part of the hatch inside it is generated; `None` = all of it.
|
||||
#[cfg(test)]
|
||||
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_until(d, h, loops, px, view, None)
|
||||
}
|
||||
@@ -380,6 +435,21 @@ pub(crate) fn pattern_lines_until(
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
|
||||
}
|
||||
|
||||
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pattern_lines_within(
|
||||
d: &Drawing,
|
||||
h: &Hatch,
|
||||
loops: &[Vec<Vec2>],
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
max_runs: usize,
|
||||
work_limit: u64,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
let lines = lines_of(d, h);
|
||||
let scale = scale_of(h);
|
||||
@@ -388,7 +458,7 @@ pub(crate) fn pattern_lines_until(
|
||||
// Shared work budget across all families and spans, counting dash-processing steps even when
|
||||
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
|
||||
// their cost (CWE-400).
|
||||
let mut work: u64 = MAX_HATCH_WORK;
|
||||
let mut work: u64 = work_limit;
|
||||
for fam in &lines {
|
||||
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
|
||||
// are not swept at all.
|
||||
@@ -403,8 +473,8 @@ pub(crate) fn pattern_lines_until(
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= max_runs || work == 0 || is_set(cancel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -415,6 +485,8 @@ pub(crate) fn pattern_lines_until(
|
||||
const SWATCH_REPEATS: f64 = 6.0;
|
||||
/// Most runs a swatch holds.
|
||||
const MAX_SWATCH_RUNS: usize = 4000;
|
||||
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
|
||||
const MAX_SWATCH_WORK: u64 = 2_000_000;
|
||||
|
||||
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
|
||||
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
|
||||
@@ -441,14 +513,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines: lines.to_vec(),
|
||||
};
|
||||
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
|
||||
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
|
||||
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
|
||||
// (about a million runs) to keep a few thousand.
|
||||
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
|
||||
runs.truncate(MAX_SWATCH_RUNS);
|
||||
runs.shrink_to_fit();
|
||||
runs
|
||||
}
|
||||
|
||||
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
|
||||
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
|
||||
struct Family<'a> {
|
||||
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
|
||||
max_runs: usize,
|
||||
origin: Vec2,
|
||||
ang: f64,
|
||||
delta: Vec2,
|
||||
@@ -487,6 +564,13 @@ impl Family<'_> {
|
||||
|
||||
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
|
||||
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
|
||||
let transform_charge = verts / 8 + 1;
|
||||
if transform_charge > *work {
|
||||
return;
|
||||
}
|
||||
*work -= transform_charge;
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
@@ -505,10 +589,17 @@ impl Family<'_> {
|
||||
// family draws anything at all).
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
|
||||
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
|
||||
// operations, whether or not any dash is made.
|
||||
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
|
||||
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
|
||||
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
|
||||
return;
|
||||
}
|
||||
*work -= crossings_charge;
|
||||
let y = k as f64 * self.spacing;
|
||||
let xs = crossings(&local, y);
|
||||
let offset = shift_per_line * k as f64;
|
||||
@@ -538,10 +629,10 @@ impl Family<'_> {
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
*work = work.saturating_sub(1);
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
|
||||
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
*work -= 1;
|
||||
let len = d.abs();
|
||||
if *d > 0.0 {
|
||||
let a = x.max(x0);
|
||||
|
||||
@@ -29,11 +29,17 @@ const MAX_TEXTS: usize = 10_000;
|
||||
/// repeated many times).
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
|
||||
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
|
||||
const MAX_TEXT_POINTS: usize = 4_000_000;
|
||||
|
||||
/// One text or shape element of the pattern, shaped once at the origin.
|
||||
struct Element {
|
||||
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
|
||||
at: f64,
|
||||
local: Shaped,
|
||||
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
|
||||
points: usize,
|
||||
rotation: f64,
|
||||
absolute: bool,
|
||||
offset: Vec2,
|
||||
@@ -58,6 +64,7 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
return out;
|
||||
}
|
||||
let mut walk = Walk { pts, i: 0, start: 0.0 };
|
||||
let mut points_left = MAX_TEXT_POINTS;
|
||||
let mut base = 0.0;
|
||||
let mut points = 0usize;
|
||||
'repeats: while base <= len {
|
||||
@@ -67,6 +74,8 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
|
||||
break 'repeats;
|
||||
}
|
||||
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
|
||||
points_left = left;
|
||||
let Some((p, dir)) = walk.at(s) else { break 'repeats };
|
||||
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
|
||||
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
|
||||
@@ -111,6 +120,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
{
|
||||
out.push(Element {
|
||||
at,
|
||||
points: point_count(&local),
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -135,8 +145,16 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
..TextParams::new(Vec2::ZERO, height)
|
||||
};
|
||||
let (local, _) = cadcraft_fonts::place(&font, text, ¶ms);
|
||||
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
|
||||
// glyphs still has a cost proportional to its size.
|
||||
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
|
||||
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
|
||||
+ local.strokes.len()
|
||||
+ local.glyphs.len()
|
||||
+ 1;
|
||||
out.push(Element {
|
||||
at,
|
||||
points,
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -217,6 +235,20 @@ mod tests {
|
||||
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
|
||||
#[test]
|
||||
fn placed_labels_stop_at_the_point_allowance() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut lt = gas();
|
||||
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
|
||||
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
|
||||
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], <, 1.0, 0.0);
|
||||
let points: usize =
|
||||
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
|
||||
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
|
||||
assert!(points <= MAX_TEXT_POINTS, "{points}");
|
||||
}
|
||||
|
||||
/// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2.
|
||||
fn register_shapes() {
|
||||
let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n";
|
||||
|
||||
+45
-13
@@ -133,6 +133,20 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
|
||||
if parts.len() <= 1 {
|
||||
return layout(d, t);
|
||||
}
|
||||
// Every part is laid out and kept, so the cells of all of them count together (a header repeated
|
||||
// on each part adds to each); past the limit only the table's outline is drawn.
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let total: usize = parts.iter().map(|p| p.rows.len().min(10_000).saturating_mul(cols)).fold(0, usize::saturating_add);
|
||||
if total > MAX_LAYOUT_CELLS {
|
||||
// One outline per part, at the part's origin and as tall as its displayed rows.
|
||||
let w = edges(&t.col_widths, cols).last().copied().unwrap_or(0.0);
|
||||
let mut g = TableGeom::default();
|
||||
for p in parts {
|
||||
let h: f64 = p.rows.iter().take(10_000).map(|r| t.row_heights.get(*r).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum();
|
||||
outline_box(&mut g, p.origin, w, h);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
let mut g = TableGeom::default();
|
||||
for p in parts {
|
||||
let mut pg = layout(d, &part_table(t, &p.rows, p.origin));
|
||||
@@ -156,16 +170,20 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
|
||||
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
|
||||
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
|
||||
// `run[i]`: how many rows from part row `i` follow one another in the table, found once so a
|
||||
// merge is cut by a lookup, not by walking the rows it spans.
|
||||
let mut run = vec![1u32; rows.len()];
|
||||
for i in (0..rows.len().saturating_sub(1)).rev() {
|
||||
if rows.get(i + 1).zip(rows.get(i)).is_some_and(|(next, this)| *next == this + 1) {
|
||||
run[i] = run.get(i + 1).copied().unwrap_or(0).saturating_add(1);
|
||||
}
|
||||
}
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
}
|
||||
let n = rs.max(1).min(run.get(i).copied().unwrap_or(1));
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +204,27 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// The table's outline alone, for a table too large to lay out.
|
||||
fn outline(t: &Table) -> TableGeom {
|
||||
let rows = t.row_heights.len().min(10_000);
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let (w, h) = (edges(&t.col_widths, cols).last().copied().unwrap_or(0.0), edges(&t.row_heights, rows).last().copied().unwrap_or(0.0));
|
||||
let mut g = TableGeom::default();
|
||||
outline_box(&mut g, t.insert.xy(), w, h);
|
||||
g
|
||||
}
|
||||
|
||||
/// The four sides of a `w` by `h` box hanging down from `o`.
|
||||
fn outline_box(g: &mut TableGeom, o: Vec2, w: f64, h: f64) {
|
||||
let p = |x: f64, y: f64| o + Vec2::new(x, -y);
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
if let [a, b] = s {
|
||||
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out one part.
|
||||
fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
let st = table_style_of(d, t);
|
||||
@@ -203,14 +242,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
}
|
||||
let cov = coverage(t);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
|
||||
let (w, h) = (x_at(cols), y_at(rows));
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
if let [a, b] = s {
|
||||
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
|
||||
}
|
||||
}
|
||||
return g;
|
||||
return outline(t);
|
||||
}
|
||||
let k = style_scale(&st, t);
|
||||
// Cell styles by name, scaled to the table.
|
||||
|
||||
@@ -1165,3 +1165,55 @@ fn without_linetypes_every_line_is_drawn_whole() {
|
||||
let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default());
|
||||
assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len());
|
||||
}
|
||||
|
||||
/// A broken table whose parts, with their repeated labels, together hold more cells than a table may
|
||||
/// lay out is drawn as its outline, though the table's own cells are within the limit.
|
||||
#[test]
|
||||
fn broken_table_cells_count_across_its_parts() {
|
||||
let d = Drawing::new_imperial();
|
||||
let t = cadcraft_doc::Table {
|
||||
col_widths: vec![1.0; 600],
|
||||
row_heights: vec![1.0; 1000],
|
||||
title: true,
|
||||
header: true,
|
||||
breaks: Some(cadcraft_doc::TableBreak { enabled: true, height: 3.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(t.parts().len() > 500 && 1000 * 600 <= crate::MAX_LAYOUT_CELLS);
|
||||
let g = crate::table_geometry(&d, &t);
|
||||
let parts = t.parts();
|
||||
assert_eq!((g.lines.len(), g.texts.len(), g.fills.len()), (4 * parts.len(), 0, 0));
|
||||
// Each part's box starts at its own origin.
|
||||
let top = |i: usize| g.lines[4 * i].a;
|
||||
assert!((0..parts.len()).all(|i| top(i) == parts[i].origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reversed_copies_of_a_curved_loop_fill_once() {
|
||||
let ring = |rot: usize, rev: bool| {
|
||||
let mut v: Vec<PolyVertex> = [(0.0, 0.0, 0.5), (4.0, 0.0, -0.25), (4.0, 3.0, 0.0), (0.0, 3.0, 0.1)]
|
||||
.map(|(x, y, bulge)| PolyVertex { bulge, ..PolyVertex::new(Vec2::new(x, y)) })
|
||||
.to_vec();
|
||||
if rev {
|
||||
// The same boundary wound the other way: bulges move to the other end of their edge and flip.
|
||||
let b: Vec<f64> = v.iter().map(|q| q.bulge).collect();
|
||||
v.reverse();
|
||||
let n = v.len();
|
||||
for (j, q) in v.iter_mut().enumerate() {
|
||||
q.bulge = -b[(n - 1 - j + n - 1) % n];
|
||||
}
|
||||
}
|
||||
v.rotate_left(rot);
|
||||
HatchLoop { vertices: v, outer: true, sources: Vec::new() }
|
||||
};
|
||||
let mut h = pattern_hatch("SOLID", 1.0, (0.0, 0.0, 1.0, 1.0));
|
||||
h.loops = vec![ring(0, false), ring(2, false), ring(1, true), ring(3, true)];
|
||||
assert_eq!(loops_of(&h).len(), 1);
|
||||
// A different boundary is kept.
|
||||
h.loops.push(HatchLoop {
|
||||
vertices: ring(0, false).vertices.into_iter().map(|q| PolyVertex { bulge: 0.0, ..q }).collect(),
|
||||
outer: true,
|
||||
sources: Vec::new(),
|
||||
});
|
||||
assert_eq!(loops_of(&h).len(), 2);
|
||||
}
|
||||
|
||||
@@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
|
||||
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
|
||||
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
|
||||
#[test]
|
||||
fn compact_patterns_stop_at_the_swatch_limits() {
|
||||
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
|
||||
let started = std::time::Instant::now();
|
||||
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
|
||||
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20));
|
||||
}
|
||||
|
||||
@@ -217,15 +217,14 @@ pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
|
||||
/// then load it. A cancelled picker is a no-op; without a native picker (the web) the error names
|
||||
/// the ALIASEDIT `text` form, since the web has no file system to read a path from.
|
||||
pub fn pick_and_import(app: &mut CadApp) -> Result<(), String> {
|
||||
let dir = app.ui.last_pgp_dir.clone();
|
||||
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
|
||||
match picked {
|
||||
let dir = app.ui.last_pgp_dir.clone().unwrap_or_default();
|
||||
// Through `pick_for`, so macOS shows it as a sheet (#1527); aliases belong to no drawing.
|
||||
match app.pick_for(crate::Pick::Pgp(dir), false, Box::new(|app, path| import(app, &path).map(|()| serde_json::Value::Null))) {
|
||||
None => Err(refused(
|
||||
app,
|
||||
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
|
||||
)),
|
||||
Some(None) => Ok(()),
|
||||
Some(Some(path)) => import(app, &path),
|
||||
Some(r) => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +449,39 @@ mod tests {
|
||||
let _ = app.run("aliasedit", json!({ "clear": true }));
|
||||
}
|
||||
|
||||
/// Where the host shows dialogs without waiting (macOS sheets, #1527), Import… does not run
|
||||
/// the blocking picker; the file loads once the sheet answers.
|
||||
#[test]
|
||||
fn import_waits_for_a_sheet_instead_of_blocking() {
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
let _guard = alone();
|
||||
let dir = temp_dir("sheet");
|
||||
let path = dir.join("aliases.pgp");
|
||||
std::fs::write(&path, "XXS, *LINE\n").unwrap_or_default();
|
||||
let asked = Rc::new(RefCell::new(Vec::new()));
|
||||
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
|
||||
let (log, slot) = (Rc::clone(&asked), Rc::clone(&answer));
|
||||
let services = Services {
|
||||
pick_pgp: Some(Box::new(|_: Option<&str>| panic!("the blocking picker ran"))),
|
||||
start_pick: Some(Box::new(move |pick, done| {
|
||||
log.borrow_mut().push(pick);
|
||||
*slot.borrow_mut() = Some(done);
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
let mut app = CadApp::new(Session::new(), services);
|
||||
open(&mut app);
|
||||
|
||||
pick_and_import(&mut app).expect("the sheet starts");
|
||||
assert!(matches!(asked.borrow().as_slice(), [crate::Pick::Pgp(_)]));
|
||||
answer.borrow_mut().take().expect("a sheet was started")(Some(path.to_string_lossy().to_string()));
|
||||
app.finish_pick(&egui::Context::default());
|
||||
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user.get("xxs").map(String::as_str), Some("line"));
|
||||
clear(&mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_loads_the_aliases_shows_the_path_and_warnings() {
|
||||
let _guard = alone();
|
||||
|
||||
@@ -88,6 +88,11 @@ impl CadApp {
|
||||
/// Answer the open close prompt (no-op without one).
|
||||
pub fn answer_close(&mut self, choice: Choice) {
|
||||
let Some(uid) = self.closing.as_ref().and_then(|c| c.asking) else { return };
|
||||
// Its Save dialog is open (macOS): Save again or Don't Save would race it.
|
||||
if choice != Choice::Cancel && self.pending_pick.as_ref().is_some_and(|p| p.for_close) {
|
||||
self.set_status("Answer the Save dialog first.");
|
||||
return;
|
||||
}
|
||||
let i = match (choice, self.doc_index(uid)) {
|
||||
(Choice::Cancel, _) => {
|
||||
self.closing = None;
|
||||
@@ -114,26 +119,53 @@ impl CadApp {
|
||||
// The Block Editor's unsaved changes go into the block first (BSAVE), then the
|
||||
// drawing is saved with them.
|
||||
let r = if self.session.state().is_ok_and(|st| st.block_unsaved()) { self.run("bsave", json!({})) } else { Ok(Value::Null) };
|
||||
let waiting = self.pending_pick.is_some();
|
||||
let r = r.and_then(|_| self.run("qsave", Value::Null));
|
||||
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
|
||||
match (saved, self.doc_index(uid)) {
|
||||
(true, Some(i)) => {
|
||||
self.close_front(i);
|
||||
self.advance_close();
|
||||
}
|
||||
_ => {
|
||||
// Not saved (picker cancelled, save failed or unavailable): keep the
|
||||
// drawing and its edits, and stop closing.
|
||||
self.closing = None;
|
||||
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
|
||||
let msg = match r {
|
||||
Err(e) => format!("{title} was not saved ({e}); it stays open."),
|
||||
Ok(_) => format!("{title} was not saved; it stays open."),
|
||||
};
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg);
|
||||
}
|
||||
// The Save dialog opened without waiting (macOS): closing goes on once it has
|
||||
// saved; cancelling it leaves the prompt up.
|
||||
// The dialog is bound to this drawing: if it is closed meanwhile, nothing is
|
||||
// saved and the prompt goes on with the next drawing.
|
||||
if !waiting && let Some(mut pending) = self.pending_pick.take() {
|
||||
let then = pending.then;
|
||||
pending.for_close = true;
|
||||
pending.then = Box::new(move |app: &mut CadApp, path| {
|
||||
let r = then(app, path);
|
||||
// Unless the prompt was answered otherwise meanwhile.
|
||||
if app.closing.as_ref().and_then(|c| c.asking) == Some(uid) {
|
||||
// It reports the outcome itself; an error returned here would
|
||||
// replace its message in the status bar.
|
||||
app.close_after_save(uid, r);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
r
|
||||
});
|
||||
self.pending_pick = Some(pending);
|
||||
return;
|
||||
}
|
||||
self.close_after_save(uid, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go on closing after the drawing `uid` was asked to save with result `r`.
|
||||
fn close_after_save(&mut self, uid: u64, r: Result<Value, String>) {
|
||||
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
|
||||
match (saved, self.doc_index(uid)) {
|
||||
(true, Some(i)) => {
|
||||
self.close_front(i);
|
||||
self.advance_close();
|
||||
}
|
||||
_ => {
|
||||
// Not saved (picker cancelled, save failed or unavailable): keep the
|
||||
// drawing and its edits, and stop closing.
|
||||
self.closing = None;
|
||||
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
|
||||
let msg = match r {
|
||||
Err(e) => format!("{title} was not saved ({e}); it stays open."),
|
||||
Ok(_) => format!("{title} was not saved; it stays open."),
|
||||
};
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +296,44 @@ mod tests {
|
||||
app.session.docs.iter().map(|d| d.title.clone()).collect()
|
||||
}
|
||||
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
|
||||
/// The host shows its Save dialog without waiting (macOS): the callback that answers it.
|
||||
fn deferred_save(app: &mut CadApp) -> std::rc::Rc<std::cell::RefCell<Option<Done>>> {
|
||||
let slot: std::rc::Rc<std::cell::RefCell<Option<Done>>> = std::rc::Rc::default();
|
||||
let s = slot.clone();
|
||||
app.services = Services {
|
||||
pick_save: Some(Box::new(|_: &str| None)),
|
||||
start_pick: Some(Box::new(move |_, done| *s.borrow_mut() = Some(done))),
|
||||
..Services::default()
|
||||
};
|
||||
slot
|
||||
}
|
||||
|
||||
/// While the prompt's Save dialog is open, Don't Save (or Save again) does not race it; when
|
||||
/// the save then fails, the prompt's own "was not saved" message is what the status bar shows.
|
||||
#[test]
|
||||
fn the_close_prompt_waits_for_its_save_dialog() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
let answer = deferred_save(&mut app);
|
||||
dirty(&mut app, 0);
|
||||
app.request_close(Some(0));
|
||||
app.answer_close(Choice::Save);
|
||||
assert!(answer.borrow().is_some(), "the Save dialog is open");
|
||||
app.answer_close(Choice::Discard);
|
||||
app.answer_close(Choice::Save);
|
||||
assert_eq!(titles(&app), ["A", "B"], "Don't Save waits for the Save dialog");
|
||||
assert_eq!(app.close_prompt(), Some("A"));
|
||||
|
||||
let nowhere = std::env::temp_dir().join(format!("cadcraft-no-such-dir-{}", std::process::id())).join("A.dxf");
|
||||
answer.borrow_mut().take().unwrap()(Some(nowhere.to_string_lossy().to_string()));
|
||||
app.finish_pick(&egui::Context::default());
|
||||
assert_eq!(titles(&app), ["A", "B"], "not saved, so not closed");
|
||||
assert!(app.session.docs[0].is_dirty());
|
||||
let status = app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default();
|
||||
assert!(status.starts_with("A was not saved") && status.ends_with("it stays open."), "{status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_drawing_closes_without_prompt() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
|
||||
+176
-23
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use egui::TextureHandle;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// Bumped each time PREVIEW is started, so the window renders the drawing as it is now.
|
||||
static PREVIEW_GEN: AtomicU32 = AtomicU32::new(0);
|
||||
@@ -23,27 +23,15 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {
|
||||
app.ui.dialog = Some("preview".into());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"import" | "imp" => {
|
||||
let Some(pick) = app.services.pick_open.as_ref() else { return no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#) };
|
||||
match pick() {
|
||||
Some(path) => app.run("import", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"publish" => {
|
||||
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };
|
||||
match pick(&format!("{stem}.pdf")) {
|
||||
Some(path) => app.run("publish", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };
|
||||
match pick(&format!("{stem}.svg")) {
|
||||
Some(path) => app.run("export", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"import" | "imp" => app
|
||||
.pick_then(Pick::Open, |app, path| app.run("import", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#)),
|
||||
"publish" => app
|
||||
.pick_then(Pick::Save(format!("{stem}.pdf")), |app, path| app.run("publish", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "PUBLISH", "publish {}")),
|
||||
_ => app
|
||||
.pick_then(Pick::Save(format!("{stem}.svg")), |app, path| app.run("export", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "EXPORT", r#"export {"format": "svg"}"#)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +149,7 @@ mod tests {
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{CadApp, Services};
|
||||
use crate::{CadApp, Pick, Services};
|
||||
|
||||
/// An app whose file dialogs record what they were asked and cancel.
|
||||
fn app() -> (CadApp, Rc<RefCell<Vec<String>>>) {
|
||||
@@ -207,4 +195,169 @@ mod tests {
|
||||
let e = app.run("share", json!({})).unwrap_err();
|
||||
assert!(e.contains("not available yet"), "{e}");
|
||||
}
|
||||
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
|
||||
/// An app whose host shows its dialogs without waiting (macOS, #1527): the dialogs asked
|
||||
/// for, and the callback that answers the open one.
|
||||
fn deferred_app() -> (CadApp, Rc<RefCell<Vec<Pick>>>, Rc<RefCell<Option<Done>>>) {
|
||||
let asked = Rc::new(RefCell::new(Vec::new()));
|
||||
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
|
||||
let (log, slot) = (asked.clone(), answer.clone());
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(|| None)),
|
||||
pick_save: Some(Box::new(|_: &str| None)),
|
||||
pick_lisp: Some(Box::new(|_: &str| None)),
|
||||
start_pick: Some(Box::new(move |pick, done| {
|
||||
log.borrow_mut().push(pick);
|
||||
*slot.borrow_mut() = Some(done);
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
(CadApp::new(Session::new(), services), asked, answer)
|
||||
}
|
||||
|
||||
fn status(app: &CadApp) -> &str {
|
||||
app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn uid(app: &CadApp) -> u64 {
|
||||
app.session.state().unwrap().uid
|
||||
}
|
||||
|
||||
/// Where the host shows its dialogs without waiting (macOS, #1527), the command acts on the
|
||||
/// path in a later frame, and no second dialog opens while one is up.
|
||||
#[test]
|
||||
fn a_deferred_dialog_acts_when_its_answer_arrives() {
|
||||
let (mut app, asked, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// Cancelled: nothing happens, and the next command may ask again.
|
||||
app.start("export");
|
||||
assert_eq!(*asked.borrow(), [Pick::Save("Drawing1.svg".into())]);
|
||||
answer.borrow_mut().take().unwrap()(None);
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_none());
|
||||
|
||||
app.start("imp");
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_some() && app.status.is_none(), "nothing runs before the answer");
|
||||
// A second dialog is refused, and says so: in the status bar for a typed or menu
|
||||
// command, and as an error for a programmatic call.
|
||||
app.start("publish");
|
||||
assert!(status(&app).contains("already open"), "{:?}", app.status);
|
||||
let e = app.run("ui.open", json!(null)).unwrap_err();
|
||||
assert!(e.contains("already open"), "{e}");
|
||||
assert_eq!(asked.borrow().len(), 2, "no second dialog while one is open");
|
||||
app.status = None;
|
||||
let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id()));
|
||||
answer.borrow_mut().take().unwrap()(Some(missing.to_string_lossy().to_string()));
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(app.status.is_some(), "IMPORT ran with the picked file and reported its error");
|
||||
}
|
||||
|
||||
/// A dialog's answer acts on the drawing that asked for it, even when another drawing was
|
||||
/// made active before it arrived (a control-channel `document.switch` runs before the answer
|
||||
/// in the same frame): Save As must not write the other drawing to the chosen file.
|
||||
#[test]
|
||||
fn a_deferred_dialog_acts_on_the_drawing_that_asked() {
|
||||
let (mut app, _, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("new", json!({})).unwrap();
|
||||
app.run("document.switch", json!({ "index": 0 })).unwrap();
|
||||
let first = uid(&app);
|
||||
let acted_on = Rc::new(RefCell::new(None));
|
||||
let seen = acted_on.clone();
|
||||
let r = app.pick_then(Pick::Save("Drawing1.dxf".into()), move |app, path| {
|
||||
*seen.borrow_mut() = Some((uid(app), path));
|
||||
Ok(json!(null))
|
||||
});
|
||||
assert_eq!(r, Some(Ok(json!(null))));
|
||||
|
||||
app.run("document.switch", json!({ "index": 1 })).unwrap();
|
||||
assert_ne!(uid(&app), first);
|
||||
answer.borrow_mut().take().unwrap()(Some("a.dxf".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*acted_on.borrow(), Some((first, "a.dxf".to_string())));
|
||||
assert_eq!(app.session.active, 0, "the drawing that asked is shown again");
|
||||
}
|
||||
|
||||
/// The drawing that asked was closed before the answer: nothing is done, and the status bar
|
||||
/// says so. A dialog that opens a drawing (OPEN, NEW from a template) still acts.
|
||||
#[test]
|
||||
fn a_deferred_dialog_for_a_closed_drawing_does_nothing() {
|
||||
let (mut app, _, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("new", json!({})).unwrap();
|
||||
let ran = Rc::new(RefCell::new(0));
|
||||
let r1 = ran.clone();
|
||||
let _ = app.pick_then(Pick::Save("Drawing2.svg".into()), move |_, _| {
|
||||
*r1.borrow_mut() += 1;
|
||||
Ok(json!(null))
|
||||
});
|
||||
app.run("close", json!({ "index": 1 })).unwrap();
|
||||
answer.borrow_mut().take().unwrap()(Some("b.svg".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*ran.borrow(), 0, "the closed drawing's export did not run on another drawing");
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("closed") && s.contains("b.svg")), "{:?}", app.status);
|
||||
|
||||
app.run("new", json!({})).unwrap();
|
||||
let r2 = ran.clone();
|
||||
let _ = app.pick_then_open(Pick::Open, move |_, _| {
|
||||
*r2.borrow_mut() += 1;
|
||||
Ok(json!(null))
|
||||
});
|
||||
app.run("close", json!({ "index": 1 })).unwrap();
|
||||
answer.borrow_mut().take().unwrap()(Some("c.dxf".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*ran.borrow(), 1, "OPEN goes on whichever drawing closed meanwhile");
|
||||
}
|
||||
|
||||
/// The host dropped the answer (its dialog thread could not start): the status bar says
|
||||
/// the dialog could not be shown, unlike a cancelled dialog, and the next one may open.
|
||||
#[test]
|
||||
fn a_dialog_that_never_answers_is_reported() {
|
||||
let services =
|
||||
Services { pick_save: Some(Box::new(|_: &str| None)), start_pick: Some(Box::new(|_, done| drop(done))), ..Services::default() };
|
||||
let mut app = CadApp::new(Session::new(), services);
|
||||
app.start("export");
|
||||
app.finish_pick(&egui::Context::default());
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(status(&app).contains("could not be shown"), "{:?}", app.status);
|
||||
}
|
||||
|
||||
/// With no drawing open, a dialog bound to the active drawing is not shown: its answer could
|
||||
/// otherwise save or export a drawing opened while it was up.
|
||||
#[test]
|
||||
fn a_bound_dialog_needs_an_open_drawing() {
|
||||
let (mut app, asked, _) = deferred_app();
|
||||
app.run("close", json!({ "index": 0 })).unwrap();
|
||||
assert!(app.session.state().is_err());
|
||||
app.start("ui.saveas");
|
||||
let r = app.run("ui.saveas", json!(null));
|
||||
assert!(asked.borrow().is_empty(), "no dialog");
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(r.is_err() && app.status.is_some(), "{r:?} {:?}", app.status);
|
||||
// OPEN needs no drawing.
|
||||
app.start("ui.open");
|
||||
assert_eq!(*asked.borrow(), [Pick::Open]);
|
||||
}
|
||||
|
||||
/// A command started while APPLOAD's dialog was open (a control request, say) keeps
|
||||
/// running; APPLOAD is refused and says to run it again.
|
||||
#[test]
|
||||
fn appload_does_not_cancel_a_command_started_meanwhile() {
|
||||
let (mut app, asked, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.start("appload");
|
||||
assert!(matches!(asked.borrow().as_slice(), [Pick::Lisp(_)]));
|
||||
app.start("line");
|
||||
assert!(app.session.prompting());
|
||||
answer.borrow_mut().take().unwrap()(Some("routine.lsp".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line"), "LINE was not cancelled");
|
||||
assert!(status(&app).starts_with("APPLOAD: another command started"), "{:?}", app.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,12 @@ impl Default for UiState {
|
||||
pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Where set, the file dialogs are shown with this instead of calling `pick_open` /
|
||||
/// `pick_save` / `pick_lisp` (which must still be set, to say which dialogs there are), and
|
||||
/// the answer is acted on in a later frame ([`CadApp::pick_then`]). macOS needs it: a panel run modally
|
||||
/// inside the window's event handler gets the window events of its own resizing sent back
|
||||
/// into that handler, and winit aborts the app (#1527).
|
||||
pub start_pick: Option<StartPick>,
|
||||
/// Pick a `.lsp` file for APPLOAD, starting in the given folder (empty for the default one).
|
||||
/// `None` where there is no native file dialog: the desktop without one asks for the name,
|
||||
/// and the web (no file system) says to drop the file (`no_file_system`).
|
||||
@@ -238,6 +244,36 @@ pub struct Services {
|
||||
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
|
||||
}
|
||||
|
||||
/// The file dialog to show: Open, Save with the suggested file name, APPLOAD's `.lsp` picker or
|
||||
/// ALIASEDIT's `.pgp` picker, starting in the given folder (empty for the default one).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
Open,
|
||||
Save(String),
|
||||
Lisp(String),
|
||||
Pgp(String),
|
||||
}
|
||||
|
||||
/// Shows a file dialog without waiting for it and calls the callback (from any thread) with the
|
||||
/// chosen path, or `None` when the dialog is cancelled ([`Services::start_pick`]).
|
||||
pub type StartPick = Box<dyn Fn(Pick, Box<dyn FnOnce(Option<String>) + Send>)>;
|
||||
|
||||
/// What to do with the path a file dialog chose ([`CadApp::pick_then`]).
|
||||
type PickDone = Box<dyn FnOnce(&mut CadApp, String) -> Result<Value, String>>;
|
||||
|
||||
/// A file dialog shown with [`Services::start_pick`] that has not answered yet.
|
||||
struct PendingPick {
|
||||
answer: Receiver<Option<String>>,
|
||||
/// The drawing (its `uid`) the answer is for: Save As writes that drawing, export and plot
|
||||
/// read it, and so on, whichever drawing is active when the answer arrives. `None` for the
|
||||
/// dialogs that open a drawing (Open, New from Template, Recover).
|
||||
doc: Option<u64>,
|
||||
/// The close prompt's Save is waiting for this dialog: its other answers wait too
|
||||
/// ([`closing`]).
|
||||
for_close: bool,
|
||||
then: PickDone,
|
||||
}
|
||||
|
||||
pub struct CadApp {
|
||||
pub system_languages: Vec<String>,
|
||||
/// Whether the operating system shows animations, as the host reads it; `None` when the OS
|
||||
@@ -329,6 +365,8 @@ pub struct CadApp {
|
||||
pub hatch_dlg: Option<hatchdlg::HatchDlgState>,
|
||||
/// The Edit Block Definition dialog ([`bedit`]).
|
||||
pub bedit: bedit::State,
|
||||
/// A file dialog shown with [`Services::start_pick`]: its answer, and what to do with it.
|
||||
pending_pick: Option<PendingPick>,
|
||||
/// The startup update check and its prompt (desktop; [`updates_ui`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub updates_rt: updates_ui::Runtime,
|
||||
@@ -389,9 +427,139 @@ impl CadApp {
|
||||
bedit: bedit::State::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime::default(),
|
||||
pending_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a file with the host's dialog (`pick`) and run `then` with the chosen path, on the
|
||||
/// drawing that is active now. `None` when the host has no dialog of that kind. Cancelling
|
||||
/// gives `Ok(Null)`. With a synchronous picker the result is `then`'s; with
|
||||
/// [`Services::start_pick`] the dialog opens and this returns `Ok(Null)` at once: `then` runs
|
||||
/// in a later frame ([`Self::logic`]) with that drawing made active again (if something else
|
||||
/// was switched to meanwhile, such as a control-channel `document.switch`), and not at all if
|
||||
/// the drawing was closed. Its error, if any, shows in the status bar. With no drawing open
|
||||
/// it refuses without showing a dialog. While one dialog is open, no other is shown: the
|
||||
/// request is refused with an error, also shown in the status bar.
|
||||
pub fn pick_then(
|
||||
&mut self,
|
||||
pick: Pick,
|
||||
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
|
||||
) -> Option<Result<Value, String>> {
|
||||
self.pick_for(pick, true, Box::new(then))
|
||||
}
|
||||
|
||||
/// [`Self::pick_then`] for a dialog that opens a drawing (Open, New from Template, Recover):
|
||||
/// `then` does not act on the drawing that was active, so it runs whatever was switched to
|
||||
/// or closed meanwhile.
|
||||
pub fn pick_then_open(
|
||||
&mut self,
|
||||
pick: Pick,
|
||||
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
|
||||
) -> Option<Result<Value, String>> {
|
||||
self.pick_for(pick, false, Box::new(then))
|
||||
}
|
||||
|
||||
/// Show the dialog `pick` for `then`, bound to the active drawing when `bound`.
|
||||
fn pick_for(&mut self, pick: Pick, bound: bool, then: PickDone) -> Option<Result<Value, String>> {
|
||||
let available = match pick {
|
||||
Pick::Open => self.services.pick_open.is_some(),
|
||||
Pick::Save(_) => self.services.pick_save.is_some(),
|
||||
Pick::Lisp(_) => self.services.pick_lisp.is_some(),
|
||||
Pick::Pgp(_) => self.services.pick_pgp.is_some(),
|
||||
};
|
||||
if !available {
|
||||
return None;
|
||||
}
|
||||
if self.pending_pick.is_some() {
|
||||
return Some(Err(self.refuse_pick("A file dialog is already open; answer it first.".into())));
|
||||
}
|
||||
// A bound answer must not land on a drawing created or opened while the dialog is up.
|
||||
let doc = match (bound, self.session.state()) {
|
||||
(false, _) => None,
|
||||
(true, Ok(st)) => Some(st.uid),
|
||||
(true, Err(e)) => {
|
||||
let why = e.to_string();
|
||||
return Some(Err(self.refuse_pick(why)));
|
||||
}
|
||||
};
|
||||
if let Some(start) = &self.services.start_pick {
|
||||
let (tx, answer) = std::sync::mpsc::channel();
|
||||
start(
|
||||
pick,
|
||||
Box::new(move |path| {
|
||||
let _ = tx.send(path);
|
||||
}),
|
||||
);
|
||||
self.pending_pick = Some(PendingPick { answer, doc, for_close: false, then });
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let path = match &pick {
|
||||
Pick::Open => self.services.pick_open.as_ref().and_then(|f| f()),
|
||||
Pick::Save(name) => self.services.pick_save.as_ref().and_then(|f| f(name)),
|
||||
Pick::Lisp(dir) => self.services.pick_lisp.as_ref().and_then(|f| f(dir)),
|
||||
Pick::Pgp(dir) => self.services.pick_pgp.as_ref().and_then(|f| f(Some(dir.as_str()).filter(|d| !d.is_empty()))),
|
||||
};
|
||||
Some(match path {
|
||||
Some(path) => then(self, path),
|
||||
None => Ok(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// A file dialog that is not shown: the reason goes to the status bar too, since a typed or
|
||||
/// menu command's result is not shown ([`Self::start`]).
|
||||
fn refuse_pick(&mut self, why: String) -> String {
|
||||
self.set_status(why.clone());
|
||||
why
|
||||
}
|
||||
|
||||
/// Start the interactive command `id` with a dialog's answer. `was_prompting`: whether a
|
||||
/// command was running when the dialog was asked for. Starting cancels the running command,
|
||||
/// so a command started while the dialog was open (a control request, say) is kept and this
|
||||
/// one refused instead; one that was already running is cancelled as when the dialog answers
|
||||
/// at once.
|
||||
pub fn start_after_pick(&mut self, id: &str, was_prompting: bool) -> Result<(), String> {
|
||||
if !was_prompting && self.session.prompting() {
|
||||
return Err(format!("{}: another command started while the file dialog was open; run it again.", id.to_ascii_uppercase()));
|
||||
}
|
||||
self.session.start(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Act on the answer of a dialog started with [`Services::start_pick`], once it arrives.
|
||||
fn finish_pick(&mut self, ctx: &egui::Context) {
|
||||
let Some(pending) = &self.pending_pick else { return };
|
||||
match pending.answer.try_recv() {
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(100)),
|
||||
Ok(None) => self.pending_pick = None,
|
||||
// The host dropped the answer without giving one: the dialog never opened (its
|
||||
// thread could not start) or failed.
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.pending_pick = None;
|
||||
self.set_status("The file dialog could not be shown.");
|
||||
}
|
||||
Ok(Some(path)) => {
|
||||
if let Some(PendingPick { doc, then, .. }) = self.pending_pick.take()
|
||||
&& let Err(e) = self.make_active(doc, &path).and_then(|()| then(self, path))
|
||||
{
|
||||
self.set_status(e);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the drawing `uid` active again for the answer `path` of a dialog it asked for, or
|
||||
/// refuse when it was closed meanwhile.
|
||||
fn make_active(&mut self, uid: Option<u64>, path: &str) -> Result<(), String> {
|
||||
let Some(uid) = uid else { return Ok(()) };
|
||||
let Some(i) = self.session.docs.iter().position(|d| d.uid == uid) else {
|
||||
return Err(format!("The drawing was closed before the file dialog answered; nothing was done with {path}."));
|
||||
};
|
||||
if self.session.active != i {
|
||||
self.run("document.switch", json!({ "index": i }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The interface language shown: the chosen one, or English when this computer has no font
|
||||
/// for it (Chinese or Korean without a suitable font would be boxes).
|
||||
pub fn language(&self) -> &'static str {
|
||||
@@ -702,6 +870,7 @@ impl CadApp {
|
||||
// Consume-based queues: each item is handled once, whichever callback delivers it. A
|
||||
// reply arriving during a later pass is never starved until the next frame.
|
||||
self.drain_control(ctx);
|
||||
self.finish_pick(ctx);
|
||||
self.finish_opening();
|
||||
// Finishing an open switches to the new drawing.
|
||||
capture::note_identity(self, ctx);
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use egui::{RichText, Sense, Stroke, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::parametric::expr_field;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// The Block Attribute Manager (BATTMAN).
|
||||
mod battman;
|
||||
@@ -121,6 +121,7 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
let library: Vec<String> = cadcraft_doc::library::standard_linetypes().into_iter().map(|l| l.name).filter(|n| d.linetype(n).is_none()).collect();
|
||||
let mut sel = temp::<String>(ctx, "ltmgr_sel").filter(|s| rows.iter().any(|r| r.0.eq_ignore_ascii_case(s))).unwrap_or_else(|| current.clone());
|
||||
let mut action = None;
|
||||
let mut pick_lin = false;
|
||||
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
|
||||
@@ -135,12 +136,10 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
}
|
||||
}
|
||||
// The user's own .lin file (where the host has a file picker).
|
||||
if let Some(pick) = app.services.pick_open.as_ref() {
|
||||
if app.services.pick_open.is_some() {
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, "From File...").clicked()
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
action = Some(("linetype", json!({ "load": "*", "file": path })));
|
||||
if ui.selectable_label(false, "From File...").clicked() {
|
||||
pick_lin = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -187,6 +186,10 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
});
|
||||
});
|
||||
set_temp(ctx, "ltmgr_sel", sel);
|
||||
// The user's .lin file, asked for once the window is drawn. Errors are echoed by `run`.
|
||||
if pick_lin {
|
||||
let _ = app.pick_then(Pick::Open, |app, path| app.run("linetype", json!({ "load": "*", "file": path })));
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
|
||||
enum Field {
|
||||
Num(&'static str, &'static str, f64),
|
||||
Bool(&'static str, &'static str, bool),
|
||||
Choice(&'static str, &'static str, String, Vec<String>),
|
||||
Choice(&'static str, &'static str, String, std::sync::Arc<[String]>),
|
||||
}
|
||||
|
||||
struct Style {
|
||||
@@ -371,7 +374,8 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
|
||||
no_drawing(ctx, "Multileader Style", open);
|
||||
return None;
|
||||
};
|
||||
let text_styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
// One list shared by every row: a copy per style multiplied the two counts.
|
||||
let text_styles: std::sync::Arc<[String]> = d.text_styles.iter().map(|s| s.name.clone()).collect();
|
||||
let mut styles: Vec<Style> = d
|
||||
.mleader_styles
|
||||
.iter()
|
||||
@@ -382,7 +386,7 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
|
||||
Field::Num("textHeight", "Text height", s.text_height),
|
||||
Field::Num("landingGap", "Landing gap", s.landing_gap),
|
||||
Field::Num("dogleg", "Landing length", s.dogleg),
|
||||
Field::Choice("textStyle", "Text style", s.text_style.clone(), text_styles.clone()),
|
||||
Field::Choice("textStyle", "Text style", s.text_style.clone(), std::sync::Arc::clone(&text_styles)),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
@@ -451,7 +455,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
|
||||
ui.label(*label);
|
||||
let mut picked = None;
|
||||
craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
|
||||
for c in choices {
|
||||
for c in choices.iter() {
|
||||
if ui.selectable_label(c == v, c).clicked() && c != v {
|
||||
picked = Some(c.clone());
|
||||
}
|
||||
@@ -1038,33 +1042,46 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
return action;
|
||||
}
|
||||
if ok {
|
||||
let mut params = plot_params(&st, None);
|
||||
if let Some(pick) = app.services.pick_save.as_ref() {
|
||||
let params = plot_params(&st, None);
|
||||
let target = st.target.clone();
|
||||
if app.services.pick_save.is_some() {
|
||||
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
|
||||
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
|
||||
let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);
|
||||
// Cancelling the file dialog keeps the Plot dialog open with its settings.
|
||||
let Some(path) = pick(&suggested) else { return action };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
*open = false;
|
||||
match app.run("plot", params) {
|
||||
Ok(r) => {
|
||||
let msg = match r.get("path").and_then(Value::as_str) {
|
||||
Some(path) => format!("Plotted {} to {path}", st.target),
|
||||
None => format!("Plotted {} ({} bytes of PDF; there is no file dialog here)", st.target, r["bytes"]),
|
||||
};
|
||||
app.session.echo(msg);
|
||||
}
|
||||
// `run` already echoed the error; keep the dialog open to fix the settings.
|
||||
Err(_) => *open = true,
|
||||
let _ = app.pick_then(Pick::Save(suggested), move |app, path| {
|
||||
let mut params = params;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
}
|
||||
run_plot(app, params, &target);
|
||||
Ok(Value::Null)
|
||||
});
|
||||
} else {
|
||||
run_plot(app, params, &target);
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// Plot with the Plot dialog's settings: it closes, and opens again when the plot fails.
|
||||
fn run_plot(app: &mut CadApp, params: Value, target: &str) {
|
||||
if app.ui.dialog.as_deref() == Some("plot") {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
match app.run("plot", params) {
|
||||
Ok(r) => {
|
||||
let msg = match r.get("path").and_then(Value::as_str) {
|
||||
Some(path) => format!("Plotted {target} to {path}"),
|
||||
None => format!("Plotted {target} ({} bytes of PDF; there is no file dialog here)", r["bytes"]),
|
||||
};
|
||||
app.session.echo(msg);
|
||||
}
|
||||
// `run` already echoed the error; keep the dialog open to fix the settings.
|
||||
Err(_) => app.ui.dialog = Some("plot".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet in the chosen orientation with its printable area, and its size.
|
||||
fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) {
|
||||
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
|
||||
|
||||
+35
-39
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// The top-level menus, in order.
|
||||
pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools", "Draw", "Dimension", "Modify", "Window", "Help"];
|
||||
@@ -405,13 +405,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = match id {
|
||||
"ui.open" | "open" if no_path => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f());
|
||||
if let Some(p) = picked {
|
||||
"ui.open" | "open" if no_path => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
app.open_path(std::path::Path::new(&p));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
// APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last
|
||||
// one came from. JSON calls carry `path` and never open one. A picked file is loaded as a
|
||||
// typed name is, through the interactive command, so a routine it starts that asks for a
|
||||
@@ -423,57 +422,54 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
app.set_status(hint);
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let pick = app.services.pick_lisp.as_ref()?;
|
||||
let dir = app.ui.last_lisp_dir.clone().unwrap_or_default();
|
||||
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
|
||||
app.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
if let Err(e) = app.session.start("appload") {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
app.cmdline(&path);
|
||||
Ok(Value::Null)
|
||||
let was_prompting = app.session.prompting();
|
||||
return app.pick_then(Pick::Lisp(dir), move |app, path| {
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
|
||||
app.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
app.start_after_pick("appload", was_prompting)?;
|
||||
app.cmdline(&path);
|
||||
Ok(Value::Null)
|
||||
});
|
||||
}
|
||||
// Pick a template (.dwt or .dxf) and start a new drawing from it.
|
||||
"ui.newtemplate" => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.newtemplate" => app
|
||||
.pick_then_open(Pick::Open, |app, p| app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()))
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
// RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the
|
||||
// file name on the command line.
|
||||
"recover" if no_path && app.services.pick_open.is_some() => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
"recover" if no_path && app.services.pick_open.is_some() => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
let r = app.run("recover", json!({ "path": p }));
|
||||
app.opened(r);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
"ui.saveas" | "saveas" if no_path => {
|
||||
let title = app.session.state().map(|s| s.title.clone()).ok();
|
||||
let name = app.ui.save_format.suggested_name(title.as_deref());
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) {
|
||||
return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
// Bound to this drawing: the answer saves it, not whichever drawing is active then.
|
||||
app.pick_then(Pick::Save(name), |app, p| {
|
||||
app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null))
|
||||
}
|
||||
"qsave" if no_path => return user_qsave(app),
|
||||
// EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot
|
||||
// dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry
|
||||
// params, never get here and never open a dialog.
|
||||
"exportpdf" if params.is_null() && app.session.state().is_ok() => {
|
||||
let pick = app.services.pick_save.as_ref()?;
|
||||
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
|
||||
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
|
||||
let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) };
|
||||
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
|
||||
app.session.echo(match &r {
|
||||
Ok(_) => format!("Plotted to {path}"),
|
||||
Err(e) => e.clone(),
|
||||
return app.pick_then(Pick::Save(format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))), |app, path| {
|
||||
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
|
||||
app.session.echo(match &r {
|
||||
Ok(_) => format!("Plotted to {path}"),
|
||||
Err(e) => e.clone(),
|
||||
});
|
||||
r
|
||||
});
|
||||
r
|
||||
}
|
||||
"ui.sample" => {
|
||||
let d = cadcraft_engine::sample::default_sample();
|
||||
|
||||
+20
-15
@@ -6,8 +6,8 @@
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::docking::Panel;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// XREF typed or chosen from a menu opens this palette; XATTACH there asks for the file first
|
||||
/// (with the host's file picker, where there is one). JSON calls (with parameters) run the
|
||||
@@ -19,19 +19,18 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
match cadcraft_engine::cmd::resolve_alias(id).as_str() {
|
||||
"xref" => Some(open_palette(app)),
|
||||
"xattach" => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f())?;
|
||||
let path = relative_to_host(app, &picked);
|
||||
if let Err(e) = app.session.start("xattach") {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
// Submit the picked filename as literal text, not through the command line: a path is
|
||||
// attacker-controlled (the file picker returns whatever the user selected), and
|
||||
// `cmdline` interprets command syntax, so a name like `'closeall` would run an engine
|
||||
// command instead of naming the file (CWE-74).
|
||||
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(path)) {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
Some(Ok(Value::Null))
|
||||
app.services.pick_open.as_ref()?;
|
||||
let was_prompting = app.session.prompting();
|
||||
app.pick_then(Pick::Open, move |app, picked| {
|
||||
let path = relative_to_host(app, &picked);
|
||||
app.start_after_pick("xattach", was_prompting)?;
|
||||
// Submit the picked filename as literal text, not through the command line: a
|
||||
// path is attacker-controlled (the file picker returns whatever the user
|
||||
// selected), and `cmdline` interprets command syntax, so a name like `'closeall`
|
||||
// would run an engine command instead of naming the file (CWE-74).
|
||||
app.session.input(cadcraft_engine::Input::Text(path)).map_err(|e| e.to_string())?;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -78,10 +77,16 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
|
||||
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
|
||||
let mut counts = vec![0usize; xrefs.len()];
|
||||
if !xrefs.is_empty() {
|
||||
// Xref name (ASCII-upper-cased) → its row, the first of names that differ only in case, so
|
||||
// each INSERT is one lookup instead of a scan of the xref list.
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (k, b) in xrefs.iter().enumerate() {
|
||||
index.entry(b.name.to_ascii_uppercase()).or_insert(k);
|
||||
}
|
||||
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
|
||||
for e in store.iter() {
|
||||
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
|
||||
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
|
||||
&& let Some(&k) = index.get(&i.block.to_ascii_uppercase())
|
||||
&& let Some(c) = counts.get_mut(k)
|
||||
{
|
||||
*c += 1;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Release and source Flatpaks must allow release checks and host update helpers.
|
||||
fn finish_args(manifest: &str) -> Vec<&str> {
|
||||
manifest
|
||||
.split("finish-args:")
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
|
||||
.filter_map(|line| line.trim().strip_prefix("- "))
|
||||
.map(|arg| arg.trim_matches(['\'', '"']))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_update_permissions() {
|
||||
let manifests = [
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml"),
|
||||
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.yml"),
|
||||
];
|
||||
let args = manifests.map(finish_args);
|
||||
assert_eq!(args[0], args[1], "source and release permissions must agree");
|
||||
for permissions in args {
|
||||
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
|
||||
assert!(permissions.contains(&required), "missing update permission: {required}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ runtime-version: '26.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: cadcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
@@ -18,6 +18,8 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: cadcraft
|
||||
finish-args:
|
||||
# The shared updater inspects and updates this installation via flatpak-spawn --host.
|
||||
- --talk-name=org.freedesktop.Flatpak
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
|
||||
Reference in New Issue
Block a user