mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 08:52:05 -06:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8505cee19b | ||
|
|
5ed192555c | ||
|
|
4c00f8b9e3 | ||
|
|
7b6be7835f | ||
|
|
455b2a87cb | ||
|
|
1b7c705fac | ||
|
|
77142aa79e | ||
|
|
31e6e16bc0 | ||
|
|
f691d0b349 | ||
|
|
e24c4553d2 | ||
|
|
c602a402c3 | ||
|
|
a13870f376 | ||
|
|
a6ba747329 |
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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(§ions(&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);
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<_>>() }))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,9 +415,12 @@ impl Session {
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// One pass over the buffer, dropping the printed lines once at the end: draining each line
|
||||
// from the front shifted the rest every time, quadratic in newline-dense output.
|
||||
let mut done = 0;
|
||||
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
|
||||
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
|
||||
done += i + 1;
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
@@ -427,6 +430,7 @@ impl Session {
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
env.out.drain(..done);
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
|
||||
@@ -307,6 +307,16 @@ fn printing() {
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
|
||||
#[test]
|
||||
fn printing_many_lines_is_linear() {
|
||||
let mut s = session();
|
||||
let started = std::time::Instant::now();
|
||||
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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();
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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… 添加…
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user