mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-13 01:10:18 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776badbc20 | ||
|
|
2d2f5b6fc9 | ||
|
|
8e6b45ad08 |
@@ -8,6 +8,9 @@ use cadcraft_geom::{Line, Vec2};
|
||||
pub const MAX_OBJECTS: usize = 200;
|
||||
/// Most candidates returned.
|
||||
pub const MAX_CANDIDATES: usize = 2000;
|
||||
/// Residual-evaluation work all the solves of one AUTOCONSTRAIN may make together (four
|
||||
/// full-size solves; a few thousand small ones).
|
||||
const MAX_AUTO_WORK: f64 = 4.0 * crate::MAX_EVAL_WORK;
|
||||
|
||||
/// Inference options.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -309,11 +312,22 @@ fn on_arc(d: &Drawing, h: Handle, c: Vec2, p: Vec2) -> bool {
|
||||
/// Infer and apply constraints over `handles`; returns the ids added. Redundant or conflicting
|
||||
/// candidates are skipped.
|
||||
pub fn autoconstrain(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> Vec<u32> {
|
||||
autoconstrain_report(d, handles, o).0
|
||||
}
|
||||
|
||||
/// [`autoconstrain`], and whether it stopped early because the solver-work allowance of the command
|
||||
/// was spent (the remaining candidates were not tried).
|
||||
pub fn autoconstrain_report(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> (Vec<u32>, bool) {
|
||||
let mut added = Vec::new();
|
||||
// One allowance of solver work for all the candidates: each is a full solve of its component.
|
||||
let mut work = MAX_AUTO_WORK;
|
||||
for c in infer(d, handles, o) {
|
||||
if let Ok(id) = crate::add(d, c, &crate::SolveOptions::default()) {
|
||||
added.push(id);
|
||||
match crate::add_within(d, c, &crate::SolveOptions::default(), &mut work) {
|
||||
Ok(id) => added.push(id),
|
||||
// The allowance is spent: what was added stays, the remaining candidates are not tried.
|
||||
Err(crate::SolveError::TooLarge) if work < 0.0 => return (added, true),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
added
|
||||
(added, false)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, infer, infer_placed};
|
||||
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, autoconstrain_report, infer, infer_placed};
|
||||
pub use dof::{DofReport, DofStatus, ObjectDof, dof};
|
||||
pub use expr::{Env, ExprError};
|
||||
pub use model::{ref_points, rigid_points, supported};
|
||||
@@ -53,6 +53,14 @@ const MAX_ITER: usize = 200;
|
||||
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
|
||||
/// shared by solving, redundancy checks and DOF analysis.
|
||||
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most multiply-adds (rows x columns squared) of the dense rank factorisations one Fix makes:
|
||||
/// about a thousand parameters locked at once is 1e9; 2e10 is a few seconds.
|
||||
const MAX_QR_WORK: f64 = 2e10;
|
||||
|
||||
/// Whether a dense rank factorisation of a `rows` x `cols` matrix passes [`MAX_QR_WORK`].
|
||||
fn qr_too_large(rows: usize, cols: usize) -> bool {
|
||||
(rows as f64) * (cols as f64) * (cols as f64) > MAX_QR_WORK
|
||||
}
|
||||
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
|
||||
const TRIAL_EVALS: f64 = 14.0;
|
||||
|
||||
@@ -335,8 +343,14 @@ impl Comp {
|
||||
}
|
||||
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
|
||||
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
|
||||
self.conflicts_probing(x_failed, true)
|
||||
}
|
||||
|
||||
/// [`Comp::conflicts`]; `probe` false skips the re-solves that look for the constraints whose
|
||||
/// removal lets the rest converge.
|
||||
fn conflicts_probing(&self, x_failed: &[f64], probe: bool) -> Vec<u32> {
|
||||
let mut ids = Vec::new();
|
||||
if self.built.len() <= MAX_CONFLICT_PROBES {
|
||||
if probe && self.built.len() <= MAX_CONFLICT_PROBES {
|
||||
for i in 0..self.built.len() {
|
||||
if let Ok(o) = self.run(Some(i))
|
||||
&& o.converged
|
||||
@@ -741,7 +755,14 @@ pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
/// Add a constraint: assigns its id (and name/expression for dimensional constraints when
|
||||
/// empty), refuses redundant or conflicting constraints, solves and writes the geometry back.
|
||||
/// On error the drawing is unchanged.
|
||||
pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u32, SolveError> {
|
||||
pub fn add(d: &mut Drawing, c: Constraint, opts: &SolveOptions) -> Result<u32, SolveError> {
|
||||
let mut unlimited = f64::INFINITY;
|
||||
add_within(d, c, opts, &mut unlimited)
|
||||
}
|
||||
|
||||
/// [`add`] charging the solve's residual-evaluation work to `work`: `TooLarge` (nothing changed)
|
||||
/// once it would take more than is left.
|
||||
pub(crate) fn add_within(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions, work: &mut f64) -> Result<u32, SolveError> {
|
||||
if d.constraints.len() >= MAX_CONSTRAINTS {
|
||||
return Err(SolveError::Other("too many constraints".into()));
|
||||
}
|
||||
@@ -777,10 +798,24 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
let Some(comp) = comps.into_iter().find(|k| k.built.iter().any(|b| b.id == id)) else {
|
||||
return Err(SolveError::Invalid { id, msg: "could not be interpreted".into() });
|
||||
};
|
||||
*work -= comp.eval_work(&comp.free(None), None);
|
||||
if *work < 0.0 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let o = comp.run(None)?;
|
||||
if !o.converged {
|
||||
// The new constraint first, then the existing ones it fights with.
|
||||
let mut ids = comp.conflicts(&o.x);
|
||||
// Each probe is another solve (up to one per constraint): they are charged too, and skipped
|
||||
// when the allowance cannot pay for them.
|
||||
let probes = if comp.built.len() <= MAX_CONFLICT_PROBES { comp.built.len() as f64 } else { 0.0 };
|
||||
let cost = probes * comp.eval_work(&comp.free(None), None);
|
||||
// Paid for before it is spent: when the allowance cannot cover the probes they are skipped and
|
||||
// it is left as it was for the candidates still to try.
|
||||
let affordable = cost <= *work;
|
||||
if affordable {
|
||||
*work -= cost;
|
||||
}
|
||||
let mut ids = comp.conflicts_probing(&o.x, affordable);
|
||||
ids.retain(|i| *i != id);
|
||||
ids.insert(0, id);
|
||||
return Err(SolveError::Conflict { ids });
|
||||
@@ -819,6 +854,10 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
let nf = before.len();
|
||||
// The synthetic lock rows enlarge the matrix after the solver's own limits were checked.
|
||||
if qr_too_large(rows_old + locked.len(), nf) {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, Some(pos));
|
||||
let mut jac = lm::jacobian(&f, &o.x, &before, rows_old);
|
||||
let r_old = lm::rank_qr(&jac, rows_old, nf);
|
||||
|
||||
@@ -516,3 +516,41 @@ fn next_name_skips_names_in_use() {
|
||||
});
|
||||
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fix_over_a_huge_component_is_refused_before_its_dense_factorisation() {
|
||||
assert!(!super::qr_too_large(1_000, 1_000));
|
||||
assert!(super::qr_too_large(3_000, 3_000));
|
||||
assert!(super::qr_too_large(usize::MAX, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spent_work_allowance_refuses_a_constraint_and_leaves_the_drawing() {
|
||||
let mut d = Drawing::default();
|
||||
let l = line(&mut d, (0.0, 0.0), (10.0, 0.01));
|
||||
let before = d.clone();
|
||||
let c = con(ConstraintKind::Horizontal, &[w(l)]);
|
||||
assert!(matches!(add_within(&mut d, c.clone(), &SolveOptions::default(), &mut 0.0), Err(SolveError::TooLarge)));
|
||||
assert_eq!(d, before);
|
||||
let mut plenty = 1e9;
|
||||
assert!(add_within(&mut d, c, &SolveOptions::default(), &mut plenty).is_ok());
|
||||
assert!(plenty < 1e9, "the solve was charged");
|
||||
let (_, stopped) = autoconstrain_report(&mut d, &[l], &InferOptions::default());
|
||||
assert!(!stopped, "a small command never spends its allowance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_conflict_probes_do_not_spend_the_allowance() {
|
||||
let mut d = Drawing::default();
|
||||
let b = line(&mut d, (0.0, 0.0), (3.0, 4.0));
|
||||
ok(&mut d, con(ConstraintKind::Fix, &[w(b)]));
|
||||
let candidate = || con(ConstraintKind::Vertical, &[w(b)]);
|
||||
// The whole cost of the conflicting candidate: its solve and its probes.
|
||||
let mut rich = 1e12;
|
||||
assert!(matches!(add_within(&mut d.clone(), candidate(), &SolveOptions::default(), &mut rich), Err(SolveError::Conflict { .. })));
|
||||
let total = 1e12 - rich;
|
||||
// Enough for the solve but not the probes: they are skipped, and what is left stays positive.
|
||||
let mut work = total * 0.6;
|
||||
assert!(matches!(add_within(&mut d, candidate(), &SolveOptions::default(), &mut work), Err(SolveError::Conflict { .. })));
|
||||
assert!(work > 0.0 && work < total * 0.6, "{work} of {total}");
|
||||
}
|
||||
|
||||
@@ -19,21 +19,6 @@ 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?}";
|
||||
|
||||
@@ -182,8 +167,6 @@ 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 };
|
||||
@@ -192,47 +175,18 @@ 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: 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)
|
||||
@@ -495,15 +449,6 @@ 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]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! of nested xrefs (`xref|nested|name`) bind with every `|` written `$0$`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
@@ -20,16 +20,6 @@ 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(),
|
||||
@@ -42,12 +32,12 @@ fn taken_with(d: &Drawing, sym: Sym, name: &str, blocks: Option<&HashSet<String>
|
||||
}
|
||||
|
||||
/// 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, blocks: Option<&HashSet<String>>) -> String {
|
||||
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool) -> String {
|
||||
let rest = rest.replace('|', "$0$");
|
||||
if insert {
|
||||
return rest;
|
||||
}
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken_with(d, sym, c, blocks)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken(d, sym, c)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
}
|
||||
|
||||
/// Every table's symbol names, for one table at a time.
|
||||
@@ -63,8 +53,9 @@ fn names(d: &Drawing, sym: Sym) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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);
|
||||
macro_rules! table {
|
||||
($v:expr) => {{
|
||||
if merge {
|
||||
@@ -110,17 +101,12 @@ 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, blocks.as_ref());
|
||||
let new = bound_name(d, sym, &key, rest, insert);
|
||||
// Insert type: a host symbol of the bound name is used instead of the xref's.
|
||||
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());
|
||||
}
|
||||
let merged = taken(d, sym, &new);
|
||||
rename_symbol(d, sym, &old, &new);
|
||||
if sym == Sym::Block && !merged {
|
||||
bound_blocks.push(new.clone());
|
||||
}
|
||||
@@ -256,27 +242,14 @@ 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, None), "SITE$0$Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false), "SITE$0$Walls");
|
||||
d.ensure_layer("SITE$0$Walls");
|
||||
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!(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!(split("A|b|c"), Some(("A", "b|c")));
|
||||
assert_eq!(split("|b"), None);
|
||||
}
|
||||
|
||||
@@ -821,11 +821,15 @@ fn run_autoconstrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.types = t.iter().filter_map(Value::as_str).map(|n| kind_from_name(n).map_or_else(|| n.to_string(), |k| k.name().to_string())).collect();
|
||||
}
|
||||
let mut d = s.doc()?.clone();
|
||||
let ids = cs::autoconstrain(&mut d, &hs, &o);
|
||||
let (ids, stopped) = cs::autoconstrain_report(&mut d, &hs, &o);
|
||||
if !ids.is_empty() {
|
||||
*s.doc_mut()? = d;
|
||||
}
|
||||
Ok(json!({ "added": ids.len(), "ids": ids, "message": format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len()) }))
|
||||
let mut message = format!("{} constraint(s) applied to {} object(s)", ids.len(), hs.len());
|
||||
if stopped {
|
||||
message.push_str("; stopped early: the rest need more solver work than one command may spend, so run AUTOCONSTRAIN on fewer objects");
|
||||
}
|
||||
Ok(json!({ "added": ids.len(), "ids": ids, "stopped": stopped, "message": message }))
|
||||
}
|
||||
|
||||
fn run_delconstraint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -497,30 +497,6 @@ 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"));
|
||||
@@ -531,13 +507,6 @@ 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();
|
||||
@@ -592,13 +561,6 @@ 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();
|
||||
|
||||
@@ -7,32 +7,16 @@ use super::{
|
||||
// NCOPY
|
||||
// =====================================================================================
|
||||
|
||||
/// 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<()> {
|
||||
/// 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)>) {
|
||||
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let Some(b) = d.block(&ins.block) else { return Ok(()) };
|
||||
let Some(b) = d.block(&ins.block) else { return };
|
||||
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, visits, out)?,
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
|
||||
EntityKind::AttDef(_) => {}
|
||||
k => {
|
||||
let mut k = k.clone();
|
||||
@@ -41,14 +25,13 @@ fn nested_leaves(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leaves_of(s: &Session, h: Handle, visits: &mut usize) -> Result<Vec<(Common, EntityKind)>> {
|
||||
fn leaves_of(s: &Session, h: Handle) -> 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, visits, &mut out)?;
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -70,7 +53,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, &mut 0)?;
|
||||
let leaves = leaves_of(s, h)?;
|
||||
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
|
||||
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
|
||||
None => leaves,
|
||||
@@ -91,8 +74,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -116,7 +97,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, &mut self.visits)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h)?, 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);
|
||||
|
||||
@@ -777,25 +777,3 @@ 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}");
|
||||
}
|
||||
|
||||
@@ -697,7 +697,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) use drawing::from_json as drawing_from_json;
|
||||
pub(crate) use drawing::{load_source, support_file};
|
||||
pub(crate) use drawing::load_source;
|
||||
|
||||
impl Session {
|
||||
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
|
||||
|
||||
@@ -235,8 +235,7 @@ 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.
|
||||
// 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 file_lit = to_text(&Value::Str(file.clone()), 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(['"', '\\'], "/")
|
||||
|
||||
@@ -1128,9 +1128,7 @@ 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> {
|
||||
// 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) {
|
||||
if let Some(t) = s.lisp_file(name) {
|
||||
return Some(t);
|
||||
}
|
||||
let candidates =
|
||||
@@ -1141,37 +1139,10 @@ 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() {
|
||||
|
||||
Reference in New Issue
Block a user