Author SHA1 Message Date
skylar taylor-barrick e4214f7180 Merge pull request #1677 from MarekSpodBiedry/issue-1644
Write the ACAD_TABLE *T block relative to its insertion point (#1644)
2026-10-22 07:51:40 -06:00
skylar taylor-barrick 35934718ea Merge pull request #1683 from MarekSpodBiedry/issue-1667
Keep a layout's plot window corners when saving DXF (#1667)
2026-10-22 07:51:33 -06:00
skylar taylor-barrick 916d440bed Merge pull request #1718 from oracola91/fix/control-accept-backoff
Control server: wait after a failed accept instead of retrying at once
2026-10-22 07:51:26 -06:00
skylar taylor-barrick 632967aa78 Merge pull request #1336 from midasmatterhorn/fix/r4-dxf-table-record-caps
io: the four symbol tables honour the same record cap as their peers
2026-10-22 07:51:19 -06:00
skylar taylor-barrick f15be2305c Merge pull request #1680 from MarekSpodBiedry/issue-1650
DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650)
2026-10-22 07:51:12 -06:00
skylar taylor-barrick f2dadec23a Merge pull request #1678 from MarekSpodBiedry/issue-1648
Multileader sizes no longer multiplied by DIMSCALE (#1648)
2026-10-22 07:51:06 -06:00
skylar taylor-barrick 00acec952e Merge pull request #1774 from philipp-tch/fix/foreign-handles-above-2-48
DXF: keep foreign objects whose handles are above 2^48 (fixes #1773)
2026-10-22 07:51:00 -06:00
skylar taylor-barrick 78254b4d3f Merge pull request #1480 from Silvertree2010/fix/h2426-polyline-segments-with-a-tiny-no
Treat polyline bulges with a sub-EPS sweep as straight segments
2026-10-22 07:50:53 -06:00
skylar taylor-barrick 8094d7746f Merge pull request #1635 from Hamza0101/entsel-pickbox
AutoLISP: an entsel prompt shows the pick box whatever its message says
2026-10-22 07:50:47 -06:00
skylar taylor-barrick 4ad4f4f598 Merge pull request #1628 from Hamza0101/lisp-eq-symbols
AutoLISP: `=` compares the symbols `type` returns
2026-10-22 07:50:41 -06:00
BFlat 477eec5026 Merge pull request #1894 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:10 +07:00
bflatastic 38750c6eb8 README: the app icon above the title 2026-10-22 17:57:20 +07:00
Dylan M. Taylor 75d9d72703 Merge pull request #1837 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:55 -04:00
Philipp ThomannandClaude Opus 5.5 82424e47c9 DXF: keep foreign objects whose handles are above 2^48
AutoCAD writes handles above 2^48 (whole drawings whose handles start at 0xDAD1...).
dxf_raw dropped every foreign object with such a handle, so these drawings lost their
dynamic blocks and all other unmodelled objects on read, DXF to DXF as well. DXF handles
are up to 16 hex digits; the whole 64-bit range is now believed (null still is not).
Modelled entities and table records already accepted such handles.

Test: the DOOR fixture with every handle moved above 2^48 keeps its dynamic block
through a save (red before the change).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:23:16 +02:00
oracola91 4d826ed91f Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:58 +03:00
MarekSpodBiedry a7bb28d722 DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650) 2026-10-19 22:33:02 +02:00
MarekSpodBiedry 27bff9cc30 Write the ACAD_TABLE *T block relative to its insertion point (#1644) 2026-10-19 22:29:01 +02:00
MarekSpodBiedry d600a80354 Multileader sizes no longer multiplied by DIMSCALE (#1648) 2026-10-19 22:27:34 +02:00
MarekSpodBiedry 81f0aa53ff Keep a layout's plot window corners when saving DXF (#1667) 2026-10-19 22:27:04 +02:00
Hamza 27861ad0ed Read a prompt's wording only where the engine wrote it
The pick-box heuristic read the message for a leading "Select", and
this branch had widened it past leading whitespace so that a routine's
own message would be read the same way. That makes a plain point prompt
look like a pick: (getpoint "\nSelect a point: ") showed the pick box
and lost its point modifiers, since pointmod refuses them wherever
picks_objects holds.

A routine writes all of its own message (raw), and says through
picks_one when it picks an object, so the wording needs reading only
where the engine wrote it. Covered for both spellings a routine might
use, with and without the leading newline.
2026-10-20 00:31:05 +05:00
Hamza b72680cfb7 AutoLISP: an entsel prompt shows the pick box whatever its message says
Whether the cursor shows the pick box came from `Prompt::picks_objects()`,
which read the prompt's wording: a selection-set prompt, or a point prompt whose
message starts with "Select". An `entsel`/`nentsel` prompt takes a point and is
not a selection set, so it fell to the wording -- and an AutoLISP routine writes
its own message, conventionally starting with a newline:

  (nentsel)                                       "Select object: "   pick box
  (nentsel "\nSelect second line or polyline: ")   starts with "\n"   none
  (entsel  "\nPick a line: ")                     says no such word  none

A prompt now says that it picks one object rather than being read for it, and
`GetRequest::prompt()` sets that for `GetKind::EntSel`, so all three show the
pick box. The wording test is read past leading whitespace as well, so a command
machine's prompt beginning with a newline is not caught by the same thing.

Point modifiers (`.x`, `.y`…) are offered at a point prompt that is not a pick,
so they no longer appear at an `entsel` prompt with a message -- as they never
did at one without.

Fix: #1598
2026-10-19 19:13:23 +05:00
Hamza d9adce038c AutoLISP: = compares the symbols type returns
`(= (type x) 'LIST)` is how an AutoLISP routine tests a type, because `type`
returns a symbol. `=` coerced both of its arguments to numbers, so the symbol
failed the coercion and the routine stopped at its first type test:

  _$ (= (type 1) 'INT)
  bad argument type: numberp: INT

`=` and `/=` now compare numbers and strings as before, and any other atom -- a
symbol, `nil`, an entity name, a selection set, a file handle -- by what it is,
the way `eq` does. Two consequences, both closer to AutoLISP than raising was:
a mismatch of kinds answers `nil` rather than failing, so `(= 1 "1")` and
`(= '(1) '(1))` answer (comparing list structure is `equal`'s job), and
`(= nil nil)` is `T`.

The ordering comparisons still take numbers and strings only, as AutoLISP has
them: `(< 'a 'b)` remains `bad argument type: numberp: A`.

Fix: #1597
2026-10-19 17:26:39 +05:00
NetterSilverandClaude Haiku 5.5 4c75b0c458 Treat polyline bulges with a sub-EPS sweep as straight segments
Found while testing crates/geom/src/curve.rs:435 (bulge_to_arc) together with crates/geom/src/lib.rs:45-48 (ccw_sweep)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 04:43:01 +00:00
midasmatterhorn 8a2b8f4bdf io: the four symbol tables honour the same record cap as their peers
LAYER, LTYPE, STYLE and DIMSTYLE records were appended with no cap while
the VIEW, UCS and VPORT arms of the same loop guard at MAX_OBJECTS — so
a hostile DXF could grow the layer table (each record found by a linear
case-insensitive name scan) without bound. The same guard now applies to
all seven table kinds.

Behaviour is unchanged for every legal drawing: no drawing approaches a
million table records, so no regression test can reach the cap at unit
scale (the same reason the peer guards shipped without one). The
quadratic cost of the name lookups themselves is a separate, larger
change (resolving table names through a map while reading) deliberately
left to the maintainers.
2026-10-15 17:43:04 +03:00
18 changed files with 283 additions and 27 deletions

No files matched your search

+4
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
</p>
<h1 align="center">CADCraft</h1>
<p align="center">
+23 -1
View File
@@ -61,6 +61,16 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
@@ -80,7 +90,7 @@ impl Drop for Slot {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
open.fetch_sub(1, Ordering::SeqCst);
@@ -222,6 +232,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
mod tests {
use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
+3
View File
@@ -752,6 +752,8 @@ pub struct PageSetup {
pub landscape: bool,
/// "layout" | "extents" | "display" | "window" | "limits".
pub plot_area: String,
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
pub plot_window: [f64; 4],
pub scale_to_fit: bool,
/// Paper units per drawing unit.
pub scale: f64,
@@ -779,6 +781,7 @@ impl Default for PageSetup {
margins_mm: [6.35, 6.35, 6.35, 6.35],
landscape: true,
plot_area: "layout".into(),
plot_window: [0.0; 4],
scale_to_fit: false,
scale: 1.0,
plot_style_table: String::new(),
+27
View File
@@ -494,6 +494,16 @@ mod native {
}
}
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
fn current_text_style(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_text_style_name.clone();
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
doc.header.current_text_style_handle = h;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
@@ -512,6 +522,7 @@ mod native {
dimension_flags(&mut doc, true);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
@@ -585,6 +596,22 @@ mod tests {
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
}
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn current_text_style_reaches_the_dwg() {
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
assert!(romans.is_some(), "ROMANS is missing from the DWG");
assert_eq!(Some(doc.header.current_text_style_handle), romans);
}
#[test]
fn garbage_is_an_error_not_a_crash() {
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
+6 -6
View File
@@ -427,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
let d = s.doc().ok()?;
let cur = d.header.str("CMLEADERSTYLE", "Standard");
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
let th = st.text_height * k;
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
let th = st.text_height;
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
let dogleg = if landing { st.dogleg * k } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
let dogleg = if landing { st.dogleg } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
@@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size * k,
arrow_size: st.arrow_size,
spline: false,
block: None,
}))
+23 -2
View File
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
}
Ok(Value::t())
}
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
/// only, as AutoLISP has them.
fn eq_chain(a: &[Value], want_equal: bool) -> R {
arity(a, 1, usize::MAX)?;
for w in a.windows(2) {
let [x, y] = w else { continue };
let same = match (x, y) {
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
_ => eq(x, y),
};
if same != want_equal {
return Ok(Value::Nil);
}
}
Ok(Value::t())
}
fn num_eq(a: &[Value]) -> R {
chain(a, |o| o.is_eq())
eq_chain(a, true)
}
fn num_ne(a: &[Value]) -> R {
chain(a, |o| o.is_ne())
eq_chain(a, false)
}
fn lt(a: &[Value]) -> R {
chain(a, |o| o.is_lt())
+6
View File
@@ -67,6 +67,12 @@ impl GetRequest {
let mut p = Prompt::new(message, accept).base_opt(base);
p.keywords = self.initget.keywords.clone();
p.raw = true;
// `entsel`/`nentsel` pick one object. The routine writes the message, so the prompt says
// so itself rather than being read for the word "Select" -- with a message of its own
// ("\nSelect the second line: ", or no mention of selecting at all) the pick box went
// missing, while the same call without one got it from the default "Select object: "
// (#1598).
p.picks_one = matches!(self.kind, GetKind::EntSel);
p
}
+64
View File
@@ -70,6 +70,39 @@ fn arithmetic_follows_autolisp() {
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
}
/// `=` answers for the symbols `type` returns, which is how AutoLISP routines test a type:
/// `(= (type x) 'INT)`. Coercing the symbol to a number failed the whole routine with "bad
/// argument type" instead, so a routine written for AutoLISP stopped at its first type test
/// (#1597). The ordering comparisons are still numbers and strings, as AutoLISP has them.
#[test]
fn equality_compares_symbols_as_autolisp_does() {
let mut s = session();
assert_eq!(ok(&mut s, "(= (type 1) 'INT)"), "T");
assert_eq!(ok(&mut s, "(= (type 1.0) 'INT)"), "nil");
assert_eq!(ok(&mut s, "(= (type '(1 2)) 'LIST)"), "T");
assert_eq!(ok(&mut s, "(= (type \"a\") 'STR)"), "T");
assert_eq!(ok(&mut s, "(/= (type 1) 'REAL)"), "T");
assert_eq!(ok(&mut s, "(= 'a 'a 'a)"), "T");
assert_eq!(ok(&mut s, "(= 'a 'b)"), "nil");
// The routine from the report runs.
ok(&mut s, "(defun isint (a) (if (= (type a) 'INT) T nil))");
assert_eq!(ok(&mut s, "(isint 3)"), "T");
assert_eq!(ok(&mut s, "(isint 3.0)"), "nil");
assert_eq!(ok(&mut s, "(isint \"3\")"), "nil");
// A mismatch of kinds is nil, not an error; numbers and strings are unchanged.
assert_eq!(ok(&mut s, "(= 1 \"1\")"), "nil");
assert_eq!(ok(&mut s, "(= '(1) '(1))"), "nil", "`=` is not `equal`");
assert_eq!(ok(&mut s, "(= nil nil)"), "T");
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
assert_eq!(ok(&mut s, "(= \"a\" \"a\" \"a\")"), "T");
assert_eq!(ok(&mut s, "(= 1 1 2)"), "nil");
// Ordering still refuses what AutoLISP refuses.
assert_eq!(eval(&mut s, "(< 'a 'b)"), Err("bad argument type: numberp: A".into()));
}
#[test]
fn lists_and_strings() {
let mut s = session();
@@ -452,6 +485,37 @@ fn hostile_lisp_never_panics() {
assert!(!s.lisp_running());
}
/// `entsel` and `nentsel` pick one object, so the cursor shows the pick box at their prompt. It
/// was read off the message's first word, and a routine's own message begins with a newline -- so
/// the same call showed the pick box without a message and lost it with one (#1598).
#[test]
fn an_entsel_prompt_picks_objects_whatever_its_message_says() {
let mut s = session();
// No message: the default "Select object: ".
s.cmdline("(nentsel)").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// The reported case: the routine's own message, starting with a newline.
s.cmdline("(nentsel \"\nSelect second line or polyline: \")").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// Wording that never says "select" picks an object just the same.
s.cmdline("(entsel \"\nPick a line: \")").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// A point prompt is not a pick, and keeps its point modifiers -- including one whose own
// message happens to begin with the word the built-in commands are read by. `picks_one` says
// which calls pick, so the wording is only ever read where the engine wrote it (#1635 review).
for point in ["(getpoint \"\nSpecify a point: \")", "(getpoint \"\nSelect a point: \")", "(getpoint \"Select a point: \")"] {
s.cmdline(point).unwrap();
assert!(!s.current_prompt().unwrap().picks_objects(), "{point}: {}", s.prompt_text());
s.cancel();
}
}
#[test]
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
let mut s = session();
+11 -4
View File
@@ -62,6 +62,10 @@ pub struct Prompt {
/// pointer boxes.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub distance: bool,
/// The prompt picks one object whatever it says: an AutoLISP `entsel`/`nentsel`, whose message
/// the routine writes, so it cannot be read for the word "Select" (#1598).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub picks_one: bool,
}
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
@@ -128,11 +132,14 @@ impl Prompt {
self.default = Some(d.into());
self
}
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
/// single object themselves). The UI shows the pick box there.
/// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object
/// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to
/// offset", "Select first line"…, the commands that pick a single object themselves). The UI
/// shows the pick box there. The wording is only read where the engine wrote it: a routine
/// writes its own message (`raw`), which may open with "Select" and still ask for a point,
/// and says through `picks_one` when it picks instead (#1635 review).
pub fn picks_objects(&self) -> bool {
self.accept.select || (self.accept.point && self.message.starts_with("Select"))
self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select"))
}
/// "Specify next point or [Undo/Close] <default>:".
pub fn display(&self) -> String {
+17
View File
@@ -121,3 +121,20 @@ fn json_form_takes_leader_type_and_block() {
assert!(s.execute("mleader", &bad).is_err(), "{bad}");
}
}
/// DIMSCALE belongs to dimension styles: a multileader keeps its style's text height, arrowhead
/// and dogleg whatever DIMSCALE is (#1648).
#[test]
fn mleader_sizes_ignore_dimscale() {
let mut s = Session::new();
s.execute("mleaderstyle", &json!({"name": "M5", "textHeight": 5, "arrowSize": 5, "current": true})).unwrap();
s.execute("setvar", &json!({"name": "DIMSCALE", "value": 20})).unwrap();
s.execute("mleader", &json!({"points": [[0, 0], [30, 20]], "text": "CADCRAFT"})).unwrap();
let m = last_mleader(&s);
assert_eq!(m.arrow_size, 5.0);
let st = s.doc().unwrap().mleader_styles.iter().find(|x| x.name == "M5").unwrap().clone();
assert_eq!(m.dogleg, st.dogleg);
let t = m.text.unwrap();
assert_eq!(t.height, 5.0);
assert!((t.insert.x - (30.0 + st.dogleg + st.landing_gap)).abs() < 1e-9, "{}", t.insert.x);
}
+4
View File
@@ -437,6 +437,10 @@ pub fn bulge_to_arc(a: Vec2, b: Vec2, bulge: f64) -> Option<(Arc, bool)> {
return None;
}
let theta = 4.0 * bulge.atan(); // signed included angle
// A sweep at or below EPS would be read back as a full circle by `ccw_sweep`; it is a straight segment.
if theta.abs() <= EPS {
return None;
}
let chord = a.dist(b);
let r = chord / (2.0 * (theta / 2.0).sin().abs());
let mid = a.mid(b);
+10
View File
@@ -74,6 +74,16 @@ fn bulge_quarter_arc() {
assert!(close(arc.sweep(), PI / 2.0));
}
#[test]
fn tiny_bulge_is_not_a_full_circle() {
let a = Vec2::new(0.0, 0.0);
let b = Vec2::new(10.0, 0.0);
let p = Polyline { vertices: vec![PolyVertex::with_bulge(a, 1e-10), PolyVertex::new(b)], closed: false };
assert!((p.len() - 10.0).abs() < 1e-6, "len={}", p.len());
let bb = p.bounds();
assert!(bb.max.x < 10.001 && bb.max.y < 0.001 && bb.min.y > -0.001, "bounds={bb:?}");
}
#[test]
fn polyline_area_and_length() {
let p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)], true);
+3 -4
View File
@@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000;
const MAX_XDATA_GROUPS: usize = 16_384;
/// Longest ownership chain followed.
const MAX_DEPTH: usize = 64;
/// Handles above this are not believed (hostile files); references to them are dropped.
const MAX_HANDLE: u64 = 1 << 48;
/// Record types that structure a file: never written as an entity of another type.
const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"];
@@ -85,9 +83,10 @@ fn tag(r: &RawTag) -> Tag {
Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone())))
}
/// A handle value we believe: hexadecimal, not null, not absurdly large.
/// A handle value we believe: hexadecimal and not null. DXF handles are up to 16 hex digits and
/// AutoCAD writes some above 2^48 (e.g. 0xDAD1…), so the whole 64-bit range is believed.
fn handle(s: &str) -> Option<Handle> {
Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE)
Handle::parse_hex(s).filter(|h| h.0 != 0)
}
/// Group codes whose values are handles of other objects (DXF Reference, group code ranges).
+5 -4
View File
@@ -1425,7 +1425,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
match kind.as_str() {
"LAYER" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.layers.len() >= MAX_OBJECTS {
continue;
}
let aci = t.i(62).unwrap_or(7);
@@ -1462,7 +1462,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"LTYPE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() || d.linetype(&name).is_some() {
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
continue;
}
let pattern = ltype_pattern(&tg, &ltype_styles);
@@ -1470,7 +1470,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"STYLE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS {
continue;
}
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
@@ -1552,7 +1552,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"DIMSTYLE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS {
continue;
}
// Every variable with a DIMSTYLE group code; values the style can't take are
@@ -2248,6 +2248,7 @@ fn page_setup(t: &T) -> PageSetup {
_ => "layout",
}
.into();
p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 });
p.plot_style_table = t.s(7).unwrap_or_default();
p
}
+9 -6
View File
@@ -1441,8 +1441,11 @@ fn table_size(t: &Table) -> (usize, usize) {
/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw
/// them, for readers that only draw the block.
fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
let g = cadcraft_render::table_geometry(d, t);
///
/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point.
pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
let at_origin = Table { insert: Vec3::ZERO, ..t.clone() };
let g = cadcraft_render::table_geometry(d, &at_origin);
let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
let mut out = Vec::new();
for f in &g.fills {
@@ -2565,10 +2568,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
(45, page.height_mm),
(46, 0.0),
(47, 0.0),
(48, 0.0),
(49, 0.0),
(140, 0.0),
(141, 0.0),
(48, page.plot_window[0]),
(49, page.plot_window[1]),
(140, page.plot_window[2]),
(141, page.plot_window[3]),
(142, scale),
(143, 1.0),
] {
+17
View File
@@ -1343,6 +1343,23 @@ fn tables_roundtrip_with_title_header_and_merges() {
assert_eq!(again.blocks.len(), back.blocks.len());
}
#[test]
fn table_block_is_relative_to_the_insertion_point() {
let d = Drawing::new_imperial();
let t = table_sample();
let ents = crate::dxf_write::table_block_entities(&d, &t, "0");
let mut xs = Vec::new();
for e in &ents {
if let EntityKind::Line(l) = &e.kind {
xs.extend([l.a.x, l.b.x]);
}
}
assert!(!xs.is_empty());
let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)));
// Columns total 8 wide; the block starts at its own origin, not at the insert x of 5.
assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}");
}
#[test]
fn text_style_flags_roundtrip() {
let mut d = Drawing::new_imperial();
+26
View File
@@ -536,3 +536,29 @@ fn dragging_a_distance_grip_changes_the_distance() {
assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
assert_eq!(d, before);
}
/// The DOOR drawing with every handle moved above 2^48: AutoCAD writes such handles (seen in
/// drawings whose handles start at 0xDAD1…), and the dynamic block's objects must still be kept.
fn far_handles(src: &str) -> String {
const SHIFT: u64 = 0x000D_AD10_0000_0000;
let is_handle = |code: i32| matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005);
let mut out = String::new();
for line in src.lines() {
let (code, value) = line.trim().split_once(' ').unwrap_or((line.trim(), ""));
let moved = match (code.parse::<i32>(), u64::from_str_radix(value, 16)) {
(Ok(c), Ok(h)) if is_handle(c) && h != 0 => format!("{:X}", h + SHIFT),
_ => value.to_string(),
};
out.push_str(&format!("{code} {moved}\n"));
}
out
}
#[test]
fn a_dynamic_block_with_handles_above_2_pow_48_is_kept() {
let src = far_handles(&fixture());
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
let before = dynamic_block(&d, "Door").expect("the dynamic block with far handles");
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before));
}
+25
View File
@@ -0,0 +1,25 @@
//! #1667: a layout whose page setup plots a window (DXF plot type 4) lost the window corners
//! (groups 48, 49, 140, 141) on save: they were written as zero.
use cadcraft_doc::{Drawing, Layout, PageSetup};
use cadcraft_io::{read, write_dxf};
#[test]
fn plot_window_corners_round_trip_through_dxf() {
let mut d = Drawing::new_metric();
let mut l = Layout::new("WIN", 3);
l.page = PageSetup {
width_mm: 297.0,
height_mm: 420.0,
margins_mm: [0.0; 4],
plot_area: "window".into(),
plot_window: [0.0, -297.0, 420.0, 0.0],
..PageSetup::default()
};
d.layouts.push(l);
let back = read(write_dxf(&d).as_bytes(), "win.dxf").unwrap();
let page = &back.layouts.iter().find(|l| l.name == "WIN").unwrap().page;
assert_eq!(page.plot_area, "window");
assert_eq!(page.plot_window, [0.0, -297.0, 420.0, 0.0]);
}