mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcfbef480 | ||
|
|
55a5ce8620 | ||
|
|
e08d91d086 | ||
|
|
06d9cd3c6e | ||
|
|
0843770389 | ||
|
|
36f5ad64f9 | ||
|
|
7804e94ee0 | ||
|
|
bc9ad6b059 |
@@ -148,7 +148,20 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// A message can carry text from a file (a code page name, a path), so it must not be able to
|
||||
// start what looks like another record: control characters other than tab and line feed are
|
||||
// shown escaped, and every continuation line is indented, so only this function's own
|
||||
// timestamp is ever at the start of a line.
|
||||
let mut safe = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => safe.push_str("\n "),
|
||||
'\t' => safe.push('\t'),
|
||||
c if c.is_control() => safe.extend(c.escape_default()),
|
||||
c => safe.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -441,8 +454,14 @@ mod tests {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A multi-line message keeps its lines, indented so they can't pass for records; the
|
||||
// record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
|
||||
// Control characters in a message (from a file) can't forge a record.
|
||||
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
|
||||
assert_eq!(forged.lines().count(), 2);
|
||||
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
|
||||
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -232,6 +232,10 @@ 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;
|
||||
}
|
||||
@@ -267,20 +271,22 @@ 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()));
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && 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);
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
|
||||
let selected: HashSet<Handle> = handles.iter().copied().collect();
|
||||
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -85,7 +85,8 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
|
||||
@@ -204,7 +204,8 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
|
||||
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -303,11 +303,9 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -175,8 +175,14 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
|
||||
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
|
||||
return true;
|
||||
}
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
|
||||
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
|
||||
// long text (a layer name from a file) isn't parsed, copied and split per entity.
|
||||
const MAX_COLOR_TEXT: usize = 64;
|
||||
let (pc, vc) = (ptxt.trim(), vtxt.trim());
|
||||
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
|
||||
return false;
|
||||
}
|
||||
match (Color::parse(pc), Color::parse(vc)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
@@ -400,3 +406,26 @@ mod tests {
|
||||
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod long_text_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long string property is compared as text only, and a colour name still equals its index.
|
||||
#[test]
|
||||
fn long_values_skip_colour_parsing() {
|
||||
let long = Value::String("x".repeat(100_000));
|
||||
assert!(compare(&long, Op::Eq, &long));
|
||||
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
|
||||
}
|
||||
|
||||
/// Padding is trimmed before the length limit, so a padded colour still matches.
|
||||
#[test]
|
||||
fn padded_colours_still_match() {
|
||||
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
|
||||
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
|
||||
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,11 +238,9 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -370,6 +368,16 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -415,9 +415,12 @@ impl Session {
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// One pass over the buffer, dropping the printed lines once at the end: draining each line
|
||||
// from the front shifted the rest every time, quadratic in newline-dense output.
|
||||
let mut done = 0;
|
||||
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
|
||||
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
|
||||
done += i + 1;
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
@@ -427,6 +430,7 @@ impl Session {
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
env.out.drain(..done);
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
|
||||
@@ -307,6 +307,16 @@ fn printing() {
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
|
||||
#[test]
|
||||
fn printing_many_lines_is_linear() {
|
||||
let mut s = session();
|
||||
let started = std::time::Instant::now();
|
||||
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -103,7 +103,11 @@ 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()
|
||||
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
|
||||
// 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<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -118,6 +122,10 @@ 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;
|
||||
|
||||
@@ -484,6 +484,9 @@ pub struct Polyline {
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// Sampled points one [`Polyline::tessellate`] call produces before arcs become chords.
|
||||
pub const MAX_TESSELLATED_POINTS: usize = 4_000_000;
|
||||
|
||||
impl Polyline {
|
||||
pub fn from_points(pts: &[Vec2], closed: bool) -> Self {
|
||||
Polyline { vertices: pts.iter().map(|p| PolyVertex::new(*p)).collect(), closed }
|
||||
@@ -516,10 +519,24 @@ impl Polyline {
|
||||
b
|
||||
}
|
||||
pub fn tessellate(&self, tol: f64) -> Vec<Vec2> {
|
||||
let mut budget = MAX_TESSELLATED_POINTS;
|
||||
self.tessellate_within(tol, &mut budget)
|
||||
}
|
||||
/// [`Polyline::tessellate`] spending about `budget` sampled points (shared by the caller
|
||||
/// across many polylines). The budget is checked before each segment, so the last segment
|
||||
/// started may overshoot it by that one segment's samples. Once it is spent, the remaining arcs are drawn as chords, so the
|
||||
/// output stays proportional to the input vertices. Each arc can sample thousands of points,
|
||||
/// so a compact polyline could otherwise expand into gigabytes (CWE-400).
|
||||
pub fn tessellate_within(&self, tol: f64, budget: &mut usize) -> Vec<Vec2> {
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
for s in self.segments() {
|
||||
let mut pts = Vec::new();
|
||||
s.tessellate(tol, &mut pts);
|
||||
if *budget > 0 {
|
||||
s.tessellate(tol, &mut pts);
|
||||
*budget = budget.saturating_sub(pts.len());
|
||||
} else {
|
||||
pts.extend([s.start(), s.end()]);
|
||||
}
|
||||
let skip = usize::from(out.last().is_some_and(|l| pts.first().is_some_and(|f| l.near(*f, 1e-9))));
|
||||
out.extend(pts.into_iter().skip(skip));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ mod vec;
|
||||
|
||||
pub use bounds::Bounds2;
|
||||
pub use curve::{
|
||||
Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, polygon_centroid, shoelace,
|
||||
Arc, Circle, Ellipse, Line, MAX_TESSELLATED_POINTS, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon,
|
||||
polygon_centroid, shoelace,
|
||||
};
|
||||
pub use fillet::{CornerFit, TanCurve, tangent_circles};
|
||||
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
||||
|
||||
@@ -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) -> Vec<Island> {
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
|
||||
let comp = components(g);
|
||||
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
|
||||
let outer_pts = points(g, outer)?;
|
||||
let ob = Bounds2::from_points(outer_pts.iter().copied());
|
||||
let core = prune(g);
|
||||
// Leftmost (then lowest) vertex still on a cycle, per component.
|
||||
@@ -464,19 +464,32 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> 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) || !point_in_polygon(&outer_pts, pu) {
|
||||
if !ob.contains(pu) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(outer_pts.len())?;
|
||||
edges_left = left;
|
||||
if !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) || point_in_polygon(&lp, p) {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
let mut src = sources.remove(&c).unwrap_or_default();
|
||||
@@ -484,7 +497,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
src.dedup();
|
||||
out.push(Island { outline: lp, sources: src });
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
|
||||
@@ -497,6 +510,10 @@ 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(());
|
||||
@@ -598,7 +615,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();
|
||||
|
||||
@@ -221,6 +221,18 @@ fn segment_offset() {
|
||||
assert!(s.offset(6.0).is_none());
|
||||
}
|
||||
|
||||
/// A polyline of many fine arcs samples at most the point budget, then draws chords.
|
||||
#[test]
|
||||
fn polyline_tessellation_is_bounded() {
|
||||
let vertices: Vec<PolyVertex> = (0..3000).map(|i| PolyVertex { bulge: 1.0, ..PolyVertex::new(Vec2::new(f64::from(i) * 1e6, 0.0)) }).collect();
|
||||
let pl = Polyline { vertices, closed: false };
|
||||
let pts = pl.tessellate(1e-9);
|
||||
assert!(pts.len() <= MAX_TESSELLATED_POINTS + 2 * 3000 + 4096, "{}", pts.len());
|
||||
assert!(pts.len() > 3000);
|
||||
let mut budget = 10;
|
||||
assert!(pl.tessellate_within(1e-9, &mut budget).len() <= 4096 + 2 * 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_segments_bounded() {
|
||||
assert_eq!(arc_segments(0.0, 1.0, 0.1), 1);
|
||||
|
||||
@@ -128,7 +128,10 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
|
||||
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
|
||||
// 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;
|
||||
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,
|
||||
|
||||
@@ -77,10 +77,16 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
|
||||
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
|
||||
let mut counts = vec![0usize; xrefs.len()];
|
||||
if !xrefs.is_empty() {
|
||||
// Xref name (ASCII-upper-cased) → its row, the first of names that differ only in case, so
|
||||
// each INSERT is one lookup instead of a scan of the xref list.
|
||||
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (k, b) in xrefs.iter().enumerate() {
|
||||
index.entry(b.name.to_ascii_uppercase()).or_insert(k);
|
||||
}
|
||||
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
|
||||
for e in store.iter() {
|
||||
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
|
||||
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
|
||||
&& let Some(&k) = index.get(&i.block.to_ascii_uppercase())
|
||||
&& let Some(c) = counts.get_mut(k)
|
||||
{
|
||||
*c += 1;
|
||||
|
||||
Reference in New Issue
Block a user