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
20 changed files with 147 additions and 350 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());
};
+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);
}
+2 -3
View File
@@ -12,17 +12,16 @@ pub(crate) fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
};
let lw = match e.common.lineweight {
Lineweight::ByBlock => ctx.block_lw,
Lineweight::ByLayer => ctx.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default),
Lineweight::ByLayer => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.lineweight).unwrap_or(Lineweight::Default),
x => x,
};
let lt = match e.common.linetype.to_ascii_lowercase().as_str() {
"byblock" => ctx.block_ltype.clone(),
"bylayer" => ctx.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()),
"bylayer" => ctx.d.layer(layer.as_deref().unwrap_or("0")).map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()),
_ => e.common.linetype.clone(),
};
Ctx {
d: ctx.d,
layers: ctx.layers,
xf: ctx.xf.then_before(m),
block_color: color,
block_layer: layer,
+2 -70
View File
@@ -256,16 +256,8 @@ pub struct PolyClip {
band_h: f64,
/// The region as triangles (for clipping fills), with each triangle's bounds.
tris: Vec<([Vec2; 3], Bounds2)>,
/// Indices into `tris` of the triangles whose y range meets each band (at most
/// [`TRI_BAND_SPAN`] bands of it), so a fill triangle meets only the clip triangles near it.
tri_bands: Vec<Vec<u32>>,
/// Indices of the triangles spanning more bands than that: tested against every fill triangle.
tall_tris: Vec<u32>,
}
/// Most bands one clip triangle is registered in; a taller one goes to [`PolyClip::tall_tris`].
const TRI_BAND_SPAN: usize = 8;
impl PolyClip {
/// A clip region for `poly` (see [`clean_polygon`]); `None` for a degenerate outline.
pub fn new(poly: &[Vec2]) -> Option<PolyClip> {
@@ -274,15 +266,7 @@ impl PolyClip {
let band_h = bounds.height() / BANDS as f64;
let mut bands: Vec<Vec<u32>> = vec![Vec::new(); BANDS];
let n = poly.len();
let mut clip = PolyClip {
poly: Vec::new(),
bounds,
bands: Vec::new(),
band_h,
tris: Vec::new(),
tri_bands: vec![Vec::new(); BANDS],
tall_tris: Vec::new(),
};
let mut clip = PolyClip { poly: Vec::new(), bounds, bands: Vec::new(), band_h, tris: Vec::new() };
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
let (lo, hi) = (clip.band(a.y.min(b.y)), clip.band(a.y.max(b.y)));
@@ -298,17 +282,6 @@ impl PolyClip {
.map(|t| (*t, Bounds2::from_points(t.iter().copied())))
.filter(|(_, b)| b.width() > 0.0 && b.height() > 0.0)
.collect();
for k in 0..clip.tris.len() {
let Some((_, tb)) = clip.tris.get(k) else { continue };
let (lo, hi) = (clip.band(tb.min.y), clip.band(tb.max.y));
if hi - lo >= TRI_BAND_SPAN {
clip.tall_tris.push(k as u32);
} else {
for band in clip.tri_bands.iter_mut().take(hi + 1).skip(lo) {
band.push(k as u32);
}
}
}
clip.poly = poly;
clip.bands = bands;
Some(clip)
@@ -431,15 +404,7 @@ impl PolyClip {
out.extend_from_slice(t);
continue;
}
// The clip triangles near `t`, in clip order (the same output as a scan of all of them).
let (lo, hi) = (self.band(tb.min.y), self.band(tb.max.y));
let mut near: Vec<u32> = self.tall_tris.clone();
for band in self.tri_bands.iter().take(hi + 1).skip(lo) {
near.extend_from_slice(band);
}
near.sort_unstable();
near.dedup();
for (c, cb) in near.iter().filter_map(|&k| self.tris.get(k as usize)) {
for (c, cb) in &self.tris {
if !cb.intersects(&tb) {
continue;
}
@@ -625,39 +590,6 @@ mod tests {
assert!(t.iter().all(|p| !(p.x > 5.0 + 1e-9 && p.y > 5.0 + 1e-9)));
}
/// A fill clipped through the band index equals the same fill against a plain scan of every clip
/// triangle (the unindexed result), for a boundary with tall and short triangles.
#[test]
fn indexed_triangle_clipping_equals_a_full_scan() {
// A comb: tall teeth over a base, so its triangles span many bands.
let mut poly = vec![Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0)];
for i in (0..20).rev() {
let x = i as f64 * 2.0;
poly.extend([
Vec2::new(x + 2.0, 20.0),
Vec2::new(x + 1.0, 20.0),
Vec2::new(x + 1.0, 2.0),
Vec2::new(x + 0.5, 2.0),
Vec2::new(x + 0.5, 20.0),
Vec2::new(x, 20.0),
]);
}
let c = PolyClip::new(&poly).unwrap();
assert!(!c.tall_tris.is_empty());
let fill: Vec<Vec2> = (0..30)
.flat_map(|i| {
let (x, y) = (i as f64 * 1.3, (i % 7) as f64 * 2.5);
[Vec2::new(x, y), Vec2::new(x + 6.0, y), Vec2::new(x + 2.0, y + 9.0)]
})
.collect();
let got = c.clip_triangles(&fill);
let mut scan = c.clone();
scan.tri_bands = vec![(0..scan.tris.len() as u32).collect(); BANDS];
scan.tall_tris.clear();
assert_eq!(got, scan.clip_triangles(&fill));
assert!(!got.is_empty());
}
#[test]
fn degenerate_and_hostile_outlines() {
assert!(PolyClip::new(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)]).is_none());
+1 -1
View File
@@ -42,7 +42,7 @@ pub(crate) fn dimension(
let dim_rgb = ink(style.dim_line_color, layer_color, own);
let ext_rgb = ink(style.ext_line_color, layer_color, own);
let txt_rgb = ink(style.text_color, layer_color, own);
let layer = ctx.layer(layer_name);
let layer = d.layer(layer_name);
let weight = |v: i16| -> f32 {
if !b.opts.lineweights {
return 0.0;
+15 -118
View File
@@ -1,109 +1,40 @@
//! Polygon filling: even-odd regions to triangles (slab decomposition), wide polylines.
use cadcraft_doc::LwPolyline;
use cadcraft_geom::{Bounds2, Polyline, Vec2};
use cadcraft_geom::{Polyline, Vec2};
/// Triangulate an even-odd region bounded by closed loops (holes allowed) by cutting it into
/// horizontal slabs at every vertex y and emitting trapezoids. Two edges that cross inside a slab
/// (a bowtie, two loops crossing each other) swap order there, so such a slab is cut again at the
/// crossing and each part is paired on its own. Robust for any input; output size is
/// O(slabs × crossings), capped for hostile inputs. Groups of loops whose bounding boxes are
/// separated (by a vertical or a horizontal gap, recursively) cannot affect each other's even-odd
/// parity, so each group is cut on its own: disjoint outlines do not multiply each other's slabs.
/// O(slabs × crossings), capped for hostile inputs.
pub fn triangulate_evenodd(loops: &[Vec<Vec2>]) -> Vec<Vec2> {
// (original index, bounds, edges) per loop.
let mut items: Vec<(usize, Bounds2, Vec<(Vec2, Vec2)>)> = Vec::new();
let mut total = 0usize;
for (k, l) in loops.iter().enumerate() {
let mut edges: Vec<(Vec2, Vec2)> = Vec::new();
for l in loops {
let n = l.len();
if n < 3 {
continue;
}
let mut edges = Vec::new();
for i in 0..n {
let (Some(&a), Some(&b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
if (a.y - b.y).abs() > 1e-15 && a.is_finite() && b.is_finite() {
edges.push(if a.y < b.y { (a, b) } else { (b, a) });
}
}
total += edges.len();
if total > 200_000 {
return Vec::new();
}
if !edges.is_empty() {
let bounds = Bounds2::from_points(edges.iter().flat_map(|(a, b)| [*a, *b]));
items.push((k, bounds, edges));
}
}
let mut out = Vec::new();
// Slabs created by cutting at crossings, over the whole region; past this the remaining
// crossings are left unsplit rather than letting a hostile input run away.
let mut cuts_left = MAX_CROSSING_CUTS;
// Loop visits all the partitioning may make: an input that peels one loop off a big group at a
// time would rescan the rest each time, so past this a group is cut as it stands (as before partitioning existed).
let mut split_work = 64 * items.len() + 4_000_000;
let mut stack = vec![items];
while let Some(mut group) = stack.pop() {
if group.is_empty() {
continue;
}
let parts = match split_work.checked_sub(2 * group.len()) {
Some(rest) => {
split_work = rest;
let parts = split_groups(&mut group, true);
if parts.len() > 1 { parts } else { split_groups(&mut group, false) }
}
None => Vec::new(),
};
if parts.len() > 1 {
// Lowest-first when popped.
stack.extend(parts.into_iter().rev());
continue;
}
group.sort_by_key(|g| g.0);
let edges: Vec<(Vec2, Vec2)> = group.into_iter().flat_map(|g| g.2).collect();
if slabs(edges, &mut out, &mut cuts_left) {
break;
}
if edges.is_empty() || edges.len() > 200_000 {
return Vec::new();
}
out
}
type Item = (usize, Bounds2, Vec<(Vec2, Vec2)>);
/// `group` cut into the chains of loops whose extents along x (or y) overlap, in order along it;
/// one chain when nothing separates them (then `group` is left as it was, but for its order).
fn split_groups(group: &mut Vec<Item>, along_x: bool) -> Vec<Vec<Item>> {
let span = |b: &Bounds2| if along_x { (b.min.x, b.max.x) } else { (b.min.y, b.max.y) };
group.sort_by(|a, b| span(&a.1).0.total_cmp(&span(&b.1).0));
let mut parts: Vec<Vec<Item>> = Vec::new();
let mut reach = f64::NEG_INFINITY;
for item in std::mem::take(group) {
match parts.last_mut() {
Some(part) if span(&item.1).0 <= reach => {
reach = reach.max(span(&item.1).1);
part.push(item);
}
_ => {
reach = span(&item.1).1;
parts.push(vec![item]);
}
}
}
if parts.len() == 1 {
*group = parts.pop().unwrap_or_default();
}
parts
}
/// The slab decomposition of `edges` appended to `out`; true once `out` passes its cap.
fn slabs(mut edges: Vec<(Vec2, Vec2)>, out: &mut Vec<Vec2>, cuts_left: &mut usize) -> bool {
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y));
let mut out = Vec::new();
let mut active: Vec<(Vec2, Vec2)> = Vec::new();
let mut next = 0usize;
// Slabs created by cutting at crossings, over the whole region; past this the remaining
// crossings are left unsplit rather than letting a hostile input run away.
let mut cuts_left = MAX_CROSSING_CUTS;
let mut pending: Vec<(f64, f64)> = Vec::new();
for w in ys.windows(2) {
let (Some(&y0), Some(&y1)) = (w.first(), w.get(1)) else { continue };
@@ -123,7 +54,7 @@ fn slabs(mut edges: Vec<(Vec2, Vec2)>, out: &mut Vec<Vec2>, cuts_left: &mut usiz
let ymid = (ya + yb) / 2.0;
let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|e| (x_at(e, ymid), x_at(e, ya), x_at(e, yb))).collect();
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
if *cuts_left > 0 {
if cuts_left > 0 {
let mut cuts: Vec<f64> = Vec::new();
for pair in xs.windows(2) {
let (Some(l), Some(r)) = (pair.first(), pair.get(1)) else { continue };
@@ -134,8 +65,8 @@ fn slabs(mut edges: Vec<(Vec2, Vec2)>, out: &mut Vec<Vec2>, cuts_left: &mut usiz
if !cuts.is_empty() {
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
cuts.truncate(*cuts_left);
*cuts_left -= cuts.len();
cuts.truncate(cuts_left);
cuts_left -= cuts.len();
// Push the parts top-down so the lowest is processed first.
let mut top = yb;
for &c in cuts.iter().rev() {
@@ -153,11 +84,11 @@ fn slabs(mut edges: Vec<(Vec2, Vec2)>, out: &mut Vec<Vec2>, cuts_left: &mut usiz
}
}
if out.len() > 6_000_000 {
return true;
return out;
}
}
}
false
out
}
/// How many extra slab boundaries crossings may add over one region.
@@ -233,40 +164,6 @@ mod tests {
assert!((area(&t) - 96.0).abs() < 1e-9);
}
/// A thousand disjoint squares side by side (every slab of one would otherwise be cut at the
/// vertex heights of all the others) triangulate in linear output.
#[test]
fn disjoint_outlines_do_not_multiply_each_others_slabs() {
let n = 1_000;
let loops: Vec<Vec<Vec2>> = (0..n)
.map(|i| {
let (x, jitter) = (i as f64 * 3.0, i as f64 * 0.001);
vec![Vec2::new(x, jitter), Vec2::new(x + 2.0, jitter), Vec2::new(x + 2.0, 2.0 + jitter), Vec2::new(x, 2.0 + jitter)]
})
.collect();
let t = triangulate_evenodd(&loops);
assert!((area(&t) - 4.0 * n as f64).abs() < 1e-6);
// Two triangles per square: no slab was cut at another square's height.
assert_eq!(t.len(), 6 * n);
}
/// Rectangles that peel off one at a time along alternating axes (each split isolates a single loop
/// from a large group) still triangulate correctly, with the partition work bounded.
#[test]
fn alternating_peeling_is_bounded_and_correct() {
let m = 1_000;
let (mf, mut loops) = (m as f64, Vec::new());
let rect = |x0: f64, x1: f64, y0: f64, y1: f64| vec![Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)];
for i in 0..m {
let i = i as f64;
loops.push(rect(2.0 * i, 2.0 * i + 1.0, 2.0 * i, 2.0 * mf));
loops.push(rect(2.0 * i + 2.0, 2.0 * mf + 1.0, 2.0 * i, 2.0 * i + 1.0));
}
let expected: f64 = (0..m).map(|i| (2.0 * mf - 2.0 * i as f64) + (2.0 * mf - 2.0 * i as f64 - 1.0)).sum();
let t = triangulate_evenodd(&loops);
assert!((area(&t) - expected).abs() < 1e-6 * expected, "{} vs {expected}", area(&t));
}
#[test]
fn triangle_area() {
let t = triangulate_evenodd(&[vec![Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, 3.0)]]);
+1 -33
View File
@@ -78,32 +78,10 @@ fn max_scale(m: &Mat3) -> f64 {
if s.is_finite() { s } else { 0.0 }
}
/// A drawing's layers by lower-cased name (the first of a duplicated name wins, as the table's own
/// search does), built once per display list: a lookup per object over the layer vector multiplies
/// the object count by the layer count.
struct LayerIndex(Option<std::collections::HashMap<String, usize>>);
impl LayerIndex {
fn new(d: &Drawing) -> Self {
let mut m = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
m.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
LayerIndex(Some(m))
}
/// No index: lookups scan the layer vector, as `Drawing::layer` does. For a few objects (a
/// rubber band redrawn at every pointer move), where building an index would cost more.
fn linear() -> Self {
LayerIndex(None)
}
}
/// Graphics state inherited through block references.
#[derive(Clone)]
struct Ctx<'a> {
d: &'a Drawing,
layers: &'a LayerIndex,
xf: Mat3,
block_color: Color,
block_layer: Option<String>,
@@ -125,19 +103,12 @@ struct Ctx<'a> {
}
impl Ctx<'_> {
/// The layer called `name` (any case), as [`Drawing::layer`] finds it.
fn layer(&self, name: &str) -> Option<&cadcraft_doc::Layer> {
let Some(map) = &self.layers.0 else { return self.d.layer(name) };
let i = if name.bytes().any(|b| b.is_ascii_uppercase()) { map.get(&name.to_ascii_lowercase()) } else { map.get(name) };
i.and_then(|i| self.d.layers.get(*i))
}
/// A layer's colour, honouring the viewport's override.
fn layer_color(&self, name: &str) -> Option<Color> {
if let Some((_, c)) = self.vp_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)) {
return Some(*c);
}
self.layer(name).map(|l| l.color)
self.d.layer(name).map(|l| l.color)
}
}
@@ -154,8 +125,6 @@ struct Builder<'a> {
shared_budget: Option<&'a std::sync::atomic::AtomicUsize>,
/// Transparency (percent) of the entity being drawn.
transparency: u8,
/// The drawing's layers by name.
layers: &'a LayerIndex,
}
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
@@ -371,7 +340,6 @@ impl<'a> Builder<'a> {
named: self.named,
shared_budget: self.shared_budget,
transparency: self.transparency,
layers: self.layers,
}
}
/// Take one unit of the block-expansion budget; false once it is spent.
+10 -56
View File
@@ -3,9 +3,7 @@ use super::display::append;
use super::display::{DisplayList, Kind, NO_LAYER, PlotText, VIEWPORT_CONTENT};
use super::draw::entity;
use super::viewports::{MAX_VIEWPORTS, viewport};
use super::{
Bounds2, Builder, Color, Ctx, Drawing, Entity, EntityKind, Handle, LayerIndex, Lineweight, Mat3, Space, Vec2, paper, pen, plotstyle, point,
};
use super::{Bounds2, Builder, Color, Ctx, Drawing, Entity, EntityKind, Handle, Lineweight, Mat3, Space, Vec2, paper, pen, plotstyle, point};
/// Build options.
#[derive(Clone, Debug)]
@@ -82,17 +80,9 @@ pub fn build_plot(d: &Drawing, space: &Space, opts: &Options) -> DisplayList {
build_space(d, space, opts, true)
}
pub(crate) fn top_ctx<'a>(
d: &'a Drawing,
layers: &'a LayerIndex,
xf: Mat3,
top: Handle,
frozen: &'a [String],
vp_colors: &'a [(String, Color)],
) -> Ctx<'a> {
pub(crate) fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_colors: &'a [(String, Color)]) -> Ctx<'a> {
Ctx {
d,
layers,
xf,
block_color: Color::Index(7),
block_layer: None,
@@ -111,17 +101,7 @@ pub(crate) fn top_ctx<'a>(
fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList {
let opts = &with_point_view(d, space, opts);
let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t));
let layers = LayerIndex::new(d);
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting,
styles: styler.as_ref(),
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 };
if let Space::Paper(name) = space {
b.list.sheet = paper::sheet(d, name);
}
@@ -144,7 +124,7 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
}
continue;
}
entity(&mut b, &top_ctx(d, &layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
}
// A layout is drawn in paper units, so its pens become geometry now.
@@ -168,22 +148,14 @@ fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStor
use rayon::prelude::*;
let ents: Vec<&std::sync::Arc<Entity>> = store.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let (opts, plotting, layers) = (b.opts, b.plotting, b.layers);
let (opts, plotting) = (b.opts, b.plotting);
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
.map(|chunk| {
let mut part = Builder {
list: DisplayList::default(),
opts,
plotting,
styles: None,
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
let mut part =
Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget), transparency: 0 };
for e in chunk {
entity(&mut part, &top_ctx(d, layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
part.list
})
@@ -222,10 +194,6 @@ pub(crate) fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Opt
o
}
/// Loose entity lists shorter than this, with no block reference (whose contents expand to many
/// objects), look layers up by scanning rather than building an index.
const SMALL_LIST: usize = 64;
/// Build the display list for a set of loose entities (previews, rubber bands).
pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents: I, opts: &Options) -> DisplayList {
let ents: Vec<&Entity> = ents.into_iter().collect();
@@ -237,23 +205,9 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
} else {
opts
};
let layers = if ents.len() < SMALL_LIST && !ents.iter().any(|e| matches!(e.kind, EntityKind::Insert(_))) {
LayerIndex::linear()
} else {
LayerIndex::new(d)
};
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting: false,
styles: None,
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None, transparency: 0 };
for e in ents {
entity(&mut b, &top_ctx(d, &layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
b.list
}
+1 -1
View File
@@ -59,7 +59,7 @@ impl Styler {
let normal = || by_name(cadcraft_doc::NORMAL_STYLE);
let p = c.plot_style.trim();
if p.is_empty() || p.eq_ignore_ascii_case("bylayer") {
ctx.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
ctx.d.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
} else if p.eq_ignore_ascii_case("byblock") {
ctx.block_pstyle.or_else(normal)
} else {
+1 -1
View File
@@ -14,7 +14,7 @@ pub(crate) fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool
let d = ctx.d;
// Layer "0" inside a block takes the insert's layer.
let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() };
let layer = ctx.layer(layer_name);
let layer = d.layer(layer_name);
let visible =
c.visible && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name));
let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7));
-17
View File
@@ -1217,20 +1217,3 @@ fn rotated_and_reversed_copies_of_a_curved_loop_fill_once() {
});
assert_eq!(loops_of(&h).len(), 2);
}
/// The per-list layer index finds what `Drawing::layer` finds: any case, the first of a duplicate.
#[test]
fn the_layer_index_agrees_with_the_layer_table() {
let mut d = Drawing::new_imperial();
let mut first = cadcraft_doc::Layer::new("Walls");
first.color = Color::Index(1);
let mut dup = cadcraft_doc::Layer::new("WALLS");
dup.color = Color::Index(2);
d.layers.extend([first, dup, cadcraft_doc::Layer::new("Doors")]);
for index in [LayerIndex::new(&d), LayerIndex::linear()] {
let top = crate::pipeline::top_ctx(&d, &index, cadcraft_geom::Mat3::IDENTITY, cadcraft_doc::Handle(1), &[], &[]);
for name in ["Walls", "walls", "WALLS", "doors", "0", "Missing"] {
assert_eq!(top.layer(name).map(|l| (&l.name, l.color)), d.layer(name).map(|l| (&l.name, l.color)), "{name}");
}
}
}
+5 -19
View File
@@ -5,9 +5,7 @@ use super::draw::entity;
#[cfg(not(target_arch = "wasm32"))]
use super::pipeline::PARALLEL_CHUNK;
use super::pipeline::{Options, style_table, top_ctx, with_point_view};
use super::{
Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, LayerIndex, MAX_BLOCK_EXPANSION, Mat3, Pen, Space, Vec2, clip, paper, pen, plotstyle,
};
use super::{Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_EXPANSION, Mat3, Pen, Space, Vec2, clip, paper, pen, plotstyle};
/// Upper bound on viewports drawn per layout (hostile files).
pub(crate) const MAX_VIEWPORTS: usize = 256;
@@ -23,7 +21,7 @@ pub(crate) fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_d
let poly = viewport_clip(d, e.handle, vp);
// Border (on the viewport's layer; layer off hides only the border).
if poly.is_none() {
entity(b, &top_ctx(d, b.layers, Mat3::IDENTITY, e.handle, &[], &[]), e);
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
}
if b.opts.skip_viewport == Some(e.handle) {
return;
@@ -69,17 +67,8 @@ pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds
}
let opts = &with_point_view(d, space, opts);
let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t));
let layers = LayerIndex::new(d);
let mut b = Builder {
list: DisplayList::default(),
opts,
plotting: false,
styles: styler.as_ref(),
named: None,
shared_budget: None,
transparency: 0,
layers: &layers,
};
let mut b =
Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 };
contents(&mut b, d, vp, area, &Clip::Rect(area), paper::unit_mm_of(d, handle));
b.list
}
@@ -108,8 +97,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
// paper space, whatever the viewport scale.
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
let layers = b.layers;
let ctx = || Ctx { lt_factor, ..top_ctx(d, layers, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) };
let ctx = || Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) };
let seen = |me: &Entity| {
if matches!(me.kind, EntityKind::Viewport(_)) {
return false;
@@ -132,7 +120,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
#[cfg(not(target_arch = "wasm32"))]
let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK;
@@ -156,7 +143,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
named: None,
shared_budget: Some(&budget),
transparency: 0,
layers,
};
let c = ctx();
for me in chunk.iter().filter(|me| seen(me)) {
+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