Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 7526dc62c1 Merge tested main for Flatpak update patch release 2026-11-11 15:25:57 -05:00
Dylan M. Taylor e1d379ad9f chore: bump version to 0.20.1 for the Flatpak update fix (#3084) 2026-11-11 14:55:43 -05:00
Dylan M. Taylor 161af2a8da Release 0.20.1: repair Flatpak update checks and self-updates (#3078)
* Release 0.20.1: fix Flatpak update permissions

* test: avoid unchecked option parsing in Flatpak regression
2026-11-11 14:27:14 -05:00
Dylan M. Taylor dc7f382cec Fix Flatpak permissions for update checks and self-updates (#3075)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:01 -05:00
FredApps e5021f0175 Merge pull request #3047 from V1dalTheAbidal/pr/bsaveas-source
Block Editor: BSAVEAS keeps the source block's last saved definition
2026-11-11 18:56:45 +01:00
Dylan M. Taylor 2d25308cfc Merge pull request #3068 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:38 -05:00
Dylan M. Taylor 20b4472f0b Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:30 -05:00
Dylan M. Taylor ecb362aa55 ci: run Harden-Runner on every job (#3058)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:09:19 -05:00
FredApps 28241abb8f Merge main into release for CADCraft v0.20.0 2026-11-11 16:38:54 +01:00
V1dalTheAbidal f132d81609 Document the BSAVEAS source test 2026-11-11 11:14:22 +02:00
V1dalTheAbidal 650ae383f2 Block Editor: BSAVEAS keeps the source block's last saved definition
After BSAVE the editor's block table can still hold the older source definition.
Restore the saved source block from the host when composing a copy and when
reconciling the editor and its history, so a later save of the copy keeps it.
2026-11-11 01:32:16 +02: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
Dylan M. Taylor 227e3c385b Release 0.19.0 2026-11-09 06:29:44 -05:00
18 changed files with 140 additions and 253 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+4
View File
@@ -30,6 +30,10 @@ jobs:
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
Generated
+18 -17
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
@@ -697,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -798,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1224,6 +1224,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -5993,7 +5994,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde_json",
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.20.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -71,7 +71,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+9 -12
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
restore_saved_definition(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,16 +176,13 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -302,7 +299,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
restore_saved_definition(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+6 -61
View File
@@ -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]
+13 -40
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, 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);
}
-38
View File
@@ -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();
+9 -28
View File
@@ -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);
-22
View File
@@ -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}");
}
+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, support_file};
pub(crate) use drawing::load_source;
impl Session {
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
+1 -2
View File
@@ -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(['"', '\\'], "/")
+1 -30
View File
@@ -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() {
+37
View File
@@ -0,0 +1,37 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
+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