Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 8505cee19b fix(engine): move LISP output to the command history in one pass
lisp_flush drained each line from the front of the output buffer, shifting everything after it every time, so newline-dense PRINC output cost quadratic time outside the interpreter's step budget. It now walks the buffer once and drops the printed prefix once.
2026-11-03 13:55:27 +00:00
5ed192555c EXPLODE: a block reference with different X and Y scales gives ellipses, not circles (#2649)
* EXPLODE: a block reference with different X and Y scales gives ellipses, not circles

EXPLODE of a block reference whose X and Y scales differ kept its circles
as circles with the mean scale, its arcs as circular arcs, its ellipses
with their old ratio and its polyline arcs as circular bulges: not the
shape the reference draws. BURST and INSERT with explode go the same way.

When the reference's transform isn't a similarity, a circle now becomes a
full ELLIPSE, an arc an elliptical arc, an ellipse gets its true axes, and
a polyline with arc segments (and no widths) its lines and elliptical
arcs. Everything else, and every evenly scaled or mirrored reference,
transforms as before.

The exact ellipse transform and the similarity test move unchanged from
snap.rs to cadcraft-geom (Ellipse::transformed, Mat3::similarity_scale)
and are shared by object snaps and EXPLODE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* contributors: add Tyler-Chiriboga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:54:03 -05:00
4c00f8b9e3 FIND: a Find and Replace window, not only the command line (#2607)
* FIND: report the text around each match

The result list needs to show what matched, not just its handle, so each entry now carries a snippet of the text around the first hit, the hit's offset in it and its length. Offsets count characters, not bytes, so a Chinese or Vietnamese drawing never splits a glyph.

Two optional parameters let a window search part of the space: `types` narrows the search by object type name and `handles` limits it to the objects a caller has already listed, which is what makes replacing a single row possible. Both default to the old behaviour, so scripts, LISP and the MCP server get the answer they always got.

* FIND: a Find and Replace window

FIND only had a command line: it asked for the text, ran, and printed a count, and the palette's Find and Replace tile opened the same prompt. The answer lived in the drawing and the place to read it was the command line.

This adds a window behind the same command. The result list shows each object with the hit lit up, a click selects that object and zooms to it, Replace edits the row under the cursor and Replace All the rows on the list. The window stays open while the drawing is edited, because finding and replacing is a loop: look, locate, edit, look again.

A JSON call carries its parameters, so scripts, LISP and the MCP server still get the engine's answer; only the bare command opens the window. Cmd+F reaches it from anywhere, and the twenty labels it adds are in every interface language.

* FIND: clear the window's state when it closes or the drawing changes

The result rows hold handles, and a handle only means something in the
drawing it was found in. The state now lives exactly as long as the window
does, and an epoch change while the window sits open clears the rows instead
of letting Replace All edit an object the search never matched. A successful
replacement keeps the engine's replacement report rather than letting the
re-search overwrite it with a match count.

* FIND: an empty replacement is a deletion, and stale rows die on any change

Replacing with nothing deletes the matched text, which is a legitimate
replacement, so the buttons no longer require text in the Replace field.

The result rows answer one question - this text, with these switches, in this
drawing and space. Editing the search term or either switch now clears the
rows and owes a fresh search, and so does switching between model space and a
layout: a handle means nothing outside the space it was found in.

* rustfmt: sort the module list after the merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:24 -05:00
7b6be7835f fix(io): a solid HATCH keeps its angle, scale and double flag (#2665)
* fix(io): a solid HATCH keeps its angle, scale and double flag

The writer wrote groups 52 (angle), 41 (scale) and 77 (double) for
pattern fills only, so a SOLID hatch read with them lost them on save.
Write them for solid fills too, as read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(io): fixture for a solid HATCH keeps angle, scale and double flag

Add a synthetic DXF with a SOLID and an ANSI31 hatch and a test that saves it
and compares groups 52, 41 and 77 of each hatch by handle.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* docs(io): document the functions this change touches

Add doc comments to the functions the diff changes whose complexity is above
one, so the docstring coverage check passes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* Drop the DXF fixture: the inline test covers the fix; test data belongs in a corpus repo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:53:20 -05:00
455b2a87cb EXTEND: a polyline's arc end segment runs on round its circle (#2656)
EXTEND on an open polyline ran a ray along the chord of an arc end segment
and kept the old bulge, so the arc changed radius and centre, or the
boundary was reported missed when only the circle reached it. The arc end
segment now runs on round its own circle to the nearest crossing ahead,
turning the same way, and its bulge becomes that of the total turn, as
EXTEND does with an ARC. The polyline also keeps its constant width,
elevation and linetype generation, which the rebuilt polyline dropped.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:52:39 -05:00
Dylan M. Taylor 1b7c705fac Dynamic blocks: index kept objects once per listing; copy each value record once (#2678)
* Dynamic blocks: index kept objects once per listing and copy each value record once

Listing blocks rebuilt a handle index of every kept foreign object for each
block with an extension dictionary, multiplying the block count by the object
count. The listing now builds one ObjectIndex and asks is_dynamic_block_in per
block; dynamic_block answers None before indexing when the block has no
extension dictionary.

templates() copied a value record's tags once per dictionary key, so a record
listed under many keys was copied many times, and it looked up each
dictionary entry by scanning every object. It now resolves entries through
its handle index and copies each record at most once (CWE-400, CWE-407).

* Dynamic blocks: list a block as dynamic from its graph object without walking the graph's members
2026-11-02 22:52:35 -05:00
qshh 77142aa79e fonts: scan the Android and HarmonyOS system font directories (#2650)
* fonts: scan the Android system font directories

System fonts are the only fallback for text whose own font lacks a glyph
(CJK in an SHX big font, for example), and Android was falling through to
the XDG directories it does not have: a drawing came out in the stroke font
with missing-glyph boxes for Chinese. Search /system/fonts and the product
and vendor partitions too.

The directory list moves out of scan() so the platform's main location is
tested directly.

* fonts: scan the HarmonyOS system font directories

HarmonyOS targets are target_os = "linux" with target_env = "ohos", so they
fell into the XDG branch and found no fonts at all: Chinese text in a drawing
came out as missing-glyph boxes while the stroke font drew the ASCII around
it. Search /system/fonts there too, as on Android.

* contributors: add qinshah

Credits them by the name on their public GitHub profile.

* fonts: document the platform font directories
2026-11-02 22:52:31 -05:00
Dylan M. Taylor 31e6e16bc0 constraints: evaluate a named expression without copying its text (#2675)
* constraints: evaluate a named expression without copying its text

Env::value cloned the stored expression before parse rejected it as too
long, and failures are not cached, so an oversized imported expression
referenced from many dimensional constraints was copied on every
reference. Parse the stored text in place, and keep only a bounded
prefix of an over-long expression when it is inserted (it still fails
as too long).

* constraints: test repeated evaluation of an oversized named expression
2026-11-02 22:51:07 -05:00
Tanish Manku f691d0b349 Merge pull request #2683 from storytold/land/1976
DXF: read up to 100 million group codes, or a set limit, without running out of memory silently (#1976)
2026-11-02 20:05:13 -07:00
Tanish Manku e24c4553d2 DXF: 100 million group codes by default, and memory for them that can be refused
The tag list for a counted ASCII file is reserved once with a fallible
allocation, so where the system refuses the memory, opening fails with an error
instead of ending the app. Growing it would also take half as much again for a
moment. 100 million rather than 150 bounds a crafted 512 MB file to about 4 GB
of tags; CADCRAFT_MAX_DXF_TAGS raises it, and the error says so.
2026-11-02 19:47:36 -07:00
Tanish Manku c602a402c3 Merge commit 'refs/pull/1976/head' of github.com:storytold/cadcraft into land/1976 2026-11-02 19:46:17 -07:00
MrBeeboh a13870f376 dxf: check the tag limit in a const block 2026-10-22 20:56:55 -07:00
MrBeeboh a6ba747329 dxf: raise the group-code limit to 150 million and allow CADCRAFT_MAX_DXF_TAGS to override it (#1948) 2026-10-22 20:20:11 -07:00
43 changed files with 1304 additions and 85 deletions
+7
View File
@@ -100,5 +100,12 @@ sync_github_name = true
[people.GULERTOLGA]
sync_github_name = true
[people.Tyler-Chiriboga]
real_name = "Tyler Chiriboga"
sync_github_name = true
[people.starise]
real_name = "Andrea Brandi"
[people.qinshah]
sync_github_name = true
+26 -2
View File
@@ -284,7 +284,14 @@ impl Env {
pub fn new() -> Self {
Env::default()
}
/// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so
/// only a bounded prefix of it is kept: it still fails (as too long), without the environment
/// holding or copying the whole text.
pub fn insert(&mut self, name: &str, expr: &str) {
let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) {
Some(end) => expr.get(..end).unwrap_or(expr),
None => expr,
};
self.exprs.insert(name.to_string(), expr.to_string());
self.cache.clear();
}
@@ -309,8 +316,9 @@ impl Env {
if stack.len() >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?;
// Parse the stored text in place: `parse` refuses an oversized expression before reading
// it, so a long imported expression referenced many times is never copied (CWE-400).
let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?;
stack.push(name.to_string());
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
stack.pop();
@@ -400,6 +408,22 @@ mod tests {
.unwrap();
}
/// An oversized named expression is kept only as a bounded prefix and still fails, however
/// often it is referenced.
#[test]
fn oversized_named_expression_is_bounded_and_fails() {
let mut env = Env::new();
let long = format!("1+{}", "é".repeat(500_000));
env.insert("big", &long);
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
for _ in 0..200 {
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
}
env.insert("ok", "1+1+1");
assert_eq!(env.value("ok"), Ok(3.0));
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+41 -6
View File
@@ -323,8 +323,9 @@ impl<'a> Cursor<'a> {
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
@@ -334,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let graph = evaluation_graph(objects, xdict)?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
@@ -343,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
/// that asks about many blocks (listing them) builds it once and passes it to
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
/// multiplies the block count by the object count (CWE-407).
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
impl<'a> ObjectIndex<'a> {
pub fn new(d: &'a Drawing) -> Self {
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
}
}
/// The extension dictionary of block `name`'s record, if it has one.
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// A block without an extension dictionary is never dynamic: answer before indexing objects.
block_xdict(d, name)?;
dynamic_block_in(d, &ObjectIndex::new(d), name)
}
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
}
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
let xdict = block_xdict(d, name)?;
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
for o in graph_members(&objects.0, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
+47 -6
View File
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
}
/// The object entry `key` (any case) of dictionary object `o` names.
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
}
/// Every entry of dictionary object `o`: (key, object).
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
@@ -692,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
///
/// Each record is copied once at most: a key naming a record an earlier key already took is
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
/// made), so a file listing one large record under many keys would otherwise multiply its size by
/// the key count (CWE-400); AutoCAD gives every key its own record.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// Dictionary entries looked up through the index, not by scanning every object per
// dictionary (which is quadratic in the object count).
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
let mut taken: HashSet<Handle> = HashSet::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
let Some(values) = objects.get(&values) else { continue };
for (k, h) in dictionary_entries(values) {
if out.contains_key(&k) || taken.contains(&h) {
continue;
}
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
taken.insert(h);
out.insert(k, x.tags.clone());
}
}
}
@@ -1044,4 +1066,23 @@ mod tests {
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
#[test]
fn a_value_record_listed_under_many_keys_is_copied_once() {
// One large XRECORD named by many keys of a reference's value dictionary: the templates
// keep one copy, not one per key.
let mut d = Drawing::new_imperial();
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
f.objects.push(dictionary(values, cache, &entries));
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
let t = templates(&d, def_record);
assert_eq!(t.len(), 1, "one record, one template");
assert_eq!(t.get("0").map(Vec::len), Some(1000));
}
}
+57 -9
View File
@@ -23,8 +23,11 @@ pub enum DxfError {
Eof,
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
/// limit was reached), `limit` the maximum.
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")]
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
TooLarge { tags: usize, limit: usize },
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
OutOfMemory { tags: usize },
}
pub type Result<T> = std::result::Result<T, DxfError>;
@@ -163,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value {
}
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
pub const MAX_TAGS: usize = 50_000_000;
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
/// raise or lower it.
pub const MAX_TAGS: usize = 100_000_000;
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
/// [`MAX_TAGS`].
pub fn max_tags() -> usize {
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
}
fn max_tags_from(v: Option<&str>) -> usize {
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
}
mod codepage;
pub use codepage::decode_code_page;
/// Parse a DXF file (ASCII or binary).
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
parse_with_limit(bytes, MAX_TAGS)
parse_with_limit(bytes, max_tags())
}
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
@@ -199,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags)
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
}
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
@@ -271,12 +290,17 @@ fn unescape_unicode(s: &str) -> String {
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_with_limit(text, MAX_TAGS)
parse_ascii_counted(text, max_tags(), 0)
}
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
/// with an allocation that can fail: where the system refuses that much memory, opening the file
/// is an error instead of the end of the app. Growing the list instead could also briefly take
/// half as much again.
#[allow(clippy::while_let_loop)]
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
@@ -357,7 +381,7 @@ impl<'a> Cur<'a> {
/// Binary DXF (R13+: 2-byte group codes).
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
parse_binary_with_limit(b, MAX_TAGS)
parse_binary_with_limit(b, max_tags())
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
@@ -777,3 +801,27 @@ mod tests {
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn tag_limit_defaults_and_env_override() {
assert_eq!(max_tags_from(None), MAX_TAGS);
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
const { assert!(MAX_TAGS >= 100_000_000) };
}
#[test]
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
assert_eq!((tags.len(), tags.capacity()), (3, 3));
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
}
}
+4 -1
View File
@@ -379,8 +379,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
// One object index for the whole listing: rebuilding it per block multiplies the block count
// by the drawing's kept object count (CWE-407).
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
+58 -3
View File
@@ -1,19 +1,74 @@
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
// ---------------- explode / array / order / break / join ----------------
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
let g = ell.transformed(m);
let len = g.major.len();
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
})
}
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
let kinds = match &be.kind {
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
EntityKind::Ellipse(el) => {
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
vec![placed_ellipse(g, m, el.center.z)?]
}
EntityKind::LwPolyline(p)
if p.vertices.iter().any(|v| v.bulge != 0.0)
&& p.const_width == 0.0
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
{
let z = p.elevation;
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
pl.segments()
.into_iter()
.map(|s| match s {
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
})
.collect::<Option<Vec<_>>>()?
}
_ => return None,
};
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
let mut common = be.common.clone();
if matches!(be.kind, EntityKind::LwPolyline(_)) {
common.xdata.clear();
}
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
}
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
if let Some(items) = crate::cmd::array::explode_array(d, e) {
return Some(items);
}
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
let stretched = m.similarity_scale().is_none();
Some(
blk.entities
.iter()
.map(|be| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
.flat_map(|be| {
stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
vec![ne]
})
})
.map(|mut ne| {
if ne.common.layer == "0" {
ne.common.layer = e.common.layer.clone();
}
+49 -11
View File
@@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
}
}
/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep`
/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the
/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle.
fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> {
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true };
let a_end = ga.center.angle_to(end);
let ccw = sweep > 0.0;
// How far past `end` each crossing is, turning the way the segment turns; crossings on the
// segment itself (the last |sweep| of a turn) are not ahead of it.
let (x, more) = cut
.iter()
.flat_map(|c| intersect_ext(&full, c, false))
.map(|x| {
let a = ga.center.angle_to(x);
(x, norm_angle(if ccw { a - a_end } else { a_end - a }))
})
.filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9)
.min_by(|a, b| a.1.total_cmp(&b.1))?;
let total = sweep.abs() + more;
Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total })))
}
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
if crate::cmd::curves::is_locked(s, h) {
@@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
// The end segment's bulge, signed for travel from `prev` out to `end`.
let i_seg = if from_end { i_prev } else { i_end };
let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge });
let none = || EngineError::Other("Object does not intersect an edge.".into());
if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) {
// An arc end segment runs on round its circle, as an arc does.
let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
if let Some(v) = vs.get_mut(i_seg) {
v.bulge = if from_end { b } else { -b };
}
} else {
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
}
lwpoly(vs, false)
// Width, elevation and linetype generation stay as they were.
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
}
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
};
+104 -3
View File
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
.noundo()
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params(
"{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \
{matches: [{handle, type, count, snippet, hit, hitLen}], total, message}",
),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
.params("{mode: distance|radius|angle|area|volume, ...}")
.noundo()
@@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------- FIND ----------------
/// Characters of context kept on each side of a hit in the Find dialog's result list.
const SNIPPET_CONTEXT: usize = 18;
/// The text around the first hit of `find` in `text`, for the result list: at most
/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the
/// snippet (in characters) and its length (in characters).
///
/// The offset and length are character counts, not bytes: the dialog slices the snippet by
/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a
/// string's character count in rare scripts, so every slice goes through `get` and a snippet that
/// cannot be cut is reported as absent rather than guessed at.
fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> {
if find.is_empty() {
return None;
}
let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) };
let mut at = 0;
let pos = loop {
let rest = hay.get(at..)?;
let p = rest.find(&needle)? + at;
let end = p + needle.len();
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
if !whole || (boundary(p) && after) {
break p;
}
at = end.max(p + 1);
if at >= hay.len() {
return None;
}
};
let start = hay.get(..pos)?.chars().count();
let hit_len = needle.chars().count();
let chars: Vec<char> = text.chars().collect();
let from = start.saturating_sub(SNIPPET_CONTEXT);
let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len());
let snippet: String = chars.get(from..to)?.iter().collect();
Some((snippet, start.saturating_sub(from), hit_len))
}
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
if find.is_empty() {
return (text.to_string(), 0);
@@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
let rep = str_param(p, "replace").map(str::to_string);
let case = bool_or(p, "matchCase", false);
let whole = bool_or(p, "wholeWord", false);
// Only the named object types, when the dialog narrows the search; `type_name()` is the same
// name the result list shows, so a caller never has to guess the spelling.
let types: Option<Vec<String>> =
p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect());
let space = s.space();
let d = s.doc()?;
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
// The whole current space by default; the Find window narrows it to the objects its result
// list already holds, so Replace All touches exactly what the user can see.
let hs: Vec<Handle> = match p.get("handles").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(),
None => d.space(&space).map(|st| st.handles()).unwrap_or_default(),
};
let mut found: Vec<Value> = Vec::new();
let mut total = 0;
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else { continue };
if let Some(names) = &types
&& !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase())
{
continue;
}
let mut k = e.kind.clone();
let mut hits = 0;
// The first hit's surroundings, taken before a replacement rewrites the text.
let mut snippet: Option<(String, usize, usize)> = None;
let r = rep.as_deref().unwrap_or(&find);
let mut apply = |t: &mut String| {
let (nt, n) = replace_in(t, &find, r, case, whole);
if n > 0 {
hits += n;
if snippet.is_none() {
snippet = snippet_at(t, &find, case, whole);
}
if rep.is_some() {
*t = nt;
}
@@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
}
if hits > 0 {
total += hits;
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
let (text, at, len) = snippet.unwrap_or_default();
found.push(json!({
"handle": h.hex(),
"type": e.kind.type_name(),
"count": hits,
"snippet": text,
"hit": at,
"hitLen": len,
}));
if rep.is_some() {
edits.push((h, k));
}
@@ -577,4 +647,35 @@ mod tests {
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
}
/// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0.
#[test]
fn snippet_reports_the_hit_offset() {
assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7)));
}
/// Matching ignores case but the snippet keeps the text as it is written, so the result list
/// shows the drawing's own spelling.
#[test]
fn snippet_keeps_the_original_case() {
assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7)));
}
/// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in
/// characters so a CJK hit is never split.
#[test]
fn snippet_is_windowed_in_characters() {
let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30));
let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit");
assert_eq!((hit, len), (SNIPPET_CONTEXT, 2));
assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2);
}
/// A whole-word search skips an embedded hit, and text without a hit has no snippet.
#[test]
fn snippet_respects_whole_word() {
assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None);
assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2));
assert_eq!(snippet_at("nothing", "BOLT", true, false), None);
}
}
+7 -3
View File
@@ -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);
+10
View File
@@ -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();
+5 -32
View File
@@ -813,22 +813,13 @@ fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool {
b.expand(aperture).intersects(probe)
}
/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None`
/// when it distorts shapes (non-uniform scale or shear).
fn similarity_scale(m: &Mat3) -> Option<f64> {
let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d));
let (lu, lv) = (u.len(), v.len());
let tol = 1e-9 * lu.max(lv);
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
}
/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale
/// become ellipses and elliptical arcs.
fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
let mirror = m.is_mirroring();
match p {
Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))),
Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) {
Prim::Seg(Segment::Arc { arc, ccw }) => match m.similarity_scale() {
Some(s) => {
let c = m.apply(arc.center);
let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point())));
@@ -837,14 +828,14 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
}
None => {
let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end };
Prim::Ellipse(xform_ellipse(&e, m))
Prim::Ellipse(e.transformed(m))
}
},
Prim::Circle(c) => match similarity_scale(m) {
Prim::Circle(c) => match m.similarity_scale() {
Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)),
None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)),
None => Prim::Ellipse(Ellipse::full(c.center, Vec2::X * c.radius, 1.0).transformed(m)),
},
Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)),
Prim::Ellipse(e) => Prim::Ellipse(e.transformed(m)),
Prim::Spline(s) => {
let mut s = s.clone();
s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q));
@@ -856,24 +847,6 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
}
}
/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate
/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them
/// (and reverse under a mirroring transform).
fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
let center = m.apply(e.center);
let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor()));
// |am·cos t + an·sin t| is largest at t0: the new major axis.
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
let (s0, c0) = t0.sin_cos();
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
if e.is_full() {
return Ellipse::full(center, u, ratio);
}
let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) };
Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) }
}
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option<Bounds2>) -> Option<SnapHit> {
let mut best: Option<(f64, SnapHit)> = None;
@@ -83,6 +83,10 @@ fn blocks_without_dynamic_data_and_bad_names() {
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap();
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null));
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null));
let list = s.execute("blocks.list", &json!({})).unwrap();
let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect();
assert!(flags.contains(&("Window".into(), json!(false))), "{flags:?}");
assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}");
for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] {
assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}");
}
+140
View File
@@ -0,0 +1,140 @@
//! EXPLODE of a block reference whose X and Y scales differ: its circles, arcs, ellipses and
//! polyline arcs come out as the ellipses and elliptical arcs the reference draws, not as circles
//! scaled by the mean of the two scales.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Entity, EntityKind};
use serde_json::json;
const AT: [f64; 2] = [100.0, 50.0];
const ROTATION: f64 = 30.0;
/// Block S (base 0,0): a circle (r 5 at 0,0), an arc (r 4 at 20,0, 0°..90°), an elliptical arc
/// (centre 40,0, major 6 along X, ratio 0.5, 0°..180°), a slot polyline (two lines, two
/// semicircles of r 2 at 60,0 and 70,0) and a line. One reference to it at `AT`, rotated by
/// `ROTATION`, scaled by `scale`, exploded.
fn exploded(scale: [f64; 2]) -> Vec<Entity> {
let mut s = Session::new();
let mut hs = Vec::new();
for (id, p) in [
("circle", json!({"center": [0, 0], "radius": 5})),
("arc", json!({"center": [20, 0], "radius": 4, "start": 0, "end": 90})),
("ellipse", json!({"center": [40, 0], "major": [6, 0], "ratio": 0.5, "start": 0, "end": 180})),
("pline", json!({"vertices": [[60, -2], {"p": [70, -2], "bulge": 1}, [70, 2], {"p": [60, 2], "bulge": 1}], "closed": true})),
("line", json!({"points": [[0, 10], [10, 10]]})),
] {
s.execute(id, &p).unwrap();
hs.push(s.doc().unwrap().model.iter().last().unwrap().handle.hex());
}
s.execute("block", &json!({"name": "S", "base": [0, 0], "keep": "delete", "handles": hs})).unwrap();
let r = s.execute("insert", &json!({"name": "S", "at": AT, "scale": scale, "rotation": ROTATION})).unwrap();
s.execute("explode", &json!({"handles": [r["handle"]]})).unwrap();
s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect()
}
/// A drawing point back in block coordinates.
fn to_block(p: [f64; 2], scale: [f64; 2]) -> [f64; 2] {
let (s, c) = (-ROTATION.to_radians()).sin_cos();
let (dx, dy) = (p[0] - AT[0], p[1] - AT[1]);
[(dx * c - dy * s) / scale[0], (dx * s + dy * c) / scale[1]]
}
/// Points along an ELLIPSE from its start to its end parameter.
fn samples(e: &cadcraft_engine::doc::Ellipse) -> Vec<[f64; 2]> {
let mut sweep = e.end - e.start;
if sweep <= 0.0 {
sweep += std::f64::consts::TAU;
}
let (mx, my) = (e.major.x, e.major.y);
let (nx, ny) = (-my * e.ratio, mx * e.ratio);
(0..=16)
.map(|i| {
let t = e.start + sweep * f64::from(i) / 16.0;
[e.center.x + mx * t.cos() + nx * t.sin(), e.center.y + my * t.cos() + ny * t.sin()]
})
.collect()
}
fn ellipses(v: &[Entity]) -> Vec<cadcraft_engine::doc::Ellipse> {
v.iter()
.filter_map(|e| match &e.kind {
EntityKind::Ellipse(el) => Some(el.clone()),
_ => None,
})
.collect()
}
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).hypot(a[1] - b[1]) < 1e-9
}
/// Every point of `el`, taken back into the block, lies on the circle of radius `r` about `c`
/// in block coordinates, and its ends are `ends` (the whole circle when `None`).
fn on_circle(el: &cadcraft_engine::doc::Ellipse, scale: [f64; 2], c: [f64; 2], r: f64, ends: Option<([f64; 2], [f64; 2])>) {
let pts: Vec<[f64; 2]> = samples(el).into_iter().map(|p| to_block(p, scale)).collect();
for q in &pts {
let d = (q[0] - c[0]).hypot(q[1] - c[1]);
assert!((d - r).abs() < 1e-9, "{q:?} is {d} from {c:?}, not {r}: {el:?}");
}
if let Some((a, b)) = ends {
let (first, last) = (pts[0], pts[pts.len() - 1]);
assert!(near(first, a) && near(last, b) || near(first, b) && near(last, a), "ends {first:?} {last:?}, expected {a:?} {b:?}");
}
}
#[test]
fn explode_of_a_stretched_reference_draws_circles_and_arcs_as_ellipses() {
for scale in [[2.0, 0.5], [-2.0, 0.5], [1.0, 3.0]] {
let v = exploded(scale);
assert!(!v.iter().any(|e| matches!(e.kind, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_))), "{scale:?}: {v:?}");
let els = ellipses(&v);
// Circle, arc, elliptical arc and the slot's two semicircles.
assert_eq!(els.len(), 5, "{scale:?}: {els:?}");
let at = |c: [f64; 2]| els.iter().filter(move |e| near(to_block([e.center.x, e.center.y], scale), c));
let circle = at([0.0, 0.0]).next().unwrap();
on_circle(circle, scale, [0.0, 0.0], 5.0, None);
// Its axes are the scaled radii: 10 and 2.5 for 2 x 0.5.
let major = circle.major.x.hypot(circle.major.y);
let (big, small) = (5.0 * scale[0].abs().max(scale[1].abs()), 5.0 * scale[0].abs().min(scale[1].abs()));
assert!((major - big).abs() < 1e-9 && (major * circle.ratio - small).abs() < 1e-9, "{circle:?}");
on_circle(at([20.0, 0.0]).next().unwrap(), scale, [20.0, 0.0], 4.0, Some(([24.0, 0.0], [20.0, 4.0])));
on_circle(at([60.0, 0.0]).next().unwrap(), scale, [60.0, 0.0], 2.0, Some(([60.0, 2.0], [60.0, -2.0])));
on_circle(at([70.0, 0.0]).next().unwrap(), scale, [70.0, 0.0], 2.0, Some(([70.0, -2.0], [70.0, 2.0])));
// The elliptical arc: its points, back in the block, are on the block's ellipse
// ((x-40)/6)^2 + (y/3)^2 = 1, from (46,0) over the top to (34,0).
let ell = at([40.0, 0.0]).next().unwrap();
let pts: Vec<[f64; 2]> = samples(ell).into_iter().map(|p| to_block(p, scale)).collect();
for q in &pts {
let f = ((q[0] - 40.0) / 6.0).powi(2) + (q[1] / 3.0).powi(2);
assert!((f - 1.0).abs() < 1e-9 && q[1] > -1e-9, "{q:?}: {ell:?}");
}
let (first, last) = (pts[0], pts[pts.len() - 1]);
assert!(near(first, [46.0, 0.0]) && near(last, [34.0, 0.0]) || near(first, [34.0, 0.0]) && near(last, [46.0, 0.0]), "{pts:?}");
// The slot's straight sides and the line are lines, where the reference draws them.
let lines: Vec<[[f64; 2]; 2]> = v
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(l) => Some([to_block([l.a.x, l.a.y], scale), to_block([l.b.x, l.b.y], scale)]),
_ => None,
})
.collect();
assert_eq!(lines.len(), 3);
for [a, b] in [[[60.0, -2.0], [70.0, -2.0]], [[70.0, 2.0], [60.0, 2.0]], [[0.0, 10.0], [10.0, 10.0]]] {
assert!(lines.iter().any(|l| near(l[0], a) && near(l[1], b)), "{a:?}-{b:?} not in {lines:?}");
}
}
}
#[test]
fn explode_of_an_evenly_scaled_or_mirrored_reference_keeps_circles_arcs_and_polylines() {
for scale in [[2.0, 2.0], [-1.0, 1.0]] {
let v = exploded(scale);
let count = |f: fn(&EntityKind) -> bool| v.iter().filter(|e| f(&e.kind)).count();
assert_eq!(count(|k| matches!(k, EntityKind::Circle(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::Arc(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::LwPolyline(_))), 1, "{scale:?}");
assert_eq!(count(|k| matches!(k, EntityKind::Ellipse(_))), 1, "{scale:?}");
let r = v.iter().find_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }).unwrap();
assert!((r - 5.0 * scale[0].abs()).abs() < 1e-9);
}
}
@@ -0,0 +1,137 @@
//! EXTEND on an open polyline whose end segment is an arc: the arc runs on round its own circle
//! to the boundary, as EXTEND does with an ARC, instead of the end vertex sliding along the chord
//! with the old bulge (which changed the arc's radius and centre, or missed the boundary). The
//! polyline keeps its width, elevation and linetype generation.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Entity, EntityKind, Handle, LwPolyline};
use cadcraft_engine::geom::{PolyVertex, Vec2};
use serde_json::json;
const TOL: f64 = 1e-9;
fn add(s: &mut Session, kind: EntityKind) -> String {
let space = s.space();
let d = s.doc_mut().unwrap();
let handle = d.new_handle();
d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind });
handle.hex()
}
fn v(x: f64, y: f64, bulge: f64) -> PolyVertex {
PolyVertex { p: Vec2::new(x, y), bulge, ..Default::default() }
}
fn pline(vertices: Vec<PolyVertex>) -> LwPolyline {
LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false }
}
fn edge(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
r["handles"][0].as_str().unwrap().to_string()
}
fn polyline(s: &Session, h: &str) -> LwPolyline {
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
EntityKind::LwPolyline(p) => p.clone(),
k => panic!("not a polyline: {k:?}"),
}
}
fn near(a: Vec2, x: f64, y: f64) -> bool {
(a.x - x).abs() < 1e-6 && (a.y - y).abs() < 1e-6
}
/// tan(22.5°): the bulge of a quarter circle.
fn quarter() -> f64 {
(std::f64::consts::PI / 8.0).tan()
}
/// The bulge of an arc turning through `deg` degrees (positive counterclockwise).
fn bulge(deg: f64) -> f64 {
(deg.to_radians() / 4.0).tan()
}
#[test]
fn a_counterclockwise_arc_at_the_end_runs_on_round_its_circle() {
let mut s = Session::new();
let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]);
// A line in from (20,0), then a quarter circle about the origin from (10,0) to (0,10).
let mut pl = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]);
pl.const_width = 0.5;
pl.elevation = 3.0;
pl.plinegen = true;
let h = add(&mut s, EntityKind::LwPolyline(pl));
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
let p = polyline(&s, &h);
assert_eq!(p.vertices.len(), 3);
// Round the circle (radius 10) through (-10,0) to y = -5: a 210° turn in all.
let end = p.vertices[2].p;
assert!(near(end, -(75.0f64.sqrt()), -5.0), "end {end:?}");
assert!((p.vertices[1].bulge - bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
assert!(near(p.vertices[0].p, 20.0, 0.0) && near(p.vertices[1].p, 10.0, 0.0));
assert_eq!((p.const_width, p.elevation, p.plinegen), (0.5, 3.0, true));
}
#[test]
fn an_arc_at_the_start_runs_on_backwards_round_its_circle() {
let mut s = Session::new();
let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]);
// The same path drawn the other way: clockwise from (0,10) to (10,0), then out to (20,0).
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(0.0, 10.0, -quarter()), v(10.0, 0.0, 0.0), v(20.0, 0.0, 0.0)])));
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
let p = polyline(&s, &h);
assert!(near(p.vertices[0].p, -(75.0f64.sqrt()), -5.0), "start {:?}", p.vertices[0].p);
assert!((p.vertices[0].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[0].bulge);
assert!(near(p.vertices[1].p, 10.0, 0.0) && near(p.vertices[2].p, 20.0, 0.0));
}
#[test]
fn a_clockwise_arc_at_the_end_runs_on_clockwise() {
let mut s = Session::new();
let e = edge(&mut s, [-50.0, 5.0], [50.0, 5.0]);
// Clockwise about the origin from (10,0) to (0,-10); on round through (-10,0) to y = 5.
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, -quarter()), v(0.0, -10.0, 0.0)])));
s.execute("extend", &json!({ "handle": h, "pick": [0.5, -9.9], "edges": [e] })).unwrap();
let p = polyline(&s, &h);
assert!(near(p.vertices[2].p, -(75.0f64.sqrt()), 5.0), "end {:?}", p.vertices[2].p);
assert!((p.vertices[1].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
}
#[test]
fn the_nearest_crossing_ahead_on_the_circle_wins() {
let mut s = Session::new();
// A boundary crossing the circle twice ahead of the end: x = -6 at about 127° and 233°.
let e = edge(&mut s, [-6.0, -50.0], [-6.0, 50.0]);
let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)])));
s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap();
let p = polyline(&s, &h);
assert!(near(p.vertices[2].p, -6.0, 8.0), "end {:?}", p.vertices[2].p);
let turn = 8.0f64.atan2(-6.0).to_degrees();
assert!((p.vertices[1].bulge - bulge(turn)).abs() < TOL, "bulge {}", p.vertices[1].bulge);
}
#[test]
fn an_arc_end_whose_circle_misses_every_edge_is_left_alone() {
let mut s = Session::new();
// The chord (10,0)→(0,10) runs on to this edge, but the circle never reaches it.
let e = edge(&mut s, [-50.0, 30.0], [50.0, 30.0]);
let before = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]);
let h = add(&mut s, EntityKind::LwPolyline(before.clone()));
assert!(s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).is_err());
assert_eq!(polyline(&s, &h), before);
}
#[test]
fn a_straight_end_keeps_the_polyline_width_and_elevation() {
let mut s = Session::new();
let e = edge(&mut s, [50.0, -50.0], [50.0, 50.0]);
let mut pl = pline(vec![v(0.0, 0.0, 0.0), v(10.0, 0.0, 0.0)]);
pl.const_width = 0.5;
pl.elevation = 3.0;
let h = add(&mut s, EntityKind::LwPolyline(pl));
s.execute("extend", &json!({ "handle": h, "pick": [9.0, 0.0], "edges": [e] })).unwrap();
let p = polyline(&s, &h);
assert!(near(p.vertices[1].p, 50.0, 0.0), "end {:?}", p.vertices[1].p);
assert_eq!((p.const_width, p.elevation), (0.5, 3.0));
}
+2
View File
@@ -46,6 +46,8 @@ mod draworder;
mod dyn_input;
mod dynamic_block_data;
mod dynamic_grips;
mod explode_stretched;
mod extend_polyline_arc_end;
mod fillet_chamfer;
mod fix_holds_grips;
mod followups_626;
+43 -3
View File
@@ -5,6 +5,11 @@
//! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections
//! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face.
//!
//! System fonts are searched in the platform's own directories: `/System/Library/Fonts` and
//! friends on macOS, `C:\Windows\Fonts` on Windows, `/system/fonts` and the product/vendor
//! partitions on Android and HarmonyOS, and the XDG directories elsewhere. Web builds have no
//! system fonts.
//!
//! Two process-wide settings (like AutoCAD's profile variables) control substitution:
//! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX
//! file we don't read, a missing TTF). Empty = the built-in stroke font.
@@ -270,15 +275,20 @@ fn lock() -> MutexGuard<'static, Db> {
g
}
/// The platform's system font directories, in search order.
#[cfg(not(target_arch = "wasm32"))]
fn scan() -> HashMap<String, PathBuf> {
let mut out = HashMap::new();
fn font_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into));
if let Some(h) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(h).join("Library/Fonts"));
}
} else if cfg!(any(target_os = "android", target_env = "ohos")) {
// Android and HarmonyOS keep system fonts under /system/fonts; Android devices also use
// the product and vendor partitions for faces of their own. An app may read all of them,
// and directories a device lacks are skipped.
dirs.extend(["/system/fonts", "/product/fonts", "/system_ext/fonts", "/vendor/fonts"].map(Into::into));
} else if cfg!(windows) {
dirs.push("C:\\Windows\\Fonts".into());
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
@@ -291,7 +301,14 @@ fn scan() -> HashMap<String, PathBuf> {
dirs.push(PathBuf::from(h).join(".local/share/fonts"));
}
}
let mut stack = dirs;
dirs
}
/// Every font file under [`font_dirs`], by lower-case file stem.
#[cfg(not(target_arch = "wasm32"))]
fn scan() -> HashMap<String, PathBuf> {
let mut out = HashMap::new();
let mut stack = font_dirs();
let mut seen = 0usize;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
@@ -1080,3 +1097,26 @@ mod geometry_budget_tests {
assert!(shaped.text.is_empty());
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod scan_tests {
use super::*;
/// Each platform searches its own system font directories. A branch that is missing or
/// misnamed leaves a device with no fonts at all, so the platform's main location is checked
/// here rather than discovered by a drawing full of missing-glyph boxes.
#[test]
fn the_platforms_system_font_directory_is_searched() {
let dirs = font_dirs();
#[cfg(target_os = "macos")]
assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}");
#[cfg(target_os = "android")]
assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}");
#[cfg(target_env = "ohos")]
assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}");
#[cfg(windows)]
assert!(dirs.iter().any(|d| d == std::path::Path::new("C:\\Windows\\Fonts")), "{dirs:?}");
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_env = "ohos"))))]
assert!(dirs.iter().any(|d| d == std::path::Path::new("/usr/share/fonts")), "{dirs:?}");
}
}
+18 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::{Bounds2, EPS, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle};
use crate::{Bounds2, EPS, Mat3, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle};
/// A straight segment.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@@ -247,6 +247,23 @@ impl Ellipse {
pub fn sweep(&self) -> f64 {
ccw_sweep(self.start, self.end)
}
/// The image under the affine transform `m`, exactly: the conjugate semi-diameters
/// `A·major`, `A·minor` give the new principal axes; parameters shift with them (and
/// reverse under a mirroring transform).
pub fn transformed(&self, m: &Mat3) -> Ellipse {
let center = m.apply(self.center);
let (am, an) = (m.apply_vec(self.major), m.apply_vec(self.minor()));
// |am·cos t + an·sin t| is largest at t0: the new major axis.
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
let (s0, c0) = t0.sin_cos();
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
if self.is_full() {
return Ellipse::full(center, u, ratio);
}
let (start, end) = if u.cross(v) >= 0.0 { (self.start - t0, self.end - t0) } else { (t0 - self.end, t0 - self.start) };
Ellipse { center, major: u, ratio, start: norm_angle(start), end: norm_angle(end) }
}
pub fn tessellate(&self, tol: f64, out: &mut Vec<Vec2>) {
let r = self.major.len();
let sweep = self.sweep();
+8
View File
@@ -98,6 +98,14 @@ impl Mat3 {
pub fn is_mirroring(&self) -> bool {
self.det() < 0.0
}
/// The rotation-and-uniform-scale factor of the linear part (mirroring allowed), or `None`
/// when it distorts shapes (non-uniform scale or shear).
pub fn similarity_scale(&self) -> Option<f64> {
let (u, v) = (Vec2::new(self.a, self.b), Vec2::new(self.c, self.d));
let (lu, lv) = (u.len(), v.len());
let tol = 1e-9 * lu.max(lv);
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
}
}
/// A 4x4 transform, row-major, for 3D views and block transforms with elevation.
+8 -3
View File
@@ -600,6 +600,9 @@ fn write_annotations_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, pap
}
}
/// Writes a HATCH: its pattern name, solid and associative flags, its boundary loops, and the
/// pattern angle, scale and double flag (written for solid hatches too). Pattern lines are written
/// only for non-solid hatches.
fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) {
if let EntityKind::Hatch(h) = &e.kind {
w.s(0, "HATCH");
@@ -634,10 +637,12 @@ fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bo
}
w.i(75, i64::from(h.style));
w.i(76, 1);
// Angle, scale and double flag are written for solid fills too, as read (AutoCAD and
// other writers keep them on SOLID hatches; readers ignore them there).
w.f(52, h.angle.to_degrees());
w.f(41, h.scale);
w.i(77, 0);
if !h.solid {
w.f(52, h.angle.to_degrees());
w.f(41, h.scale);
w.i(77, 0);
// The lines read from the file, else the library's pattern of that name.
let lines = if h.pattern_lines.is_empty() {
cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default()
+17 -1
View File
@@ -1039,7 +1039,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads");
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
}
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
@@ -2896,3 +2896,19 @@ fn non_unicode_names_pick_the_format_by_extension() {
assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG"));
assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG");
}
/// A SOLID hatch keeps its angle (52), scale (41) and double flag (77) through a save: the writer
/// wrote them for pattern fills only.
#[test]
fn solid_hatch_keeps_angle_scale_and_double_flag() {
let src = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n5\n40\n8\n0\n100\nAcDbHatch\n10\n0\n20\n0\n30\n0\n2\nSOLID\n70\n1\n71\n0\n\
91\n1\n92\n2\n72\n0\n73\n1\n93\n4\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n97\n0\n\
75\n0\n76\n1\n52\n30\n41\n2\n77\n0\n98\n0\n0\nENDSEC\n0\nEOF\n";
let out = write_dxf(&read_dxf(src.as_bytes()).unwrap());
let pairs = dxf_pairs(&out);
let h = dxf_records(&pairs, "HATCH")[0];
let f = |code: &str| group(h, code).and_then(|v| v.parse::<f64>().ok());
assert!(f("52").is_some_and(|a| (a - 30.0).abs() < 1e-9), "{h:?}");
assert!(f("41").is_some_and(|s| (s - 2.0).abs() < 1e-12), "{h:?}");
assert_eq!(group(h, "77"), Some("0"), "{h:?}");
}
+1
View File
@@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
"units" => units(app, ctx, &mut open),
"hatch" => crate::hatchdlg::dialog(app, ctx, &mut open),
"aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),
"find" => crate::finddlg::dialog(app, ctx, &mut open),
"about" => about(ctx, &mut open),
"preview" => crate::exchange::dialog(app, ctx, &mut open),
"commands" => commands(app, ctx, &mut open),
+294
View File
@@ -0,0 +1,294 @@
//! Find and Replace: the window behind the FIND command.
//!
//! FIND used to be a command line that asked for its parameters and then printed a count. The
//! parameters are still what the engine takes; this window is the surface where a user can see
//! *what* matched, jump to it and replace one object at a time. It stays open while the drawing
//! is edited — find-and-replace in a drawing is a loop (look, locate, edit, look again), so a
//! window that closed on the first result would be closed and reopened all afternoon.
//!
//! Everything the engine cannot answer is left out rather than faked: the search covers the
//! current space, and the note under the switches says so in as many words.
use egui::{Color32, RichText, Sense, TextFormat};
use serde_json::{Value, json};
use crate::CadApp;
use crate::theme::{self, Tokens};
/// One object the search hit, as the engine reports it.
#[derive(Clone, Default)]
struct Row {
handle: String,
kind: String,
count: u64,
/// The text around the first hit, and where the hit sits inside it (characters, not bytes).
snippet: String,
hit: usize,
hit_len: usize,
}
/// A `u64` from the reply as a `usize`, or 0 when it does not fit: an offset past the end of a
/// snippet is a bad reply, not a reason to fail the search that produced it.
fn as_index(v: Option<&Value>) -> usize {
v.and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0)
}
impl Row {
/// Read one `matches` entry; anything missing keeps its default so a partially understood
/// reply still lists something.
fn from_json(v: &Value) -> Self {
Self {
handle: v.get("handle").and_then(Value::as_str).unwrap_or_default().to_string(),
kind: v.get("type").and_then(Value::as_str).unwrap_or_default().to_string(),
count: v.get("count").and_then(Value::as_u64).unwrap_or(0),
snippet: v.get("snippet").and_then(Value::as_str).unwrap_or_default().to_string(),
hit: as_index(v.get("hit")),
hit_len: as_index(v.get("hitLen")),
}
}
/// The snippet in three pieces: before the hit, the hit, after it. Every slice is by
/// character and every range is clamped, so a reply with a nonsensical offset still draws.
fn pieces(&self) -> (String, String, String) {
let n = self.snippet.chars().count();
let at = self.hit.min(n);
let end = at.saturating_add(self.hit_len).min(n);
let before: String = self.snippet.chars().take(at).collect();
let hit: String = self.snippet.chars().skip(at).take(end.saturating_sub(at)).collect();
let after: String = self.snippet.chars().skip(end).collect();
(before, hit, after)
}
}
/// The window's state between frames: kept in egui's temporary store, like the other dialogs,
/// so it lives exactly as long as the window does and never reaches the preferences file.
#[derive(Default, Clone)]
struct State {
find: String,
replace: String,
match_case: bool,
whole_word: bool,
/// The `doc_epoch` and space the rows were found in. A row's handle is only meaningful in
/// the drawing and the space it was found in, so when either moves the rows must go.
epoch: u64,
space: cadcraft_doc::Space,
rows: Vec<Row>,
/// The row Enter and Replace act on.
current: Option<usize>,
message: String,
}
/// Run FIND and keep the parsed matches, or the engine's complaint in `message`.
fn search(app: &mut CadApp, st: &mut State) {
if st.find.trim().is_empty() {
st.rows.clear();
st.current = None;
st.message = crate::i18n::t("Type something to find").to_string();
return;
}
let params = json!({ "find": st.find, "matchCase": st.match_case, "wholeWord": st.whole_word });
match app.run("find", params) {
Ok(v) => {
st.rows = v.get("matches").and_then(Value::as_array).map(|a| a.iter().map(Row::from_json).collect()).unwrap_or_default();
st.current = if st.rows.is_empty() { None } else { Some(0) };
let total = v.get("total").and_then(Value::as_u64).unwrap_or(0);
let objects = v.get("matches").and_then(Value::as_array).map_or(0, Vec::len);
st.message = format!("{total} match(es) in {objects} object(s)");
}
Err(e) => {
st.rows.clear();
st.current = None;
st.message = e;
}
}
}
/// Select `row` in the drawing and zoom to it: the result list's rows navigate, they do not just
/// report.
fn reveal(app: &mut CadApp, st: &State, index: usize) {
let Some(row) = st.rows.get(index) else { return };
let Some(h) = cadcraft_doc::Handle::parse_hex(&row.handle) else { return };
app.session.set_selection(vec![h]);
let _ = app.run("zoom", json!({ "mode": "object" }));
}
/// Replace inside the objects the result list already holds. `only` limits it to one row, so
/// Replace touches the object the user is looking at and Replace All touches the whole list.
fn replace(app: &mut CadApp, st: &mut State, only: Option<usize>) {
if st.find.trim().is_empty() {
return;
}
let handles: Vec<String> = match only {
Some(i) => st.rows.get(i).map(|r| r.handle.clone()).into_iter().collect(),
None => st.rows.iter().map(|r| r.handle.clone()).collect(),
};
if handles.is_empty() {
return;
}
let params = json!({
"find": st.find,
"replace": st.replace,
"matchCase": st.match_case,
"wholeWord": st.whole_word,
"handles": handles,
});
match app.run("find", params) {
Ok(v) => {
// The engine's reply reports what the replacement did; the re-search below would
// overwrite that message with a match count, so the replacement report is what
// the user ends up reading.
let replaced = v.get("message").and_then(Value::as_str).unwrap_or_default().to_string();
search(app, st);
if !replaced.is_empty() {
st.message = replaced;
}
}
Err(e) => st.message = e,
}
}
/// The Find and Replace window.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let sid = egui::Id::unique("find_state");
let mut st: State = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default();
// A handle from an earlier drawing or space now names whatever object took its place; if
// Replace All ran on such a row it would edit an object the search never matched. Clearing
// the rows on an epoch or space change costs a re-click on Find All, which is the cheap
// side of that trade.
if *open && (st.epoch != app.session.doc_epoch || st.space != app.session.space()) {
st.epoch = app.session.doc_epoch;
st.space = app.session.space();
st.rows.clear();
st.current = None;
st.message.clear();
}
// The rows answer one question — this text, with these switches, in this drawing. Editing
// any of them makes the rows stale: Replace All would rewrite with the old switches while
// the user is looking at the new ones, so the rows go and a fresh search is owed.
let criteria = (st.find.clone(), st.match_case, st.whole_word);
let mut do_search = false;
let mut do_replace: Option<Option<usize>> = None;
let mut do_select_all = false;
let mut pick: Option<usize> = None;
egui::Window::new(crate::tl!("Find and Replace"))
.id(egui::Id::unique("Find and Replace"))
.open(open)
.collapsible(false)
.resizable(true)
.default_width(480.0)
.default_height(400.0)
.show(ctx, |ui| {
egui::Grid::new("find_fields").num_columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(crate::tl!("Find what:"));
let r = ui.add(egui::TextEdit::singleline(&mut st.find).desired_width(f32::INFINITY).hint_text(crate::tl!("Text to find")));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
do_search = true;
}
ui.end_row();
ui.label(crate::tl!("Replace with:"));
ui.add(egui::TextEdit::singleline(&mut st.replace).desired_width(f32::INFINITY).hint_text(crate::tl!("Replacement text")));
ui.end_row();
});
ui.horizontal(|ui| {
ui.checkbox(&mut st.match_case, crate::tl!("Match case"));
ui.checkbox(&mut st.whole_word, crate::tl!("Whole words"));
});
ui.label(RichText::new(crate::tl!("Searches the current space.")).color(t.text_faint).small());
ui.add_space(2.0);
ui.separator();
let found = if st.rows.is_empty() { crate::tl!("Nothing found").to_string() } else { st.rows.len().to_string() };
ui.label(RichText::new(format!("{} · {found}", crate::tl!("Results"))).color(t.text_dim).small());
let body_h = (ui.available_height() - 76.0).clamp(80.0, 280.0);
egui::Frame::NONE.fill(t.field).corner_radius(3).inner_margin(egui::Margin::same(2)).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(ui.available_width());
for (i, row) in st.rows.iter().enumerate() {
let bg = if st.current == Some(i) { t.select_bg } else { Color32::TRANSPARENT };
let inner = egui::Frame::NONE.fill(bg).corner_radius(2).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::t(&row.kind)).color(t.text_dim).small());
ui.label(RichText::new(row.handle.as_str()).color(t.text_faint).small().monospace());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(format!("×{}", row.count)).color(t.text_faint).small());
});
});
let (before, hit, after) = row.pieces();
let mut job = egui::text::LayoutJob::default();
let plain = TextFormat { font_id: theme::small(), color: t.text, ..Default::default() };
let marked = TextFormat { font_id: theme::mono(), color: accent(), ..Default::default() };
job.append(&before, 0.0, plain.clone());
job.append(&hit, 0.0, marked);
job.append(&after, 0.0, plain);
ui.add(egui::Label::new(job).wrap());
});
let click = ui.interact(inner.response.rect, ui.scope_id().with(("find_row", i)), Sense::click());
if click.clicked() {
st.current = Some(i);
pick = Some(i);
}
}
});
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button(crate::tl!("Find All")).clicked() {
do_search = true;
}
let has = !st.rows.is_empty();
if ui.add_enabled(has, egui::Button::new(crate::tl!("Select All"))).clicked() {
do_select_all = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// An empty replacement is deleting the matched text, which is a legitimate
// replacement, so the buttons care only about having something to act on.
if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace All"))).clicked() {
do_replace = Some(None);
}
if ui.add_enabled(st.current.is_some(), egui::Button::new(crate::tl!("Replace"))).clicked() {
do_replace = Some(st.current);
}
});
});
if !st.message.is_empty() {
ui.label(RichText::new(st.message.as_str()).color(t.text_faint).small());
}
});
if let Some(i) = pick {
reveal(app, &st, i);
}
if criteria != (st.find.clone(), st.match_case, st.whole_word) {
st.rows.clear();
st.current = None;
if !do_search {
st.message.clear();
}
}
if do_search {
search(app, &mut st);
}
if do_select_all {
let hs: Vec<cadcraft_doc::Handle> = st.rows.iter().filter_map(|r| cadcraft_doc::Handle::parse_hex(&r.handle)).collect();
if !hs.is_empty() {
app.session.set_selection(hs);
st.message = format!("{} object(s) selected", st.rows.len());
}
}
if let Some(only) = do_replace {
replace(app, &mut st, only);
}
// The state lives exactly as long as the window does: a closed window leaves nothing behind,
// so reopening it reads the drawing instead of a stale result list.
if *open {
ctx.data_mut(|d| d.insert_temp(sid, st));
} else {
ctx.data_mut(|d| d.remove_temp::<State>(sid));
}
}
/// The colour a hit is drawn in: the theme's point colour, the same orange the drawing uses for
/// points and grips, so the highlight belongs to the drawing rather than to the interface.
fn accent() -> Color32 {
Tokens::get().icon_point
}
+12
View File
@@ -1368,3 +1368,15 @@ menu View عرض
overrides built-in يتجاوز المدمج
{alias} is already defined; select it and use Edit. {alias} معرَّف مسبقاً؛ حدده واستخدم «تحرير».
{command} is an unknown command. {command} أمر غير معروف.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 1335 and column 24.
+12
View File
@@ -1444,3 +1444,15 @@ menu Modify Izmijeni
Save changes to block "{name}"? Sačuvati promjene u bloku „{name}”?
Layouts Rasporedi
Associate Poveži
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 1444 and column 24.
+12
View File
@@ -1224,3 +1224,15 @@ menu Modify Modifikace
Check for Updates… Zkontrolovat aktualizace…
CADCraft is up to date. CADCraft je aktuální.
Could not check for updates. Aktualizace se nepodařilo zkontrolovat.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
+12
View File
@@ -1041,3 +1041,15 @@
Check for Updates… Nach Updates suchen…
CADCraft is up to date. CADCraft ist auf dem neuesten Stand.
Could not check for updates. Die Suche nach Updates ist fehlgeschlagen.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
+12
View File
@@ -1335,3 +1335,15 @@ menu View Προβολή
Check for Updates… Έλεγχος για ενημερώσεις…
CADCraft is up to date. Το CADCraft είναι ενημερωμένο.
Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
+12
View File
@@ -1255,3 +1255,15 @@
Check for Updates… Buscar actualizaciones…
CADCraft is up to date. CADCraft está actualizado.
Could not check for updates. No se pudo buscar actualizaciones.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Spanish interface catalog — original translations.
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
+12
View File
@@ -1014,3 +1014,15 @@
Check for Updates… Rechercher des mises à jour…
CADCraft is up to date. CADCraft est à jour.
Could not check for updates. Impossible de rechercher les mises à jour.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
+12
View File
@@ -1346,3 +1346,15 @@ menu View Nézet
Check for Updates… Frissítések keresése…
CADCraft is up to date. A CADCraft naprakész.
Could not check for updates. A frissítések keresése nem sikerült.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 16 and column 72.
+12
View File
@@ -1364,3 +1364,15 @@ menu Modify Edita
Check for Updates… Verifica aggiornamenti…
CADCraft is up to date. CADCraft è aggiornato.
Could not check for updates. Impossibile verificare gli aggiornamenti.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
+12
View File
@@ -1244,3 +1244,15 @@ menu Modify 수정
Check for Updates… 업데이트 확인…
CADCraft is up to date. CADCraft가 최신 버전입니다.
Could not check for updates. 업데이트를 확인하지 못했습니다.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Korean interface catalog — original Korean translations.
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
+12
View File
@@ -1253,6 +1253,18 @@
Check for Updates… Verificar atualizações…
CADCraft is up to date. O CADCraft está atualizado.
Could not check for updates. Não foi possível verificar atualizações.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31).
Add: Pick points Adicionar: indicar pontos
Add: Select objects Adicionar: selecionar objetos
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268 # Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31).
1269
1270
+12
View File
@@ -1015,3 +1015,15 @@
Check for Updates… Проверить обновления…
CADCraft is up to date. У вас последняя версия CADCraft.
Could not check for updates. Не удалось проверить обновления.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
+12
View File
@@ -1448,3 +1448,15 @@ menu Modify Ändra
Check for Updates… Sök efter uppdateringar…
CADCraft is up to date. CADCraft är uppdaterat.
Could not check for updates. Det gick inte att söka efter uppdateringar.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
1 # CADCraft Swedish interface catalog — original translations by the CADCraft contributors.
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
+1
View File
@@ -113,6 +113,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
"quick",
"vpscale",
"options/dialog",
"finddlg",
];
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
+12
View File
@@ -1349,3 +1349,15 @@ menu View Görünüm
Check for Updates… Güncellemeleri denetle…
CADCraft is up to date. CADCraft güncel.
Could not check for updates. Güncellemeler denetlenemedi.
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 11 and column 13.
+12
View File
@@ -1373,3 +1373,15 @@
CADCraft is up to date. Встановлено найновішу версію CADCraft.
Could not check for updates. Не вдалося перевірити оновлення.
Save changes to block "{name}"? Зберегти зміни в блоці «{name}»?
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 1375 and column 24.
+12
View File
@@ -1448,3 +1448,15 @@ menu Modify Hiệu chỉnh
Change Properties Đổi thuộc tính
Font Phông chữ
Save Layout as Template Lưu layout thành mẫu
Find All Find All
Find what: Find what:
Match case Match case
Replace Replace
Replace All Replace All
Replace with: Replace with:
Replacement text Replacement text
Results Results
Searches the current space. Searches the current space.
Text to find Text to find
Whole words Whole words
Nothing found Nothing found
Can't render this file because it contains an unexpected character in line 1443 and column 24.
+12
View File
@@ -1377,6 +1377,18 @@
Save Layout as Template 将布局另存为样板
Set Dynamic Property 设置动态特性
Units 单位
Find All 查找全部
Find what: 查找内容:
Match case 区分大小写
Replace 替换
Replace All 全部替换
Replace with: 替换为:
Replacement text 替换文本
Results 结果
Searches the current space. 仅搜索当前空间。
Text to find 要查找的文字
Whole words 全字匹配
Nothing found 未找到
# The strings the report listed as missing for zh.
Add… 添加…
1 # CADCraft Simplified Chinese interface catalog — original translations.
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392 # The strings the report listed as missing for zh.
1393
1394
+2
View File
@@ -33,6 +33,8 @@ pub mod credits;
pub mod dialogs;
pub mod docking;
pub mod fault;
// The Find and Replace window behind the FIND command.
pub mod finddlg;
pub mod hatchdlg;
mod mtext_editor;
mod panel_state;
+10 -1
View File
@@ -73,6 +73,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.cmdline.lines", "Command Line History Lines", &[], None),
("ui.cmdline.clear", "Clear Command History", &[], None),
("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None),
("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")),
("ui.language", "Language", &[], None),
("ui.noop", "", &[], None),
("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")),
@@ -332,6 +333,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
if let Some(result) = run_close_command(app, id, params) {
return Some(result);
}
// FIND typed, or chosen from Edit ▸ Find..., opens the window; a JSON call carries `find`
// and falls through to the engine, so scripts, LISP and the MCP server keep their answer.
if id == "find" && params.is_null() {
app.ui.dialog = Some("find".to_string());
return Some(Ok(Value::Null));
}
let r = match id {
"ui.open" | "open" if no_path => {
let picked = app.services.pick_open.as_ref().and_then(|f| f());
@@ -456,7 +463,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
| "ui.dialog.about"
| "ui.dialog.commands"
| "ui.dialog.qselect"
| "ui.dialog.parameters" => {
| "ui.dialog.parameters"
| "ui.dialog.find" => {
app.ui.dialog = Some(id.trim_start_matches("ui.dialog.").to_string());
Ok(Value::Null)
}
@@ -816,6 +824,7 @@ pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)>
(sc(cmd, Key::S), "qsave"),
(sc(cmd, Key::P), "plot"),
(sc(cmd, Key::A), "selectall"),
(sc(cmd, Key::F), "ui.dialog.find"),
(sc(cmd_shift, Key::C), "copybase"),
(sc(cmd, Key::C), "copyclip"),
(sc(cmd, Key::X), "cutclip"),