Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 59468ec6e5 test: avoid unchecked option parsing in Flatpak regression 2026-11-11 14:08:46 -05:00
Dylan M. Taylor f9dd53a77e Release 0.20.1: fix Flatpak update permissions 2026-11-11 14:04:24 -05:00
FredApps 28241abb8f Merge main into release for CADCraft v0.20.0 2026-11-11 16:38:54 +01:00
FredApps dea9f2d048 Release: CADCraft v0.20.0 (#3054) 2026-11-11 16:38:50 +01:00
Dylan M. Taylor d6d16bd474 fix(io): make four DXF/SVG passes linear in their input (#2721)
* fix(io): make four DXF/SVG passes linear in their input

Preserving foreign objects looked each root-owned object's key up by scanning the root dictionary; applying annotation scales searched every layout for each handle model space lacked; writing CLASS instance counts inserted each missing group into the middle of the tag list; and SVG attribute lookup resumed after a malformed attribute, re-running the parser's duplicate scan each time. Each is now one indexed or single pass with the same results.

* address review: first layout wins for duplicate handles; move tags instead of cloning in class count pass

* address review: reserve room for the added class counts, correct the peak-memory note
2026-11-11 05:01:27 -05:00
Dylan M. Taylor 2410a1fd07 hatch: one canonical key per source loop before tessellation (#3043) 2026-11-11 04:32:31 -05:00
Dylan M. Taylor a1574780c2 fix(render): bound hatch swatches, charge boundary sweeps and cap linetype label points (#2744)
* fix(render): bound hatch swatches, charge boundary sweeps and cap linetype label points

* address review: charge hatch boundary transform, count glyph slots in linetype text

* fix(render): charge boundary transforms and scanline sorts to the shared hatch budget
2026-11-11 04:06:57 -05:00
Dylan M. Taylor ea2637ae73 fix(lisp): charge READ text, EQUAL string bytes, SETVAR and ENTMAKE copies, and nested callbacks against one budget (#2741)
* fix(lisp): charge READ text, EQUAL string bytes, SETVAR and ENTMAKE copies, and nested callbacks against one budget

* address review: charge callback steps live and reject completion past the shared allowance
2026-11-11 03:55:17 -05:00
Dylan M. Taylor 7e780548be fix: bound MOCORO copies, array EXPLODE, COMPARE bounds, attribute extraction, PDF links and control-server parsing (#2738)
* fix: bound MOCORO copies, array EXPLODE, COMPARE bounds, attribute extraction, PDF links and control-server parsing

* address review: key extract cache by exact block, count live MOCORO objects, PDF link budget

* review: select extraction columns before copying values, count live objects for MOCORO copies

* address review: count exploded array objects before cloning; scan each block's constants once
2026-11-11 03:16:11 -05:00
Dylan M. Taylor 6d744887f4 fix(mcp): send the control token only to 127.0.0.1:PORT and read it only from a private file (#2718)
The bridge picked the token by port alone and sent it to whatever the address resolved to, so a listener another account opened on ::1 with the same port received the desktop's credential. The token is now attached only when the connected peer is exactly 127.0.0.1:PORT, where the app listens. Reading the token refuses a symlink, a non-plain file and a file others can read, and the shared temporary directory is no longer a fallback location for it.
2026-11-10 21:33:51 -05:00
Dylan M. Taylor 2c25c2d700 fix: index or bound array replacements, DIM field spellings, path samples, hatch refills and broken tables (#2734)
* fix: index or bound array replacements, DIM field spellings, path samples, hatch refills and broken tables

* address review: index placeable array replacements, search past zero-length DIVIDE runs, per-part broken-table outlines
2026-11-10 21:33:42 -05:00
Dylan M. Taylor 95fdf8d11a fix: bound region grid registrations, island containment tests, snap_ends visits, inference duplicate searches and font name decoding (#2742)
* fix: bound region grid registrations, island containment tests, snap_ends visits, inference duplicate searches and font name decoding

* address review: charge empty curves only in snap_ends search

* fix(geom): report a spent containment budget instead of partial islands

* address review: count pending point candidates in the perpendicular cap
2026-11-10 21:33:39 -05:00
Dylan M. Taylor 0843770389 fix(engine): read local files for RECOVER, INSERT, IMPORT, COMPARE, NEW, DATALINK and STYLESMANAGER through the capped reader (#2687)
A path naming an endless device or a FIFO was buffered to EOF before any size check ran, so one short command could exhaust memory or block. These paths now go through capped_read::read_capped, which refuses non-regular files and enforces the byte limit while reading.
2026-11-10 21:29:52 -05:00
Dylan M. Taylor 36f5ad64f9 fix(engine): move LISP output to the command history in one pass (#2714)
lisp_flush drained each line from the front of the output buffer, shifting everything after it every time, so newline-dense PRINC output cost quadratic time outside the interpreter's step budget. It now walks the buffer once and drops the printed prefix once.
2026-11-10 21:29:48 -05:00
Dylan M. Taylor 7804e94ee0 fix(geom): bound the points one polyline tessellation samples (#2723)
* fix(geom): bound the points one polyline tessellation samples

Polyline::tessellate sampled every arc segment (up to 4096 points each) with no total limit, and hatch boundary preparation did this for every loop, so compact curved vertices expanded into gigabytes. tessellate_within takes a point budget; once spent, remaining arcs are drawn as chords. Hatch loops share one budget.

* address review: correct tessellate_within doc
2026-11-10 21:29:40 -05:00
Dylan M. Taylor bc9ad6b059 fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines) (#2728)
* fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines)

DOF scanned the selection list per slot; the xref palette scanned the xref list per INSERT; QSELECT parsed a possibly long layer name as a colour for every entity; and a log message could carry CR/LF or other control characters that started apparent extra records. Each now uses a set or map, a length bound, or escaping, with the same results for ordinary input.

* address review: trim colour text before length limit
2026-11-10 21:29:35 -05:00
FredAppsand.CooCoo 51f5bbcc21 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954) (#3024)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.

(cherry picked from commit 6f0f1fefa8)

Co-authored-by: .CooCoo <RobertLeeLW@gmail.com>
2026-11-10 19:49:24 +01:00
Michael Higginsandmocenigo 2f8f186188 macOS: file dialogs no longer run inside the window's event handler (#1534)
On macOS the Open and Save panels ran modally inside winit's event handler, and resizing one aborted the app (#1527). Every file dialog now goes through CadApp::pick_then / pick_then_open. On macOS, Services::start_pick shows the dialog as a sheet on the window and the answer is acted on in a later frame. The answer is bound to the drawing that asked for it. Windows and Linux are unchanged.

Fixes #1527

Co-authored-by: mocenigo <4894657+mocenigo@users.noreply.github.com>
2026-11-10 19:20:25 +01:00
.CooCoo 6f0f1fefa8 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.
2026-11-10 12:01:17 +01:00
66 changed files with 1763 additions and 333 deletions
Generated
+18 -16
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.19.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.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -660,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -677,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -688,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
@@ -696,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
@@ -705,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -726,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -739,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -768,13 +769,14 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.19.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.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -796,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -823,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -5991,7 +5993,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.19.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.19.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+5 -1
View File
@@ -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]
+7 -6
View File
@@ -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 }),
}
}
+22 -3
View File
@@ -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
View File
@@ -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;
+51
View File
@@ -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);
})
}
+12 -6
View File
@@ -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() });
}
+3 -1
View File
@@ -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
View File
@@ -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();
+6
View File
@@ -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);
}
}
+9 -1
View File
@@ -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());
+11
View File
@@ -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);
}
+24 -2
View File
@@ -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),
}
}
+2 -1
View File
@@ -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 => {
+1 -1
View File
@@ -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")]
+75 -15
View File
@@ -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)
+2 -1
View File
@@ -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"))
+54 -8
View File
@@ -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();
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -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`"))
+1 -1
View File
@@ -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;
+13 -9
View File
@@ -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
+1 -1
View File
@@ -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")]
+3 -5
View File
@@ -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`"));
+12
View File
@@ -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)?;
+4 -2
View File
@@ -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);
+31 -2
View File
@@ -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")));
}
}
+14 -6
View File
@@ -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"]]);
+13 -3
View File
@@ -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);
+8 -2
View File
@@ -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)
}
}
}
+39 -2
View File
@@ -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());
}
}
+30 -11
View File
@@ -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")),
}
}
+42
View File
@@ -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\"");
}
+32 -2
View File
@@ -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");
+14
View File
@@ -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");
}
+9 -1
View File
@@ -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;
+18 -1
View File
@@ -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));
}
+2 -1
View File
@@ -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};
+23 -6
View File
@@ -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();
+12
View File
@@ -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);
+19 -1
View File
@@ -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;
}
+14 -2
View File
@@ -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);
+68 -17
View File
@@ -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
View File
@@ -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;
}
+4 -1
View File
@@ -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());
}
+10
View File
@@ -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 [
+3
View File
@@ -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"
+38 -7
View File
@@ -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).
+46 -2
View File
@@ -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
View File
@@ -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);
+32
View File
@@ -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, &params);
// 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)], &lt, 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
View File
@@ -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.
+52
View File
@@ -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);
}
+11
View File
@@ -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));
}
+37 -5
View File
@@ -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 -18
View File
@@ -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
View File
@@ -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);
}
}
+169
View File
@@ -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);
+45 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+27
View File
@@ -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