Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor f5b136e419 fix(mcp): send the control token only to 127.0.0.1:PORT and read it only from a private file
The bridge picked the token by port alone and sent it to whatever the address resolved to, so a listener another account opened on ::1 with the same port received the desktop's credential. The token is now attached only when the connected peer is exactly 127.0.0.1:PORT, where the app listens. Reading the token refuses a symlink, a non-plain file and a file others can read, and the shared temporary directory is no longer a fallback location for it.
2026-11-10 21:32:52 -05:00
47 changed files with 183 additions and 1169 deletions
Generated
+16 -16
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"acadrust",
"log",
@@ -697,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -798,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -5993,7 +5993,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.17.0"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.20.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+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),
}
}
+6 -12
View File
@@ -232,10 +232,6 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
let mut extra = Vec::new();
let mut on = Vec::new();
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
// Nothing is added once the candidates are full, so the points left need no search.
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
break;
}
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
continue;
}
@@ -271,22 +267,20 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
break 'per;
}
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
let ang = ang_between(*b1 - *a1, *b2 - *a2);
// The duplicate search scans every candidate, so it comes after the cheap tests.
if tee
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
if allow_per
&& tee
&& !dup
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
&& l1.len() > tol
&& l2.len() > tol
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
&& out.len() < MAX_CANDIDATES
{
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
}
+1 -31
View File
@@ -204,23 +204,6 @@ impl ArrayParams {
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
}
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
let (cols, rows) = match &self.kind {
ArrayKind::Rect(r) => (r.cols, r.rows),
ArrayKind::Polar(p) => (p.items, p.rows),
};
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
.replaced
.iter()
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
.collect();
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
}
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
/// parameters are not [valid](Self::is_valid).
@@ -234,10 +217,9 @@ impl ArrayParams {
..Common::default()
};
let mut out = Vec::with_capacity(items.len());
let block_for = self.block_lookup();
for it in items {
let ins = Insert {
block: block_for(it.i, it.j, it.level).to_string(),
block: self.block_for(it.i, it.j, it.level).to_string(),
insert: it.insert,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: it.rotation,
@@ -319,18 +301,6 @@ mod tests {
}
}
#[test]
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
let mut p = rect(3, 2);
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
let look = p.block_lookup();
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
assert_eq!(look(i, j, level), p.block_for(i, j, level));
}
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
}
#[test]
fn rect_items_go_column_row_level() {
let items = rect(3, 2).items().unwrap_or_default();
-6
View File
@@ -674,10 +674,6 @@ fn apply(
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
/// outline again (it may have been stretched).
fn refill_hatches(b: &mut Block) {
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
// past this many vertices in all, the remaining loops keep the outline they have.
const MAX_REFILLED_VERTICES: usize = 4_000_000;
let mut budget = MAX_REFILLED_VERTICES;
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
.entities
.iter()
@@ -693,9 +689,7 @@ fn refill_hatches(b: &mut Block) {
for l in &mut hh.loops {
if let [s] = l.sources.as_slice()
&& let Some(vs) = outlines.get(s)
&& let Some(left) = budget.checked_sub(vs.len())
{
budget = left;
l.vertices.clone_from(vs);
}
}
+1 -9
View File
@@ -486,24 +486,16 @@ impl DimStyle {
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values. When several spellings name one field, the last one wins and the
/// superseded ones are not validated.
/// had unusable values.
pub fn apply_fields(&mut self, src: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
use serde_json::Value;
let mut rejected = Vec::new();
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
// the style is checked once per field, not once per spelling (each check copies its strings).
let last_spelling: std::collections::HashMap<&str, &String> =
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
for (k, v) in src {
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
rejected.push(k.clone());
continue;
};
if last_spelling.get(field) != Some(&k) {
continue;
}
let Some(obj) = cur.as_object_mut() else { break };
let Some(old) = obj.get(field).cloned() else {
rejected.push(k.clone());
-11
View File
@@ -714,14 +714,3 @@ fn space_names_its_tab() {
assert_eq!(p.layout(), Some("Sheet A"));
assert_eq!(p.tab_name(), "Sheet A");
}
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
#[test]
fn dim_style_spellings_of_one_field_apply_once() {
let mut st = DimStyle::default();
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
let m = m.as_object().unwrap();
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
assert!(st.apply_fields(m).is_empty());
assert_eq!(st.text_height, want);
}
+2 -24
View File
@@ -178,34 +178,14 @@ 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)?;
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
// Count first: a hostile array must be refused before any entity is cloned.
let total = blk
.entities
.iter()
.map(|it| match &it.kind {
EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map_or(1, |b| b.entities.len()),
_ => 1,
})
.fold(0usize, usize::saturating_add);
if total > MAX_EXPLODED_OBJECTS {
return None;
}
let mut out = Vec::with_capacity(total);
let mut out = Vec::new();
for item in blk.entities.iter() {
let item = placed(e, item, &m);
let source = match &item.kind {
@@ -213,9 +193,7 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
_ => None,
};
match source {
Some((im, b)) => {
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),
}
}
+1 -2
View File
@@ -188,9 +188,8 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
let mut best: Option<((u32, u32, u32), f64)> = None;
// Each block's own extents, measured once.
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
let block_for = p.block_lookup();
for it in p.items()? {
let name = block_for(it.i, it.j, it.level);
let name = p.block_for(it.i, it.j, it.level);
let (base, local) = match extents.get(name) {
Some(x) => *x,
None => {
+15 -75
View File
@@ -2,9 +2,9 @@
//! 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::{Drawing, EntityKind, Insert};
use cadcraft_doc::{EntityKind, Insert};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
@@ -88,73 +88,20 @@ 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 are selected for output.
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> 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
&& selected.iter().any(|tag| tag == &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)], selected: &[String]) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = ins
.attribs
.iter()
.map(|a| (a.tag.to_ascii_uppercase(), a))
.filter(|(t, _)| selected.contains(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()));
}
}
v
}
/// Select the output columns before copying any attribute text. A block definition can contain
/// many large constants that do not survive the table's 190-column limit.
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
let mut selected = Vec::new();
let mut add = |tag: &str| {
let tag = tag.to_ascii_uppercase();
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
selected.push(tag);
}
};
let mut scanned = std::collections::HashSet::new();
for entity in ents {
let EntityKind::Insert(ins) = &entity.kind else { continue };
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
if anonymous || !matches(&o.blocks, &ins.block) {
continue;
}
for attribute in &ins.attribs {
add(&attribute.tag);
}
// A block's constants are read once, however many inserts reference it.
if scanned.insert(ins.block.as_str())
&& let Some(block) = d.block(&ins.block)
{
for entity in block.entities.iter() {
if let EntityKind::AttDef(attribute) = &entity.kind
&& attribute.constant
{
add(&attribute.tag);
}
/// 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()));
}
}
}
selected
v
}
/// The block references to extract from: `handles` when given, else model space and the layouts.
@@ -165,9 +112,7 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
} else {
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
};
let selected = selected_tags(&ents, d, o);
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);
@@ -176,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, &selected)).unwrap_or_default()),
&selected,
),
values: values(d, ins),
});
}
Ok(out)
+8 -54
View File
@@ -453,32 +453,14 @@ impl Chain {
}
/// Point and unit tangent at arc length `d` from the start (clamped).
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
self.at_length_in(&self.end_lengths(), d)
}
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
pub fn end_lengths(&self) -> Vec<f64> {
self.segs
.iter()
.scan(0.0, |acc, s| {
*acc += s.len();
Some(*acc)
})
.collect()
}
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
let first = ends.partition_point(|e| d > e + 1e-12);
// A segment of no length can't hold the point; the next one that has length does.
// `ends` never decreases, so the first segment past `first` with length is found by search
// (a run of zero-length segments can be long) instead of a scan.
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
let i = (i < self.segs.len()).then_some(i);
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
return Some((s.at(t), s.tangent(t)));
let mut acc = 0.0;
for s in &self.segs {
let l = s.len();
if d <= acc + l + 1e-12 && l > 0.0 {
let t = ((d - acc) / l).clamp(0.0, 1.0);
return Some((s.at(t), s.tangent(t)));
}
acc += l;
}
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
}
@@ -658,34 +640,6 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
mod tests {
use super::*;
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
/// ends and over a segment of no length.
#[test]
fn at_length_in_matches_a_scan() {
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
let c = Chain {
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
closed: false,
};
let ends = c.end_lengths();
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
// The scan this replaced.
let mut acc = 0.0;
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
for s in &c.segs {
let len = s.len();
if d <= acc + len + 1e-12 && len > 0.0 {
let t = ((d - acc) / len).clamp(0.0, 1.0);
want = Some((s.at(t), s.tangent(t)));
break;
}
acc += len;
}
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
assert_eq!(c.at_length(d), want);
}
}
#[test]
fn arc_builders() {
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
+2 -4
View File
@@ -36,18 +36,16 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let range = if c.closed { 0..n } else { 1..n };
let ends = c.end_lengths();
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
}
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let ends = c.end_lengths();
let mut out = Vec::new();
let mut k = 1usize;
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
let d = k as f64 * step;
out.extend(c.at_length_in(&ends, d));
out.extend(c.at_length(d));
k += 1;
}
out
+1 -2
View File
@@ -58,9 +58,8 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
closed: true,
};
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
let ends = chain.end_lengths();
for j in 0..k {
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
}
}
let b = if reverse { -0.5 } else { 0.5 };
+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
-12
View File
@@ -117,12 +117,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
}
// 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)?);
@@ -156,12 +150,6 @@ pub(super) struct MocoroM {
impl MocoroM {
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
if matches!(op, Op::Copy(_)) {
let live = counts(s, &self.objs)?.0;
if live > 0 {
crate::cmd::array::check_size(s, 1, live)?;
}
}
let before = s.state()?.doc.clone();
let base = self.base;
apply(s, &self.objs, &mut self.base, op)?;
+2 -4
View File
@@ -34,12 +34,10 @@ pub(crate) fn arraypath(
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
};
crate::cmd::array::check_size(s, n as u64, objs.len())?;
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
let ends = c.end_lengths();
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
let mut mats = Vec::new();
for i in 1..n {
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
let mut m = Mat3::translate(p - p0);
if align {
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
-6
View File
@@ -123,12 +123,6 @@ pub struct LispState {
acad_loaded: bool,
/// Memory caps (lowered by tests).
pub(crate) limits: Limits,
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
pub(crate) callback_used: u64,
/// The `callback_used` value the callback running now (and so every one it calls) must not
/// pass: the lowest of its own and its callers' allowances.
pub(crate) callback_ceiling: Option<u64>,
}
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
+2 -8
View File
@@ -1,5 +1,5 @@
use super::args::{R, arity, int, list_arg, string};
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
// ---------------- strings ----------------
@@ -114,13 +114,7 @@ pub(super) fn read_fn(a: &[Value]) -> R {
arity(a, 0, 1)?;
match a.first() {
None | Some(Value::Nil) => Ok(Value::Nil),
Some(v) => {
// The strings in the list it makes are new, each as long as its text at most: the
// text is charged, though a list result isn't inspected by `charge_result`.
let text = string(v)?;
charge_bytes(text.len())?;
read::read_one(&text)
}
Some(v) => read::read_one(&string(v)?),
}
}
+2 -39
View File
@@ -179,12 +179,7 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
Value::Nil => serde_json::Value::Null,
Value::Int(i) => serde_json::json!(i),
Value::Real(r) => serde_json::json!(r),
Value::Str(s) => {
// A string is copied whole each time it occurs: it spends the allowance by its length
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
*budget = budget.checked_sub(s.len() as u64 / 16)?;
serde_json::json!(&**s)
}
Value::Str(s) => serde_json::json!(&**s),
Value::Cons(_) => {
let mut arr = Vec::new();
for x in v.iter() {
@@ -301,16 +296,12 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
out
}
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
const MAX_GROUP_TEXT: usize = 32 << 20;
/// An association list back to DXF groups (points split into x, y, z groups). The type
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
/// left out: they aren't the entity's to set.
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
let mut kind = None;
let mut tags = Vec::new();
let mut text_left = MAX_GROUP_TEXT;
for item in list.iter() {
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
@@ -332,11 +323,7 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
_ => tags.push(match &v {
Value::Int(i) => Tag::i(code, i64::from(*i)),
Value::Real(r) => Tag::f(code, *r),
Value::Str(s) => {
// Each occurrence of a shared string becomes its own copy in the tag.
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
Tag::s(code, &**s)
}
Value::Str(s) => Tag::s(code, &**s),
Value::Ename(h) => Tag::s(code, h.hex()),
other => return Err(LispError::bad_arg("DXF group value", other)),
}),
@@ -1138,27 +1125,3 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
}
#[cfg(test)]
mod budget_tests {
use super::*;
/// One 64 KB string occurring many times is charged by its length each time.
#[test]
fn shared_strings_spend_the_conversion_budgets() {
let big = Value::str(&"x".repeat(64 << 10));
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
let list = |n: usize| {
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
Value::list(items)
};
assert!(list_to_groups(&list(600)).is_err());
assert!(list_to_groups(&list(100)).is_ok());
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
}
}
+11 -30
View File
@@ -201,10 +201,6 @@ impl Machine {
if n > budget {
return Outcome::Wait(Need::Yield);
}
// Charge a callback's step as it runs, so callbacks it calls see what it already spent.
if ctx.depth > 0 {
ctx.s.lisp.callback_used = ctx.s.lisp.callback_used.saturating_add(1);
}
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
State::Eval(expr) => self.eval(ctx, expr),
State::Call(f, args) => self.call(ctx, f, args),
@@ -650,44 +646,29 @@ impl Ctx<'_> {
super::value::charge_result(&v)?;
return Ok(v);
}
let cap = self.s.lisp.limits.callback_steps;
let start = self.s.lisp.callback_used;
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
// allowance instead of each taking a fresh one (an exponential tree otherwise).
let outer = self.s.lisp.callback_ceiling;
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
self.s.lisp.callback_ceiling = Some(ceiling);
let r = self.drive_callback(f, args, ceiling);
self.s.lisp.callback_ceiling = outer;
r
}
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
let cap = self.s.lisp.limits.callback_steps;
let mut m = Machine::calling(f.clone(), args);
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
// loop inside a comparator or predicate terminates with an error rather than hanging the
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
// one calls are counted in `callback_used` too.
// thread and bypassing the blocking execution cap (CWE-400).
let mut spent: u64 = 0;
loop {
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
let remaining = cap.saturating_sub(spent);
if remaining == 0 {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
let slice = remaining.min(SYNC_SLICE);
let outcome = m.run(&mut sub, slice);
match outcome {
// The steps were charged as they ran; finishing past the shared allowance is a failure too.
Outcome::Done(_) if sub.s.lisp.callback_used > ceiling => {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
match m.run(&mut sub, slice) {
Outcome::Done(v) => return Ok(v),
Outcome::Error(e) => return Err(e),
Outcome::Wait(Need::Yield) => {}
Outcome::Wait(Need::Yield) => {
spent = spent.saturating_add(slice);
if spent >= cap {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
}
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
}
}
-32
View File
@@ -286,35 +286,6 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
}
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
#[test]
fn nested_callbacks_share_one_step_allowance() {
let mut s = session();
s.lisp.limits.callback_steps = 1_000_000;
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
let started = std::time::Instant::now();
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
assert!(started.elapsed() < std::time::Duration::from_secs(60));
// The next run starts afresh.
assert_eq!(ok(&mut s, "(f 1)"), "T");
}
/// An outer callback's own steps count against the allowance before the callbacks it calls run.
#[test]
fn an_outer_callbacks_steps_are_charged_before_a_nested_one() {
let mut s = session();
s.lisp.limits.callback_steps = 1_000;
ok(&mut s, "(setq inner-steps 0)");
// The outer callback spends ~600 steps, then a nested one tries to spend ~600 more.
let r = eval(
&mut s,
"(vl-remove-if '(lambda (x) (repeat 150 (setq a 1)) (vl-remove-if '(lambda (y) (repeat 150 (setq inner-steps (1+ inner-steps)))) '(1)) nil) '(1))",
);
assert_eq!(r, Err("callback stopped after 1000 steps".into()));
assert!(ok(&mut s, "inner-steps").parse::<i64>().unwrap() < 150);
}
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
#[test]
fn setvar_rejects_an_oversized_shared_structure() {
@@ -1016,9 +987,6 @@ fn strings_are_capped() {
assert!(e.contains("too much text"), "{e}");
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
// READ makes new strings inside the list it returns: they count, though the result is a list.
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
assert!(e.contains("too much text"), "{e}");
// The budget is per run: the next one starts afresh.
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
}
+2 -32
View File
@@ -346,11 +346,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
equal_within(a, b, fuzz, EQUAL_BUDGET)
}
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
let mut budget: u64 = EQUAL_BUDGET;
equal_depth(a, b, fuzz, 0, &mut budget)
}
@@ -377,19 +373,7 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
a = na;
b = nb;
}
_ => {
// Comparing two strings costs their length, not one visit: a shared list of long
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
let cost = (x.len().min(y.len()) / 64) as u64;
if cost >= *budget {
*budget = 0;
return false;
}
*budget -= cost;
}
return atom_equal(&a, &b, fuzz);
}
_ => return atom_equal(&a, &b, fuzz),
}
}
}
@@ -556,20 +540,6 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
mod tests {
use super::*;
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
/// big allowance but not a small one, though it has few nodes.
#[test]
fn equal_spends_its_allowance_on_string_bytes() {
let long = Value::str(&"x".repeat(6_400));
let other = Value::str(&"x".repeat(6_400));
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
let (a, b) = (list(&long), list(&other));
// 100 strings x 100 units each, plus about 100 node visits.
assert!(equal_within(&a, &b, 0.0, 20_000));
assert!(!equal_within(&a, &b, 0.0, 5_000));
assert!(equal(&a, &b, 0.0));
}
#[test]
fn reals_print_with_six_significant_digits() {
assert_eq!(real_text(1.0), "1.0");
-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");
}
+1 -9
View File
@@ -103,11 +103,7 @@ impl Face {
let Some(f) = self.font() else { return Vec::new() };
[skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
.into_iter()
// A name table can hold many records that point at one big string: at most
// MAX_NAME_RECORDS per name id, each decoded only up to MAX_NAME_CHARS characters.
.flat_map(|id| {
f.localized_strings(id).take(MAX_NAME_RECORDS).map(|s| norm(&s.chars().take(MAX_NAME_CHARS).collect::<String>())).collect::<Vec<_>>()
})
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
.collect()
}
}
@@ -122,10 +118,6 @@ pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
}
const MAX_FACES_PER_FILE: u32 = 32;
/// Name records read per name id of a face, and characters read of each: real fonts have a few
/// dozen short ones.
const MAX_NAME_RECORDS: usize = 64;
const MAX_NAME_CHARS: usize = 256;
/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character.
const MAX_FALLBACK_FILES: usize = 24;
const MAX_FALLBACK_BYTES: usize = 160 << 20;
+6 -23
View File
@@ -431,10 +431,10 @@ fn outside(core: &Graph, u: usize, steps: usize) -> Option<Vec<Vec2>> {
}
/// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`).
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
let comp = components(g);
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
let outer_pts = points(g, outer)?;
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
let ob = Bounds2::from_points(outer_pts.iter().copied());
let core = prune(g);
// Leftmost (then lowest) vertex still on a cycle, per component.
@@ -464,32 +464,19 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
let mut starts: Vec<(usize, usize)> = start.into_iter().collect();
starts.sort_unstable();
let mut out = Vec::new();
// Each containment test scans a whole polygon, accepted or not: they share one allowance of
// polygon edges, and the search ends when it is spent.
let mut edges_left: usize = 100_000_000;
for (c, u) in starts {
if out.len() >= MAX_ISLANDS {
break;
}
let Some(&pu) = core.pts.get(u) else { continue };
if !ob.contains(pu) {
continue;
}
let left = edges_left.checked_sub(outer_pts.len())?;
edges_left = left;
if !point_in_polygon(&outer_pts, pu) {
if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) {
continue;
}
let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4);
let Some(lp) = outside(&core, u, steps) else { continue };
let area = shoelace(&lp);
let ib = Bounds2::from_points(lp.iter().copied());
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) {
continue;
}
let left = edges_left.checked_sub(lp.len())?;
edges_left = left;
if point_in_polygon(&lp, p) {
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) {
continue;
}
let mut src = sources.remove(&c).unwrap_or_default();
@@ -497,7 +484,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
src.dedup();
out.push(Island { outline: lp, sources: src });
}
Some(out)
out
}
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
@@ -510,10 +497,6 @@ fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, bu
if j == i {
continue;
}
// A curve with no segment costs a visit too, so many of them can't multiply the searches.
if other.len() < 2 {
*budget = budget.checked_sub(1).ok_or(())?;
}
for (k, w) in other.windows(2).enumerate() {
if *budget == 0 {
return Err(());
@@ -615,7 +598,7 @@ pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Opti
let g = build_gap(polys, tol, gap)?;
let ids = outer_face(&g, p)?;
let outer = points(&g, &ids)?;
let islands = islands(&g, &ids, p)?;
let islands = islands(&g, &ids, p);
let mut outer_sources: Vec<usize> =
ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect();
outer_sources.sort_unstable();
+1 -19
View File
@@ -844,33 +844,15 @@ pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
std::sync::Arc::make_mut(b).annotative = true;
}
}
// Which layout holds each paper space object, found once: `modify_entity` searches every
// layout for a handle model space lacks, which a long list of missing handles made quadratic.
let mut in_layout: std::collections::HashMap<Handle, usize> = Default::default();
if !p.objects.is_empty() {
// First layout wins, as `modify_entity` did.
for (i, l) in d.layouts.iter().enumerate() {
for h in l.entities.handles() {
in_layout.entry(h).or_insert(i);
}
}
}
for o in p.objects {
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
let store = if d.model.contains(h) {
&mut d.model
} else if let Some(l) = in_layout.get(&h).and_then(|&i| d.layouts.get_mut(i)) {
&mut l.entities
} else {
continue;
};
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
scales.push(s);
}
}
store.modify(h, |e| {
let _ = d.modify_entity(h, |e| {
if cadcraft_doc::can_be_annotative(&e.kind) {
e.common.anno_scales = scales;
}
+2 -14
View File
@@ -339,8 +339,7 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
}
let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle);
let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default();
let root_keys = root_key_index(&root_entries);
let root_key = |h: &str| root_keys.get(h).map(|k| (*k).to_string());
let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone());
// An extension dictionary of an entity or record (not of another object).
let is_xdict = |k: usize| {
let Some((_, _, t)) = objects.get(k) else { return false };
@@ -495,16 +494,6 @@ fn extension_dictionaries(
dictionaries
}
/// The root dictionary's key for each entry handle (the first key when several name one handle),
/// built once: a lookup per object scanning the entries made a large dictionary quadratic.
fn root_key_index(root_entries: &[(String, String)]) -> HashMap<&str, &str> {
let mut index = HashMap::with_capacity(root_entries.len());
for (key, handle) in root_entries {
index.entry(handle.as_str()).or_insert(key.as_str());
}
index
}
/// Determine which objects belong to unmodelled dictionary entries or owners.
fn retained_objects(
objects: &[(String, usize, &[Tag])],
@@ -515,8 +504,7 @@ fn retained_objects(
root_entries: &[(String, String)],
xdict_of: &HashMap<String, String>,
) -> HashMap<usize, bool> {
let root_keys = root_key_index(root_entries);
let root_key = |handle: &str| root_keys.get(handle).copied();
let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key);
let is_xdict = |index: usize| {
let Some((_, _, tags)) = objects.get(index) else { return false };
let header = head(tags);
+17 -68
View File
@@ -1204,56 +1204,33 @@ fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
return;
}
let mut counts: HashMap<String, i64> = HashMap::new();
let mut classes = 0;
for t in tags.iter().filter(|t| t.code == 0) {
let kind = t.str();
if kind != "CLASS" {
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
} else {
classes += 1;
}
}
// One pass into a new list: inserting each missing count in place shifted every later tag,
// quadratic in the number of CLASS records.
// Tags move by value (no clone); the old and new buffers coexist while it runs. At most one
// group 91 is added per CLASS record, so that is all the room the new list needs beyond the old one.
let old = std::mem::take(tags);
tags.reserve(old.len() + classes);
let mut it = old.into_iter();
let mut pending = it.next();
while let Some(t) = pending.take() {
if t.code != 0 || t.str() != "CLASS" {
tags.push(t);
pending = it.next();
let mut start = 0;
while start < tags.len() {
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
start += 1;
continue;
}
let mut record = vec![t];
for n in it.by_ref() {
if n.code == 0 {
pending = Some(n);
break;
}
record.push(n);
}
let name = record.iter().skip(1).find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
let count = counts.get(&name).copied().unwrap_or(0);
// The first 91 gets the count; without one, a 91 goes in after the first 90.
let mut added = record.iter().skip(1).any(|t| t.code == 91);
let mut replaced = false;
for (i, t) in record.into_iter().enumerate() {
let code = t.code;
if i > 0 && code == 91 && !replaced {
tags.push(Tag::i(91, count));
replaced = true;
continue;
}
tags.push(t);
if i > 0 && code == 90 && !added {
tags.push(Tag::i(91, count));
added = true;
}
}
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
tags[start + 1 + i] = Tag::i(91, count);
false
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
tags.insert(start + 2 + i, Tag::i(91, count));
true
} else {
false
};
// Inserting the group shifts the next record's start by one.
start = end + usize::from(inserted);
}
}
@@ -1281,31 +1258,3 @@ fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
}
}
}
#[cfg(test)]
mod class_count_tests {
use super::*;
fn record(kind: &str, name: &str, extra: &[Tag]) -> Vec<Tag> {
let mut r = vec![Tag::s(0, kind), Tag::s(1, name), Tag::i(90, 0)];
r.extend_from_slice(extra);
r
}
/// The first 91 gets the count, a CLASS without one gets it after its 90, in one pass over many records.
#[test]
fn counts_are_set_and_added_in_one_pass() {
let mut tags = record("CLASS", "FOO", &[]);
tags.extend(record("CLASS", "BAR", &[Tag::i(91, 7), Tag::i(91, 8)]));
for i in 0..50_000 {
tags.extend(record("CLASS", &format!("X{i}"), &[]));
}
tags.push(Tag::s(0, "FOO"));
let started = std::time::Instant::now();
add_class_instance_counts(&mut tags, Version::R2018);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
assert_eq!(&tags[..4], &[Tag::s(0, "CLASS"), Tag::s(1, "FOO"), Tag::i(90, 0), Tag::i(91, 1)]);
assert_eq!(&tags[4..9], &[Tag::s(0, "CLASS"), Tag::s(1, "BAR"), Tag::i(90, 0), Tag::i(91, 0), Tag::i(91, 8)]);
assert_eq!(tags.iter().filter(|t| t.code == 91).count(), 50_003);
}
}
+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;
}
+1 -4
View File
@@ -590,10 +590,7 @@ fn hidden(e: &BytesStart) -> bool {
}
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
// Stop at the first malformed attribute: skipping errors resumed the parser's duplicate check,
// which rescans the attributes before it each time, so many duplicates were quadratic.
for a in e.attributes() {
let Ok(a) = a else { return None };
for a in e.attributes().flatten() {
if a.key.into_inner().as_bytes() == name {
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
}
-10
View File
@@ -396,16 +396,6 @@ fn many_distinct_symbol_names_read_quickly() {
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
}
/// Elements with many duplicate attributes are read in bounded time.
#[test]
fn svg_duplicate_attributes_are_not_rescanned() {
let dup = " x=\"1\"".repeat(20_000);
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", format!("<rect{dup} width=\"1\" height=\"1\"/>").repeat(20));
let started = std::time::Instant::now();
let _ = read(svg.as_bytes(), "dup.svg");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [
+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)]]);
+10 -98
View File
@@ -12,7 +12,7 @@ use super::{Builder, Ctx, Ink, fill, ink, max_scale};
use cadcraft_color::{Color, Rgb};
use cadcraft_doc::library::{PatternLine, pattern_for};
use cadcraft_doc::{Drawing, Entity, Gradient, Hatch};
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon};
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
use std::sync::atomic::{AtomicBool, Ordering};
/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance.
@@ -130,15 +130,8 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
// One point budget for all the loops: many short arcs across loops must not add up unbounded.
let mut budget = cadcraft_geom::MAX_TESSELLATED_POINTS;
// Source loops that are the same boundary up to start vertex and winding are tessellated once, so a
// duplicate cannot be sampled coarser than its twin by the shared budget and then escape `dedup_polygons`.
let mut seen = std::collections::HashSet::new();
let mut loops: Vec<Vec<Vec2>> = h
.loops
.iter()
.filter(|l| seen.insert(canonical_key(&l.vertices)))
.map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget))
.collect();
let mut loops: Vec<Vec<Vec2>> =
h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget)).collect();
dedup_polygons(&mut loops);
let max_depth = match h.style {
1 => 1,
@@ -156,50 +149,6 @@ pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
loops.iter().enumerate().filter(|(i, l)| depth(*i, l) <= max_depth).map(|(_, l)| l.clone()).collect()
}
/// Start index of the least rotation of `s` (two-pointer scan, O(n)).
fn least_rotation(s: &[[u64; 3]]) -> usize {
let n = s.len();
let (mut i, mut j, mut k) = (0, 1, 0);
while i < n && j < n && k < n {
let (a, b) = (s.get((i + k) % n), s.get((j + k) % n));
if a == b {
k += 1;
continue;
}
if a > b {
i += k + 1;
} else {
j += k + 1;
}
if i == j {
j += 1;
}
k = 0;
}
i.min(j)
}
/// A key equal for loops that are the same boundary starting at another vertex or wound the other way
/// (reversing moves each bulge to the other end of its edge and negates it). Exact bits only; the
/// tolerance-based `dedup_polygons` still catches near matches.
fn canonical_key(v: &[PolyVertex]) -> Vec<[u64; 3]> {
let bits = |x: f64| (x + 0.0).to_bits(); // -0.0 and 0.0 match
let n = v.len();
let fwd: Vec<[u64; 3]> = v.iter().map(|q| [bits(q.p.x), bits(q.p.y), bits(q.bulge)]).collect();
let rev: Vec<[u64; 3]> = (0..n)
.map(|j| {
let i = n - 1 - j;
let edge = v.get((i + n - 1) % n).map_or(0.0, |q| -q.bulge);
v.get(i).map_or([0; 3], |q| [bits(q.p.x), bits(q.p.y), bits(edge)])
})
.collect();
let rotated = |s: &[[u64; 3]]| {
let k = least_rotation(s);
s.iter().cycle().skip(k).take(s.len()).copied().collect::<Vec<_>>()
};
rotated(&fwd).min(rotated(&rev))
}
/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge
/// (vertices may touch an enclosing loop; edge midpoints rarely do).
fn probe(l: &[Vec2]) -> Option<Vec2> {
@@ -421,7 +370,6 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
/// the part of the hatch inside it is generated; `None` = all of it.
#[cfg(test)]
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
pattern_lines_until(d, h, loops, px, view, None)
}
@@ -435,21 +383,6 @@ pub(crate) fn pattern_lines_until(
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
) -> Vec<Vec<Vec2>> {
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
}
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
#[allow(clippy::too_many_arguments)]
fn pattern_lines_within(
d: &Drawing,
h: &Hatch,
loops: &[Vec<Vec2>],
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
max_runs: usize,
work_limit: u64,
) -> Vec<Vec<Vec2>> {
let lines = lines_of(d, h);
let scale = scale_of(h);
@@ -458,7 +391,7 @@ fn pattern_lines_within(
// Shared work budget across all families and spans, counting dash-processing steps even when
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
// their cost (CWE-400).
let mut work: u64 = work_limit;
let mut work: u64 = MAX_HATCH_WORK;
for fam in &lines {
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
// are not swept at all.
@@ -473,8 +406,8 @@ fn pattern_lines_within(
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= max_runs || work == 0 || is_set(cancel) {
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
break;
}
}
@@ -485,8 +418,6 @@ fn pattern_lines_within(
const SWATCH_REPEATS: f64 = 6.0;
/// Most runs a swatch holds.
const MAX_SWATCH_RUNS: usize = 4000;
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
const MAX_SWATCH_WORK: u64 = 2_000_000;
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
@@ -513,19 +444,14 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
pattern_lines: lines.to_vec(),
};
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
// (about a million runs) to keep a few thousand.
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
runs.truncate(MAX_SWATCH_RUNS);
runs.shrink_to_fit();
runs
}
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
struct Family<'a> {
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
max_runs: usize,
origin: Vec2,
ang: f64,
delta: Vec2,
@@ -564,13 +490,6 @@ impl Family<'_> {
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
let transform_charge = verts / 8 + 1;
if transform_charge > *work {
return;
}
*work -= transform_charge;
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
@@ -589,17 +508,10 @@ impl Family<'_> {
// family draws anything at all).
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
// operations, whether or not any dash is made.
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
for k in k0..=k1 {
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
return;
}
*work -= crossings_charge;
let y = k as f64 * self.spacing;
let xs = crossings(&local, y);
let offset = shift_per_line * k as f64;
@@ -629,10 +541,10 @@ impl Family<'_> {
'outer: while x < x1 {
for d in self.dashes {
guard += 1;
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
*work = work.saturating_sub(1);
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
break 'outer;
}
*work -= 1;
let len = d.abs();
if *d > 0.0 {
let a = x.max(x0);
+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.
-32
View File
@@ -29,17 +29,11 @@ const MAX_TEXTS: usize = 10_000;
/// repeated many times).
const MAX_POINTS: usize = 2_000_000;
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
const MAX_TEXT_POINTS: usize = 4_000_000;
/// One text or shape element of the pattern, shaped once at the origin.
struct Element {
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
at: f64,
local: Shaped,
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
points: usize,
rotation: f64,
absolute: bool,
offset: Vec2,
@@ -64,7 +58,6 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
return out;
}
let mut walk = Walk { pts, i: 0, start: 0.0 };
let mut points_left = MAX_TEXT_POINTS;
let mut base = 0.0;
let mut points = 0usize;
'repeats: while base <= len {
@@ -74,8 +67,6 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
break 'repeats;
}
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
points_left = left;
let Some((p, dir)) = walk.at(s) else { break 'repeats };
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
@@ -120,7 +111,6 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
{
out.push(Element {
at,
points: point_count(&local),
local,
rotation: finite(el.rotation),
absolute: el.absolute,
@@ -145,16 +135,8 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
..TextParams::new(Vec2::ZERO, height)
};
let (local, _) = cadcraft_fonts::place(&font, text, &params);
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
// glyphs still has a cost proportional to its size.
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
+ local.strokes.len()
+ local.glyphs.len()
+ 1;
out.push(Element {
at,
points,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
@@ -235,20 +217,6 @@ mod tests {
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
}
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
#[test]
fn placed_labels_stop_at_the_point_allowance() {
let d = Drawing::new_imperial();
let mut lt = gas();
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], &lt, 1.0, 0.0);
let points: usize =
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
assert!(points <= MAX_TEXT_POINTS, "{points}");
}
/// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2.
fn register_shapes() {
let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n";
+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));
+13 -45
View File
@@ -133,20 +133,6 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
if parts.len() <= 1 {
return layout(d, t);
}
// Every part is laid out and kept, so the cells of all of them count together (a header repeated
// on each part adds to each); past the limit only the table's outline is drawn.
let cols = t.col_widths.len().min(10_000);
let total: usize = parts.iter().map(|p| p.rows.len().min(10_000).saturating_mul(cols)).fold(0, usize::saturating_add);
if total > MAX_LAYOUT_CELLS {
// One outline per part, at the part's origin and as tall as its displayed rows.
let w = edges(&t.col_widths, cols).last().copied().unwrap_or(0.0);
let mut g = TableGeom::default();
for p in parts {
let h: f64 = p.rows.iter().take(10_000).map(|r| t.row_heights.get(*r).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum();
outline_box(&mut g, p.origin, w, h);
}
return g;
}
let mut g = TableGeom::default();
for p in parts {
let mut pg = layout(d, &part_table(t, &p.rows, p.origin));
@@ -170,20 +156,16 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
// `run[i]`: how many rows from part row `i` follow one another in the table, found once so a
// merge is cut by a lookup, not by walking the rows it spans.
let mut run = vec![1u32; rows.len()];
for i in (0..rows.len().saturating_sub(1)).rev() {
if rows.get(i + 1).zip(rows.get(i)).is_some_and(|(next, this)| *next == this + 1) {
run[i] = run.get(i + 1).copied().unwrap_or(0).saturating_add(1);
}
}
for (i, row) in cells.iter_mut().enumerate() {
row.resize_with(cols, Default::default);
for cell in row.iter_mut() {
if let Some((rs, cs)) = cell.merged {
// Rows of the merge that follow in this part.
let n = rs.max(1).min(run.get(i).copied().unwrap_or(1));
let start = rows.get(i).copied().unwrap_or(0);
let mut n = 1u32;
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
n += 1;
}
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
}
}
@@ -204,27 +186,6 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
}
}
/// The table's outline alone, for a table too large to lay out.
fn outline(t: &Table) -> TableGeom {
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
let (w, h) = (edges(&t.col_widths, cols).last().copied().unwrap_or(0.0), edges(&t.row_heights, rows).last().copied().unwrap_or(0.0));
let mut g = TableGeom::default();
outline_box(&mut g, t.insert.xy(), w, h);
g
}
/// The four sides of a `w` by `h` box hanging down from `o`.
fn outline_box(g: &mut TableGeom, o: Vec2, w: f64, h: f64) {
let p = |x: f64, y: f64| o + Vec2::new(x, -y);
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
for s in corners.windows(2) {
if let [a, b] = s {
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
}
}
}
/// Lay out one part.
fn layout(d: &Drawing, t: &Table) -> TableGeom {
let st = table_style_of(d, t);
@@ -242,7 +203,14 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
}
let cov = coverage(t);
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
return outline(t);
let (w, h) = (x_at(cols), y_at(rows));
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
for s in corners.windows(2) {
if let [a, b] = s {
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
}
}
return g;
}
let k = style_scale(&st, t);
// Cell styles by name, scaled to the table.
-69
View File
@@ -1165,72 +1165,3 @@ fn without_linetypes_every_line_is_drawn_whole() {
let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default());
assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len());
}
/// A broken table whose parts, with their repeated labels, together hold more cells than a table may
/// lay out is drawn as its outline, though the table's own cells are within the limit.
#[test]
fn broken_table_cells_count_across_its_parts() {
let d = Drawing::new_imperial();
let t = cadcraft_doc::Table {
col_widths: vec![1.0; 600],
row_heights: vec![1.0; 1000],
title: true,
header: true,
breaks: Some(cadcraft_doc::TableBreak { enabled: true, height: 3.0, ..Default::default() }),
..Default::default()
};
assert!(t.parts().len() > 500 && 1000 * 600 <= crate::MAX_LAYOUT_CELLS);
let g = crate::table_geometry(&d, &t);
let parts = t.parts();
assert_eq!((g.lines.len(), g.texts.len(), g.fills.len()), (4 * parts.len(), 0, 0));
// Each part's box starts at its own origin.
let top = |i: usize| g.lines[4 * i].a;
assert!((0..parts.len()).all(|i| top(i) == parts[i].origin));
}
#[test]
fn rotated_and_reversed_copies_of_a_curved_loop_fill_once() {
let ring = |rot: usize, rev: bool| {
let mut v: Vec<PolyVertex> = [(0.0, 0.0, 0.5), (4.0, 0.0, -0.25), (4.0, 3.0, 0.0), (0.0, 3.0, 0.1)]
.map(|(x, y, bulge)| PolyVertex { bulge, ..PolyVertex::new(Vec2::new(x, y)) })
.to_vec();
if rev {
// The same boundary wound the other way: bulges move to the other end of their edge and flip.
let b: Vec<f64> = v.iter().map(|q| q.bulge).collect();
v.reverse();
let n = v.len();
for (j, q) in v.iter_mut().enumerate() {
q.bulge = -b[(n - 1 - j + n - 1) % n];
}
}
v.rotate_left(rot);
HatchLoop { vertices: v, outer: true, sources: Vec::new() }
};
let mut h = pattern_hatch("SOLID", 1.0, (0.0, 0.0, 1.0, 1.0));
h.loops = vec![ring(0, false), ring(2, false), ring(1, true), ring(3, true)];
assert_eq!(loops_of(&h).len(), 1);
// A different boundary is kept.
h.loops.push(HatchLoop {
vertices: ring(0, false).vertices.into_iter().map(|q| PolyVertex { bulge: 0.0, ..q }).collect(),
outer: true,
sources: Vec::new(),
});
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)) {
-11
View File
@@ -211,14 +211,3 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
}
}
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
#[test]
fn compact_patterns_stop_at_the_swatch_limits() {
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
let started = std::time::Instant::now();
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
assert!(started.elapsed() < std::time::Duration::from_secs(20));
}
+4 -5
View File
@@ -309,7 +309,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 {
@@ -374,8 +374,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()
@@ -386,7 +385,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();
@@ -455,7 +454,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());
}