Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor dad7d1c138 fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time (#2724)
* fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time

Array copies skipped ATTDEF entries before charging the expansion budget, each attribute searched its block for a definition, each dash scanned every centreline sample, and outline chaining searched a vertex's segment list from its start on every visit. Each is now charged, indexed or cursored.

* address review: drop wall-clock bound from outline test
2026-11-03 18:11:23 -05:00
Dylan M. Taylor b1b3754ffa fix(engine): autosave files are owner-only and lock files are never written through a link (#2717)
The heartbeat opened <instance>.lock with a create-and-truncate that followed symlinks, and fell back to std::fs::write, so another account sharing SAVEFILEPATH could plant a link and have the app truncate a file of the victim's. The lock is now opened with O_NOFOLLOW|O_NONBLOCK and must be a plain file, and unlocked writes replace the file through the atomic writer. Autosave files are created 0600 regardless of umask. Lock pruning parses the folder's autosave names once instead of once per lock.
2026-11-03 18:11:06 -05:00
12 changed files with 142 additions and 132 deletions
+6 -7
View File
@@ -250,18 +250,17 @@ fn parse_line(bytes: &[u8]) -> Line {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Value::Object(mut obj) = msg else {
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(Value::String(method)) = obj.remove("method") else {
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
// Moved out of the parsed object, not cloned: `params` can be most of the line.
Line::Request {
id: obj.remove("id").unwrap_or(Value::Null),
method,
params: obj.remove("params").unwrap_or(json!({})),
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
}
}
+58 -6
View File
@@ -405,10 +405,11 @@ mod native {
pub fn prune_locks(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
// The instances that own autosave files, parsed once rather than once per lock file.
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
for n in &names {
let Some(instance) = parse_lock_name(n) else { continue };
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !owner_alive(dir, instance) {
if !with_files.contains(instance) && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -435,7 +436,22 @@ mod native {
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
// The file holds unsaved work: owner-only from its creation, whatever the umask.
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
}
/// Owner read/write only (`None` where file modes don't exist).
fn private_mode() -> Option<std::fs::Permissions> {
#[cfg(unix)]
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
#[cfg(not(unix))]
None
}
/// Write this app's lock file at `path` without following a link planted there: the file is
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
}
impl Autosaver {
@@ -586,7 +602,7 @@ mod native {
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => std::fs::write(held, id.to_string()),
None => write_lock(held, id),
};
if held == path {
current = Some(refreshed);
@@ -595,10 +611,22 @@ mod native {
if let Some(refreshed) = current {
return refreshed;
}
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// A symlink planted at the lock path must not be opened (and truncated) through,
// nor a FIFO wait for a reader.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).mode(0o600);
}
let mut file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the lock file is not a plain file"));
}
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return std::fs::write(path, id.to_string());
return write_lock(path, id);
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
@@ -627,6 +655,30 @@ mod native {
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// A symlink planted at the lock path is neither opened through nor written through.
#[test]
fn lock_writes_do_not_follow_a_planted_link() {
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"precious").unwrap();
let link = dir.join("1-a.lock");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let mut a = Autosaver::new("1-a");
assert!(a.refresh_lock(&link).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
write_lock(&link, 42).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&link).unwrap(), b"42");
std::fs::remove_dir_all(dir).unwrap();
}
}
}
#[cfg(test)]
+1 -14
View File
@@ -177,16 +177,8 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
Ok(json!({ "created": n }))
}
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
/// default MAXARRAY).
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
///
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
/// takes the array block's own items.
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
let EntityKind::Insert(ins) = &e.kind else { return None };
let p = d.array_params(e)?;
@@ -200,12 +192,7 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
_ => None,
};
match source {
Some((im, b)) => {
if out.len().saturating_add(b.entities.len()) > MAX_EXPLODED_OBJECTS {
return None;
}
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
}
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
None => out.push(item),
}
}
+14 -37
View File
@@ -2,7 +2,7 @@
//! block name and values, or one row per reference with its handle, layer, position and
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
use std::collections::{BTreeMap, HashMap};
use std::collections::BTreeMap;
use cadcraft_doc::{EntityKind, Insert};
use cadcraft_geom::Vec2;
@@ -88,34 +88,17 @@ fn matches(spec: &str, text: &str) -> bool {
spec.split(',').any(|p| wildcard(p.trim(), text))
}
/// The constant attribute values of a block definition whose tags `tags` selects.
fn constants(b: &cadcraft_doc::Block, tags: &str) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = Vec::new();
for e in b.entities.iter() {
if let EntityKind::AttDef(a) = &e.kind
&& a.constant
&& matches(tags, &a.tag.to_ascii_uppercase())
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
{
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
}
}
v
}
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
/// block (`consts`, read once per block). Values no column shows are not copied.
fn values(ins: &Insert, consts: &[(String, String)], tags: &str) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = ins
.attribs
.iter()
.map(|a| (a.tag.to_ascii_uppercase(), a))
.filter(|(t, _)| matches(tags, t))
.map(|(t, a)| (t, a.text.value.clone()))
.collect();
for (t, val) in consts {
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
v.push((t.clone(), val.clone()));
/// The attribute values of `ins`: its own, then the constant ones of its block.
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
if let Some(b) = d.block(&ins.block) {
for e in b.entities.iter() {
if let EntityKind::AttDef(a) = &e.kind
&& a.constant
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
{
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
}
}
}
v
@@ -130,7 +113,6 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
};
let mut out = Vec::new();
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
for e in &ents {
let EntityKind::Insert(ins) = &e.kind else { continue };
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
@@ -139,17 +121,12 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
}
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
out.push(Found {
block,
handle: e.handle.hex(),
layer: e.common.layer.clone(),
at: ins.insert.xy(),
rotation: ins.rotation.to_degrees(),
block: block.clone(),
values: values(
ins,
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &o.tags)).unwrap_or_default()),
&o.tags,
),
values: values(d, ins),
});
}
Ok(out)
+8 -12
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
use cadcraft_geom::{Bounds2, Vec2};
use serde_json::{Value, json};
@@ -375,20 +375,16 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
}
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
let mut boxes = Vec::new();
// One block-bounds cache for each drawing, so a block many differing entities refer to is
// measured once, not once per entity.
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
{
let mut bound = |d: &Drawing, h: Handle| {
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
boxes.push(b);
}
}
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
};
added.iter().for_each(|h| bound(&cur, *h));
removed.iter().for_each(|h| bound(&other, *h));
for (a, b, _, _) in &changed {
bound(&mut boxes, &cur, *a, &mut cur_cache);
bound(&mut boxes, &other, *b, &mut other_cache);
bound(&cur, *a);
bound(&other, *b);
}
let regions = regions(&boxes);
let ch: Vec<Value> = changed
-6
View File
@@ -108,12 +108,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
}
// Every option is checked before the drawing changes.
let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::<Result<Vec<_>>>()?;
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
let live = counts(s, &objs)?.0;
if live > 0 {
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
}
let mut copies = Vec::new();
for op in ops {
copies.extend(apply(s, &objs, &mut base, op)?);
-14
View File
@@ -233,17 +233,3 @@ fn options_on_objects_that_are_gone_record_nothing() {
assert!(lines(&s).is_empty());
assert_eq!(undo_len(&s), undo, "no empty undo step");
}
#[test]
fn json_form_copies_stay_within_maxarray() {
let mut s = Session::new();
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
let mut hs = vec![l];
hs.extend(others);
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
assert!(e.to_string().contains("MAXARRAY"), "{e}");
assert_eq!(lines(&s).len(), 30, "nothing was copied");
}
+3 -17
View File
@@ -50,8 +50,6 @@ const MAX_LAYER_NAME: usize = 1000;
/// Most link annotations in one file, and the longest link target (bytes).
const MAX_LINKS: usize = 10_000;
const MAX_URL: usize = 2048;
/// The most link annotations in one PDF, over all pages (a page holds at most [`MAX_LINKS`]).
const MAX_FILE_ANNOTATIONS: usize = 100_000;
/// Longest title and page label written (characters).
const MAX_TEXT: usize = 1000;
@@ -359,10 +357,7 @@ fn sheet_name(space: &Space) -> String {
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
/// layout name. The options apply to every sheet; layouts use their own page setups.
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
// Links are file-wide (`pdf_items` reads them from `o`), so a sheet's own options carry none:
// a copy of the list per sheet multiplied it by the sheet count.
let sheet = PdfOptions { links: Vec::new(), ..o.clone() };
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), sheet.clone())).collect();
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
pdf_items(d, &items, o)
}
@@ -458,16 +453,8 @@ fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as
let mut writer = Writer::new(settings, metadata);
let mut fonts = fonts::Fonts::new(!text_as_geometry);
let mut groups = Groups::default();
// Link annotations of the whole file: each page keeps its own copy of the targets, so the
// pages times the links must not multiply without a bound.
let mut annotations_left = MAX_FILE_ANNOTATIONS;
for (s, po) in items {
// Once the budget is spent no link is built (scanned and cloned) for later pages.
let mut page = sheet(drawn, s, po, if annotations_left == 0 { &links[..0] } else { &links })?;
// Only targets `write_page` accepts count against the budget.
page.links.retain(|(_, url)| uri(url).is_some());
page.links.truncate(annotations_left);
annotations_left -= page.links.len();
let page = sheet(drawn, s, po, &links)?;
let doc = writer.document_mut();
// Groups are added to the document, so they must exist before the page starts.
groups.add(doc, d, &page.layers);
@@ -638,8 +625,7 @@ pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Shee
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
// A target past MAX_URL is refused at the annotation anyway; it is left out before it is copied.
let mut out: Vec<(Handle, String)> = o.links.iter().filter(|(_, u)| u.len() <= MAX_URL).take(MAX_LINKS).cloned().collect();
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
if !o.hyperlinks {
return out;
}
+17 -9
View File
@@ -56,13 +56,15 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy()));
let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) };
for be in blk.entities.iter() {
// Every visit is charged, drawn or not, so a block of definitions can't make
// the copies loop free.
if !b.expand() {
break 'copies;
}
// Constant/visible attribute definitions inside blocks are not drawn; attribs are.
if matches!(be.kind, EntityKind::AttDef(_)) {
continue;
}
if !b.expand() {
break 'copies;
}
entity(b, &sub, be);
}
}
@@ -73,6 +75,17 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
let attmode = ctx.d.header.i64("ATTMODE", 1);
// The reference's transparency; each attribute draws with its own.
let reference = b.transparency;
// The block's first definition per tag (without case), built once for all the attributes.
let mut defs: std::collections::HashMap<String, &Entity> = std::collections::HashMap::new();
if !ins.attribs.is_empty()
&& let Some(blk) = ctx.d.block(&ins.block)
{
for be in blk.entities.iter() {
if let EntityKind::AttDef(d) = &be.kind {
defs.entry(d.tag.to_ascii_lowercase()).or_insert(be.as_ref());
}
}
}
for a in &ins.attribs {
if attmode == 0 || (a.invisible && attmode != 2) {
continue;
@@ -83,7 +96,7 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc:
// Properties of the attribute's definition (as AutoCAD copies them to the attribute
// on insertion), except those set on the attribute itself: ByBlock = the reference's,
// layer 0 = the reference's layer.
let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag)));
let def = defs.get(&a.tag.to_ascii_lowercase()).copied();
let (ink, lw, shown, t) = match (def, a.props.is_empty()) {
(None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference),
(def, _) => {
@@ -117,8 +130,3 @@ fn by_block() -> cadcraft_doc::Common {
..cadcraft_doc::Common::default()
}
}
/// The attribute definition with tag `tag` (any case).
fn attdef_tag(e: &Entity, tag: &str) -> bool {
matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag))
}
+28 -4
View File
@@ -106,10 +106,12 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
}
}
segs.sort_unstable_by(|(a, b), (c, d)| cmp(*a, *c).then_with(|| cmp(*b, *d)));
let mut at: HashMap<Key, Vec<usize>> = HashMap::new();
// Segments at each vertex with a cursor past the used ones, so many segments meeting at one
// vertex are each examined once rather than from the start on every visit.
let mut at: HashMap<Key, (Vec<usize>, usize)> = HashMap::new();
for (i, (a, b)) in segs.iter().enumerate() {
at.entry(key(*a)).or_default().push(i);
at.entry(key(*b)).or_default().push(i);
at.entry(key(*a)).or_default().0.push(i);
at.entry(key(*b)).or_default().0.push(i);
}
let mut used = vec![false; segs.len()];
let mut out = Vec::new();
@@ -125,7 +127,15 @@ fn chain(mut segs: Vec<(Vec2, Vec2)>) -> Vec<Vec<Vec2>> {
let mut end = b;
// Each step uses one segment, so the walk ends within `segs.len()` steps.
loop {
let next = at.get(&key(end)).and_then(|ids| ids.iter().copied().find(|&i| !used.get(i).copied().unwrap_or(true)));
let next = at.get_mut(&key(end)).and_then(|(ids, cursor)| {
while let Some(&i) = ids.get(*cursor) {
if !used.get(i).copied().unwrap_or(true) {
return Some(i);
}
*cursor += 1;
}
None
});
let Some(i) = next else { break };
let Some(&(p, q)) = segs.get(i) else { break };
if let Some(u) = used.get_mut(i) {
@@ -147,6 +157,20 @@ mod tests {
lines.iter().flat_map(|l| l.windows(2).map(|w| w[0].dist(w[1]))).sum()
}
/// Thousands of lobes meeting at one vertex chain in one pass over that vertex's segments.
#[test]
fn many_lobes_at_one_vertex_chain_quickly() {
let mut tris = Vec::new();
for i in 0..20_000 {
let x = f64::from(i + 1);
tris.extend_from_slice(&[Vec2::ZERO, Vec2::new(x, 1.0), Vec2::new(x + 0.5, 2.0)]);
}
// No wall-clock bound (flaky on loaded runners): a quadratic scan would make this test crawl
// rather than fail, so check the output is complete instead.
let lines = fill_outlines(&tris);
assert_eq!(lines.iter().map(|l| l.len() - 1).sum::<usize>(), 60_000, "every triangle edge is emitted once");
}
#[test]
fn a_square_of_two_triangles_is_one_loop() {
let (a, b, c, d) = (Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 1.0), Vec2::new(0.0, 1.0));
+3 -1
View File
@@ -95,7 +95,9 @@ pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern:
continue;
}
let (Some(first), Some(last)) = (at(run, a), at(run, b)) else { continue };
let inner = run.iter().filter(|x| x.s > a && x.s < b).copied();
// The samples strictly between a and b: `run` is ordered by `s`, so a slice, not a scan.
let (lo, hi) = (run.partition_point(|x| x.s <= a), run.partition_point(|x| x.s < b));
let inner = run.get(lo..hi.max(lo)).unwrap_or_default().iter().copied();
let pts: Vec<Sample> = std::iter::once(first).chain(inner).chain(std::iter::once(last)).collect();
for w in pts.windows(2) {
if let [x, y] = w {
+4 -5
View File
@@ -306,7 +306,7 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
enum Field {
Num(&'static str, &'static str, f64),
Bool(&'static str, &'static str, bool),
Choice(&'static str, &'static str, String, std::sync::Arc<[String]>),
Choice(&'static str, &'static str, String, Vec<String>),
}
struct Style {
@@ -371,8 +371,7 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
no_drawing(ctx, "Multileader Style", open);
return None;
};
// One list shared by every row: a copy per style multiplied the two counts.
let text_styles: std::sync::Arc<[String]> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let text_styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let mut styles: Vec<Style> = d
.mleader_styles
.iter()
@@ -383,7 +382,7 @@ fn mleader_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Acti
Field::Num("textHeight", "Text height", s.text_height),
Field::Num("landingGap", "Landing gap", s.landing_gap),
Field::Num("dogleg", "Landing length", s.dogleg),
Field::Choice("textStyle", "Text style", s.text_style.clone(), std::sync::Arc::clone(&text_styles)),
Field::Choice("textStyle", "Text style", s.text_style.clone(), text_styles.clone()),
],
})
.collect();
@@ -452,7 +451,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
ui.label(*label);
let mut picked = None;
craft_ui::ComboBox::from_id_salt((cmd, *key)).selected_text(v.as_str()).show_ui(ui, |ui| {
for c in choices.iter() {
for c in choices {
if ui.selectable_label(c == v, c).clicked() && c != v {
picked = Some(c.clone());
}