Compare commits

..
12 changed files with 242 additions and 97 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ clap_mangen = { version = "0.3.3", features = ["env"] }
thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
+16 -52
View File
@@ -221,17 +221,12 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, h
}
}
/// Longest request `id` (it is copied into the reply).
const MAX_ID_BYTES: usize = 4096;
/// One request line, parsed.
enum Line<'a> {
enum Line {
Request {
id: Value,
method: String,
/// Left as text until the token is checked: decoding it first would let an unauthenticated
/// peer turn a flat array into many owned JSON values.
params: Option<&'a serde_json::value::RawValue>,
params: Value,
token: Option<String>,
},
Blank,
@@ -240,7 +235,7 @@ enum Line<'a> {
}
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line<'_> {
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
@@ -251,28 +246,23 @@ fn parse_line(bytes: &[u8]) -> Line<'_> {
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
// Checked without building values; then only the object's own keys are read, each value kept as text.
if let Err(e) = serde_json::from_str::<&serde_json::value::RawValue>(text) {
return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection"));
}
let Ok(mut obj) = serde_json::from_str::<std::collections::BTreeMap<String, &serde_json::value::RawValue>>(text) else {
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Value::Object(mut obj) = msg else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
// `method` and `token` are decoded as strings only (anything else is no string); `id` is echoed back
// in replies, so it is decoded but bounded.
let string = |v: Option<&serde_json::value::RawValue>| v.and_then(|r| serde_json::from_str::<String>(r.get()).ok());
let Some(method) = string(obj.remove("method")) else {
let Some(Value::String(method)) = obj.remove("method") else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
let id = match obj.remove("id") {
None => Value::Null,
Some(r) if r.get().len() <= MAX_ID_BYTES => match serde_json::from_str(r.get()) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad `id` ({e}); closing the connection")),
},
Some(_) => return Line::Reject(format!("`id` is longer than {MAX_ID_BYTES} bytes; closing the connection")),
};
Line::Request { id, method, params: obj.remove("params"), token: string(obj.remove("token")) }
// Moved out of the parsed object, not cloned: `params` can be most of the line.
Line::Request {
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 }),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
@@ -315,16 +305,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
let _ = writeln!(out, "{r}");
break;
}
// Absent `params` is `{}`; ones that cannot be decoded (an unpaired surrogate escape) are an
// error, not a request run with defaults.
let params = match params.map(|r| serde_json::from_str::<Value>(r.get())) {
None => json!({}),
Some(Ok(v)) => v,
Some(Err(e)) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("bad params ({e}); closing the connection"), "id": id}));
break;
}
};
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
@@ -392,22 +372,6 @@ mod tests {
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
/// The parameters stay text until the token is checked, and a request decodes as before.
#[test]
fn params_are_decoded_only_as_text_by_the_parser() {
let line = br#"{"id":7,"method":"m","token":"t","params":[1,2,{"a":[3]}],"extra":null}"#;
let Line::Request { id, method, params, token } = parse_line(line) else { panic!("a request") };
assert_eq!((id, method.as_str(), token.as_deref()), (json!(7), "m", Some("t")));
assert_eq!(params.map(|p| p.get()), Some(r#"[1,2,{"a":[3]}]"#));
assert!(matches!(parse_line(b"[1,2,3]"), Line::Reject(e) if e.contains("expected a JSON object")));
assert!(matches!(parse_line(b"{\"method\":"), Line::Reject(e) if e.contains("bad JSON")));
assert!(matches!(parse_line(br#"{"id":[1],"method":5}"#), Line::Reject(e) if e.contains("missing string `method`")));
let long_id = format!(r#"{{"id":"{}","method":"m"}}"#, "x".repeat(MAX_ID_BYTES));
assert!(matches!(parse_line(long_id.as_bytes()), Line::Reject(e) if e.contains("`id` is longer")));
assert!(matches!(parse_line(br#"{"id":"\ud800","method":"m"}"#), Line::Reject(e) if e.contains("bad `id`")));
assert!(matches!(parse_line(br#"{"id":1}"#), Line::Reject(e) if e.contains("missing string `method`")));
}
fn held() -> Held {
Held::new(Budget::new(usize::MAX))
}
+61 -6
View File
@@ -19,6 +19,21 @@ const MAX_TABLE_ROWS: usize = 2000;
const MAX_TABLE_COLS: usize = 200;
/// Attribute columns kept at most.
const MAX_TAGS: usize = 190;
/// Attribute text one extraction may hold across all its references (each row owns a copy of its
/// values, constants included). Far above any real schedule; an error, never a silent cut.
const MAX_VALUE_BYTES: usize = 1 << 30;
/// Add `add` bytes to `used`; an error past `limit`.
fn charge(used: &mut usize, add: usize, limit: usize) -> Result<()> {
*used = used.saturating_add(add);
if *used > limit {
return Err(bad(
"dataextraction",
format!("the attribute text of the block references passes {} MiB; narrow `blocks`, `tags` or `handles`", limit >> 20),
));
}
Ok(())
}
const PARAMS: &str = "{blocks?: \"A*,B\" (block names, wildcards, default *), tags?: attribute tags to include (wildcards, default *), handles? (default: the block references in model space and every layout), combine?: bool (default true: identical rows once, with a Count column; false: one row per reference with handle, layer, position and rotation), file?: path (.csv; .txt is tab-separated), at?: [x,y] (insert a table there), title?: table title} → {columns, rows, file?, table?}";
@@ -167,6 +182,8 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
};
let selected = selected_tags(&ents, d, o);
let mut out = Vec::new();
let mut used = 0usize;
let mut const_sizes: HashMap<String, Vec<(String, usize)>> = HashMap::new();
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
for e in &ents {
let EntityKind::Insert(ins) = &e.kind else { continue };
@@ -175,18 +192,47 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
continue;
}
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
// Charged from the lengths, before any text is copied.
let selected_bytes: usize =
ins.attribs.iter().filter(|a| selected.contains(&a.tag.to_ascii_uppercase())).map(|a| a.text.value.len() + a.tag.len()).sum();
// The constants this row copies (those its own attributes do not override), from the lengths.
let first_of_block = !const_sizes.contains_key(&block);
let consts_sizes = const_sizes.entry(block.clone()).or_insert_with(|| {
d.block(&ins.block).map_or_else(Vec::new, |b| {
b.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(a) if a.constant && selected.iter().any(|t| t == &a.tag.to_ascii_uppercase()) => {
Some((a.tag.to_ascii_uppercase(), a.tag.len() + a.text.value.len()))
}
_ => None,
})
.collect()
})
});
if first_of_block {
// The per-block cache of constants is one more copy of them, made once.
charge(&mut used, consts_sizes.iter().map(|(_, n)| *n).fold(0, usize::saturating_add), MAX_VALUE_BYTES)?;
}
let const_bytes: usize = consts_sizes
.iter()
.filter(|(tag, _)| !ins.attribs.iter().any(|a| a.tag.eq_ignore_ascii_case(tag)))
.map(|(_, n)| *n)
.fold(0, usize::saturating_add);
charge(&mut used, selected_bytes.saturating_add(const_bytes), MAX_VALUE_BYTES)?;
let 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,
);
out.push(Found {
handle: e.handle.hex(),
layer: e.common.layer.clone(),
at: ins.insert.xy(),
rotation: ins.rotation.to_degrees(),
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,
),
values,
});
}
Ok(out)
@@ -449,6 +495,15 @@ impl Interactive for ExtractM {
#[cfg(test)]
mod tests {
#[test]
fn attribute_text_is_charged_against_one_budget() {
let mut used = 0;
assert!(super::charge(&mut used, 60, 100).is_ok());
assert!(super::charge(&mut used, 40, 100).is_ok());
assert!(super::charge(&mut used, 1, 100).is_err());
assert!(super::charge(&mut used, usize::MAX, 100).is_err());
}
use super::*;
#[test]
+40 -13
View File
@@ -6,7 +6,7 @@
//! of nested xrefs (`xref|nested|name`) bind with every `|` written `$0$`.
use std::cell::RefCell;
use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
@@ -20,6 +20,16 @@ fn split(name: &str) -> Option<(&str, &str)> {
/// Whether table `sym` of `d` has a symbol called `name`.
fn taken(d: &Drawing, sym: Sym, name: &str) -> bool {
taken_with(d, sym, name, None)
}
/// [`taken`], answering for blocks from `blocks` (the upper-cased names of `d`'s blocks, kept
/// current by the caller) when given: a lookup that misses scans every block, which a bind
/// naming thousands of blocks would repeat for each.
fn taken_with(d: &Drawing, sym: Sym, name: &str, blocks: Option<&HashSet<String>>) -> bool {
if let (Sym::Block, Some(set)) = (sym, blocks) {
return set.contains(&name.to_ascii_uppercase());
}
match sym {
Sym::Layer => d.layer(name).is_some(),
Sym::Linetype => d.linetype(name).is_some(),
@@ -32,12 +42,12 @@ fn taken(d: &Drawing, sym: Sym, name: &str) -> bool {
}
/// The bound name of `key|rest`: `key$n$rest` with the first free `n`, or `rest` (Insert type).
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool) -> String {
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool, blocks: Option<&HashSet<String>>) -> String {
let rest = rest.replace('|', "$0$");
if insert {
return rest;
}
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken(d, sym, c)).unwrap_or_else(|| format!("{key}$${rest}"))
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken_with(d, sym, c, blocks)).unwrap_or_else(|| format!("{key}$${rest}"))
}
/// Every table's symbol names, for one table at a time.
@@ -53,9 +63,8 @@ fn names(d: &Drawing, sym: Sym) -> Vec<String> {
}
}
/// Rename symbol `old` of table `sym` to `new`, or drop it (`new` already exists: Insert type).
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str) {
let merge = !new.eq_ignore_ascii_case(old) && taken(d, sym, new);
/// Rename symbol `old` of table `sym` to `new`, or drop it (`merge`: `new` already exists, Insert type).
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str, merge: bool) {
macro_rules! table {
($v:expr) => {{
if merge {
@@ -101,12 +110,17 @@ pub(crate) fn bind(d: &mut Drawing, key: &str, insert: bool) -> Result<(), Strin
let mut map: HashMap<(Sym, String), String> = HashMap::new();
let mut bound_blocks = vec![key.clone()];
for sym in TABLES {
let mut blocks: Option<HashSet<String>> = (sym == Sym::Block).then(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
for old in names(d, sym).into_iter().filter(|n| has_prefix(n, &prefix)) {
let rest = old.get(prefix.len()..).unwrap_or_default();
let new = bound_name(d, sym, &key, rest, insert);
let new = bound_name(d, sym, &key, rest, insert, blocks.as_ref());
// Insert type: a host symbol of the bound name is used instead of the xref's.
let merged = taken(d, sym, &new);
rename_symbol(d, sym, &old, &new);
let merged = taken_with(d, sym, &new, blocks.as_ref());
rename_symbol(d, sym, &old, &new, !new.eq_ignore_ascii_case(&old) && merged);
if let Some(set) = &mut blocks {
set.remove(&old.to_ascii_uppercase());
set.insert(new.to_ascii_uppercase());
}
if sym == Sym::Block && !merged {
bound_blocks.push(new.clone());
}
@@ -242,14 +256,27 @@ fn xbind_in(d: &mut Drawing, sym: Sym, name: &str, depth: usize) -> Result<Strin
mod tests {
use super::*;
#[test]
fn a_block_name_set_answers_as_the_scan_does() {
let mut d = Drawing::new_imperial();
for n in ["SITE$0$Plan", "Other"] {
d.blocks.insert(n.into(), Arc::new(cadcraft_doc::Block::new(n)));
}
let set: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
for n in ["site$0$plan", "SITE$0$Plan", "OTHER", "SITE$1$Plan", "nothing"] {
assert_eq!(taken_with(&d, Sym::Block, n, Some(&set)), taken(&d, Sym::Block, n), "{n}");
}
assert_eq!(bound_name(&d, Sym::Block, "SITE", "plan", false, Some(&set)), "SITE$1$plan");
}
#[test]
fn bound_names() {
let mut d = Drawing::new_imperial();
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false), "SITE$0$Walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false, None), "SITE$0$Walls");
d.ensure_layer("SITE$0$Walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false), "SITE$1$walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true), "Walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false), "SITE$0$TREES$0$Oak");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false, None), "SITE$1$walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true, None), "Walls");
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false, None), "SITE$0$TREES$0$Oak");
assert_eq!(split("A|b|c"), Some(("A", "b|c")));
assert_eq!(split("|b"), None);
}
+38
View File
@@ -497,6 +497,30 @@ fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, u
(books, skipped)
}
/// Most page-by-entity renderings one PDFBOOK may ask for: every page draws the whole model through
/// its window, so the work is the frames times the model's objects (1,000 sheets of a 500,000-object
/// drawing).
const MAX_BOOK_WORK: u64 = 500_000_000;
/// Objects one page draws: the model's objects, and the contents of each block reference (once per
/// array cell, and the reference's own attributes; nested blocks count as their own entries).
fn model_visits(d: &Drawing) -> usize {
d.model
.iter()
.map(|e| match &e.kind {
EntityKind::Insert(i) => {
d.block(&i.block).map_or(1, |b| 1 + b.entities.len().saturating_mul((i.rows as usize).saturating_mul(i.cols as usize)))
}
_ => 1,
})
.fold(0usize, usize::saturating_add)
}
/// Whether `frames` pages of a model with `entities` objects stay within [`MAX_BOOK_WORK`].
fn book_work_fits(frames: usize, entities: usize) -> bool {
(frames as u64).saturating_mul(entities as u64) <= MAX_BOOK_WORK
}
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
if io().and_then(|h| h.plot).is_none() {
return Err(bad("pdfbook", "plotting is not available in this build"));
@@ -507,6 +531,13 @@ fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
if books.is_empty() {
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
}
let sheets: usize = books.iter().map(Vec::len).sum();
if !book_work_fits(sheets, model_visits(s.doc()?)) {
return Err(bad(
"pdfbook",
format!("{sheets} sheets of a model this large are too much work for one command; plot part of the drawing at a time"),
));
}
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
let pb = target.as_deref().map(std::path::Path::new);
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
@@ -561,6 +592,13 @@ mod tests {
Ok(())
}
#[test]
fn a_book_is_bounded_by_pages_times_objects() {
assert!(book_work_fits(1_000, 500_000));
assert!(!book_work_fits(100_000, 100_000));
assert!(!book_work_fits(usize::MAX, usize::MAX));
}
#[test]
fn web_export_delivers_a_download_instead_of_writing_a_path() {
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
+28 -9
View File
@@ -7,16 +7,32 @@ use super::{
// NCOPY
// =====================================================================================
/// The leaf objects of a block reference in world coordinates (nested references expanded).
fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) {
/// Block entities one NCOPY may visit in all, leaves or not: references to shared, empty or
/// repeated sub-blocks multiply the visits (not the leaves) with each level of nesting.
const MAX_VISITS: usize = 1_000_000;
/// The leaf objects of a block reference in world coordinates (nested references expanded). An
/// error when the nesting needs more than [`MAX_VISITS`] visits.
fn nested_leaves(
d: &cadcraft_doc::Drawing,
ins: &cadcraft_doc::Insert,
m: &Mat3,
depth: usize,
visits: &mut usize,
out: &mut Vec<(Common, EntityKind)>,
) -> Result<()> {
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
return;
return Ok(());
}
let Some(b) = d.block(&ins.block) else { return };
let Some(b) = d.block(&ins.block) else { return Ok(()) };
let mm = m.then_before(ins.transform(b.base.xy()));
for e in b.entities.iter() {
*visits += 1;
if *visits > MAX_VISITS {
return Err(other("The block reference nests too many objects to copy."));
}
match &e.kind {
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, visits, out)?,
EntityKind::AttDef(_) => {}
k => {
let mut k = k.clone();
@@ -25,13 +41,14 @@ fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3
}
}
}
Ok(())
}
fn leaves_of(s: &Session, h: Handle) -> Result<Vec<(Common, EntityKind)>> {
fn leaves_of(s: &Session, h: Handle, visits: &mut usize) -> Result<Vec<(Common, EntityKind)>> {
let e = curves::entity(s, h)?;
let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) };
let mut out = Vec::new();
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, visits, &mut out)?;
Ok(out)
}
@@ -53,7 +70,7 @@ fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) ->
pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?;
let leaves = leaves_of(s, h)?;
let leaves = leaves_of(s, h, &mut 0)?;
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
None => leaves,
@@ -74,6 +91,8 @@ pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) struct NcopyM {
items: Vec<(Common, EntityKind)>,
selecting_done: bool,
/// Block entities visited by every selection so far: one allowance for the whole command.
visits: usize,
base: Option<Vec2>,
}
@@ -97,7 +116,7 @@ impl Interactive for NcopyM {
return match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?;
let leaf = nearest_leaf(leaves_of(s, h, &mut self.visits)?, p).ok_or_else(|| other("Nothing nested there."))?;
s.echo(format!("Nested object: {}", leaf.1.type_name()));
if self.items.len() < MAX_GEN {
self.items.push(leaf);
+22
View File
@@ -777,3 +777,25 @@ fn offset_elliptical_arc_follows_picked_side() {
let outer = max_x(json!([20, 20]));
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
}
/// Nested references multiply visits even when no leaf results; NCOPY refuses past its visit budget.
#[test]
fn ncopy_charges_every_nested_visit() {
use cadcraft_doc::{Block, Common, Entity, Insert};
let mut s = Session::new();
let d = s.doc_mut().unwrap();
let mut n = 1;
for level in 0..15 {
let mut b = Block::new(&format!("B{level}"));
for _ in 0..100 {
let ins = Insert { block: format!("B{}", level + 1), ..Insert::default() };
b.entities.push(Entity::new(Handle(n), EntityKind::Insert(ins)));
n += 1;
}
d.blocks.insert(b.name.clone(), std::sync::Arc::new(b));
}
d.blocks.insert("B15".into(), std::sync::Arc::new(Block::new("B15")));
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { block: "B0".into(), ..Insert::default() })).unwrap();
let err = s.execute("ncopy", &json!({ "handle": h.hex() })).unwrap_err().to_string();
assert!(err.contains("nests too many objects"), "{err}");
}
+1 -1
View File
@@ -697,7 +697,7 @@ impl Session {
}
pub(crate) use drawing::from_json as drawing_from_json;
pub(crate) use drawing::load_source;
pub(crate) use drawing::{load_source, support_file};
impl Session {
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
+2 -1
View File
@@ -235,7 +235,8 @@ pub(super) fn autoload(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispErr
return Err(LispError::bad_arg("command name", &cmd));
}
// The stub forgets itself, loads the file and calls what it defined.
let file_lit = to_text(&Value::Str(file.clone()), true);
// A file in the support folder is loaded from there, never from a same-named file of the working folder.
let file_lit = to_text(&Value::Str(crate::lisp::support_file(ctx.s, &file).map_or_else(|| file.clone(), |p| p.into())), true);
let src = format!(
"(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))",
file.replace(['"', '\\'], "/")
+30 -1
View File
@@ -1128,7 +1128,9 @@ pub fn trans(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
/// What `(load name)` reads: a file loaded through APPLOAD (the web), or one on disk, with
/// `.lsp` added when the name has no extension.
pub fn load_source(s: &Session, name: &str) -> Option<String> {
if let Some(t) = s.lisp_file(name) {
// An absolute path names a file, not an APPLOAD entry that happens to share its base name.
let absolute = std::path::Path::new(name).is_absolute();
if !absolute && let Some(t) = s.lisp_file(name) {
return Some(t);
}
let candidates =
@@ -1139,10 +1141,37 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
}
/// The file `name` names in the support folder, when it is there: what an autoload registration
/// loads, so a same-named file in the working folder cannot take its place. `None` for an absolute
/// path, no support folder or no such file there (the general search then applies, as before).
pub fn support_file(s: &Session, name: &str) -> Option<String> {
if std::path::Path::new(name).is_absolute() {
return None;
}
let dir = s.lisp_support_dir()?;
let candidates =
if std::path::Path::new(name).extension().is_none() { vec![format!("{name}.lsp"), name.to_string()] } else { vec![name.to_string()] };
candidates.iter().map(|c| dir.join(c)).find(|p| p.is_file()).map(|p| std::path::absolute(&p).unwrap_or(p).to_string_lossy().into_owned())
}
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn autoload_prefers_the_support_folder_file() {
let dir = std::env::temp_dir().join(format!("cadcraft-lisp-support-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lazy.lsp"), "").unwrap();
let mut s = Session::new();
assert_eq!(support_file(&s, "lazy"), None);
s.set_lisp_support_dir(Some(dir.clone()));
assert_eq!(support_file(&s, "lazy"), Some(dir.join("lazy.lsp").to_string_lossy().into_owned()));
assert_eq!(support_file(&s, "other"), None);
assert_eq!(support_file(&s, dir.join("lazy.lsp").to_str().unwrap()), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// One 64 KB string occurring many times is charged by its length each time.
#[test]
fn shared_strings_spend_the_conversion_budgets() {
+1 -10
View File
@@ -371,16 +371,7 @@ fn fill(store: &mut EntityStore, by_name: &HashMap<String, MLineStyle>) {
&& let Some(st) = by_name.get(&key(&m.style))
&& st.elements.len() == m.def.elements.len()
{
// The description is the style table's; copying it into every multiline would multiply a long one.
m.def = MLineStyle {
name: st.name.clone(),
description: String::new(),
elements: st.elements.clone(),
start_caps: st.start_caps,
end_caps: st.end_caps,
joints: st.joints,
fill: st.fill,
};
m.def = st.clone();
}
});
}
+2 -3
View File
@@ -59,8 +59,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap();
let [a, b] = mlines(&back).try_into().unwrap();
// The geometry as drawn is the same, breaks included.
// The description lives in the style table (checked below), not copied into every multiline.
assert_eq!(a.def, MLineStyle { description: String::new(), ..wall() });
assert_eq!(a.def, wall());
assert_eq!(
(a.style.as_str(), a.justification, a.scale, a.closed, a.no_start_caps, a.no_end_caps),
("Wall", MLineJust::Zero, 2.0, false, false, true)
@@ -102,7 +101,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
// Saving again is stable.
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&back).as_bytes()).unwrap();
assert_eq!(mlines(&again).len(), 2);
assert_eq!(mlines(&again)[0].def, MLineStyle { description: String::new(), ..wall() });
assert_eq!(mlines(&again)[0].def, wall());
}
/// A multiline as another program writes it: style `Duo` (offsets 0.25 and -0.25, the first red)