mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-01 06:46:50 -06:00
Merge main into #2467
This commit is contained in:
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -47,7 +47,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
|
||||
@@ -17,8 +17,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -46,7 +46,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
|
||||
@@ -130,3 +130,20 @@ pub struct RawTag {
|
||||
pub code: i32,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Whether "Match orientation to layout" is on for this annotative object (AutoCAD's
|
||||
/// `AcadAnnoPO` xdata = 1). AutoCAD draws such text upright and ignores its stored rotation.
|
||||
/// The object's own `AcadAnnotative` xdata never reaches [`Common::xdata`] (the reader drops
|
||||
/// it), so the `AcadAnnoPO` flag alone decides.
|
||||
pub fn matches_layout_orientation(&self) -> bool {
|
||||
let app = self.xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("AcadAnnoPO"));
|
||||
let Some(at) = app else { return false };
|
||||
self.xdata
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.take_while(|t| t.code != 1001)
|
||||
.find(|t| t.code == 1070)
|
||||
.is_some_and(|t| t.value.trim().parse::<i64>().is_ok_and(|v| v != 0))
|
||||
}
|
||||
}
|
||||
|
||||
+78
-14
@@ -36,11 +36,26 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
|
||||
/// entities a visibility state hides are invisible this way, and they sit far from the block's
|
||||
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
|
||||
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
|
||||
/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]).
|
||||
fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool {
|
||||
// Attribute definitions are not drawn in an insert (its attribs are, see below).
|
||||
!matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& e.common.visible
|
||||
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
|
||||
!matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts)
|
||||
}
|
||||
|
||||
/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which
|
||||
/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`.
|
||||
fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool {
|
||||
layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts))
|
||||
}
|
||||
|
||||
/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its
|
||||
/// definition's (layer "0" being the insert's).
|
||||
fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str {
|
||||
a.props.layer.as_deref().unwrap_or_else(|| {
|
||||
d.block(&ins.block)
|
||||
.and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))))
|
||||
.map_or("0", |be| be.common.layer.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
|
||||
@@ -52,7 +67,9 @@ fn blank(s: &str) -> bool {
|
||||
fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
let lines = t.contents.split("\\P").count().max(1);
|
||||
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
|
||||
let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
|
||||
let estimate = t.height * 0.8 * longest as f64;
|
||||
// The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465).
|
||||
let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate };
|
||||
let mut h = t.height * 1.66 * lines as f64;
|
||||
if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) {
|
||||
// Columns side by side, the text flowing down each in turn.
|
||||
@@ -75,16 +92,27 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
Bounds2::from_points(pts)
|
||||
}
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new())
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -93,11 +121,16 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
/// definition per depth instead of one per reference (which grows exponentially with the nesting
|
||||
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
|
||||
/// bounds must not stand in for a shallower reference to the same block (#706).
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
|
||||
// Text that matches the layout's orientation is drawn upright (#2498).
|
||||
EntityKind::Text(t) if e.common.matches_layout_orientation() => {
|
||||
text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count())
|
||||
}
|
||||
EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }),
|
||||
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
|
||||
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
|
||||
EntityKind::MText(t) => mtext_box(t),
|
||||
@@ -115,8 +148,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -131,7 +164,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) {
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
@@ -172,8 +205,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Some(cached) => *cached,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -193,7 +226,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks));
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks, off_counts));
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -309,6 +342,37 @@ mod tests {
|
||||
entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0)
|
||||
}
|
||||
|
||||
fn mtext(contents: &str, width: f64) -> crate::MText {
|
||||
crate::MText {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
height: 2.5,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not
|
||||
/// stretch the drawing extents (#2465).
|
||||
#[test]
|
||||
fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() {
|
||||
let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0)));
|
||||
// "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide.
|
||||
assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x);
|
||||
assert!(b.min.x >= 10.0 - 1e-9);
|
||||
}
|
||||
|
||||
/// A wrap width narrower than the text still bounds the box to that width.
|
||||
#[test]
|
||||
fn a_narrow_mtext_wrap_width_still_sets_the_box_width() {
|
||||
let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0)));
|
||||
assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x);
|
||||
}
|
||||
#[test]
|
||||
fn right_justified_text_is_left_of_alignment_point() {
|
||||
let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline)));
|
||||
|
||||
+10
-4
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
|
||||
pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
@@ -369,13 +369,19 @@ impl Drawing {
|
||||
!self.hidden_objects.is_empty() && self.hidden_objects.contains(&h)
|
||||
}
|
||||
|
||||
/// Bounds of all visible entities in a space.
|
||||
/// Whether an entity counts in the extents: as AutoCAD 2024 counts it for ZOOM Extents and
|
||||
/// EXTMIN/EXTMAX, on a layer that is off too, but not on a frozen one.
|
||||
pub fn in_extents(&self, e: &Entity) -> bool {
|
||||
e.common.visible && self.layer(&e.common.layer).is_none_or(|l| !l.frozen) && !self.is_hidden_object(e.handle)
|
||||
}
|
||||
|
||||
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
|
||||
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
|
||||
let mut b = cadcraft_geom::Bounds2::EMPTY;
|
||||
if let Some(store) = self.space(space) {
|
||||
for e in store.iter() {
|
||||
if self.is_visible(e) {
|
||||
b = b.union(&entity_bounds(self, e, 0));
|
||||
if self.in_extents(e) {
|
||||
b = b.union(&extent_bounds(self, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-21
@@ -44,15 +44,85 @@ fn add_and_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_layers_excluded_from_extents() {
|
||||
fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let c = Common { layer: "Hidden".into(), ..Default::default() };
|
||||
d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
d.layer_mut("Hidden").unwrap().on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(200.0, 200.0));
|
||||
d.layer_mut("Hidden").unwrap().frozen = true;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
|
||||
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
|
||||
/// entity inside a block, and for a block reference itself.
|
||||
#[test]
|
||||
fn extents_count_layers_turned_off_as_autocad() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("OFFL", false, false), ("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
for (layer, b) in [("0", (10.0, 10.0)), ("OFFL", (100.0, 0.0)), ("FRZ", (0.0, 200.0))] {
|
||||
d.add(&Space::Model, Common { layer: layer.into(), ..Default::default() }, line((0.0, 0.0), b)).unwrap();
|
||||
}
|
||||
// AutoCAD: (-0.013, -0.009)…(100.027, 10.006).
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(100.0, 10.0)));
|
||||
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let mut blk = Block::new("BX");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("BOFF", line((0.0, 0.0), (50.0, 0.0)), 2));
|
||||
blk.entities.push(on_layer("BFRZ", line((0.0, 0.0), (0.0, 70.0)), 3));
|
||||
d.blocks.insert("BX".into(), std::sync::Arc::new(blk));
|
||||
let at = |x: f64| match plain_insert("BX") {
|
||||
EntityKind::Insert(i) => EntityKind::Insert(Insert { insert: Vec3::new(x, x, 0.0), ..i }),
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), at(1000.0)).unwrap();
|
||||
d.add(&Space::Model, Common { layer: "FRZ".into(), ..Default::default() }, at(-500.0)).unwrap();
|
||||
// AutoCAD: (1000, 1000)…(1050, 1001).
|
||||
assert_eq!(extents_pair(&d), (Vec2::new(1000.0, 1000.0), Vec2::new(1050.0, 1001.0)));
|
||||
|
||||
// An insert's attributes lie on their own layer, else on their definition's: one on a frozen
|
||||
// layer either way does not count, one on a layer turned off does.
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("IOFF", false, false), ("AOFF", false, false), ("AFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let attrib = |tag: &str, at: (f64, f64), layer: Option<&str>| {
|
||||
let mut a = plain_attrib(tag);
|
||||
a.text.insert = Vec3::new(at.0, at.1, 0.0);
|
||||
a.props.layer = layer.map(str::to_string);
|
||||
a
|
||||
};
|
||||
let mut blk = Block::new("BA");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("AFRZ", EntityKind::AttDef(plain_attrib("DEF")), 2));
|
||||
d.blocks.insert("BA".into(), std::sync::Arc::new(blk));
|
||||
let ins = match plain_insert("BA") {
|
||||
EntityKind::Insert(i) => {
|
||||
let attribs =
|
||||
vec![attrib("OWN", (0.0, 300.0), Some("AFRZ")), attrib("DEF", (0.0, -400.0), None), attrib("OFF", (40.0, 0.0), Some("AOFF"))];
|
||||
EntityKind::Insert(Insert { attribs, ..i })
|
||||
}
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common { layer: "IOFF".into(), ..Default::default() }, ins).unwrap();
|
||||
let (min, max) = extents_pair(&d);
|
||||
assert!(min.y > -1.0 && max.y < 50.0 && max.x > 40.0, "{min:?} {max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rays_and_xlines_do_not_stretch_extents() {
|
||||
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
|
||||
@@ -315,6 +385,31 @@ fn plain_insert(block: &str) -> EntityKind {
|
||||
})
|
||||
}
|
||||
|
||||
/// An attribute (or its definition) `tag` at the origin, one unit high, reading "x".
|
||||
fn plain_attrib(tag: &str) -> Attrib {
|
||||
Attrib {
|
||||
tag: tag.into(),
|
||||
text: Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "x".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
|
||||
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
|
||||
}
|
||||
@@ -383,24 +478,18 @@ fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_off_or_frozen_layer_is_ignored() {
|
||||
for frozen in [false, true] {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
if frozen {
|
||||
layer.frozen = true;
|
||||
} else {
|
||||
layer.on = false;
|
||||
}
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
|
||||
// Turned back on, it counts again.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.on = true;
|
||||
l.frozen = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
fn block_entity_on_frozen_layer_is_ignored_and_on_a_layer_turned_off_counts() {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
layer.frozen = true;
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
// Thawed but turned off, it counts.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.frozen = false;
|
||||
l.on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,10 +647,10 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
hidden.common.visible = false;
|
||||
dim(&mut d, hidden);
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
// Hidden by its layer state.
|
||||
// Hidden by its layer state (frozen; a layer turned off still counts).
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut l = Layer::new("*ADSK_CONSTRAINTS");
|
||||
l.on = false;
|
||||
l.frozen = true;
|
||||
d.layers.push(l);
|
||||
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
|
||||
@@ -36,13 +36,20 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
write_atomic_with(path, permissions, |file| file.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
|
||||
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
|
||||
/// at `path` is replaced by the new file, never written through.
|
||||
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
file.write_all(bytes)?;
|
||||
fill(&mut file)?;
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
})();
|
||||
@@ -55,9 +62,22 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
|
||||
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
|
||||
/// refuse to sync a directory, and the file itself is already on disk.
|
||||
fn sync_dir(dir: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(d) = File::open(dir) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = dir;
|
||||
}
|
||||
|
||||
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
|
||||
///
|
||||
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
|
||||
@@ -116,6 +136,18 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A write into a folder whose entries can be listed syncs that folder without failing, and
|
||||
/// a folder that can't be opened (gone) is no error for the sync itself.
|
||||
#[test]
|
||||
fn the_folder_is_synced_after_the_rename() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sync-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
write_atomic(&dir.join("a.bin"), b"x").unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("a.bin")).unwrap(), b"x");
|
||||
sync_dir(&dir.join("missing"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
|
||||
@@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
|
||||
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
|
||||
static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// ISAVEBAK: whether saving over a drawing keeps the previous file as `<name>.bak` (on by
|
||||
/// default, as in AutoCAD). A profile setting like SAVETIME.
|
||||
pub fn isavebak() -> bool {
|
||||
ISAVEBAK.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Turn ISAVEBAK on or off.
|
||||
pub fn set_isavebak(on: bool) {
|
||||
ISAVEBAK.store(on, Ordering::Relaxed);
|
||||
}
|
||||
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
|
||||
|
||||
@@ -107,7 +107,7 @@ pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
|
||||
|
||||
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
|
||||
s.pick_object(&s.space(), p, ap).ok().flatten()
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
|
||||
+105
-16
@@ -202,13 +202,13 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation
|
||||
insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values)
|
||||
}
|
||||
|
||||
/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block
|
||||
/// The block reference INSERT places: X/Y/Z scale factors, and an optional base point (in block
|
||||
/// coordinates) that lands on `at` instead of the block's own base point.
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
let mut ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(scale.x, scale.y, scale.x),
|
||||
scale,
|
||||
rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
@@ -223,7 +223,8 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option
|
||||
ins
|
||||
}
|
||||
|
||||
/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]).
|
||||
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
|
||||
/// [`insert_entity`]).
|
||||
pub(crate) fn insert_with(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
@@ -232,6 +233,19 @@ pub(crate) fn insert_with(
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
at: Vec2,
|
||||
scale: Vec3,
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
@@ -566,15 +580,19 @@ enum InsStage {
|
||||
/// A file whose block already exists: redefine it from the file?
|
||||
Redefine,
|
||||
Point,
|
||||
/// Basepoint: a new base point, picked on the block shown at its definition position.
|
||||
/// Basepoint: a new base point, picked on the block dropped where it was (at the cursor).
|
||||
BasePoint,
|
||||
/// Scale preset before the insertion point.
|
||||
PresetScale,
|
||||
/// X, Y or Z scale preset (0, 1, 2) before the insertion point: that axis alone.
|
||||
PresetAxis(usize),
|
||||
/// Rotate preset before the insertion point.
|
||||
PresetRotation,
|
||||
XScale,
|
||||
Corner,
|
||||
YScale,
|
||||
/// XYZ: the Z scale factor, after X and Y.
|
||||
ZScale,
|
||||
Rotation,
|
||||
Attribs,
|
||||
}
|
||||
@@ -587,6 +605,15 @@ struct InsertM {
|
||||
base: Option<Vec2>,
|
||||
x_scale: Option<f64>,
|
||||
scale: Option<Vec2>,
|
||||
/// The Z scale factor when given (a preset or XYZ); otherwise the X factor's size. AutoCAD
|
||||
/// keeps a Z factor typed at XYZ's Z prompt as given, and stores the size of any other.
|
||||
z_scale: Option<f64>,
|
||||
/// X/Y/Z presets given so far.
|
||||
axes: [Option<f64>; 3],
|
||||
/// XYZ at the X scale prompt: X, Y and Z are asked in turn.
|
||||
xyz: bool,
|
||||
/// Where the block was dropped to pick a Basepoint on it.
|
||||
dropped: Option<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
@@ -610,7 +637,7 @@ impl InsertM {
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
{
|
||||
let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?;
|
||||
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
|
||||
if self.explode {
|
||||
insert_file::explode_insert(s, h)?;
|
||||
}
|
||||
@@ -656,6 +683,23 @@ impl InsertM {
|
||||
}
|
||||
}
|
||||
|
||||
/// X/Y scale factors with Z: as given, else the X factor's size (AutoCAD 2024: X -1 with Y
|
||||
/// as X stores Z +1).
|
||||
/// X and Y from a corner: XYZ still asks Z, which defaults to the size of X.
|
||||
fn corner_scale(&mut self, s: &mut Session, sc: Vec2) -> Result<Step> {
|
||||
self.scale = Some(sc);
|
||||
if self.xyz {
|
||||
self.x_scale = Some(sc.x);
|
||||
self.stage = InsStage::ZScale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.advance(s)
|
||||
}
|
||||
|
||||
fn scale3(&self, sc: Vec2) -> Vec3 {
|
||||
Vec3::new(sc.x, sc.y, self.z_scale.unwrap_or(sc.x.abs()))
|
||||
}
|
||||
|
||||
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
|
||||
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
|
||||
let d = p - a;
|
||||
@@ -686,15 +730,18 @@ impl Interactive for InsertM {
|
||||
let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default();
|
||||
Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N")
|
||||
}
|
||||
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
||||
InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
|
||||
InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT),
|
||||
InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"),
|
||||
InsStage::PresetAxis(i) => Prompt::new(format!("Specify {} scale factor", ["X", "Y", "Z"][i.min(2)]), Accept::NUMBER).default("1"),
|
||||
InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
|
||||
InsStage::XScale if self.xyz => Prompt::new("Specify X scale factor", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at),
|
||||
InsStage::XScale => {
|
||||
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at)
|
||||
Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner", "XYZ"]).default("1").base(at)
|
||||
}
|
||||
InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at),
|
||||
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
|
||||
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
|
||||
InsStage::Attribs => {
|
||||
let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
@@ -742,14 +789,25 @@ impl Interactive for InsertM {
|
||||
self.at = Some(p);
|
||||
return self.advance(s);
|
||||
}
|
||||
Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint,
|
||||
Input::Keyword(k) if k == "Basepoint" => {
|
||||
// AutoCAD drops the block where it is being dragged, at the cursor (the origin
|
||||
// in a script: 2024 inserted (15, 5) for base point (612350.678, 2398770.432)
|
||||
// and insertion point (612365.678, 2398775.432)).
|
||||
self.dropped = Some(s.cursor);
|
||||
self.stage = InsStage::BasePoint;
|
||||
}
|
||||
Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale,
|
||||
Input::Keyword(k) if k == "X" => self.stage = InsStage::PresetAxis(0),
|
||||
Input::Keyword(k) if k == "Y" => self.stage = InsStage::PresetAxis(1),
|
||||
Input::Keyword(k) if k == "Z" => self.stage = InsStage::PresetAxis(2),
|
||||
Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation,
|
||||
_ => {}
|
||||
},
|
||||
InsStage::BasePoint => {
|
||||
if let Input::Point(p) = i {
|
||||
self.base = Some(p);
|
||||
// From the dropped block to its own coordinates.
|
||||
let def = s.doc()?.block(self.name.as_deref().unwrap_or_default()).map_or(Vec2::ZERO, |b| b.base.xy());
|
||||
self.base = Some(def + (p - self.dropped.unwrap_or(def)));
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
}
|
||||
@@ -760,12 +818,30 @@ impl Interactive for InsertM {
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(f, f));
|
||||
self.z_scale = Some(f.abs());
|
||||
// An axis preset after it changes that axis alone (Scale -3 then X 2: 2/-3/3).
|
||||
self.axes = [Some(f), Some(f), Some(f.abs())];
|
||||
// Exploded insertion asks its one scale factor after the insertion point.
|
||||
if self.at.is_some() {
|
||||
return self.advance(s);
|
||||
}
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
// One axis preset: the others are 1, and no scale factors are asked after the point.
|
||||
InsStage::PresetAxis(ax) => {
|
||||
let f = match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => 1.0,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if let Some(a) = self.axes.get_mut(ax) {
|
||||
*a = Some(f);
|
||||
}
|
||||
let [x, y, z] = self.axes.map(|a| a.unwrap_or(1.0));
|
||||
self.scale = Some(Vec2::new(x, y));
|
||||
self.z_scale = Some(z.abs());
|
||||
self.stage = InsStage::Point;
|
||||
}
|
||||
InsStage::PresetRotation => {
|
||||
self.rotation = Some(match i {
|
||||
Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
@@ -776,9 +852,10 @@ impl Interactive for InsertM {
|
||||
}
|
||||
InsStage::XScale => match i {
|
||||
Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner,
|
||||
Input::Keyword(k) if k == "XYZ" => self.xyz = true,
|
||||
Input::Point(p) => {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
|
||||
return self.corner_scale(s, sc);
|
||||
}
|
||||
Input::Text(t) => {
|
||||
self.x_scale = Some(nonzero(&t)?);
|
||||
@@ -792,8 +869,8 @@ impl Interactive for InsertM {
|
||||
},
|
||||
InsStage::Corner => {
|
||||
if let Input::Point(p) = i {
|
||||
self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?);
|
||||
return self.advance(s);
|
||||
let sc = Self::corner(self.at.unwrap_or_default(), p)?;
|
||||
return self.corner_scale(s, sc);
|
||||
}
|
||||
}
|
||||
InsStage::YScale => {
|
||||
@@ -804,6 +881,18 @@ impl Interactive for InsertM {
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.scale = Some(Vec2::new(x, y));
|
||||
if self.xyz {
|
||||
self.stage = InsStage::ZScale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::ZScale => {
|
||||
self.z_scale = Some(match i {
|
||||
Input::Text(t) => nonzero(&t)?,
|
||||
Input::Enter => self.x_scale.unwrap_or(1.0).abs(),
|
||||
_ => return Ok(Step::Continue),
|
||||
});
|
||||
return self.advance(s);
|
||||
}
|
||||
InsStage::Rotation => {
|
||||
@@ -832,7 +921,7 @@ impl Interactive for InsertM {
|
||||
let Some(b) = d.block(n) else { return Vec::new() };
|
||||
// Picking a new base point: the block sits at its definition position.
|
||||
if self.stage == InsStage::BasePoint {
|
||||
return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))];
|
||||
return vec![EntityKind::Insert(insert_entity(b, self.dropped.unwrap_or(b.base.xy()), Vec3::new(1.0, 1.0, 1.0), 0.0, None))];
|
||||
}
|
||||
let at = self.at.unwrap_or(c);
|
||||
let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) {
|
||||
@@ -841,7 +930,7 @@ impl Interactive for InsertM {
|
||||
_ => Vec2::new(1.0, 1.0),
|
||||
});
|
||||
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))]
|
||||
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ impl Interactive for GcM {
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else {
|
||||
let Some(h) = s.pick_object(&space, p, aperture(s))? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
@@ -706,11 +706,11 @@ impl Interactive for DcM {
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let ap = aperture(s);
|
||||
let d = s.doc()?;
|
||||
let Some(h) = crate::select::pick(d, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let want = match dc {
|
||||
Dc::Radius | Dc::Diameter => Want::Curve,
|
||||
Dc::Angular if self.refs.is_empty() => {
|
||||
|
||||
@@ -18,10 +18,9 @@ pub fn pick_aperture(s: &Session) -> f64 {
|
||||
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
|
||||
}
|
||||
|
||||
/// The object under `p` in the current space.
|
||||
/// The object under `p` in the current space (or the one named for `p`: [`Session::pick_object`]).
|
||||
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let space = s.space();
|
||||
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
|
||||
s.pick_object(&s.space(), p, pick_aperture(s)).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY,
|
||||
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
//! SPLINE, DONUT, SOLID, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
@@ -21,6 +21,7 @@ mod ellipse;
|
||||
mod line_pline;
|
||||
mod point;
|
||||
mod polygon;
|
||||
mod solid;
|
||||
mod spline;
|
||||
mod text;
|
||||
|
||||
@@ -30,6 +31,7 @@ use ellipse::{EllipseM, run_ellipse};
|
||||
use line_pline::{LineM, PlineM, run_line, run_pline};
|
||||
use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
|
||||
use polygon::{PolygonM, run_polygon};
|
||||
use solid::{SolidM, run_solid};
|
||||
use spline::{SplineM, run_spline};
|
||||
use text::{MTextM, TextM, run_mtext, run_text};
|
||||
|
||||
@@ -100,6 +102,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["do", "doughnut"])
|
||||
.params("{center, inside, outside}")
|
||||
.interactive(|_| Ok(Box::new(DonutM::default()))),
|
||||
CommandSpec::new("solid", "2D Solid", run_solid)
|
||||
.menu(&["Draw", "2D Solid"])
|
||||
.alias(&["so"])
|
||||
.params("{points: [[x,y],...]} (three or four for the first solid, two more for each next one, chained on its last two corners)")
|
||||
.interactive(|_| Ok(Box::new(SolidM::default()))),
|
||||
CommandSpec::new("text", "Single Line Text", run_text)
|
||||
.menu(&["Draw", "Text", "Single Line Text"])
|
||||
.alias(&["dt", "dtext"])
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! SOLID: filled triangles and quadrilaterals (2D solids), chained as AutoCAD 2024 chains them.
|
||||
//!
|
||||
//! The prompts are "Specify first point", "Specify second point", "Specify third point" and
|
||||
//! "Specify fourth point or <exit>"; Enter there makes a triangle (the fourth corner repeats the
|
||||
//! third). Then "Specify third point" and "Specify fourth point or <exit>" come again for the next
|
||||
//! solid, whose first two corners are the previous one's last two, until Enter at "Specify third
|
||||
//! point". Enter there right after the second point makes nothing.
|
||||
|
||||
use cadcraft_doc::Solid;
|
||||
|
||||
use super::{Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, v3};
|
||||
|
||||
/// A 2D solid from its corners in AutoCAD's order (the fourth is drawn opposite the first).
|
||||
fn solid(c: [Vec2; 4]) -> EntityKind {
|
||||
EntityKind::Solid(Solid { corners: c.map(v3) })
|
||||
}
|
||||
|
||||
/// `{points}`: the first solid takes three or four points, each further one two more, its first
|
||||
/// two corners being the previous one's last two; a solid given three points is a triangle.
|
||||
pub(super) fn run_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("solid", "`points` is required"))?;
|
||||
let mut it = pts.into_iter();
|
||||
let (Some(mut a), Some(mut b), Some(_)) = (it.next(), it.next(), it.clone().next()) else {
|
||||
return Err(bad("solid", "`points` needs at least three points"));
|
||||
};
|
||||
let mut hs = Vec::new();
|
||||
while let Some(c) = it.next() {
|
||||
let d = it.next().unwrap_or(c);
|
||||
hs.push(s.add_entity(solid([a, b, c, d]))?.hex());
|
||||
(a, b) = (c, d);
|
||||
}
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SolidM {
|
||||
/// The corners of the solid being drawn (the first two from the previous one after it).
|
||||
pts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for SolidM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SOLID"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT),
|
||||
1 => Prompt::new("Specify second point", Accept::POINT).base_opt(last),
|
||||
2 => Prompt::new("Specify third point", Accept::POINT).base_opt(last),
|
||||
_ => Prompt::new("Specify fourth point or", Accept::POINT).default("exit").base_opt(last),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (i, self.pts.as_slice()) {
|
||||
(Input::Point(p), &[a, b, c]) => self.make(s, [a, b, c, p])?,
|
||||
(Input::Point(p), _) => self.pts.push(p),
|
||||
(Input::Enter, &[a, b, c]) => self.make(s, [a, b, c, c])?,
|
||||
(Input::Enter, _) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
match *self.pts.as_slice() {
|
||||
[a, b] => vec![solid([a, b, cursor, cursor])],
|
||||
[a, b, c] => vec![solid([a, b, c, cursor])],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SolidM {
|
||||
fn make(&mut self, s: &mut Session, c: [Vec2; 4]) -> Result<()> {
|
||||
s.add_entity(solid(c))?;
|
||||
self.pts = vec![c[2], c[3]];
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(from) = point_param(p, "from") {
|
||||
let past_half = match curves::entity(s, h)?.kind {
|
||||
EntityKind::Circle(_) => false,
|
||||
// At an arc's centre, where a bare name given from AutoLISP stands (its DXF group 10
|
||||
// point), every point of it is as near: AutoCAD measures from its end.
|
||||
EntityKind::Arc(a) if from.dist(a.center.xy()) <= a.radius * 1e-9 => true,
|
||||
// A spline by its own parameter: its tessellation drops zero-length pieces, so the
|
||||
// segment count is not the spline's parameter range.
|
||||
EntityKind::Spline(sp) => {
|
||||
|
||||
@@ -420,11 +420,20 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
||||
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
||||
/// Put saved bytes where the user gets them: natively the file at `path`; on the web, which has
|
||||
/// no file system, a browser download named after `path` via [`deliver`].
|
||||
///
|
||||
/// Natively the file is replaced atomically ([`crate::atomic_write`]: a fresh temporary file
|
||||
/// beside it, renamed over it), so a crash leaves either the old file or the new one, never a
|
||||
/// truncated one. Saving over a drawing first keeps the previous file as `<name>.bak` (ISAVEBAK,
|
||||
/// #540).
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() {
|
||||
backup(std::path::Path::new(path))
|
||||
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
|
||||
}
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
@@ -437,6 +446,180 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
|
||||
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
path.with_extension("bak")
|
||||
}
|
||||
|
||||
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
|
||||
/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a
|
||||
/// good `.bak`.
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
loop {
|
||||
match (lines.next(), lines.next()) {
|
||||
(Some("999"), Some(_)) => continue,
|
||||
(Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a drawing's end [`looks_complete`] reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TAIL_BYTES: u64 = 256;
|
||||
|
||||
/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them
|
||||
/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it
|
||||
/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its
|
||||
/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut
|
||||
/// off by a failed save or another program doesn't replace a good backup.
|
||||
pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev();
|
||||
Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF")))
|
||||
}
|
||||
|
||||
/// [`completeness`] known to be whole.
|
||||
pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool {
|
||||
completeness(head, tail, len) != Some(false)
|
||||
}
|
||||
|
||||
/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for
|
||||
/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in
|
||||
/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section
|
||||
/// the locator records give. `None` for another layout (2007, or before R13).
|
||||
fn dwg_complete(head: &[u8], len: u64) -> Option<bool> {
|
||||
let version = head.get(..6)?;
|
||||
match version {
|
||||
b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => {
|
||||
// The header at 0x80 is XORed with the generator the specification gives.
|
||||
let enc = head.get(0x80..0x80 + 0x6C)?;
|
||||
let mut seed: u32 = 1;
|
||||
let h: Vec<u8> = enc
|
||||
.iter()
|
||||
.map(|b| {
|
||||
seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3);
|
||||
b ^ (seed >> 16) as u8
|
||||
})
|
||||
.collect();
|
||||
if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) {
|
||||
return Some(false);
|
||||
}
|
||||
let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?);
|
||||
Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end))
|
||||
}
|
||||
b"AC1012" | b"AC1014" | b"AC1015" => {
|
||||
let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?);
|
||||
if !(1..=16).contains(&n) {
|
||||
return Some(false);
|
||||
}
|
||||
let mut end = 0u64;
|
||||
for i in 0..n as usize {
|
||||
let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?;
|
||||
let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?));
|
||||
let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?));
|
||||
end = end.max(seek + size);
|
||||
}
|
||||
Some(len >= end)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's first bytes, last bytes and length, left positioned just after the first bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec<u8>, Vec<u8>, u64)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut head = Vec::with_capacity(4096);
|
||||
(&mut *src).take(4096).read_to_end(&mut head)?;
|
||||
// Its end too (a DXF's EOF), then on from where the start left off for the copy.
|
||||
let len = src.metadata()?.len();
|
||||
let mut tail = Vec::new();
|
||||
src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
|
||||
(&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?;
|
||||
src.seek(SeekFrom::Start(head.len() as u64))?;
|
||||
Ok((head, tail, len))
|
||||
}
|
||||
|
||||
/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a
|
||||
/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it
|
||||
/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong
|
||||
/// about a real file and this save replaces the only copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool {
|
||||
(looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists)
|
||||
}
|
||||
|
||||
/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that
|
||||
/// must not promise a backup the save won't write.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_keeps_current(path: &std::path::Path) -> bool {
|
||||
let Ok(mut src) = std::fs::File::open(path) else { return false };
|
||||
crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_keeps_current(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
|
||||
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
|
||||
/// to keep when there is no file yet. A file that doesn't look like a drawing
|
||||
/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup(path: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut src = match std::fs::File::open(path) {
|
||||
Ok(f) if f.metadata()?.is_file() => f,
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (head, tail, len) = ends(&mut src)?;
|
||||
let bak = backup_path(path);
|
||||
if !backup_replaced(&head, &tail, len, bak.exists()) {
|
||||
log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
// The backup is no more readable than the drawing, nor than the .bak it replaces (a private
|
||||
// 0600 drawing or .bak stays private). The mode is set as the new file is created, so a
|
||||
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
|
||||
// written through.
|
||||
#[cfg(unix)]
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut mode = src.metadata()?.permissions().mode() & 0o777;
|
||||
if let Ok(m) = std::fs::symlink_metadata(&bak)
|
||||
&& m.is_file()
|
||||
{
|
||||
mode &= m.permissions().mode();
|
||||
}
|
||||
Some(std::fs::Permissions::from_mode(mode))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = None;
|
||||
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
|
||||
std::io::Write::write_all(out, &head)?;
|
||||
std::io::copy(&mut src, out).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
||||
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
||||
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
||||
@@ -564,6 +747,17 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
||||
}
|
||||
|
||||
/// What saving the active drawing to `path` would leave out, before anything is written: empty
|
||||
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
|
||||
/// reports the same list afterwards to callers that never get a dialog.
|
||||
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
|
||||
if !drops_unknown(path) {
|
||||
return Default::default();
|
||||
}
|
||||
let version = drawing_version(s, path);
|
||||
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
/// bridge drops types it doesn't know); DXF writes them back as read.
|
||||
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
|
||||
|
||||
+100
-18
@@ -550,6 +550,9 @@ struct HatchM {
|
||||
/// Loops by pick (one hatch each when separate).
|
||||
loops: Vec<Vec<HatchLoop>>,
|
||||
asking: Option<HatchAsk>,
|
||||
/// The pattern was asked by the Pattern option: its scale and angle follow, as at AutoCAD's
|
||||
/// -HATCH Properties.
|
||||
properties: bool,
|
||||
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
|
||||
selecting: bool,
|
||||
}
|
||||
@@ -561,6 +564,7 @@ impl HatchM {
|
||||
settings: HatchSettings::from(s, &Value::Null, gradient),
|
||||
loops: Vec::new(),
|
||||
asking: None,
|
||||
properties: false,
|
||||
selecting: s.last_used.hatch_select,
|
||||
}
|
||||
}
|
||||
@@ -582,11 +586,13 @@ impl HatchM {
|
||||
h.set_i64("HPORIGINMODE", mode.index());
|
||||
Ok(())
|
||||
}
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
|
||||
/// Take an answer to sub-prompt `a`; false when it was refused.
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<bool> {
|
||||
match a {
|
||||
HatchAsk::Pattern => {
|
||||
let up = match patfile::typed_file(s, t.trim()) {
|
||||
Some(n) if n.is_empty() => return Ok(()),
|
||||
// Nothing loaded from it: the pattern is asked again.
|
||||
Some(n) if n.is_empty() => return Ok(false),
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
@@ -595,20 +601,19 @@ impl HatchM {
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
HatchAsk::Scale => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) else { return Ok(false) };
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
HatchAsk::Angle => {
|
||||
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
|
||||
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) else { return Ok(false) };
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
HatchAsk::Origin => {
|
||||
let mode = match t {
|
||||
@@ -622,7 +627,22 @@ impl HatchM {
|
||||
self.set_origin(s, None, mode)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sub-prompt that follows `a` (`taken`: its answer was taken). The Pattern option goes
|
||||
/// on, as AutoCAD 2024's -HATCH Properties does, from the pattern name to its scale and then
|
||||
/// its angle (a solid fill has neither), and asks again for a pattern or a value it refused.
|
||||
fn after(&self, a: HatchAsk, taken: bool) -> Option<HatchAsk> {
|
||||
if !self.properties {
|
||||
return None;
|
||||
}
|
||||
match a {
|
||||
_ if !taken => Some(a),
|
||||
HatchAsk::Pattern if self.settings.pattern != "SOLID" => Some(HatchAsk::Scale),
|
||||
HatchAsk::Scale => Some(HatchAsk::Angle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,9 +663,10 @@ impl Interactive for HatchM {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => {
|
||||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
let p = Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
.default(self.settings.pattern.clone())
|
||||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||||
return if self.gradient { p } else { p.kw(&["Solid"]) };
|
||||
}
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
@@ -686,11 +707,19 @@ impl Interactive for HatchM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?,
|
||||
let taken = match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => {
|
||||
self.set_origin(s, Some(*p), OriginMode::Current)?;
|
||||
true
|
||||
}
|
||||
Input::Keyword(k) if a == HatchAsk::Pattern && k == "Solid" => self.answer(s, a, "SOLID")?,
|
||||
Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?,
|
||||
_ => {}
|
||||
}
|
||||
// Enter keeps the current value; a point or a pick answers none of these prompts.
|
||||
Input::Enter => true,
|
||||
_ => false,
|
||||
};
|
||||
self.asking = self.after(a, taken);
|
||||
self.properties &= self.asking.is_some();
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
@@ -723,7 +752,10 @@ impl Interactive for HatchM {
|
||||
"Select objects" => self.set_mode(s, true),
|
||||
"picK internal point" => self.set_mode(s, false),
|
||||
"Undo" => self.loops.clear(),
|
||||
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
|
||||
"Pattern" | "seTtings" => {
|
||||
self.asking = Some(HatchAsk::Pattern);
|
||||
self.properties = !self.gradient;
|
||||
}
|
||||
"sCale" => self.asking = Some(HatchAsk::Scale),
|
||||
"Angle" => self.asking = Some(HatchAsk::Angle),
|
||||
"Origin" => self.asking = Some(HatchAsk::Origin),
|
||||
@@ -738,7 +770,7 @@ impl Interactive for HatchM {
|
||||
}
|
||||
// A pattern chosen in the picker while boundaries are picked.
|
||||
Input::Text(t) if !self.gradient => {
|
||||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
let _ = self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
@@ -838,6 +870,9 @@ mod tests {
|
||||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||||
s.input(Input::Text("BRICK".into())).unwrap();
|
||||
// Its scale and angle follow; Enter keeps them.
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||||
@@ -851,6 +886,53 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
/// -HATCH's Properties as AutoCAD 2024 takes them from AutoLISP's `command` (accoreconsole, the
|
||||
/// hatch read back with `entget`): a pattern name, then its scale and angle (Enter keeps them);
|
||||
/// Solid (`S`, `_S` or SOLID) asks neither; an unknown name is asked again.
|
||||
#[test]
|
||||
fn hatch_properties_ask_pattern_scale_and_angle_as_autocad() {
|
||||
// (properties given, pattern, solid, scale, angle in degrees)
|
||||
let cases: [(&str, &str, bool, f64, f64); 7] = [
|
||||
("\"ANSI31\" 2 45 \"_S\" (entlast) \"\"", "ANSI31", false, 2.0, 45.0),
|
||||
("\"_S\" \"_S\" (entlast) \"\"", "SOLID", true, 1.0, 0.0),
|
||||
("\"ANSI37\" 0.5 30 '(5.0 5.0)", "ANSI37", false, 0.5, 30.0),
|
||||
("\"ANSI31\" \"\" \"\" '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
("\"S\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"SOLID\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"XYZ\" \"ANSI31\" 1 0 '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
];
|
||||
for (props, pattern, solid, scale, angle) in cases {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [20, 15], "radius": 8 })).unwrap();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
s.cmdline("(setvar \"HPNAME\" \"ANSI31\")").unwrap();
|
||||
s.cmdline("(setvar \"HPSCALE\" 1.0)").unwrap();
|
||||
s.cmdline("(setvar \"HPANG\" 0.0)").unwrap();
|
||||
s.cmdline(&format!("(command \"_.-HATCH\" \"_P\" {props} \"\")")).unwrap();
|
||||
assert!(s.running.is_none(), "{props}: {}", s.prompt_text());
|
||||
let h = only_hatch(&s).1;
|
||||
assert!(
|
||||
h.pattern == pattern && h.solid == solid && (h.scale - scale).abs() < 1e-12 && (h.angle - angle.to_radians()).abs() < 1e-12,
|
||||
"{props}: {} {} {} {}",
|
||||
h.pattern,
|
||||
h.solid,
|
||||
h.scale,
|
||||
h.angle.to_degrees()
|
||||
);
|
||||
}
|
||||
// An object (an entity name from LISP) answers none of these prompts: each is asked again.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
let h = s.doc().unwrap().model.handles()[0];
|
||||
s.cmdline("(command \"_.-HATCH\" \"_P\")").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Enter a pattern name"), "{}", s.prompt_text());
|
||||
s.cmdline("ANSI31").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Specify a scale for the hatch pattern"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn);
|
||||
}
|
||||
}
|
||||
merge::rename_in_viewports(d, &old, &nn);
|
||||
if cur.eq_ignore_ascii_case(&old) {
|
||||
d.header.set_str("CLAYER", &nn);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every layout viewport's frozen list and colour overrides at a renamed layer.
|
||||
pub(super) fn rename_in_viewports(d: &mut Drawing, old: &str, new: &str) {
|
||||
for layout in &mut d.layouts {
|
||||
let hs: Vec<Handle> = layout
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) => {
|
||||
v.frozen_layers.iter().any(|l| l.eq_ignore_ascii_case(old)) || v.layer_colors.iter().any(|(l, _)| l.eq_ignore_ascii_case(old))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|e| e.handle)
|
||||
.collect();
|
||||
for h in hs {
|
||||
layout.entities.modify(h, |e| {
|
||||
if let EntityKind::Viewport(v) = &mut e.kind {
|
||||
for l in &mut v.frozen_layers {
|
||||
if l.eq_ignore_ascii_case(old) {
|
||||
*l = new.to_string();
|
||||
}
|
||||
}
|
||||
for (l, _) in &mut v.layer_colors {
|
||||
if l.eq_ignore_ascii_case(old) {
|
||||
*l = new.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_set(names: &[String]) -> HashSet<String> {
|
||||
names.iter().map(|n| n.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ impl Interactive for BreakM {
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
match s.pick_object(&space, p, ap)? {
|
||||
Some(h) => {
|
||||
self.obj = Some((h, p));
|
||||
s.set_selection(vec![h]);
|
||||
|
||||
@@ -342,7 +342,7 @@ impl Interactive for FilletM {
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
|
||||
@@ -445,7 +445,7 @@ impl Interactive for OffsetM {
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
match s.pick_object(&space, p, ap)? {
|
||||
Some(h) => {
|
||||
self.picked = Some(h);
|
||||
s.set_selection(vec![h]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME.
|
||||
//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units,
|
||||
//! RENAME.
|
||||
|
||||
mod chprop;
|
||||
mod matchprop;
|
||||
mod rename;
|
||||
|
||||
@@ -18,6 +20,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(),
|
||||
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"),
|
||||
CommandSpec::new("chprop", "Change Properties", chprop::run_chprop)
|
||||
.params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)")
|
||||
.interactive(|_| Ok(Box::new(chprop::ChpropM::new()))),
|
||||
CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop)
|
||||
.menu(&["Modify", "Match Properties"])
|
||||
.alias(&["ma", "painter"])
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! CHPROP: change object properties at the command line, as AutoCAD 2024 asks for them.
|
||||
//!
|
||||
//! "Enter property to change [Color/LAyer/LType/ltScale/LWeight/Thickness/TRansparency]" comes
|
||||
//! back after each answer until Enter; each property asks for its value and applies it at once
|
||||
//! (through `properties.set`). Measured from AutoLISP's `command` (accoreconsole, every object read
|
||||
//! back with `entget`):
|
||||
//! - a colour is 1–255, a name (red … white), BYLAYER or BYBLOCK, or Truecolor and then "r,g,b";
|
||||
//! 0, 256 and 300 are refused with "Invalid color number.";
|
||||
//! - a layer must exist: `Cannot find layer "KHONGCO".`;
|
||||
//! - a linetype not loaded yet is loaded from the standard ones (PHANTOM2);
|
||||
//! - a lineweight goes to the nearest standard one, with a note: 0.33 gives "Lineweight rounded to
|
||||
//! nearest valid value of 0.35 mm";
|
||||
//! - `L` alone is "Ambiguous response, please clarify...";
|
||||
//! - objects on locked layers are left out at "Select objects", which says how many after what was
|
||||
//! found ("1 was on a locked layer.", "2 were on a locked layer."); with none left, Enter ends
|
||||
//! the command.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Entity, Handle, Lineweight};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::curves::{KW, is_locked};
|
||||
use super::super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::super::targets;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"];
|
||||
|
||||
/// What CHPROP asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Ask {
|
||||
Property,
|
||||
Color,
|
||||
TrueColor,
|
||||
Layer,
|
||||
Linetype,
|
||||
LtScale,
|
||||
Lineweight,
|
||||
Thickness,
|
||||
Transparency,
|
||||
}
|
||||
|
||||
pub(super) struct ChpropM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
/// `None` while objects are selected.
|
||||
ask: Option<Ask>,
|
||||
}
|
||||
|
||||
impl ChpropM {
|
||||
pub(super) fn new() -> Self {
|
||||
ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None }
|
||||
}
|
||||
|
||||
/// The selection's common value of a property, or "varies".
|
||||
fn current(&self, s: &Session, f: impl Fn(&Entity) -> String) -> String {
|
||||
let Ok(d) = s.doc() else { return String::new() };
|
||||
let mut vals = self.handles.iter().filter_map(|h| d.entity(*h)).map(|e| f(e));
|
||||
let first = vals.next().unwrap_or_default();
|
||||
if vals.all(|v| v == first) { first } else { "varies".into() }
|
||||
}
|
||||
|
||||
/// Apply `props` to the selection through `properties.set`.
|
||||
fn apply(&self, s: &mut Session, props: Value) -> Result<()> {
|
||||
let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::<Vec<_>>() });
|
||||
if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) {
|
||||
p.extend(props);
|
||||
}
|
||||
super::run_set(s, &p).map(|_| ())
|
||||
}
|
||||
|
||||
/// Take an answer to `ask`; `false` when it was refused (the prompt comes again).
|
||||
fn answer(&mut self, s: &mut Session, ask: Ask, t: &str) -> Result<bool> {
|
||||
let t = t.trim();
|
||||
let word = t.strip_prefix('_').unwrap_or(t);
|
||||
match ask {
|
||||
Ask::Color => match color(word) {
|
||||
Some(c) => self.apply(s, json!({ "color": color_json(c) }))?,
|
||||
None => {
|
||||
s.echo("Invalid color number.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::TrueColor => match Color::parse(word) {
|
||||
Some(c @ Color::True(_)) => self.apply(s, json!({ "color": color_json(c) }))?,
|
||||
_ => {
|
||||
s.echo("Requires three integers from 0 to 255, separated by commas.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Layer => match s.doc()?.layer(word).map(|l| l.name.clone()) {
|
||||
Some(name) => self.apply(s, json!({ "layer": name }))?,
|
||||
None => {
|
||||
s.echo(format!("Cannot find layer \"{word}\"."));
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Linetype => {
|
||||
let by = ["ByLayer", "ByBlock"].into_iter().find(|b| b.eq_ignore_ascii_case(word));
|
||||
let name = match by {
|
||||
Some(b) => Some(b.to_string()),
|
||||
None => {
|
||||
// Not loaded yet: load it from the standard linetypes, as AutoCAD does.
|
||||
if s.doc()?.linetype(word).is_none() {
|
||||
let _ = super::run_linetype(s, &json!({ "load": word }));
|
||||
}
|
||||
s.doc()?.linetype(word).map(|l| l.name.clone())
|
||||
}
|
||||
};
|
||||
match name {
|
||||
Some(n) => self.apply(s, json!({ "linetype": n }))?,
|
||||
None => {
|
||||
s.echo(format!("Cannot find linetype \"{word}\"."));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::LtScale | Ask::Thickness => {
|
||||
let v = number(word).filter(|v| v.is_finite() && (ask == Ask::Thickness || *v > 0.0));
|
||||
match v {
|
||||
Some(v) => self.apply(s, if ask == Ask::LtScale { json!({ "ltscale": v }) } else { json!({ "thickness": v }) })?,
|
||||
None => {
|
||||
s.echo(if ask == Ask::LtScale { "Requires a positive number." } else { "Requires a number." });
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ask::Lineweight => match lineweight(word) {
|
||||
Some((lw, rounded)) => {
|
||||
if let (true, Lineweight::Mm100(v)) = (rounded, lw) {
|
||||
s.echo(format!("Lineweight rounded to nearest valid value of {:.2} mm", f64::from(v) / 100.0));
|
||||
}
|
||||
let v = match lw {
|
||||
Lineweight::Mm100(v) => json!(f64::from(v) / 100.0),
|
||||
Lineweight::ByLayer => json!("ByLayer"),
|
||||
Lineweight::ByBlock => json!("ByBlock"),
|
||||
Lineweight::Default => json!("Default"),
|
||||
};
|
||||
self.apply(s, json!({ "lineweight": v }))?;
|
||||
}
|
||||
None => {
|
||||
s.echo("Invalid lineweight.");
|
||||
return Ok(false);
|
||||
}
|
||||
},
|
||||
Ask::Transparency => {
|
||||
if self.apply(s, json!({ "transparency": word })).is_err() {
|
||||
s.echo("Requires ByLayer, ByBlock or an integer from 0 to 90.");
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ask::Property => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects among `hs` on locked layers.
|
||||
fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
|
||||
hs.iter().copied().filter(|h| is_locked(s, *h)).collect()
|
||||
}
|
||||
|
||||
fn say_locked(s: &mut Session, n: usize) {
|
||||
match n {
|
||||
0 => {}
|
||||
1 => s.echo("1 was on a locked layer."),
|
||||
n => s.echo(format!("{n} were on a locked layer.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the
|
||||
/// command leaves out too; "locked" says how many were.
|
||||
pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let locked = locked(s, &hs);
|
||||
if locked.is_empty() {
|
||||
return super::run_set(s, p).map(|r| with_locked(r, 0));
|
||||
}
|
||||
let free: Vec<Handle> = hs.into_iter().filter(|h| !locked.contains(h)).collect();
|
||||
let r = if free.is_empty() {
|
||||
json!({ "changed": 0 })
|
||||
} else {
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
super::run_set(s, &Value::Object(q))?
|
||||
};
|
||||
Ok(with_locked(r, locked.len()))
|
||||
}
|
||||
|
||||
/// `r` with how many objects were left out on locked layers.
|
||||
fn with_locked(mut r: Value, n: usize) -> Value {
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("locked".into(), json!(n));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes).
|
||||
fn color(t: &str) -> Option<Color> {
|
||||
if t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return t.parse::<u16>().ok().filter(|i| (1..=255).contains(i)).and_then(|i| u8::try_from(i).ok()).map(Color::Index);
|
||||
}
|
||||
Color::parse(t).filter(|c| !matches!(c, Color::True(_)))
|
||||
}
|
||||
|
||||
fn color_json(c: Color) -> Value {
|
||||
match c {
|
||||
Color::Index(i) => json!(i),
|
||||
Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)),
|
||||
other => json!(other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
fn lineweight_name(lw: Lineweight) -> String {
|
||||
match lw {
|
||||
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
|
||||
Lineweight::ByLayer => "ByLayer".into(),
|
||||
Lineweight::ByBlock => "ByBlock".into(),
|
||||
Lineweight::Default => "Default".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A lineweight typed in millimetres (the nearest standard one, and whether that rounded it), or
|
||||
/// ByLayer, ByBlock or Default.
|
||||
fn lineweight(t: &str) -> Option<(Lineweight, bool)> {
|
||||
match t.to_ascii_lowercase().as_str() {
|
||||
"bylayer" => return Some((Lineweight::ByLayer, false)),
|
||||
"byblock" => return Some((Lineweight::ByBlock, false)),
|
||||
"default" => return Some((Lineweight::Default, false)),
|
||||
_ => {}
|
||||
}
|
||||
let mm = number(t).filter(|v| v.is_finite() && *v >= 0.0)?;
|
||||
let want = mm * 100.0;
|
||||
let near = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs()))?;
|
||||
Some((Lineweight::Mm100(near), (f64::from(near) - want).abs() > 1e-9))
|
||||
}
|
||||
|
||||
impl Interactive for ChpropM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHPROP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.select = SelectPhase::begin(s);
|
||||
if self.select.done {
|
||||
let locked = locked(s, &self.select.picked);
|
||||
if !locked.is_empty() {
|
||||
self.select.picked.retain(|h| !locked.contains(h));
|
||||
say_locked(s, locked.len());
|
||||
s.set_selection(self.select.picked.clone());
|
||||
// Nothing left of the pickfirst selection: ask for objects instead.
|
||||
self.select.done = !self.select.picked.is_empty();
|
||||
}
|
||||
}
|
||||
if self.select.done {
|
||||
self.handles = self.select.picked.clone();
|
||||
self.ask = Some(Ask::Property);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let Some(ask) = self.ask else { return self.select.prompt() };
|
||||
match ask {
|
||||
Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES),
|
||||
Ask::Color => {
|
||||
Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase()))
|
||||
}
|
||||
Ask::TrueColor => Prompt::new("Red,Green,Blue", Accept::TEXT),
|
||||
Ask::Layer => Prompt::new("Enter new layer name", Accept::TEXT).default(self.current(s, |e| e.common.layer.clone())),
|
||||
Ask::Linetype => Prompt::new("Enter new linetype name", Accept::TEXT).default(self.current(s, |e| e.common.linetype.clone())),
|
||||
Ask::LtScale => {
|
||||
Prompt::new("Specify new linetype scale", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.ltscale)))
|
||||
}
|
||||
Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))),
|
||||
Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))),
|
||||
Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(ask) = self.ask else {
|
||||
// Objects on locked layers are left out, and said after what was found.
|
||||
let (i, n) = match i {
|
||||
Input::Pick(hs) if !self.select.removing => {
|
||||
let locked = locked(s, &hs);
|
||||
(Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len())
|
||||
}
|
||||
i => (i, 0),
|
||||
};
|
||||
let outcome = self.select.feed(s, &i)?;
|
||||
say_locked(s, n);
|
||||
return Ok(match outcome {
|
||||
SelOutcome::More => Step::Continue,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.handles = hs;
|
||||
self.ask = Some(Ask::Property);
|
||||
Step::Continue
|
||||
}
|
||||
SelOutcome::Empty => {
|
||||
s.set_selection(Vec::new());
|
||||
Step::Done
|
||||
}
|
||||
});
|
||||
};
|
||||
match (ask, i) {
|
||||
(Ask::Property, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(Ask::Property, Input::Keyword(k)) => {
|
||||
self.ask = Some(match k.as_str() {
|
||||
"Color" => Ask::Color,
|
||||
"LAyer" => Ask::Layer,
|
||||
"LType" => Ask::Linetype,
|
||||
"ltScale" => Ask::LtScale,
|
||||
"LWeight" => Ask::Lineweight,
|
||||
"Thickness" => Ask::Thickness,
|
||||
_ => Ask::Transparency,
|
||||
});
|
||||
}
|
||||
(Ask::Property, Input::Text(t)) => {
|
||||
let t = t.trim().trim_start_matches('_').to_ascii_lowercase();
|
||||
let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count();
|
||||
s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." });
|
||||
}
|
||||
(Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor),
|
||||
// Enter keeps the value.
|
||||
(_, Input::Enter) => self.ask = Some(Ask::Property),
|
||||
(ask, Input::Text(t) | Input::Keyword(t)) => {
|
||||
// A refused answer asks again.
|
||||
let taken = self.answer(s, ask, &t)?;
|
||||
self.ask = Some(if taken { Ask::Property } else { ask });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -474,7 +474,7 @@ impl Interactive for TrimM {
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
// A pick in empty space opens a crossing window there.
|
||||
self.stage = Stage::Crossing(Some(p));
|
||||
return Ok(Step::Continue);
|
||||
|
||||
@@ -105,10 +105,51 @@ impl Interactive for Guarded {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::Running;
|
||||
|
||||
thread_local! {
|
||||
static PANIC_AFTER_COMMAND: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Called at the start of the passes after a command, which nothing can make panic on demand.
|
||||
pub(crate) fn panic_if_armed() {
|
||||
assert!(!PANIC_AFTER_COMMAND.get(), "a pass after the command panicked");
|
||||
}
|
||||
|
||||
/// A panic in the passes after a JSON command: an error, and the drawing as it was.
|
||||
#[test]
|
||||
fn a_panic_after_a_command_restores_the_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &serde_json::json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let undo = s.state().unwrap().undo.len();
|
||||
PANIC_AFTER_COMMAND.set(true);
|
||||
let r = s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1}));
|
||||
PANIC_AFTER_COMMAND.set(false);
|
||||
assert!(matches!(&r, Err(EngineError::Internal(id, m)) if id == "circle" && m.contains("after the command")), "{r:?}");
|
||||
assert_eq!(points(&s), 1, "the circle is gone with the failed command");
|
||||
assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for it");
|
||||
s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
assert_eq!(points(&s), 2, "the session still works");
|
||||
}
|
||||
|
||||
/// The same at the end of an interactive command, where nothing returns the error to a caller.
|
||||
#[test]
|
||||
fn a_panic_after_an_interactive_command_restores_the_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
PANIC_AFTER_COMMAND.set(true);
|
||||
let r = s.input(Input::Enter);
|
||||
PANIC_AFTER_COMMAND.set(false);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(points(&s), 0, "the drawing is restored");
|
||||
assert!(s.log.iter().any(|l| l.contains("Internal error in line")), "{:?}", s.log);
|
||||
s.cmdline("line 0,0 1,0").unwrap();
|
||||
assert!(s.running.is_some(), "the session still works");
|
||||
}
|
||||
|
||||
fn dot(p: Vec2) -> EntityKind {
|
||||
EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 })
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct Session {
|
||||
lisp_last: Option<std::result::Result<lisp::value::Value, String>>,
|
||||
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
|
||||
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
|
||||
/// The object named with a point for the input a prompt picking one object is taking
|
||||
/// ([`Session::input_object`]): a pick at exactly that point picks that object
|
||||
/// ([`Session::pick_object`]).
|
||||
pick_hint: Option<(Handle, Vec2)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -192,6 +196,7 @@ impl Session {
|
||||
lisp: lisp::LispState::default(),
|
||||
lisp_last: None,
|
||||
hatch_picks: None,
|
||||
pick_hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,10 @@ fn items(p: &PickSet) -> Vec<Handle> {
|
||||
|
||||
/// `(command arg…)`: arguments go to the command line as typed input: a string starts a command
|
||||
/// or answers a prompt ("" is Enter), a number or a point is entered, an entity name or a
|
||||
/// selection set picks objects, and `pause` waits for the user. A command not finished by the
|
||||
/// arguments stays active for the next `command` call. No arguments: cancel the active command.
|
||||
/// selection set picks objects (a name, alone or with a point as `entsel` gives it, also answers
|
||||
/// a prompt picking one object: [`Session::input_object`]), and `pause` waits for the user. A
|
||||
/// command not finished by the arguments stays active for the next `command` call. No
|
||||
/// arguments: cancel the active command.
|
||||
pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
if a.is_empty() {
|
||||
if ctx.s.running.is_some() {
|
||||
@@ -108,14 +110,14 @@ pub fn command(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])),
|
||||
Value::Ename(h) => named(ctx.s, *h, None),
|
||||
Value::PickSet(p) => feed(ctx.s, Input::Pick(items(p))),
|
||||
Value::Cons(_) => {
|
||||
if let Some(p) = arg.as_point() {
|
||||
feed(ctx.s, Input::Point(Vec2::new(p[0], p[1])));
|
||||
} else if let Value::Ename(h) = arg.car() {
|
||||
// An `entsel` result: (ename point).
|
||||
feed(ctx.s, Input::Pick(vec![h]));
|
||||
named(ctx.s, h, arg.cdr().car().as_point().map(|p| Vec2::new(p[0], p[1])));
|
||||
} else {
|
||||
return Err(LispError::bad_arg("command argument", arg));
|
||||
}
|
||||
@@ -134,6 +136,14 @@ fn feed(s: &mut Session, input: Input) {
|
||||
}
|
||||
}
|
||||
|
||||
fn named(s: &mut Session, h: Handle, at: Option<Vec2>) {
|
||||
if s.running.is_some()
|
||||
&& let Err(e) = s.input_object(h, at)
|
||||
{
|
||||
s.echo(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- system variables ----------------
|
||||
|
||||
/// A JSON value as LISP: integers, reals, strings, lists (points).
|
||||
@@ -181,10 +191,22 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
})
|
||||
}
|
||||
|
||||
/// Angle variables CADCraft reports and takes in degrees, as the SETVAR command shows them, which
|
||||
/// AutoLISP's `getvar` and `setvar` give and take in radians, as AutoCAD 2024's do: each of these
|
||||
/// set to 30 by the SETVAR command, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987.
|
||||
const RADIAN_VARS: [&str; 6] = ["ANGBASE", "CHAMFERD", "DIMJOGANG", "HPANG", "POLARANG", "SNAPANG"];
|
||||
|
||||
fn in_radians(name: &str) -> bool {
|
||||
RADIAN_VARS.iter().any(|v| v.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let name = string(&a[0])?;
|
||||
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
|
||||
Ok(Flow::Value(match crate::sysvars::get(ctx.s, &name) {
|
||||
Some(serde_json::Value::Number(n)) if in_radians(&name) => Value::Real(n.as_f64().unwrap_or(0.0).to_radians()),
|
||||
v => v.map(|v| from_json(&v)).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors.
|
||||
@@ -195,6 +217,10 @@ pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
|
||||
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
|
||||
};
|
||||
let json = match json {
|
||||
serde_json::Value::Number(n) if in_radians(&name) => serde_json::json!(n.as_f64().unwrap_or(0.0).to_degrees()),
|
||||
other => other,
|
||||
};
|
||||
match crate::sysvars::set(ctx.s, &name, &json) {
|
||||
Ok(()) => Ok(Flow::Value(a[1].clone())),
|
||||
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
|
||||
@@ -1029,7 +1055,7 @@ pub fn rtos(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
}
|
||||
|
||||
fn decimal(v: f64, prec: usize, dimzin: i64) -> String {
|
||||
let mut s = format!("{v:.prec$}");
|
||||
let mut s = crate::units::fixed(v, prec);
|
||||
if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() {
|
||||
s = s.trim_start_matches('-').to_string();
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ fn entity_lists_hold_angles_in_radians() {
|
||||
// MTEXT: 50 before the column type (75) is the rotation, after it a column height.
|
||||
ok(
|
||||
&mut s,
|
||||
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))",
|
||||
"(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 2.0) '(50 . 30.0) '(50 . 20.0)))",
|
||||
);
|
||||
let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))),
|
||||
@@ -528,7 +528,8 @@ fn interactive_routine_waits_for_the_user() {
|
||||
#[test]
|
||||
fn getangle_is_relative_to_angbase() {
|
||||
let mut s = session();
|
||||
s.cmdline("(setvar \"ANGBASE\" 90)").unwrap();
|
||||
// AutoLISP sets angle variables in radians: ANGBASE 90 degrees.
|
||||
s.cmdline("(setvar \"ANGBASE\" (/ pi 2))").unwrap();
|
||||
// A picked point at 180° in world terms is 90° from ANGBASE.
|
||||
s.cmdline("(setq a (getangle '(0 0)))").unwrap();
|
||||
s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap();
|
||||
@@ -543,6 +544,38 @@ fn getangle_is_relative_to_angbase() {
|
||||
assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159");
|
||||
}
|
||||
|
||||
/// `getvar` and `setvar` take angle variables in radians, as AutoCAD 2024's do (each set to 30 by
|
||||
/// SETVAR, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987; DIMJOGANG's 45 degrees reads
|
||||
/// 0.785398), while the SETVAR command keeps degrees.
|
||||
#[test]
|
||||
fn getvar_and_setvar_take_angle_variables_in_radians() {
|
||||
let mut s = session();
|
||||
let degrees = |s: &Session, name: &str| crate::sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap();
|
||||
// The defaults: polar tracking every 90 degrees, a 45-degree jog.
|
||||
assert_eq!(ok(&mut s, "(list (equal (getvar \"POLARANG\") (/ pi 2) 1e-12) (equal (getvar \"DIMJOGANG\") (/ pi 4) 1e-12))"), "(T T)");
|
||||
// Set from LISP in radians, shown by SETVAR in degrees.
|
||||
for (name, radians, deg) in
|
||||
[("SNAPANG", "(/ pi 6)", 30.0), ("CHAMFERD", "0.95993108859688", 55.0), ("ANGBASE", "(/ pi 2)", 90.0), ("POLARANG", "(/ pi 4)", 45.0)]
|
||||
{
|
||||
ok(&mut s, &format!("(setvar \"{name}\" {radians})"));
|
||||
assert!((degrees(&s, name) - deg).abs() < 1e-9, "{name}: {}", degrees(&s, name));
|
||||
assert_eq!(ok(&mut s, &format!("(equal (getvar \"{name}\") {radians} 1e-12)")), "T", "{name}");
|
||||
}
|
||||
// Set by the SETVAR command in degrees, read by LISP in radians.
|
||||
s.cmdline("SETVAR HPANG 45").unwrap();
|
||||
assert!((degrees(&s, "HPANG") - 45.0).abs() < 1e-9);
|
||||
assert_eq!(ok(&mut s, "(equal (getvar \"HPANG\") 0.7853981633974482 1e-12)"), "T");
|
||||
// DIMJOGANG set to a whole number of degrees still takes a fraction of one from LISP.
|
||||
s.cmdline("SETVAR DIMJOGANG 45").unwrap();
|
||||
ok(&mut s, "(setvar \"DIMJOGANG\" 0.5)");
|
||||
assert!((degrees(&s, "DIMJOGANG") - 0.5_f64.to_degrees()).abs() < 1e-9, "{}", degrees(&s, "DIMJOGANG"));
|
||||
// It takes the value the dimension style takes: `true` is a DIMSCALE of 1.
|
||||
crate::sysvars::set(&mut s, "DIMSCALE", &serde_json::json!(true)).unwrap();
|
||||
assert_eq!(crate::sysvars::get(&s, "DIMSCALE").and_then(|v| v.as_f64()), Some(1.0));
|
||||
// Other variables are as they were.
|
||||
assert_eq!(ok(&mut s, "(getvar \"ANGDIR\")"), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_and_load() {
|
||||
let mut s = session();
|
||||
@@ -563,6 +596,111 @@ fn appload_and_load() {
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type")));
|
||||
}
|
||||
|
||||
/// `command` gives an entity name, alone or as `entsel` returns it (name and point), to a prompt
|
||||
/// picking one object: the point picks that object, and a bare name stands at its DXF group 10
|
||||
/// point. Each call and its numbers are AutoCAD 2024's (accoreconsole, read back with `entget`);
|
||||
/// AutoCAD ran TRIM and EXTEND in Standard mode there.
|
||||
#[test]
|
||||
fn command_names_objects_at_prompts_picking_one() {
|
||||
use cadcraft_doc::EntityKind;
|
||||
let near = |a: [f64; 2], b: [f64; 2]| (0..2).all(|i| (a[i] - b[i]).abs() <= 1e-9 * a[i].abs().max(1.0));
|
||||
// The drawing's points, lines and polyline widths after `forms`, each evaluated on its own.
|
||||
let run = |forms: &[&str]| {
|
||||
let mut s = session();
|
||||
ok(&mut s, "(setvar \"TRIMEXTENDMODE\" 0)");
|
||||
for f in forms {
|
||||
ok(&mut s, f);
|
||||
}
|
||||
assert!(s.running.is_none(), "{forms:?}: a command is still running");
|
||||
let (mut points, mut lines, mut widths) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for e in s.doc().unwrap().model.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Point(p) => points.push([p.p.x, p.p.y]),
|
||||
EntityKind::Line(l) => lines.push(([l.a.x, l.a.y], [l.b.x, l.b.y])),
|
||||
EntityKind::LwPolyline(pl) => widths.push(pl.const_width),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(points, lines, widths)
|
||||
};
|
||||
let has_point = |points: &[[f64; 2]], p: [f64; 2]| points.iter().any(|q| near(*q, p));
|
||||
let has_line = |lines: &[([f64; 2], [f64; 2])], a: [f64; 2], b: [f64; 2]| lines.iter().any(|(p, q)| near(*p, a) && near(*q, b));
|
||||
let line = |name: &str, a: [f64; 2], b: [f64; 2]| {
|
||||
format!("(setq {name} (progn (entmake '((0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0))) (entlast)))", a[0], a[1], b[0], b[1])
|
||||
};
|
||||
|
||||
// A circle named alone stands at its centre, where nothing is drawn.
|
||||
let (points, ..) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 15.0))) (entlast)))",
|
||||
"(command \"_.MEASURE\" a 10.275598705220492)",
|
||||
]);
|
||||
assert!(points.len() == 9 && has_point(&points, [14.89599800946832, -1.763304653744166]), "{points:?}");
|
||||
// An arc named alone stands at its centre too, as near to either end: MEASURE runs from its
|
||||
// end (250°), so its points run from 233.4° down to 34.4°, not from 46.6° up.
|
||||
let (points, ..) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"ARC\") (10 -35000.0 81000.0 0.0) (40 . 1000.0) (50 . 0.5235987755982988) (51 . 4.363323129985824))) (entlast)))",
|
||||
"(command \"_.MEASURE\" a 289.4960530820059)",
|
||||
]);
|
||||
assert!(points.len() == 13 && has_point(&points, [-35596.04133310627, 80197.0462471419]), "{points:?}");
|
||||
assert!(has_point(&points, [-34174.59349504207, 81564.53883973838]), "{points:?}");
|
||||
// PEDIT takes a polyline by name.
|
||||
let (_, _, widths) = run(&[
|
||||
"(setq a (progn (entmake '((0 . \"LWPOLYLINE\") (100 . \"AcDbEntity\") (100 . \"AcDbPolyline\") (90 . 3) (70 . 0) (10 0.0 0.0) (10 30.0 0.0) (42 . 0.4) (10 40.0 20.0))) (entlast)))",
|
||||
"(command \"_.PEDIT\" a \"_W\" 1.25 \"\")",
|
||||
]);
|
||||
assert_eq!(widths, [1.25]);
|
||||
// `entsel` lists: the point picks the end LENGTHEN moves, BREAK's first point, the piece
|
||||
// TRIM removes and the end EXTEND moves.
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [-11.745250375031729, -27.60523670660726]),
|
||||
"(command \"_.LENGTHEN\" \"_DE\" 5.0 (list a '(-10.962233683362948 -25.764887592833446)) \"\")",
|
||||
]);
|
||||
assert!(has_line(&lines, [0.0, 0.0], [-13.70279210420368, -32.2061094910418]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [-97.11873949964188, 23.831710765295487]),
|
||||
"(command \"_.BREAK\" (list a '(-19.77309329359773 4.852067095763353)) '(-77.20177993977879 18.94434070674469))",
|
||||
]);
|
||||
assert!(lines.len() == 2 && has_line(&lines, [0.0, 0.0], [-19.77309329359773, 4.852067095763352]), "{lines:?}");
|
||||
assert!(has_line(&lines, [-77.20177993977879, 18.94434070674469], [-97.11873949964188, 23.83171076529548]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [-38.39479850296135, -32.02872847799295], [38.39479850296135, 32.02872847799295]),
|
||||
&line("b", [-9.422796057479543, -30.193534825244917], [-15.589739201426854, 9.328216303207391]),
|
||||
&line("c", [35.428336962443794, 7.710053620262788], [-2.7893410422311042, 19.517216189270982]),
|
||||
"(command \"_.TRIM\" a b c \"\" (list a '(1.9066151653265737 1.5904878218740615)) \"\")",
|
||||
]);
|
||||
assert!(lines.len() == 4 && has_line(&lines, [-38.39479850296135, -32.02872847799294], [-12.50626762945319, -10.43265926101876]), "{lines:?}");
|
||||
assert!(has_line(&lines, [16.31949796010634, 13.61363490476688], [38.39479850296135, 32.02872847799294]), "{lines:?}");
|
||||
let (_, lines, _) = run(&[
|
||||
&line("a", [0.0, 0.0], [18.819750237318146, -6.7688256739971955]),
|
||||
&line("b", [17.056820153998288, -35.36317014637398], [58.2221807952743, 8.2878674503852]),
|
||||
"(command \"_.EXTEND\" a b \"\" (list a '(14.11481267798861 -5.076619255497897)) \"\")",
|
||||
]);
|
||||
assert!(has_line(&lines, [0.0, 0.0], [37.63950047463629, -13.53765134799439]), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_object_holds_for_its_own_input_in_its_own_space() {
|
||||
use cadcraft_doc::Space;
|
||||
let mut s = session();
|
||||
ok(&mut s, "(setq a (progn (entmake '((0 . \"LINE\") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0))) (entlast)))");
|
||||
// BREAK takes A at (5,3), off the line; a later pick there finds nothing.
|
||||
ok(&mut s, "(command \"_.BREAK\" (list a '(5.0 3.0)))");
|
||||
assert!(s.prompt_text().contains("Specify second break point"), "{}", s.prompt_text());
|
||||
let model = s.space();
|
||||
assert_eq!(s.pick_object(&model, cadcraft_geom::Vec2::new(5.0, 3.0), 0.5).unwrap(), None);
|
||||
s.cancel();
|
||||
// A paper-space copy of A named in model space picks nothing, not A under its start.
|
||||
let p = {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let e = d.model.iter().next().unwrap();
|
||||
let (common, kind) = (e.common.clone(), e.kind.clone());
|
||||
d.add(&Space::Paper("Layout1".into()), common, kind).unwrap()
|
||||
};
|
||||
ok(&mut s, &format!("(command \"_.BREAK\" (handent \"{}\"))", p.hex()));
|
||||
assert!(s.prompt_text().contains("Select object"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appload_reads_a_lsp_path_and_reports_its_commands() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id()));
|
||||
|
||||
@@ -180,10 +180,13 @@ impl Prompt {
|
||||
(k, k.to_ascii_lowercase(), shortcut)
|
||||
});
|
||||
// A whole keyword or its capital-letter shortcut wins over a prefix of another keyword
|
||||
// (`M` is Multiple, not a prefix of mEthod).
|
||||
// (`M` is Multiple, not a prefix of mEthod). A prefix reaches past the keyword's last
|
||||
// capital, as in AutoCAD: `L` is none of LAyer, LType, ltScale or LWeight (AutoCAD 2024's
|
||||
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
|
||||
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl)))
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
@@ -341,5 +344,32 @@ mod tests {
|
||||
let q = Prompt::new("", Accept::POINT).kw(&["3P", "2P", "Ttr"]);
|
||||
assert_eq!(q.match_keyword("3p").as_deref(), Some("3P"));
|
||||
assert_eq!(q.match_keyword("t").as_deref(), Some("Ttr"));
|
||||
// A prefix reaches past the last capital (AutoCAD 2024's CHPROP: `L` is ambiguous).
|
||||
let c =
|
||||
Prompt::new("Enter property to change", Accept::TEXT).kw(&["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]);
|
||||
assert_eq!(c.match_keyword("l"), None);
|
||||
assert_eq!(c.match_keyword("la").as_deref(), Some("LAyer"));
|
||||
assert_eq!(c.match_keyword("lts").as_deref(), Some("ltScale"));
|
||||
assert_eq!(c.match_keyword("s").as_deref(), Some("ltScale"));
|
||||
assert_eq!(c.match_keyword("tr").as_deref(), Some("TRansparency"));
|
||||
// Even when no other keyword starts with it: AutoCAD 2024 answers QDIM's `DA` with "Point or
|
||||
// option keyword required." and PEDIT's vertex `E` with "Invalid option keyword.".
|
||||
let q = Prompt::new("", Accept::POINT).kw(&[
|
||||
"Continuous",
|
||||
"Staggered",
|
||||
"Baseline",
|
||||
"Ordinate",
|
||||
"Radius",
|
||||
"Diameter",
|
||||
"datumPoint",
|
||||
"Edit",
|
||||
"seTtings",
|
||||
]);
|
||||
assert_eq!(q.match_keyword("da"), None);
|
||||
assert_eq!(q.match_keyword("datump").as_deref(), Some("datumPoint"));
|
||||
assert_eq!(q.match_keyword("p").as_deref(), Some("datumPoint"));
|
||||
let v = Prompt::new("", Accept::TEXT).kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]);
|
||||
assert_eq!(v.match_keyword("e"), None);
|
||||
assert_eq!(v.match_keyword("ex").as_deref(), Some("eXit"));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-17
@@ -181,7 +181,9 @@ pub(crate) fn candidates<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// The topmost entity within `aperture` of `p`.
|
||||
/// The topmost entity within `aperture` of `p`: the one drawn last (on top in the draw order), not
|
||||
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
|
||||
/// picked, and DRAWORDER changes which).
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
@@ -189,7 +191,7 @@ pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle
|
||||
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let mut best: Option<(f64, Handle)> = None;
|
||||
// Candidates come topmost first: the first within the aperture is the one.
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
@@ -198,30 +200,24 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
if !infinite && !bounds_of(d, e, known).expand(aperture).intersects(&probe) {
|
||||
continue;
|
||||
}
|
||||
let dist = entity_distance(d, e, p, aperture / 4.0);
|
||||
if dist <= aperture && best.is_none_or(|(bd, _)| dist < bd - 1e-12) {
|
||||
best = Some((dist, e.handle));
|
||||
if dist == 0.0 {
|
||||
break;
|
||||
}
|
||||
if entity_distance(d, e, p, aperture / 4.0) <= aperture {
|
||||
return Some(e.handle);
|
||||
}
|
||||
}
|
||||
best.map(|(_, h)| h)
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is
|
||||
/// what [`pick`] chooses): the objects selection cycling lists.
|
||||
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
|
||||
/// the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
let mut hits: Vec<(f64, Handle)> = cands
|
||||
cands
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle))
|
||||
.filter(|(dist, _)| *dist <= aperture)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
hits.into_iter().map(|(_, h)| h).collect()
|
||||
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
|
||||
.map(|(e, _)| e.handle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
|
||||
@@ -486,6 +482,26 @@ mod tests {
|
||||
assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(0.0, 1.0), (0.0, 5.0)]), 0);
|
||||
assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0);
|
||||
}
|
||||
|
||||
/// Two objects under the pickbox: AutoCAD 2024 picks the one drawn last, not the nearer one,
|
||||
/// and DRAWORDER changes which (`(ssget pt)` in accoreconsole).
|
||||
#[test]
|
||||
fn a_pick_takes_the_object_drawn_last() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let circle = d
|
||||
.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(50.0, 50.0, 0.0), radius: 10.0 }))
|
||||
.unwrap();
|
||||
let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(40.5, 45.0, 0.0), b: Vec3::new(40.5, 55.0, 0.0) });
|
||||
let line = d.add(&Space::Model, Common::default(), line).unwrap();
|
||||
// 0.05 from the circle and 0.45 from the line, both inside an aperture of 1.
|
||||
let p = Vec2::new(40.05, 50.0);
|
||||
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(line));
|
||||
assert_eq!(pick_all(&d, &Space::Model, p, 1.0), vec![line, circle]);
|
||||
d.model.send_to_back(line);
|
||||
assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(circle));
|
||||
// Only one within reach: that one, whatever the order.
|
||||
assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,27 @@ impl Session {
|
||||
// ---------------- programmatic execution ----------------
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes.
|
||||
///
|
||||
/// The command itself is guarded below, where its own panic can be told apart. This guard is
|
||||
/// for everything around it: the passes after a command (constraints, associativity, groups)
|
||||
/// are as able to panic as the command, and a panic there restores the drawing the same way.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute_guarded(id, params))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
if let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = sel;
|
||||
}
|
||||
Err(EngineError::Internal(id.into(), crate::guard::message(&*p)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_guarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
|
||||
@@ -9,6 +9,7 @@ impl Session {
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
self.pick_hint = None;
|
||||
let name = name.trim();
|
||||
let (transparent, name) = match name.strip_prefix('\'') {
|
||||
Some(n) => (true, n),
|
||||
@@ -340,7 +341,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// End a command: every way out of one comes through here. The passes after a command
|
||||
/// (constraints, associativity, groups) can panic like the command itself; a panic restores
|
||||
/// the drawing as it was before the command, as a panic in its `input` does.
|
||||
fn finish(&mut self, run: Running, cancelled: bool) {
|
||||
let (id, before, selection) = (run.id.clone(), run.before.clone(), run.selection_before.clone());
|
||||
if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.finish_guarded(run, cancelled))) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
st.doc = before;
|
||||
st.selection = selection;
|
||||
}
|
||||
self.echo(format!("Internal error in {id}: {}", crate::guard::message(&*p)));
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_guarded(&mut self, run: Running, cancelled: bool) {
|
||||
// A typed selection mode or SIngle never outlives its command.
|
||||
self.select_mode = None;
|
||||
self.select_single = false;
|
||||
@@ -390,6 +405,8 @@ impl Session {
|
||||
/// started in, so associativity is not applied across a document switch; `ok` is false for a
|
||||
/// failed or non-undoable programmatic command, which skips the constraint pass.
|
||||
pub(super) fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
|
||||
#[cfg(test)]
|
||||
crate::guard::tests::panic_if_armed();
|
||||
cmd::groups::after_command(self, before);
|
||||
cmd::constraints::after_command(self, before, ok);
|
||||
assoc::after_command(self, before, uid);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Bounds2, Handle, Input, PendingWindow, Result, Session, Vec2, cmd, select};
|
||||
use super::{Bounds2, Drawing, Entity, Handle, Input, PendingWindow, Result, Session, Space, Vec2, cmd, select};
|
||||
|
||||
impl Session {
|
||||
// ---------------- picking without a command ----------------
|
||||
@@ -125,4 +125,47 @@ impl Session {
|
||||
st.previous_selection = sel.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- one object by name ----------------
|
||||
|
||||
/// Give the running command an object by name, as AutoLISP's `command` does with an entity
|
||||
/// name or an `entsel` list (name and point). A prompt picking one object with a point
|
||||
/// (BREAK's "Select object", MEASURE's "Select object to measure"…) takes the point, and a
|
||||
/// pick there finds that object ([`Session::pick_object`]) wherever it lies. A bare name
|
||||
/// stands at the object's DXF group 10 point (a line's start, a circle's centre, a
|
||||
/// polyline's first vertex), the point AutoCAD picks it at. The name holds for that input
|
||||
/// only: a later pick at the same point finds what lies there. Other prompts take the object
|
||||
/// as a pick.
|
||||
pub fn input_object(&mut self, h: Handle, at: Option<Vec2>) -> Result<()> {
|
||||
let one = self.current_prompt().is_some_and(|p| p.accept.point && !p.accept.select && p.picks_objects());
|
||||
let p = if one { at.or_else(|| self.doc().ok().and_then(|d| group_10(d, d.entity(h)?))) } else { None };
|
||||
let Some(p) = p.filter(|p| p.is_finite()) else { return self.input(Input::Pick(vec![h])) };
|
||||
self.pick_hint = Some((h, p));
|
||||
let r = self.input(Input::Point(p));
|
||||
self.pick_hint = None;
|
||||
r
|
||||
}
|
||||
|
||||
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
|
||||
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
|
||||
/// object under `p` within `aperture`.
|
||||
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
|
||||
let d = self.doc()?;
|
||||
if let Some((h, q)) = self.pick_hint
|
||||
&& q == p
|
||||
{
|
||||
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
|
||||
}
|
||||
Ok(select::pick(d, space, p, aperture))
|
||||
}
|
||||
}
|
||||
|
||||
/// An object's DXF group 10 point: the first point of its entity list, or its first grip where
|
||||
/// entity lists are not built in.
|
||||
fn group_10(d: &Drawing, e: &Entity) -> Option<Vec2> {
|
||||
let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() };
|
||||
let (_, tags) = (codec.groups)(d, e);
|
||||
let i = tags.iter().position(|t| t.code == 10)?;
|
||||
let y = tags.get(i + 1).filter(|t| t.code == 20)?;
|
||||
Some(Vec2::new(tags[i].value.as_f64(), y.value.as_f64()))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"FONTFALLBACK",
|
||||
"SAVETIME",
|
||||
"SAVEFILEPATH",
|
||||
"ISAVEBAK",
|
||||
];
|
||||
|
||||
pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
@@ -154,6 +155,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
// Autosave (profile settings too).
|
||||
"SAVETIME" => json!(crate::autosave::savetime()),
|
||||
"SAVEFILEPATH" => json!(crate::autosave::savefilepath()),
|
||||
"ISAVEBAK" => json!(i32::from(crate::autosave::isavebak())),
|
||||
"CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()),
|
||||
"DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()),
|
||||
"DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)),
|
||||
@@ -366,6 +368,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32,
|
||||
),
|
||||
"SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?),
|
||||
"ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?),
|
||||
// A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line).
|
||||
"ANGBASE" => {
|
||||
let a = match v {
|
||||
@@ -396,6 +399,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
// A real style field (DIMJOGANG, DIMSCALE...) is a real variable, even set to a
|
||||
// whole number first, so a later fraction still fits. It takes the value the style
|
||||
// took.
|
||||
if let Some(f) = serde_json::to_value(&st).ok().and_then(|j| j.get(*field).filter(|f| f.is_f64()).and_then(|f| f.as_f64())) {
|
||||
d.header.set_f64(&n, f);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let val = match v {
|
||||
Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)),
|
||||
|
||||
@@ -700,25 +700,6 @@ fn cancel_keeps_partial_line_as_one_undo() {
|
||||
assert_eq!(s.doc().unwrap().model.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let mut s = Session::new();
|
||||
for c in command_specs() {
|
||||
for p in [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!("x"),
|
||||
json!({"points": [[1e308, -1e308], ["a"]]}),
|
||||
json!({"handles": ["zz", 99999]}),
|
||||
json!({"radius": -1, "center": [0, 0]}),
|
||||
json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
|
||||
json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}),
|
||||
] {
|
||||
let _ = s.execute(c.id, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_interactive_command_starts_and_cancels() {
|
||||
for c in command_specs().iter().filter(|c| c.interactive.is_some()) {
|
||||
|
||||
+80
-14
@@ -68,23 +68,62 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String {
|
||||
};
|
||||
format!("{}{}{}", if neg { "-" } else { "" }, whole, fs)
|
||||
}
|
||||
_ => format!("{:.*}", prec, v),
|
||||
_ => fixed(v, prec),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`).
|
||||
fn scientific(v: f64, prec: usize) -> String {
|
||||
let s = format!("{:.*E}", prec, v);
|
||||
match s.split_once('E') {
|
||||
Some((mantissa, exp)) => {
|
||||
let (sign, digits) = match exp.strip_prefix('-') {
|
||||
Some(d) => ('-', d),
|
||||
None => ('+', exp),
|
||||
};
|
||||
format!("{mantissa}E{sign}{digits:0>2}")
|
||||
}
|
||||
None => s,
|
||||
/// `v` with `prec` decimals, rounded as AutoCAD rounds a number it shows (RTOS, LUNITS): at 15
|
||||
/// significant digits, with a half rounded away from zero. AutoCAD 2024 gives 1.01 for 1.005 to two
|
||||
/// places (its binary value is just below 1.005), 3 for 2.5 and -0.13 for -0.125, where Rust's
|
||||
/// formatting rounds the binary value and a half to even (1.00, 2 and -0.12).
|
||||
pub fn fixed(v: f64, prec: usize) -> String {
|
||||
let plain = || format!("{v:.prec$}");
|
||||
if !v.is_finite() {
|
||||
return plain();
|
||||
}
|
||||
let sci = format!("{:.14e}", v.abs());
|
||||
let Some((mantissa, exp)) = sci.split_once('e') else { return plain() };
|
||||
let Ok(exp) = exp.parse::<i64>() else { return plain() };
|
||||
let sig: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
|
||||
// Digit `k` of `sig` is worth 10^(exp - k); keep those down to 10^-prec, in units of 10^-prec.
|
||||
let digit = |k: i64| usize::try_from(k).ok().and_then(|k| sig.get(k)).copied().unwrap_or(0);
|
||||
let keep = exp + 1 + prec as i64;
|
||||
let mut units: Vec<u8> = (0..keep).map(digit).collect();
|
||||
if keep >= 0 && digit(keep) >= 5 {
|
||||
let mut carry = true;
|
||||
for d in units.iter_mut().rev() {
|
||||
if *d == 9 {
|
||||
*d = 0;
|
||||
} else {
|
||||
*d += 1;
|
||||
carry = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if carry {
|
||||
units.insert(0, 1);
|
||||
}
|
||||
}
|
||||
let n: String = units.iter().map(|d| char::from(b'0' + d)).collect();
|
||||
let n = format!("{:0>width$}", n.trim_start_matches('0'), width = prec + 1);
|
||||
let (int, frac) = n.split_at(n.len() - prec);
|
||||
let body = if prec == 0 { int.to_string() } else { format!("{int}.{frac}") };
|
||||
if v < 0.0 && n.bytes().any(|b| b != b'0') { format!("-{body}") } else { body }
|
||||
}
|
||||
|
||||
/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`),
|
||||
/// the mantissa rounded as [`fixed`] rounds.
|
||||
fn scientific(v: f64, prec: usize) -> String {
|
||||
let s = format!("{:.14e}", v);
|
||||
let Some((m, e)) = s.split_once('e') else { return s };
|
||||
let (Ok(m), Ok(mut e)) = (m.parse::<f64>(), e.parse::<i64>()) else { return s };
|
||||
let mut mantissa = fixed(m, prec);
|
||||
// 9.99… rounded up to 10: one more power of ten.
|
||||
if mantissa.trim_start_matches('-').starts_with("10") {
|
||||
e += 1;
|
||||
mantissa = fixed(m / 10.0, prec);
|
||||
}
|
||||
format!("{mantissa}E{}{:0>2}", if e < 0 { '-' } else { '+' }, e.abs())
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
@@ -435,11 +474,38 @@ mod tests {
|
||||
assert_eq!(format_distance(11.6, 3, 0), "1'-0\"");
|
||||
}
|
||||
|
||||
/// RTOS in AutoCAD 2024 (accoreconsole, DIMZIN 0): a half rounds away from zero, at 15
|
||||
/// significant digits.
|
||||
#[test]
|
||||
fn numbers_round_as_autocad_rounds_them() {
|
||||
for (v, prec, want) in [
|
||||
(1500.5, 0, "1501"),
|
||||
(2.5, 0, "3"),
|
||||
(-2.5, 0, "-3"),
|
||||
(0.125, 2, "0.13"),
|
||||
(0.5, 0, "1"),
|
||||
(1.005, 2, "1.01"),
|
||||
(3.5, 0, "4"),
|
||||
(0.0625, 3, "0.063"),
|
||||
(-0.125, 2, "-0.13"),
|
||||
(1234.5678, 3, "1234.568"),
|
||||
] {
|
||||
assert_eq!(format_distance(v, 2, prec), want, "{v} to {prec}");
|
||||
}
|
||||
assert_eq!(format_distance(2.5, 1, 0), "3E+00");
|
||||
assert_eq!(format_distance(0.125, 1, 1), "1.3E-01");
|
||||
assert_eq!(format_distance(9.96, 1, 1), "1.0E+01");
|
||||
assert_eq!(fixed(-0.004, 2), "0.00");
|
||||
assert_eq!(fixed(1e-20, 3), "0.000");
|
||||
assert_eq!(fixed(123456789012.0, 1), "123456789012.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scientific_exponent_is_signed_and_padded() {
|
||||
assert_eq!(format_distance(17.5, 1, 4), "1.7500E+01");
|
||||
assert_eq!(format_distance(0.00001, 1, 2), "1.00E-05");
|
||||
assert_eq!(format_distance(1234.5, 1, 3), "1.234E+03");
|
||||
// 1.2345 is halfway: away from zero, as AutoCAD rounds 2.5 to 3E+00.
|
||||
assert_eq!(format_distance(1234.5, 1, 3), "1.235E+03");
|
||||
assert_eq!(format_distance(12345678.0, 1, 3), "1.235E+07");
|
||||
assert_eq!(format_distance(-17.5, 1, 4), "-1.7500E+01");
|
||||
assert_eq!(format_distance(0.0, 1, 2), "0.00E+00");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! CHPROP as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back
|
||||
//! with `entget`).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::color::{Color, Rgb};
|
||||
use cadcraft_engine::doc::{Handle, Lineweight, Transparency};
|
||||
use serde_json::json;
|
||||
|
||||
/// A session with line A (0,0)-(50,0) on layer 0 and line B (0,20)-(50,30) on layer L1, as `a` and
|
||||
/// `b` in LISP, after `code`; the properties of A then B.
|
||||
fn run(code: &str) -> (Session, [cadcraft_engine::doc::Common; 2]) {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().ensure_layer("L1");
|
||||
s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap();
|
||||
s.cmdline("(setq a (entlast))").unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 20], [50, 30]] })).unwrap();
|
||||
s.cmdline("(setq b (entlast))").unwrap();
|
||||
s.cmdline("(command \"_.CHPROP\" b \"\" \"_LA\" \"L1\" \"\")").unwrap();
|
||||
s.cmdline(&format!("(progn {code})")).unwrap();
|
||||
if s.running.is_some() {
|
||||
s.cancel();
|
||||
}
|
||||
let common = {
|
||||
let d = s.doc().unwrap();
|
||||
let mut it = d.model.iter().map(|e| e.common.clone());
|
||||
[it.next().unwrap(), it.next().unwrap()]
|
||||
};
|
||||
(s, common)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chprop_sets_each_property_as_autocad() {
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 1 \"\")");
|
||||
assert_eq!(a.color, Color::Index(1));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"blue\" \"\")");
|
||||
assert_eq!(a.color, Color::Index(5));
|
||||
let (_, [_, b]) = run("(command \"_.CHPROP\" b \"\" \"_C\" \"_BYBLOCK\" \"\")");
|
||||
assert_eq!(b.color, Color::ByBlock);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"\")");
|
||||
assert_eq!(a.color, Color::ByLayer);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"255,128,0\" \"\")");
|
||||
assert_eq!(a.color, Color::True(Rgb(255, 128, 0)));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"L1\" \"\")");
|
||||
assert_eq!(a.layer, "L1");
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"DASHED\" \"\")");
|
||||
assert_eq!(a.linetype, "DASHED");
|
||||
// Not loaded yet: loaded and given, as AutoCAD loads it from acadiso.lin.
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"PHANTOM2\" \"\")");
|
||||
assert_eq!(a.linetype, "PHANTOM2");
|
||||
assert!(s.doc().unwrap().linetype("PHANTOM2").is_some());
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_S\" 2.5 \"\")");
|
||||
assert_eq!(a.ltscale, 2.5);
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LW\" 0.33 \"\")");
|
||||
assert_eq!(a.lineweight, Lineweight::Mm100(35));
|
||||
assert!(s.log.iter().any(|l| l == "Lineweight rounded to nearest valid value of 0.35 mm"), "{:?}", s.log);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_T\" 5.0 \"\")");
|
||||
assert_eq!(a.thickness, 5.0);
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 50 \"\")");
|
||||
assert_eq!(a.transparency, Transparency::Percent(50));
|
||||
let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" \"_BYBLOCK\" \"\")");
|
||||
assert_eq!(a.transparency, Transparency::ByBlock);
|
||||
// Several objects and several properties in one go.
|
||||
let (_, [a, b]) = run("(command \"_.CHPROP\" a b \"\" \"_C\" 3 \"_LA\" \"L1\" \"_LW\" 0.5 \"\")");
|
||||
for c in [a, b] {
|
||||
assert_eq!((c.color, c.layer.as_str(), c.lineweight), (Color::Index(3), "L1", Lineweight::Mm100(50)));
|
||||
}
|
||||
let (_, [a, _]) = run(
|
||||
"(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"_LT\" \"_BYBLOCK\" \"_LW\" \"_BYLAYER\" \"\")",
|
||||
);
|
||||
assert_eq!((a.color, a.linetype.as_str(), a.lineweight), (Color::ByLayer, "ByBlock", Lineweight::ByLayer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chprop_refuses_what_autocad_refuses() {
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"KHONGCO\" \"\")");
|
||||
assert_eq!(a.layer, "0");
|
||||
assert!(s.log.iter().any(|l| l == "Cannot find layer \"KHONGCO\"."), "{:?}", s.log);
|
||||
for n in [0, 256, 300] {
|
||||
let (s, [a, _]) = run(&format!("(command \"_.CHPROP\" a \"\" \"_C\" {n} \"\")"));
|
||||
assert_eq!(a.color, Color::ByLayer, "{n}");
|
||||
assert!(s.log.iter().any(|l| l == "Invalid color number."), "{n}: {:?}", s.log);
|
||||
}
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"300,0,0\" \"\")");
|
||||
assert_eq!(a.color, Color::ByLayer);
|
||||
assert!(s.log.iter().any(|l| l == "Requires three integers from 0 to 255, separated by commas."), "{:?}", s.log);
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 95 \"\")");
|
||||
assert_eq!(a.transparency, Transparency::ByLayer);
|
||||
assert!(s.log.iter().any(|l| l == "Requires ByLayer, ByBlock or an integer from 0 to 90."), "{:?}", s.log);
|
||||
// `L` is LAyer, LType, ltScale or LWeight: none of them.
|
||||
let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_L\")");
|
||||
assert_eq!(a.layer, "0");
|
||||
assert!(s.log.iter().any(|l| l == "Ambiguous response, please clarify..."), "{:?}", s.log);
|
||||
}
|
||||
|
||||
/// Line a on layer 0 and lines b and c on the locked layer L1 (`a`, `b` and `c` in LISP); their
|
||||
/// handles.
|
||||
fn locked_drawing() -> (Session, Vec<Handle>) {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().ensure_layer("L1");
|
||||
for (v, y) in [("a", 0), ("b", 20), ("c", 40)] {
|
||||
s.execute("line", &json!({ "points": [[0, y], [50, y]] })).unwrap();
|
||||
s.cmdline(&format!("(setq {v} (entlast))")).unwrap();
|
||||
}
|
||||
s.cmdline("(command \"_.CHPROP\" b c \"\" \"_LA\" \"L1\" \"\")").unwrap();
|
||||
s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap();
|
||||
s.log.clear();
|
||||
let hs = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
fn colors(s: &Session) -> Vec<Color> {
|
||||
s.doc().unwrap().model.iter().map(|e| e.common.color).collect()
|
||||
}
|
||||
|
||||
/// AutoCAD 2024 leaves objects on locked layers out at "Select objects" and says how many after
|
||||
/// what was found; with none left, Enter ends the command.
|
||||
#[test]
|
||||
fn chprop_leaves_objects_on_locked_layers_alone() {
|
||||
// The colours of a, b and c after `code`, run with `pickfirst` selected.
|
||||
let after = |code: &str, pickfirst: &[usize]| {
|
||||
let (mut s, hs) = locked_drawing();
|
||||
s.set_selection(pickfirst.iter().map(|i| hs[*i]).collect());
|
||||
s.cmdline(code).unwrap();
|
||||
(colors(&s), s.log.clone(), s.running.is_some())
|
||||
};
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd b (ssadd a)) \"\" \"_C\" 3 \"\")", &[]);
|
||||
assert_eq!(c, [Color::Index(3), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" (ssadd c (ssadd b (ssadd a))) \"\" \"_C\" 4 \"\")", &[]);
|
||||
assert_eq!(c, [Color::Index(4), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "2 were on a locked layer."), "{log:?}");
|
||||
// Nothing left: Enter ends the command.
|
||||
let (_, log, running) = after("(command \"_.CHPROP\" b \"\")", &[]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
assert!(!running);
|
||||
// The pickfirst selection loses them too; with none of it left, CHPROP asks for objects.
|
||||
let (c, log, _) = after("(command \"_.CHPROP\" \"_C\" 5 \"\")", &[0, 1]);
|
||||
assert_eq!(c, [Color::Index(5), Color::ByLayer, Color::ByLayer]);
|
||||
assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}");
|
||||
let (c, _, _) = after("(command \"_.CHPROP\" a \"\" \"_C\" 6 \"\")", &[1]);
|
||||
assert_eq!(c, [Color::Index(6), Color::ByLayer, Color::ByLayer]);
|
||||
// The JSON form leaves them out as well, and says how many.
|
||||
let (mut s, hs) = locked_drawing();
|
||||
let r = s.execute("chprop", &json!({ "handles": [hs[0].hex(), hs[1].hex()], "color": 3 })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
let r = s.execute("chprop", &json!({ "handles": [hs[2].hex()], "color": 4 })).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(colors(&s), [Color::Index(3), Color::ByLayer, Color::ByLayer]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Command-line INSERT: X/Y scale prompts and the Basepoint/Scale/Rotate options (#245).
|
||||
//! Command-line INSERT: X/Y/Z scale prompts and the Basepoint/Scale/X/Y/Z/Rotate options (#245).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
@@ -53,3 +53,79 @@ fn script_insert_reads_x_and_y_scale_then_rotation_and_honours_its_options() {
|
||||
s.execute("insert", &json!({ "name": "B1", "at": [0, 80], "scale": [2, 3], "basePoint": [10, 0] })).unwrap();
|
||||
assert_eq!(inserts(&s).last(), Some(&(-20.0, 80.0, 2.0, 3.0, 0.0)));
|
||||
}
|
||||
|
||||
/// (x, y, x scale, y scale, z scale, rotation in degrees) of the last block reference.
|
||||
fn last_insert(s: &Session) -> (f64, f64, f64, f64, f64, f64) {
|
||||
let r = |v: f64| (v * 1e9).round() / 1e9;
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some((r(i.insert.x), r(i.insert.y), r(i.scale.x), r(i.scale.y), r(i.scale.z), r(i.rotation.to_degrees()))),
|
||||
_ => None,
|
||||
})
|
||||
.last()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The options of AutoCAD 2024's -INSERT, each run as AutoLISP's `command` gives it (accoreconsole,
|
||||
/// the block reference read back with `entget`): X/Y/Z presets set that axis alone and skip the
|
||||
/// scale prompts, XYZ asks Z too, a Z factor not asked is the X factor's size, and a Basepoint is
|
||||
/// picked on the block dropped at the cursor (the origin in a script).
|
||||
#[test]
|
||||
fn insert_options_match_autocad() {
|
||||
// A line (0,0)-(10,0) and a circle at (5,5) r 3, around `base`, as block `name`.
|
||||
let block = |s: &mut Session, name: &str, base: [f64; 2], unit: f64| {
|
||||
let at = |x: f64, y: f64| [base[0] + x * unit, base[1] + y * unit];
|
||||
s.execute("line", &json!({ "points": [at(0.0, 0.0), at(10.0, 0.0)] })).unwrap();
|
||||
s.execute("circle", &json!({ "center": at(5.0, 5.0), "radius": 3.0 * unit })).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect();
|
||||
s.execute("block", &json!({ "name": name, "base": base, "keep": "delete", "handles": hs })).unwrap();
|
||||
};
|
||||
let cases: [(&str, [f64; 2], f64, &str, (f64, f64, f64, f64, f64, f64)); 17] = [
|
||||
// X -1, Y as X: Z +1.
|
||||
("B3", [612345.678, 2398765.432], 1.0, "\"B3\" '(612365.678 2398775.432) -1 \"\" 0", (612365.678, 2398775.432, -1.0, -1.0, 1.0, 0.0)),
|
||||
("B4", [0.0, 0.0], 1.0, "\"B4\" \"_S\" 2.5 '(20.0 10.0) 45", (20.0, 10.0, 2.5, 2.5, 2.5, 45.0)),
|
||||
("B5", [0.0, 0.0], 1.0, "\"B5\" \"_R\" 60 '(20.0 10.0) 2 \"\"", (20.0, 10.0, 2.0, 2.0, 2.0, 60.0)),
|
||||
// X alone: the next number is the rotation.
|
||||
("B6", [0.5, 0.25], 0.001, "\"B6\" \"_X\" 2 '(0.52 0.26) 3", (0.52, 0.26, 2.0, 1.0, 1.0, 3.0)),
|
||||
(
|
||||
"B7",
|
||||
[612345.678, 2398765.432],
|
||||
1.0,
|
||||
"\"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0",
|
||||
(15.0, 5.0, 1.0, 1.0, 1.0, 0.0),
|
||||
),
|
||||
("B8", [0.0, 0.0], 1.0, "\"B8\" '(20.0 10.0) \"_XYZ\" 2 3 4 0", (20.0, 10.0, 2.0, 3.0, 4.0, 0.0)),
|
||||
("B9", [0.0, 0.0], 1.0, "\"B9\" '(20.0 10.0) \"_C\" '(26.0 14.0) 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
|
||||
// Z is stored as a size unless typed at XYZ's Z prompt: Scale -2, XYZ -2 with Enter at Y
|
||||
// and Z, and a Z preset of -3 all give +; XYZ's -4 stays.
|
||||
("C1", [0.0, 0.0], 1.0, "\"C1\" \"_S\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
|
||||
("C2", [0.0, 0.0], 1.0, "\"C2\" '(20.0 10.0) \"_XYZ\" -2 \"\" \"\" 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)),
|
||||
("C5", [0.0, 0.0], 1.0, "\"C5\" \"_Z\" -3 '(20.0 10.0) 0", (20.0, 10.0, 1.0, 1.0, 3.0, 0.0)),
|
||||
("C6", [0.0, 0.0], 1.0, "\"C6\" '(20.0 10.0) \"_XYZ\" 2 3 -4 0", (20.0, 10.0, 2.0, 3.0, -4.0, 0.0)),
|
||||
("C7", [0.0, 0.0], 1.0, "\"C7\" \"_X\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, 1.0, 1.0, 0.0)),
|
||||
// Scale and the axis presets in turn: the later one wins for its axes.
|
||||
("C8", [0.0, 0.0], 1.0, "\"C8\" \"_S\" -3 \"_X\" 2 '(20.0 10.0) 0", (20.0, 10.0, 2.0, -3.0, 3.0, 0.0)),
|
||||
("C9", [0.0, 0.0], 1.0, "\"C9\" \"_X\" 2 \"_S\" -3 '(20.0 10.0) 0", (20.0, 10.0, -3.0, -3.0, 3.0, 0.0)),
|
||||
("CA", [0.0, 0.0], 1.0, "\"CA\" \"_S\" 2 \"_Z\" -5 '(20.0 10.0) 0", (20.0, 10.0, 2.0, 2.0, 5.0, 0.0)),
|
||||
// XYZ then a corner (by Corner or straight away) still asks Z, which defaults to X's size.
|
||||
("C3", [0.0, 0.0], 1.0, "\"C3\" '(20.0 10.0) \"_XYZ\" \"_C\" '(26.0 14.0) \"\" 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)),
|
||||
("C4", [0.0, 0.0], 1.0, "\"C4\" '(20.0 10.0) \"_XYZ\" '(14.0 14.0) \"\" 0", (20.0, 10.0, -6.0, 4.0, 6.0, 0.0)),
|
||||
];
|
||||
for (name, base, unit, args, want) in cases {
|
||||
let mut s = Session::new();
|
||||
block(&mut s, name, base, unit);
|
||||
s.cmdline(&format!("(command \"_.-INSERT\" {args})")).unwrap();
|
||||
assert!(s.running.is_none(), "{name}: {}", s.prompt_text());
|
||||
assert_eq!(last_insert(&s), want, "{name}");
|
||||
}
|
||||
// Basepoint is picked on the block dropped at the cursor: with the cursor at (100, 50)
|
||||
// instead of the origin, B7's call inserts 100 and 50 further on.
|
||||
let mut s = Session::new();
|
||||
block(&mut s, "B7", [612345.678, 2398765.432], 1.0);
|
||||
s.cursor = cadcraft_engine::geom::Vec2::new(100.0, 50.0);
|
||||
s.cmdline("(command \"_.-INSERT\" \"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0)").unwrap();
|
||||
assert_eq!(last_insert(&s), (115.0, 55.0, 1.0, 1.0, 1.0, 0.0));
|
||||
}
|
||||
|
||||
@@ -159,3 +159,16 @@ fn layer_delete_clears_viewport_overrides_for_the_layer() {
|
||||
s.execute("layer.new", &json!({ "name": "C" })).unwrap();
|
||||
assert!(viewport_lists(&s).iter().all(|(f, _)| !f.contains(&"C".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_layer_repoints_viewport_lists() {
|
||||
let mut s = drawing();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["A".to_string(), "C".to_string()], vec!["B".to_string()])]);
|
||||
s.execute("rename", &json!({ "table": "layer", "from": "A", "to": "Murs" })).unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])]);
|
||||
// layer.set's newName form (what the Properties palette sends) goes through the same path.
|
||||
s.execute("layer.set", &json!({ "name": "B", "newName": "Cloisons" })).unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["Cloisons".to_string()])]);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])], "undo brings the old name back");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ mod break_closed_polyline;
|
||||
mod break_join_locked_layers;
|
||||
mod break_keeps_z;
|
||||
mod burst;
|
||||
mod chprop_command;
|
||||
mod compare_export;
|
||||
mod constraint_geometry;
|
||||
mod constraint_infer;
|
||||
@@ -126,6 +127,7 @@ mod snap_nearest;
|
||||
mod snap_overrides;
|
||||
mod snap_tracking;
|
||||
mod snaps_more;
|
||||
mod solid_command;
|
||||
mod space_is_enter;
|
||||
mod spline_degree;
|
||||
mod spline_options;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! SOLID (2D solids) as AutoCAD 2024 draws them from AutoLISP's `command` (accoreconsole, every
|
||||
//! solid read back with `entget`).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// The corners of every solid, in drawing order.
|
||||
fn solids(s: &Session) -> Vec<[Vec2; 4]> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Solid(so) => Some(so.corners.map(|c| Vec2::new(c.x, c.y))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_chains_its_corners_as_autocad() {
|
||||
let p = |x: f64, y: f64| (x, y);
|
||||
// (points, Enters after them, each solid as indexes into the points)
|
||||
let cases: [(Vec<(f64, f64)>, usize, Vec<[usize; 4]>); 7] = [
|
||||
// Enter at the fourth point: a triangle repeating its third corner.
|
||||
(
|
||||
vec![p(19.451648928934738, -21.25585202371201), p(34.61693931374715, -3.839951210926415), p(14.55170247613551, -36.00688291519149)],
|
||||
1,
|
||||
vec![[0, 1, 2, 2]],
|
||||
),
|
||||
// Two solids, the second on the first one's last two corners.
|
||||
(
|
||||
vec![
|
||||
p(612336.9748522255, 2398742.6897319513),
|
||||
p(612351.3507889743, 2398794.3456611084),
|
||||
p(612371.7043989347, 2398771.9517326546),
|
||||
p(612379.1755178814, 2398787.9912080113),
|
||||
p(612308.5250187548, 2398780.014694856),
|
||||
p(612356.1118879627, 2398726.474738389),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 5]],
|
||||
),
|
||||
// A solid, then a triangle.
|
||||
(
|
||||
vec![
|
||||
p(15.962585354071173, 3.3526103822549587),
|
||||
p(-0.8095657933516947, 13.123790321338404),
|
||||
p(-19.412229808271626, -27.60123059493223),
|
||||
p(-33.66123919971652, 12.18177290955613),
|
||||
p(14.002713873827737, -8.925735622823005),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 4]],
|
||||
),
|
||||
// Three solids, the last a triangle.
|
||||
(
|
||||
vec![
|
||||
p(29.3005193409103, -24.874809106460187),
|
||||
p(-21.460879071052776, 35.356609837704795),
|
||||
p(-31.347336242570112, -21.756568658673665),
|
||||
p(-39.08169761088666, 23.588062542950155),
|
||||
p(23.0763904721503, 10.219805555956789),
|
||||
p(-22.701751810515756, 22.76615204356692),
|
||||
p(4.338066830902321, -37.50391578060281),
|
||||
],
|
||||
1,
|
||||
vec![[0, 1, 2, 3], [2, 3, 4, 5], [4, 5, 6, 6]],
|
||||
),
|
||||
// Enter twice: the triangle, then Enter at the third point ends the command.
|
||||
(
|
||||
vec![p(612325.6793323631, 2398757.396299568), p(612370.1009101832, 2398796.2224137546), p(612378.8199480829, 2398804.1981022176)],
|
||||
2,
|
||||
vec![[0, 1, 2, 2]],
|
||||
),
|
||||
// Corners stay in the order given (a "Z" order makes a rectangle).
|
||||
(vec![p(0.0, 0.0), p(30.0, 0.0), p(0.0, 20.0), p(30.0, 20.0)], 1, vec![[0, 1, 2, 3]]),
|
||||
// Enter after two points: nothing.
|
||||
(vec![p(-34769.59558954922, 81960.0236700466), p(-35927.22173307072, 79264.64372697374)], 1, vec![]),
|
||||
];
|
||||
let same = |a: Vec2, b: (f64, f64)| (a.x - b.0).abs() <= 1e-9 * b.0.abs().max(1.0) && (a.y - b.1).abs() <= 1e-9 * b.1.abs().max(1.0);
|
||||
for (pts, enters, want) in cases {
|
||||
let mut s = Session::new();
|
||||
let args: Vec<String> = pts.iter().map(|(x, y)| format!("'({x:?} {y:?})")).chain(std::iter::repeat_n("\"\"".to_string(), enters)).collect();
|
||||
s.cmdline(&format!("(command \"_.SOLID\" {})", args.join(" "))).unwrap();
|
||||
// One Enter leaves the command at "Specify third point", as AutoCAD's does (its log then
|
||||
// shows the measuring routine ending it).
|
||||
if s.running.is_some() {
|
||||
assert!(s.prompt_text().contains("Specify third point"), "{pts:?}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
let got = solids(&s);
|
||||
assert_eq!(got.len(), want.len(), "{pts:?}: {got:?}");
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!(g.iter().zip(w).all(|(c, &i)| same(*c, pts[i])), "{pts:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_json_chains_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
s.execute("solid", &json!({ "points": [[0, 0], [10, 0], [0, 5], [10, 5], [0, 10]] })).unwrap();
|
||||
let got = solids(&s);
|
||||
let v = |x: f64, y: f64| Vec2::new(x, y);
|
||||
assert_eq!(got, vec![[v(0.0, 0.0), v(10.0, 0.0), v(0.0, 5.0), v(10.0, 5.0)], [v(0.0, 5.0), v(10.0, 5.0), v(0.0, 10.0), v(0.0, 10.0)]]);
|
||||
assert!(s.execute("solid", &json!({ "points": [[0, 0], [1, 0]] })).is_err());
|
||||
// The alias.
|
||||
s.cmdline("so 20,0 30,0 20,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(solids(&s).len(), 3);
|
||||
}
|
||||
@@ -137,6 +137,9 @@ fn hatch_pattern_prompt_takes_a_pat_file_name() {
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline(&path).unwrap();
|
||||
// Its scale and angle follow; Enter keeps them.
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
// The pattern named like the file is the one used.
|
||||
s.cmdline("5,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
@@ -148,9 +151,21 @@ fn hatch_pattern_prompt_takes_a_pat_file_name() {
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline("myother").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("5,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1);
|
||||
|
||||
// A file with no pattern in it, or none at all, asks for the pattern again.
|
||||
let empty = dir.file("EMPTY.pat", ";; no patterns\n");
|
||||
for file in [empty, dir.path_str("MISSING.pat")] {
|
||||
s.cmdline("hatch").unwrap();
|
||||
s.cmdline("p").unwrap();
|
||||
s.cmdline(&file).unwrap();
|
||||
assert!(s.prompt_text().contains("Enter a pattern name"), "{file}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Fuzz every registered command with adversarial params: a command must return `Err`, never panic
|
||||
//! or hang. Each call gets a fresh, populated session on its own thread with a deadline, so one bad
|
||||
//! command can't change what the next one sees or wedge the run.
|
||||
//!
|
||||
//! `Session::execute` contains a command's panic and returns an ordinary `Err`, so looking at the
|
||||
//! result can't find one, and neither can `catch_unwind` here. A panic hook counts them instead,
|
||||
//! on whichever thread they happen. `contained_panics_count_and_graceful_errors_do_not` proves it.
|
||||
|
||||
use cadcraft_engine::{Session, command_specs};
|
||||
use serde_json::{Value, json};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, Once, PoisonError, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A drawing with something to act on, so commands reach their real work: with nothing in it most
|
||||
/// of them stop at "nothing to do", and with no drawing at all they don't run.
|
||||
fn fresh() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0], [10, 10]]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Wall-clock budget for one command on the small drawing. Real work takes milliseconds, so this
|
||||
/// only catches hangs and work that grows with a number in the params.
|
||||
const DEADLINE: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Budget for the self-test, which checks how panics are counted: the hook prints a backtrace
|
||||
/// under `RUST_BACKTRACE=1`, and the first symbol lookup can be slow on a loaded runner.
|
||||
const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Panics anywhere in the process, counted by a hook.
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// One run at a time, so a panic is charged to the run that caused it.
|
||||
static RUN: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it finished
|
||||
/// without panicking, contained or not (returning `Ok` or a graceful `Err` both count).
|
||||
fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option<bool> {
|
||||
static HOOK: Once = Once::new();
|
||||
HOOK.call_once(|| {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
default(info);
|
||||
}));
|
||||
});
|
||||
let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let r = catch_unwind(AssertUnwindSafe(f));
|
||||
let _ = tx.send(r.is_ok());
|
||||
});
|
||||
rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before)
|
||||
}
|
||||
|
||||
fn run_once(id: &str, p: &Value) -> Option<bool> {
|
||||
let (id, p) = (id.to_string(), p.clone());
|
||||
run_watched(DEADLINE, move || {
|
||||
let mut s = fresh();
|
||||
let _ = s.execute(&id, &p);
|
||||
})
|
||||
}
|
||||
|
||||
/// `Err` with a reason on a panic or a hang. A real hang reproduces and one-time setup (the first
|
||||
/// font scan) doesn't, so a timeout counts only if a rerun on a fresh session times out too.
|
||||
fn run_guarded(id: &str, p: &Value) -> Result<(), String> {
|
||||
match run_once(id, p).or_else(|| run_once(id, p)) {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => Err("panicked".into()),
|
||||
None => Err(format!("hung (>{}s, twice)", DEADLINE.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes and sizes a hostile script, drawing or agent could send. No param names a real path:
|
||||
/// the hunt must not write files.
|
||||
fn adversarial() -> Vec<Value> {
|
||||
let long = "x".repeat(100_000);
|
||||
vec![
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!([]),
|
||||
json!("x"),
|
||||
json!({"points": [[1e308, -1e308], ["a"]], "handles": ["zz", 99999]}),
|
||||
json!({"points": [], "handles": [], "center": [], "from": [], "to": [], "name": "", "layer": "", "style": "", "text": ""}),
|
||||
json!({"center": [1e308, 1e308], "radius": 1e308, "angle": 1e308, "scale": 1e308, "height": 1e308, "width": 1e308, "distance": 1e308}),
|
||||
json!({"center": [0, 0], "radius": -1, "angle": -1e308, "scale": 0, "height": 0, "width": 0, "distance": -1, "factor": 0}),
|
||||
json!({"count": 1e9, "rows": 1e9, "columns": 1e9, "levels": 1e9, "items": 1e9, "sides": 1e9, "segments": 1e9, "index": 1e9}),
|
||||
json!({"count": -1, "rows": -1, "columns": -1, "levels": -1, "items": -1, "sides": -1, "segments": -1, "index": -1}),
|
||||
json!({"name": long, "layer": long, "text": long, "style": long, "mode": long}),
|
||||
json!({"name": "\u{0}\u{202e}\u{1f4a5}", "text": r"%%%\P{\H0;", "gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_command_panics_or_hangs_on_adversarial_params() {
|
||||
let params = adversarial();
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
for spec in command_specs() {
|
||||
for p in ¶ms {
|
||||
if let Err(why) = run_guarded(spec.id, p) {
|
||||
let shown: String = p.to_string().chars().take(120).collect();
|
||||
bad.push(format!("{} [{why}] <- {shown}", spec.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
|
||||
/// A panic the code under test contains still fails the run; a graceful `Err` does not. Without
|
||||
/// this the hunt could quietly stop detecting anything.
|
||||
#[test]
|
||||
fn contained_panics_count_and_graceful_errors_do_not() {
|
||||
let contained = run_watched(SELF_TEST_DEADLINE, || {
|
||||
let _ = catch_unwind(|| panic!("contained, as Session::execute does"));
|
||||
});
|
||||
assert_eq!(contained, Some(false));
|
||||
let graceful = run_watched(SELF_TEST_DEADLINE, || {
|
||||
let mut s = fresh();
|
||||
assert!(s.execute("no-such-command", &json!({})).is_err());
|
||||
});
|
||||
assert_eq!(graceful, Some(true));
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Saving over a drawing keeps the previous file as `<name>.bak` (ISAVEBAK, #540), and the
|
||||
//! save itself replaces the file atomically: no temporary files are left behind.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, backup_path, looks_complete, looks_like_drawing, save_keeps_current, set_io};
|
||||
use serde_json::json;
|
||||
|
||||
fn hooks() {
|
||||
set_io(IoHooks {
|
||||
read: |b, n| cadcraft_io::read(b, n).map_err(|e| e.to_string()),
|
||||
write: |d, n| cadcraft_io::write(d, n).map_err(|e| e.to_string()),
|
||||
plot: None,
|
||||
});
|
||||
// Saves at a chosen version (a DWG 2000), as the app's.
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, n, o| {
|
||||
cadcraft_io::write_with(d, n, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("cadcraft-bak-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn circles(path: &std::path::Path) -> usize {
|
||||
let d = cadcraft_io::read(&std::fs::read(path).unwrap(), &*path.to_string_lossy()).unwrap();
|
||||
d.model.iter().filter(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Circle(_))).count()
|
||||
}
|
||||
|
||||
/// Only the drawing and its backup are in the folder: atomic saves leave no staging files.
|
||||
fn files(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// ISAVEBAK is process-wide: the tests that save hold this lock, so one turning it off can't
|
||||
/// race another's save. Dropping the guard turns it back on, also when a test fails.
|
||||
static SAVES: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct Saving(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
impl Drop for Saving {
|
||||
fn drop(&mut self) {
|
||||
cadcraft_engine::autosave::set_isavebak(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn saving() -> Saving {
|
||||
let g = SAVES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cadcraft_engine::autosave::set_isavebak(true);
|
||||
Saving(g)
|
||||
}
|
||||
|
||||
fn add_circle(s: &mut Session, x: f64) {
|
||||
s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_save_keeps_the_previous_file_as_bak() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("rotate");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
assert_eq!(files(&dir), ["Plan.dxf"], "a first save has nothing to back up");
|
||||
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save");
|
||||
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("damaged");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1);
|
||||
|
||||
// Another program truncates the drawing; the next save keeps the good backup.
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1));
|
||||
|
||||
// Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup
|
||||
// stays too.
|
||||
let whole = std::fs::read(&path).unwrap();
|
||||
std::fs::write(&path, &whole[..whole.len() / 2]).unwrap();
|
||||
add_circle(&mut s, 11.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "half a DXF");
|
||||
std::fs::write(&path, b"AC1032").unwrap();
|
||||
add_circle(&mut s, 12.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone");
|
||||
|
||||
// ISAVEBAK 0: the backup is left as it was.
|
||||
s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap();
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1));
|
||||
assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawings_are_recognised_by_their_start() {
|
||||
assert!(looks_like_drawing(b"AC1032\0\0\0\0\0"));
|
||||
assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"));
|
||||
assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n"));
|
||||
assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0"));
|
||||
for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"<svg>", b"hello"] {
|
||||
assert!(!looks_like_drawing(junk), "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A private drawing (0600) keeps a private backup: a new .bak, and a .bak already there that
|
||||
/// others could read, both take the drawing's mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_backup_is_no_more_readable_than_the_drawing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
let dir = temp_dir("mode");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a new .bak");
|
||||
|
||||
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a .bak already there");
|
||||
assert_eq!(circles(&backup_path(&path)), 2);
|
||||
|
||||
// A .bak kept more private than the drawing is never widened.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(mode(&backup_path(&path)), 0o600, "a more private .bak");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A symlink planted at the .bak is replaced by the backup, never written through: the file it
|
||||
/// pointed at is left as it was.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_at_the_backup_is_not_followed() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("link");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let other = dir.join("other.txt");
|
||||
std::fs::write(&other, b"someone else's file").unwrap();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
std::os::unix::fs::symlink(&other, backup_path(&path)).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"someone else's file");
|
||||
assert!(std::fs::symlink_metadata(backup_path(&path)).unwrap().is_file(), "the .bak is a file now");
|
||||
assert_eq!(circles(&backup_path(&path)), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A drawing looks whole from its end: an ASCII DXF ends with 0/EOF, a binary one with EOF and
|
||||
/// its NUL, and a DWG is not a few bytes long.
|
||||
#[test]
|
||||
fn whole_drawings_are_told_from_cut_off_ones() {
|
||||
let ascii = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
assert!(looks_complete(ascii, ascii, ascii.len() as u64));
|
||||
let cut = &ascii[..30];
|
||||
assert!(!looks_complete(cut, cut, cut.len() as u64));
|
||||
let mut binary = b"AutoCAD Binary DXF\r\n\x1a\0".to_vec();
|
||||
binary.extend_from_slice(b"\0\0SECTION\0\0\0EOF\0");
|
||||
assert!(looks_complete(&binary, &binary, binary.len() as u64));
|
||||
assert!(!looks_complete(&binary, &binary[..binary.len() - 5], binary.len() as u64 - 5));
|
||||
assert!(!looks_complete(b"AC1032", b"AC1032", 6));
|
||||
assert!(!looks_complete(b"AC1032", b"", 30_000), "a DWG tag without its header is not whole");
|
||||
}
|
||||
|
||||
/// A DWG reaches the end its own header gives: cut off anywhere (not only right after its start),
|
||||
/// in a 2018 and a 2000 file, it doesn't replace the good backup; whole, it does.
|
||||
#[test]
|
||||
fn a_dwg_cut_off_anywhere_keeps_the_good_backup() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
for version in [2018, 2000] {
|
||||
let dir = temp_dir(&format!("dwg{version}"));
|
||||
let path = dir.join("Plan.dwg");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p, "version": version })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the first save is backed up");
|
||||
let whole = std::fs::read(&path).unwrap();
|
||||
let head = &whole[..whole.len().min(4096)];
|
||||
assert!(looks_complete(head, &whole[whole.len().saturating_sub(256)..], whole.len() as u64), "{version}: whole");
|
||||
for keep in [whole.len() / 2, whole.len() * 9 / 10, whole.len() - 1] {
|
||||
assert!(!looks_complete(head, &[], keep as u64), "{version}: cut to {keep} of {}", whole.len());
|
||||
}
|
||||
// Another program leaves it cut off at 90%: the next save keeps the good backup.
|
||||
std::fs::write(&path, &whole[..whole.len() * 9 / 10]).unwrap();
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(circles(&backup_path(&path)), 1, "{version}: the cut-off file didn't replace it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks can be wrong about a real file. With no backup yet, a file that doesn't look whole
|
||||
/// is still kept, because the save is about to replace the only copy of it.
|
||||
#[test]
|
||||
fn a_file_that_looks_damaged_is_kept_when_there_is_no_backup_yet() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("first");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
let odd = b"AC1032 but not as this header expects it".to_vec();
|
||||
std::fs::write(&path, &odd).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), odd);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// What the warning promises and what the save does agree. A 2007 DWG, whose completeness can't
|
||||
/// be told, replaces an older backup; a file known to be cut off doesn't.
|
||||
#[test]
|
||||
fn the_backup_a_save_promises_is_the_one_it_writes() {
|
||||
let _saving = saving();
|
||||
hooks();
|
||||
let dir = temp_dir("promise");
|
||||
let path = dir.join("Plan.dxf");
|
||||
let p = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
add_circle(&mut s, 0.0);
|
||||
s.execute("saveas", &json!({ "path": p })).unwrap();
|
||||
add_circle(&mut s, 5.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
let old = std::fs::read(backup_path(&path)).unwrap();
|
||||
|
||||
let mut dwg2007 = b"AC1021".to_vec();
|
||||
dwg2007.resize(4096, 7);
|
||||
std::fs::write(&path, &dwg2007).unwrap();
|
||||
assert!(save_keeps_current(&path));
|
||||
add_circle(&mut s, 10.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), dwg2007, "the promise was kept");
|
||||
|
||||
std::fs::write(backup_path(&path), &old).unwrap();
|
||||
std::fs::write(&path, b"AC1032").unwrap();
|
||||
assert!(!save_keeps_current(&path));
|
||||
add_circle(&mut s, 15.0);
|
||||
s.execute("qsave", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(backup_path(&path)).unwrap(), old, "cut off: nothing was promised");
|
||||
|
||||
cadcraft_engine::autosave::set_isavebak(false);
|
||||
assert!(!save_keeps_current(&path), "backups off");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -43,28 +43,53 @@ fn transparency_440(v: i64) -> Transparency {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tags outside application-defined groups (`102 {` to `102 }`, whose codes are the
|
||||
/// application's), with their positions in `tags`.
|
||||
pub(super) fn outside_app(tags: &[Tag]) -> impl Iterator<Item = (usize, &Tag)> {
|
||||
let mut in_app = false;
|
||||
tags.iter().enumerate().filter(move |(_, t)| {
|
||||
if t.code == 102 {
|
||||
in_app = t.str().starts_with('{');
|
||||
}
|
||||
!in_app && t.code != 102
|
||||
})
|
||||
}
|
||||
|
||||
/// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all
|
||||
/// of it in files without markers, and in lists with no AcDbEntity marker, whose layer and colour
|
||||
/// may follow the entity's marker), or up to an embedded object (101). Later groups reuse the
|
||||
/// codes: an MTEXT's column width is 48. Markers inside an application-defined group (`102 {`
|
||||
/// to `102 }`, whose codes are the application's) do not count.
|
||||
fn entity_groups(tags: &[Tag]) -> &[Tag] {
|
||||
let outside_app = || outside_app(tags);
|
||||
let marked = outside_app().any(|(_, t)| t.code == 100 && t.str() == "AcDbEntity");
|
||||
let end = outside_app().find(|(_, t)| (marked && t.code == 100 && t.str() != "AcDbEntity") || t.code == 101).map_or(tags.len(), |(i, _)| i);
|
||||
tags.get(..end).unwrap_or(tags)
|
||||
}
|
||||
|
||||
pub(super) fn common(t: &T) -> Common {
|
||||
let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() };
|
||||
if let Some(v) = t.i(62) {
|
||||
let e = T(entity_groups(t.0));
|
||||
let mut c = Common { layer: e.s(8).unwrap_or_else(|| "0".into()), linetype: e.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() };
|
||||
if let Some(v) = e.i(62) {
|
||||
c.color = Color::from_aci(v as i16);
|
||||
if v < 0 {
|
||||
c.color = Color::ByLayer;
|
||||
c.visible = true;
|
||||
}
|
||||
}
|
||||
if let Some(tc) = t.i(420) {
|
||||
if let Some(tc) = e.i(420) {
|
||||
c.color = Color::True(Rgb::from_u32(tc as u32));
|
||||
}
|
||||
if let Some(lw) = t.i(370) {
|
||||
if let Some(lw) = e.i(370) {
|
||||
c.lineweight = Lineweight::from_dxf(lw as i16);
|
||||
}
|
||||
if let Some(s) = t.f(48) {
|
||||
if let Some(s) = e.f(48) {
|
||||
c.ltscale = if s > 0.0 { s } else { 1.0 };
|
||||
}
|
||||
if t.i(60) == Some(1) {
|
||||
if e.i(60) == Some(1) {
|
||||
c.visible = false;
|
||||
}
|
||||
if let Some(v) = t.i(440) {
|
||||
if let Some(v) = e.i(440) {
|
||||
c.transparency = transparency_440(v);
|
||||
}
|
||||
c.thickness = t.fd(39, 0.0);
|
||||
|
||||
@@ -539,7 +539,7 @@ pub(super) fn insert_attribs(recs: &[(String, Vec<Tag>)], i: &mut usize, ins_lay
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: false,
|
||||
constant: own.i(70).unwrap_or(0) & 2 != 0,
|
||||
prompt: String::new(),
|
||||
props: attrib_props(&tt, ins_layer),
|
||||
multiline,
|
||||
|
||||
@@ -74,16 +74,22 @@ fn finite(v: f64) -> f64 {
|
||||
|
||||
/// The columns of an MTEXT, and the column width (group 48) when there are columns.
|
||||
pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> {
|
||||
let Some(start) = own(tags).iter().position(|t| t.code == 75) else { return read_embedded_columns(tags) };
|
||||
let tags = own(tags);
|
||||
let kind = match tags.get(start).map(Tag::i64) {
|
||||
// An application-defined group in the record may hold any code, 50 included.
|
||||
let own_tags: Vec<&Tag> = super::common::outside_app(own(tags)).map(|(_, t)| t).collect();
|
||||
let Some(start) = own_tags.iter().position(|t| t.code == 75) else { return read_embedded_columns(tags) };
|
||||
let tags = own_tags;
|
||||
let kind = match tags.get(start).map(|t| t.i64()) {
|
||||
Some(1) => MTextColumnType::Static,
|
||||
Some(2) => MTextColumnType::Dynamic,
|
||||
_ => return None,
|
||||
};
|
||||
let cols = tags.get(start..).unwrap_or_default();
|
||||
let first = |code: i32| cols.iter().find(|t| t.code == code);
|
||||
let heights: Vec<f64> = cols.iter().filter(|t| t.code == 50).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect();
|
||||
let first = |code: i32| cols.iter().find(|t| t.code == code).copied();
|
||||
// The first 50 is the number of heights that follow, unless the file predates the count
|
||||
// (CADCraft up to f536bf4 wrote the n heights of group 76 alone).
|
||||
let fifties = cols.iter().filter(|t| t.code == 50);
|
||||
let counted = first(76).is_some_and(|t| fifties.clone().count() as i64 - 1 == t.i64());
|
||||
let heights: Vec<f64> = fifties.skip(usize::from(counted)).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect();
|
||||
let count = first(76).map(Tag::i64).unwrap_or(heights.len() as i64).clamp(1, MAX_COLUMNS as i64);
|
||||
let auto_height = first(79).map(Tag::i64).unwrap_or(0) != 0;
|
||||
let defined = tags.iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0);
|
||||
@@ -184,6 +190,10 @@ pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec<Tag> {
|
||||
Tag::f(48, t.width),
|
||||
Tag::f(49, c.gutter),
|
||||
];
|
||||
// No count for an empty list: a lone 50 = 0 would read back as one height of 0.
|
||||
if !heights.is_empty() {
|
||||
v.push(Tag::i(50, heights.len() as i64));
|
||||
}
|
||||
v.extend(heights.into_iter().map(|h| Tag::f(50, h)));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later
|
||||
//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in
|
||||
//! `$ACADVER`, from 2004 in the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller
|
||||
//! picks the text encoding) and MULTILEADER entities (older files get LEADER + MTEXT).
|
||||
//! versions keep that structure and differ in `$ACADVER`, from 2004 in CLASS instance counts and
|
||||
//! the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller picks the text encoding) and
|
||||
//! MULTILEADER entities (older files get LEADER + MTEXT).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -241,8 +241,19 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 {
|
||||
if q.is_finite() { Vec3::new(q.x, q.y, t.insert.z) } else { t.insert }
|
||||
}
|
||||
|
||||
/// `attdef_prompt` is `Some` for an ATTDEF (its prompt, which may be empty) and `None` for an ATTRIB.
|
||||
pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) {
|
||||
w.p(10, text_insert(d, t));
|
||||
let attdef = attdef_prompt.is_some();
|
||||
// An attribute definition with no default value is drawn as its tag, whatever its prompt: the
|
||||
// baseline start AutoCAD stored is judged against the tag's width too. A stored point that is
|
||||
// close to the empty text's own start is kept as it is, so the font's width of the tag never
|
||||
// moves it.
|
||||
let own = text_insert(d, t);
|
||||
let insert = match attrib {
|
||||
Some((a, _)) if attdef && t.value.is_empty() && own != t.insert => text_insert(d, &Text { value: a.tag.clone(), ..t.clone() }),
|
||||
_ => own,
|
||||
};
|
||||
w.p(10, insert);
|
||||
w.f(40, t.height);
|
||||
w.s(1, &t.value);
|
||||
if t.rotation != 0.0 {
|
||||
@@ -277,7 +288,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri
|
||||
}
|
||||
match attrib {
|
||||
Some((a, target)) => {
|
||||
w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" });
|
||||
w.s(100, if attdef { "AcDbAttributeDefinition" } else { "AcDbAttribute" });
|
||||
if target >= Version::R2010 {
|
||||
w.i(280, 0);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: b
|
||||
thickness(w, e);
|
||||
w.p(10, l.a);
|
||||
w.p(11, l.b);
|
||||
extrusion(w, e);
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
w.s(0, "POINT");
|
||||
@@ -753,6 +754,24 @@ fn thickness(w: &mut W, e: &Entity) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The extrusion (210) of an entity whose points are world coordinates, when it is not the Z axis.
|
||||
fn extrusion(w: &mut W, e: &Entity) {
|
||||
// Zero and non-finite vectors are not a direction: nothing is written. `Vec3::normalized` would
|
||||
// also drop a tiny one (its zero threshold is the drawing tolerance) and turn infinity into NaN.
|
||||
let v = e.common.extrusion;
|
||||
let max = v.x.abs().max(v.y.abs()).max(v.z.abs());
|
||||
if !v.is_finite() || max == 0.0 {
|
||||
return;
|
||||
}
|
||||
// Scaled by the largest component first: the length of a huge one would overflow to infinity.
|
||||
let v = Vec3::new(v.x / max, v.y / max, v.z / max);
|
||||
let len = v.len();
|
||||
let n = Vec3::new(v.x / len, v.y / len, v.z / len);
|
||||
if n != Vec3::Z {
|
||||
w.p(210, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a
|
||||
/// world position (`1011`, moved with the hatch by other programs) and the background colour
|
||||
/// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other
|
||||
|
||||
@@ -41,6 +41,7 @@ const HEADER_VARS: &[(&str, i32)] = &[
|
||||
("PLINEWID", 40),
|
||||
("CELWEIGHT", 370),
|
||||
("LWDISPLAY", 290),
|
||||
("PSTYLEMODE", 290),
|
||||
("INSUNITS", 70),
|
||||
("MEASUREMENT", 70),
|
||||
("PSLTSCALE", 70),
|
||||
|
||||
@@ -1117,14 +1117,27 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Ta
|
||||
}
|
||||
crate::dxf_raw::finish(&mut w.t, d);
|
||||
crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013);
|
||||
add_class_instance_counts(&mut w.t);
|
||||
add_class_instance_counts(&mut w.t, target);
|
||||
mark_xrefs(&mut w.t, d);
|
||||
w.t
|
||||
}
|
||||
|
||||
/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish`
|
||||
/// restores foreign classes and objects so every class gets its actual instance count.
|
||||
fn add_class_instance_counts(tags: &mut Vec<Tag>) {
|
||||
/// DXF Reference group 91 of CLASS records, the instance count, which came with the 2004 format.
|
||||
/// From 2004 every CLASS record gets its actual count, counted after `dxf_raw::finish` restores
|
||||
/// foreign classes and objects (AutoCAD rejects a 2004+ file without it, #635). An R2000 file has
|
||||
/// none, not even on a class kept from a newer file: AutoCAD LT 2026 stops responding when an
|
||||
/// R2000 file's CLASS records carry a 91.
|
||||
fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
|
||||
if target < Version::R2004 {
|
||||
let mut in_class = false;
|
||||
tags.retain(|t| {
|
||||
if t.code == 0 {
|
||||
in_class = t.str() == "CLASS";
|
||||
}
|
||||
!(in_class && t.code == 91)
|
||||
});
|
||||
return;
|
||||
}
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
let kind = t.str();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Annotative TEXT read from a file with "Match orientation to layout" on (`AcadAnnoPO` xdata = 1)
|
||||
//! is drawn and measured upright, ignoring its stored rotation, as AutoCAD shows it (#2498).
|
||||
|
||||
use cadcraft_doc::{EntityKind, Space, entity_bounds};
|
||||
|
||||
/// The xdata AutoCAD writes on such text: the `AcadAnnotative` data, then `AcadAnnoPO`.
|
||||
const ANNO_PO: &str = "1001\nAcadAnnotative\n1000\nAnnotativeData\n1002\n{\n1070\n1\n1070\n1\n1002\n}\n1001\nAcadAnnoPO\n1070\n1\n";
|
||||
|
||||
/// One TEXT "TITLE" at 10,5, height 2.5, rotated `deg` degrees, followed by `xdata`.
|
||||
fn dxf(deg: f64, xdata: &str) -> String {
|
||||
format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n5\n2A\n8\n0\n10\n10\n20\n5\n40\n2.5\n1\nTITLE\n50\n{deg}\n{xdata}0\nENDSEC\n0\nEOF\n")
|
||||
}
|
||||
|
||||
/// (rendered box, extents box) of the drawing's only object, as (min x, min y, max x, max y).
|
||||
fn boxes(text: &str) -> ([f64; 4], [f64; 4]) {
|
||||
let d = cadcraft_io::read(text.as_bytes(), "t.dxf").unwrap();
|
||||
let r = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
|
||||
let e = d.model.iter().find(|e| matches!(e.kind, EntityKind::Text(_))).map(|e| entity_bounds(&d, e, 0)).unwrap();
|
||||
([r.min.x, r.min.y, r.max.x, r.max.y], [e.min.x, e.min.y, e.max.x, e.max.y])
|
||||
}
|
||||
|
||||
fn same(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_orientation_to_layout_draws_the_text_upright() {
|
||||
let d = cadcraft_io::read(dxf(180.0, ANNO_PO).as_bytes(), "t.dxf").unwrap();
|
||||
assert!(d.model.iter().any(|e| e.common.matches_layout_orientation()), "the AcadAnnoPO flag is read");
|
||||
let (upright_r, upright_e) = boxes(&dxf(0.0, ""));
|
||||
let (r, e) = boxes(&dxf(180.0, ANNO_PO));
|
||||
assert!(same(r, upright_r), "drawn at {r:?}, upright at {upright_r:?}");
|
||||
assert!(same(e, upright_e), "extents {e:?}, upright {upright_e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_flag_the_stored_rotation_applies() {
|
||||
let (upright_r, upright_e) = boxes(&dxf(0.0, ""));
|
||||
let off = ANNO_PO.replace("AcadAnnoPO\n1070\n1\n", "AcadAnnoPO\n1070\n0\n");
|
||||
for xdata in ["", off.as_str()] {
|
||||
let (r, e) = boxes(&dxf(180.0, xdata));
|
||||
assert!(!same(r, upright_r), "drawn at {r:?}");
|
||||
assert!(!same(e, upright_e), "extents {e:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A centre-justified ATTDEF keeps its insertion point in a DWG (#2464): AutoCAD puts it where the
|
||||
//! baseline of the displayed tag starts, which for an attribute with an empty default value is
|
||||
//! further from the alignment point than half a height.
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
/// The `insert` x of the only ATTDEF in `d`.
|
||||
fn attdef_x(d: &cadcraft_doc::Drawing) -> f64 {
|
||||
d.model.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.text.insert.x) } else { None }).expect("an ATTDEF")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centred_attdef_with_an_empty_value_keeps_its_insertion_point() {
|
||||
// The tag "LONGTAG" at height 2.5, centred on x = 100: its baseline starts half a width left.
|
||||
// The stored point is 1 off that start (within the tolerance), so a write that recomputed
|
||||
// it from the tag would not pass for keeping it.
|
||||
let width = cadcraft_fonts::line_width("LONGTAG", 2.5, 1.0);
|
||||
let start = 100.0 - width / 2.0 - 1.0;
|
||||
assert!(100.0 - start > 3.0, "the point is well over a height from the alignment point");
|
||||
assert_kept(start);
|
||||
}
|
||||
|
||||
/// A stored point that a tag-width baseline would move is kept too (review of #2466): the check
|
||||
/// does not rely on the font's width of the tag, so this one is only 0.5 from the alignment point.
|
||||
#[test]
|
||||
fn centred_attdef_point_near_the_alignment_point_is_kept() {
|
||||
assert_kept(99.5);
|
||||
}
|
||||
|
||||
/// A centred ATTDEF with an empty default value and group 10 at `start`, group 11 at x = 100 is
|
||||
/// read from DXF and kept by a write as DXF and as DWG (both versions). The displayed text is the
|
||||
/// tag whatever the prompt: with a prompt, an empty one (group 3 empty) and none (no group 3).
|
||||
fn assert_kept(start: f64) {
|
||||
for prompt in ["3\nprompt\n", "3\n\n", ""] {
|
||||
let dxf = format!(
|
||||
"0\nSECTION\n2\nENTITIES\n0\nATTDEF\n8\n0\n10\n{start}\n20\n5.0\n40\n2.5\n1\n\n7\nSTANDARD\n72\n1\n11\n100.0\n21\n5.0\n{prompt}2\nLONGTAG\n70\n8\n0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let d = read(dxf.as_bytes(), "t.dxf").unwrap();
|
||||
assert!((attdef_x(&d) - start).abs() < 1e-6, "read from DXF");
|
||||
let as_dxf = cadcraft_io::write(&d, "t.dxf").unwrap();
|
||||
assert!((attdef_x(&read(&as_dxf, "t.dxf").unwrap()) - start).abs() < 1e-6, "DXF, prompt {prompt:?}: insertion point changed");
|
||||
for version in [Version::R2000, Version::R2018] {
|
||||
let dwg = write_with(&d, "t.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();
|
||||
let back = read(&dwg, "t.dwg").unwrap();
|
||||
assert!((attdef_x(&back) - start).abs() < 1e-6, "{version:?}, prompt {prompt:?}: insertion point {} instead of {start}", attdef_x(&back));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{read_dxf, write_dxf};
|
||||
use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with};
|
||||
|
||||
fn attrib(tag: &str, modes: AttribModes) -> Attrib {
|
||||
let text = Text {
|
||||
@@ -28,6 +28,10 @@ const LOCKED: AttribModes = AttribModes { verify: false, preset: false, lock_pos
|
||||
|
||||
/// Block TAG with definitions A (every mode) and B (none), and a reference whose A is locked.
|
||||
fn drawing() -> Drawing {
|
||||
drawing_c(false)
|
||||
}
|
||||
|
||||
fn drawing_c(constant: bool) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut b = Block::new("TAG");
|
||||
for a in [attrib("A", ALL), attrib("B", AttribModes::default())] {
|
||||
@@ -40,7 +44,7 @@ fn drawing() -> Drawing {
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![attrib("A", LOCKED), attrib("B", AttribModes::default())],
|
||||
attribs: vec![Attrib { constant, ..attrib("A", LOCKED) }, attrib("B", AttribModes::default())],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
@@ -113,3 +117,27 @@ fn a_280_before_the_tag_is_the_version_not_lock_position() {
|
||||
.collect();
|
||||
assert!(!modes(&read_dxf(text.as_bytes()).expect("read")).0[0].lock_position);
|
||||
}
|
||||
|
||||
/// The Constant mode (flag 2) of an ATTRIB attached to an INSERT, not only of an ATTDEF (#2246).
|
||||
#[test]
|
||||
fn constant_attached_attrib_survives_dxf_and_dwg() {
|
||||
let d = drawing_c(true);
|
||||
let constants = |d: &Drawing| -> Vec<bool> {
|
||||
d.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.attribs.iter().map(|a| a.constant).collect()) } else { None })
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(constants(&d), [true, false]);
|
||||
let dxf = cadcraft_io::write(&d, "x.dxf").expect("dxf");
|
||||
assert_eq!(constants(&cadcraft_io::read(&dxf, "x.dxf").expect("read dxf")), [true, false], "dxf");
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg");
|
||||
assert_eq!(constants(&cadcraft_io::read(&dwg, "x.dwg").expect("read dwg")), [true, false], "dwg");
|
||||
// Every save version, in both DXF encodings and in DWG.
|
||||
for version in Version::ALL {
|
||||
for (name, binary) in [("x.dxf", false), ("x.dxf", true), ("x.dwg", false)] {
|
||||
let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary }).expect("write");
|
||||
assert_eq!(constants(&cadcraft_io::read(&bytes, name).expect("read")), [true, false], "{version:?} {name} binary={binary}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,21 @@ fn header_settings_round_trip_through_dwg() {
|
||||
check("DWG", &r);
|
||||
}
|
||||
|
||||
/// `$PSTYLEMODE` (0 = named plot styles, 1 = colour-dependent) is written as read, as DXF and
|
||||
/// as DWG: left out, readers default to colour-dependent and the drawing plots with the wrong
|
||||
/// table (#2332).
|
||||
#[test]
|
||||
fn plot_style_mode_round_trips() {
|
||||
let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$PSTYLEMODE\n290\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
let d = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
|
||||
assert_eq!(d.header.i64("PSTYLEMODE", -1), 0, "read");
|
||||
let as_dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
assert_eq!(as_dxf.header.i64("PSTYLEMODE", -1), 0, "DXF");
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
|
||||
let as_dwg = cadcraft_io::read(&dwg, "x.dwg").unwrap();
|
||||
assert_eq!(as_dwg.header.i64("PSTYLEMODE", -1), 0, "DWG");
|
||||
}
|
||||
|
||||
/// The current layer (`$CLAYER`) survives a DWG save and reopen, ASCII or not. A DWG stores a
|
||||
/// handle to the layer, and the writer used the null handle, so the drawing came back on "0" (#2252).
|
||||
#[test]
|
||||
|
||||
@@ -99,6 +99,31 @@ fn dxf_at_every_version_reads_back_the_same() {
|
||||
}
|
||||
}
|
||||
|
||||
/// CLASS records (DXF Reference, CLASSES): group 91, the instance count, came with the 2004
|
||||
/// format and follows the proxy flags. AutoCAD LT 2026 stops responding on an R2000 file whose
|
||||
/// CLASS records carry a 91, and AutoCAD rejects a 2004+ file without it (#635).
|
||||
#[test]
|
||||
fn class_records_follow_the_version() {
|
||||
let d = sample();
|
||||
for v in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
|
||||
let classes: Vec<Vec<i32>> = cadcraft_dxf::sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|s| s.name == "CLASSES")
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind == "CLASS")
|
||||
.map(|(_, t)| t.iter().map(|x| x.code).collect())
|
||||
.collect();
|
||||
assert!(!classes.is_empty(), "{v} binary={binary}");
|
||||
let want: &[i32] = if v >= Version::R2004 { &[1, 2, 3, 90, 91, 280, 281] } else { &[1, 2, 3, 90, 280, 281] };
|
||||
for codes in classes {
|
||||
assert_eq!(codes, want, "{v} binary={binary}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote.
|
||||
#[test]
|
||||
fn r2000_ascii_is_unchanged() {
|
||||
|
||||
@@ -24,6 +24,49 @@ fn square(size: f64) -> Vec<PolyVertex> {
|
||||
[(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_extrusion_roundtrips_with_world_endpoints() {
|
||||
// A LINE's endpoints are world coordinates whatever its extrusion, which only directs its
|
||||
// thickness; a mirrored normal (0, 0, -1) must come back from DXF and DWG (#2241).
|
||||
let mut d = Drawing::new_metric();
|
||||
let common = Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() };
|
||||
let line = Line { a: Vec3::new(1.0, 2.0, 0.0), b: Vec3::new(11.0, 7.0, 0.0) };
|
||||
let h = d.add(&Space::Model, common, EntityKind::Line(line.clone())).expect("add");
|
||||
let dxf = write_dxf(&d);
|
||||
assert!(dxf.contains("230\r\n-1.0\r\n"), "the extrusion is written");
|
||||
let back = roundtrip(&d);
|
||||
let e = get(&back, h);
|
||||
assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0));
|
||||
assert_eq!(e.kind, EntityKind::Line(line.clone()));
|
||||
let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg");
|
||||
let back = cadcraft_io::read(&dwg, "x.dwg").expect("reopen dwg");
|
||||
let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))) else { panic!("no line") };
|
||||
assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0), "through DWG");
|
||||
assert_eq!(e.kind, EntityKind::Line(line));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_extrusion_is_written_as_a_unit_vector_or_not_at_all() {
|
||||
// A normal that is not a direction (zero, NaN) is dropped and one that is not unit length is
|
||||
// normalized, so a bad 210/220/230 read from a file is not passed on as it is (#2241).
|
||||
let line = Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) };
|
||||
let dxf = |extrusion| {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.add(&Space::Model, Common { extrusion, ..Common::default() }, EntityKind::Line(line.clone())).expect("add");
|
||||
write_dxf(&d)
|
||||
};
|
||||
let default = dxf(Vec3::Z);
|
||||
for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(f64::INFINITY, 0.0, 0.0), Vec3::new(0.0, 0.0, 5.0)] {
|
||||
assert_eq!(dxf(bad), default, "{bad:?} writes nothing, like the Z axis");
|
||||
}
|
||||
assert!(dxf(Vec3::new(0.0, 0.0, -5.0)).contains("230\r\n-1.0\r\n"));
|
||||
// A tiny normal is still a direction: it must not read back as +Z (it would flip a thickness).
|
||||
assert!(dxf(Vec3::new(0.0, 0.0, -1e-10)).contains("230\r\n-1.0\r\n"), "a tiny normal keeps its direction");
|
||||
// A huge finite normal's length overflows to infinity: it is a direction all the same.
|
||||
let huge = dxf(Vec3::new(0.0, -1.5e308, 1.5e308));
|
||||
assert!(huge.contains("220\r\n-0.7071067811865475\r\n230\r\n0.7071067811865475\r\n"), "a huge normal keeps its direction:\n{huge}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thickness_roundtrips_on_every_entity_that_has_one() {
|
||||
let mut d = Drawing::new_metric();
|
||||
|
||||
@@ -2,8 +2,10 @@ mod acad_interop;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod acis_data_store;
|
||||
mod angle_header;
|
||||
mod annotative_orientation;
|
||||
mod annotative_roundtrip;
|
||||
mod assoc_array_roundtrip;
|
||||
mod attdef_insert_dwg;
|
||||
mod attrib_modes;
|
||||
mod attrib_props;
|
||||
mod block_expansion;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, entity_from_groups, read_dxf, write_dxf, write_with};
|
||||
|
||||
@@ -76,6 +77,115 @@ fn paragraph_codes_tabs_and_carets_round_trip() {
|
||||
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MText(m) if m.contents == contents)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_column_groups_stay_in_the_mtext_subclass() {
|
||||
// Columns as other programs write them (#2247): the column width (48) and the height count
|
||||
// (the first 50) are groups of the AcDbMText subclass, not the entity's linetype scale and a
|
||||
// column height.
|
||||
let mut d = Drawing::new_metric();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap();
|
||||
let legacy =
|
||||
" 75\r\n1\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n3\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n";
|
||||
let dxf = write_dxf(&d).replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{legacy}"), 1);
|
||||
assert!(dxf.contains(" 75\r\n"), "legacy groups inserted");
|
||||
let back = read_dxf(dxf.as_bytes()).unwrap();
|
||||
let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::MText(_))) else { panic!("no mtext") };
|
||||
assert_eq!(e.common.ltscale, 1.0, "48 is the column width, not the linetype scale");
|
||||
let EntityKind::MText(t) = &e.kind else { panic!("no mtext") };
|
||||
let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 2.5, height: 20.0, ..Default::default() };
|
||||
assert_eq!(t.columns, Some(c));
|
||||
// Written back as read: the height count before the heights.
|
||||
let out = write_dxf(&back);
|
||||
assert!(out.contains(" 50\r\n3\r\n 50\r\n20.0\r\n"), "height count before the heights");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_written_without_a_count_are_all_kept() {
|
||||
// Files written before the height count (CADCraft up to f536bf4) have group 76 = n and n
|
||||
// heights, no count: the first 50 is a height there (#2247 review).
|
||||
let mut d = Drawing::new_metric();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap();
|
||||
let dxf = write_dxf(&d);
|
||||
for (heights, old) in [
|
||||
(vec![7.0], " 75\r\n2\r\n 76\r\n1\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n7.0\r\n"),
|
||||
(
|
||||
vec![5.0, 3.0, 4.0],
|
||||
" 75\r\n2\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n5.0\r\n 50\r\n3.0\r\n 50\r\n4.0\r\n",
|
||||
),
|
||||
] {
|
||||
let back = read_dxf(dxf.replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{old}"), 1).as_bytes()).unwrap();
|
||||
let Some(EntityKind::MText(t)) = back.model.iter().map(|e| &e.kind).find(|k| matches!(k, EntityKind::MText(_))) else { panic!("no mtext") };
|
||||
let n = heights.len();
|
||||
let c = MTextColumns {
|
||||
kind: MTextColumnType::Dynamic,
|
||||
count: n as u16,
|
||||
gutter: 2.5,
|
||||
height: heights.iter().copied().fold(0.0, f64::max),
|
||||
auto_height: false,
|
||||
heights: heights.clone(),
|
||||
flow_reversed: false,
|
||||
};
|
||||
assert_eq!(t.columns, Some(c), "{n} heights without a count");
|
||||
// Written back with the count, and still n columns.
|
||||
let out = write_dxf(&back);
|
||||
assert!(out.contains(&format!(" 76\r\n{n}\r\n 78\r\n")), "76 stays {n}: {out}");
|
||||
assert!(out.contains(&format!(" 50\r\n{n}\r\n 50\r\n{:.1}\r\n", heights[0])), "count then the first height: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_inside_an_application_group_are_not_column_heights() {
|
||||
// An application-defined group (102 {...}) in the record may hold a 50 of its own: it is
|
||||
// neither a height nor the count that tells a legacy record apart (#2247 review).
|
||||
let tags = [
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(40, 0.5),
|
||||
Tag::f(41, 12.5),
|
||||
Tag::s(1, "x"),
|
||||
Tag::i(75, 2),
|
||||
Tag::i(76, 3),
|
||||
Tag::i(78, 0),
|
||||
Tag::i(79, 0),
|
||||
Tag::f(48, 12.5),
|
||||
Tag::f(49, 2.5),
|
||||
Tag::f(50, 5.0),
|
||||
Tag::f(50, 3.0),
|
||||
Tag::f(50, 4.0),
|
||||
Tag::s(102, "{ACME_APP"),
|
||||
Tag::f(50, 8.0),
|
||||
Tag::s(102, "}"),
|
||||
];
|
||||
let Some((_, EntityKind::MText(t))) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") };
|
||||
let c = MTextColumns {
|
||||
kind: MTextColumnType::Dynamic,
|
||||
count: 3,
|
||||
gutter: 2.5,
|
||||
height: 5.0,
|
||||
auto_height: false,
|
||||
heights: vec![5.0, 3.0, 4.0],
|
||||
flow_reversed: false,
|
||||
};
|
||||
assert_eq!(t.columns, Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_manual_heights_round_trip() {
|
||||
// A dynamic manual column with no heights stays without heights: no count is written for an
|
||||
// empty list (a lone 50 = 0 would read back as one height of 0) (#2247 review).
|
||||
let c = MTextColumns {
|
||||
kind: MTextColumnType::Dynamic,
|
||||
count: 1,
|
||||
gutter: 0.5,
|
||||
height: 0.0,
|
||||
auto_height: false,
|
||||
heights: Vec::new(),
|
||||
flow_reversed: false,
|
||||
};
|
||||
let (t, dxf) = round_trip(mtext("x", 0.0, Some(c.clone())));
|
||||
assert_eq!(t.columns, Some(c));
|
||||
assert!(!dxf.contains(" 50\r\n0\r\n"), "no height count for an empty list: {dxf}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_column_groups_do_not_panic() {
|
||||
let mut d = Drawing::new_metric();
|
||||
@@ -92,6 +202,48 @@ fn hostile_column_groups_do_not_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_groups_do_not_supply_mtext_common_ltscale() {
|
||||
// Without subclass markers, an embedded object (101) reuses 48 for its own data (#2247, test from #2276).
|
||||
let tags = [Tag::s(8, "0"), Tag::f(40, 0.5), Tag::f(41, 12.5), Tag::s(1, "own text"), Tag::s(101, "Embedded Object"), Tag::f(48, 99.0)];
|
||||
let Some((common, _)) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") };
|
||||
assert_eq!(common.ltscale, 1.0, "48 after 101 belongs to the embedded object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_after_an_entmake_subclass_marker_is_kept() {
|
||||
// An entity list built by hand may put a subclass marker before the layer and have no AcDbEntity
|
||||
// marker at all: the whole list is then the entity's (#2247 review).
|
||||
let tags = [Tag::s(100, "AcDbLine"), Tag::s(8, "Walls"), Tag::i(62, 3), Tag::f(10, 0.0), Tag::f(20, 0.0), Tag::f(11, 1.0), Tag::f(21, 0.0)];
|
||||
let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") };
|
||||
assert_eq!(common.layer, "Walls");
|
||||
assert_eq!(common.color, cadcraft_color::Color::from_aci(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_inside_an_application_group_do_not_end_the_entity_groups() {
|
||||
// An application-defined group (102 {...} before AcDbEntity) may hold any code, 100 and 101
|
||||
// included: the entity's layer and colour come after it (#2247 review).
|
||||
let tags = [
|
||||
Tag::s(5, "A1"),
|
||||
Tag::s(102, "{ACME_APP"),
|
||||
Tag::s(100, "AcmeMarker"),
|
||||
Tag::s(101, "AcmeObject"),
|
||||
Tag::s(102, "}"),
|
||||
Tag::s(100, "AcDbEntity"),
|
||||
Tag::s(8, "Walls"),
|
||||
Tag::i(62, 3),
|
||||
Tag::s(100, "AcDbLine"),
|
||||
Tag::f(10, 0.0),
|
||||
Tag::f(20, 0.0),
|
||||
Tag::f(11, 1.0),
|
||||
Tag::f(21, 0.0),
|
||||
];
|
||||
let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") };
|
||||
assert_eq!(common.layer, "Walls");
|
||||
assert_eq!(common.color, cadcraft_color::Color::from_aci(3));
|
||||
}
|
||||
|
||||
// ---- #2248: R2018 embedded column object ----
|
||||
|
||||
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<cadcraft_dxf::Tag> {
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_io::{read_dxf, write_dxf};
|
||||
use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with};
|
||||
|
||||
/// DXF text from lines of `code value` (the value may be empty).
|
||||
fn dxf(src: &str) -> String {
|
||||
@@ -878,19 +878,50 @@ fn saving_twice_writes_the_same_records() {
|
||||
assert_eq!(a.counts(), b.counts());
|
||||
}
|
||||
|
||||
/// CLASS group 91, the instance count, came with the 2004 format (DXF Reference, CLASSES). A
|
||||
/// 2004+ save counts the records of every class, generated and kept; an R2000 save has none,
|
||||
/// even for a kept class whose record had one (AutoCAD LT 2026 stops responding on an R2000 file
|
||||
/// whose CLASS records carry a 91).
|
||||
#[test]
|
||||
fn class_records_have_instance_counts_for_generated_and_preserved_records() {
|
||||
let (_, a, b) = saves(&fixture());
|
||||
fn class_records_have_instance_counts_from_2004_only() {
|
||||
// The fixture's ACME_OBJECT class as a 2004+ writer has it, with its count.
|
||||
let src = fixture().replacen("ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n", "ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n91\n1\n", 1);
|
||||
assert_ne!(src, fixture());
|
||||
let (d, a, b) = saves(&src);
|
||||
for out in [&a, &b] {
|
||||
let counts = out.counts();
|
||||
for class in out.in_section("CLASSES", "CLASS") {
|
||||
let name = s(class, 1);
|
||||
let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64);
|
||||
assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}");
|
||||
let codes: Vec<i32> = class.iter().map(|t| t.code).collect();
|
||||
assert_eq!(codes, [1, 2, 3, 90, 280, 281], "R2000 CLASS {}", s(class, 1));
|
||||
}
|
||||
assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"));
|
||||
assert_eq!(counts.get("ACME_OBJECT"), Some(&1));
|
||||
}
|
||||
// Binary files the same way: none at R2000, the count at 2018.
|
||||
let acme = |version: Version| {
|
||||
let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).expect("save binary");
|
||||
let tags = cadcraft_dxf::parse(&bytes).expect("parse binary");
|
||||
let class = sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|sec| sec.name == "CLASSES")
|
||||
.flat_map(|sec| records(&sec.tags))
|
||||
.find(|(kind, class)| kind == "CLASS" && s(class, 1) == "ACME_OBJECT")
|
||||
.map(|(_, class)| class)
|
||||
.expect("ACME_OBJECT CLASS");
|
||||
let codes: Vec<i32> = class.iter().map(|t| t.code).collect();
|
||||
(codes, class.iter().find(|t| t.code == 91).map(Tag::i64))
|
||||
};
|
||||
assert_eq!(acme(Version::R2000), (vec![1, 2, 3, 90, 280, 281], None), "binary R2000 ACME_OBJECT CLASS");
|
||||
assert_eq!(acme(Version::R2018), (vec![1, 2, 3, 90, 91, 280, 281], Some(1)), "binary 2018 ACME_OBJECT CLASS");
|
||||
let r2018 = write_with(&d, "x.dxf", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("save 2018");
|
||||
let out = Out::new(std::str::from_utf8(&r2018).expect("UTF-8"));
|
||||
let counts = out.counts();
|
||||
for class in out.in_section("CLASSES", "CLASS") {
|
||||
let name = s(class, 1);
|
||||
let codes: Vec<i32> = class.iter().map(|t| t.code).collect();
|
||||
assert_eq!(codes, [1, 2, 3, 90, 91, 280, 281], "2018 CLASS {name}");
|
||||
let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64);
|
||||
assert_eq!(instance_count, Some(counts.get(&name).map_or(0, |n| *n as i64)), "CLASS {name}");
|
||||
}
|
||||
assert_eq!(counts.get("ACME_OBJECT"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -103,6 +103,13 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
if !b.opts.text {
|
||||
return;
|
||||
}
|
||||
let upright;
|
||||
let t = if e.common.matches_layout_orientation() && t.rotation != 0.0 {
|
||||
upright = cadcraft_doc::Text { rotation: 0.0, ..t.clone() };
|
||||
&upright
|
||||
} else {
|
||||
t
|
||||
};
|
||||
let (sh, _) = place_text_entity(ctx.d, t, &t.value);
|
||||
b.shaped(ctx, rgb, lw, &sh);
|
||||
}
|
||||
@@ -114,7 +121,11 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
if !b.opts.text {
|
||||
return;
|
||||
}
|
||||
mtext(b, ctx, t, rgb, lw);
|
||||
if e.common.matches_layout_orientation() && t.rotation != 0.0 {
|
||||
mtext(b, ctx, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }, rgb, lw);
|
||||
} else {
|
||||
mtext(b, ctx, t, rgb, lw);
|
||||
}
|
||||
}
|
||||
EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb),
|
||||
EntityKind::Dimension(dm) => draw_dimension(b, ctx, e, dm, lw, lt.as_ref(), ltscale),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Annotative text with "Match orientation to layout" (`AcadAnnoPO` xdata = 1) draws upright,
|
||||
//! ignoring its stored rotation (#2498).
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, RawTag, Space, Text};
|
||||
use cadcraft_geom::{Bounds2, Vec3};
|
||||
use cadcraft_render::{Options, build};
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.into() }
|
||||
}
|
||||
|
||||
fn bounds(rotation: f64, match_orientation: Option<&str>) -> Bounds2 {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut common = Common::default();
|
||||
if let Some(flag) = match_orientation {
|
||||
common.xdata = vec![
|
||||
tag(1001, "AcadAnnotative"),
|
||||
tag(1000, "AnnotativeData"),
|
||||
tag(1002, "{"),
|
||||
tag(1070, "1"),
|
||||
tag(1070, "1"),
|
||||
tag(1002, "}"),
|
||||
tag(1001, "AcadAnnoPO"),
|
||||
tag(1070, flag),
|
||||
];
|
||||
}
|
||||
let text = Text {
|
||||
insert: Vec3::new(10.0, 5.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 2.5,
|
||||
value: "TITLE".into(),
|
||||
rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
};
|
||||
d.add(&Space::Model, common, EntityKind::Text(text)).unwrap();
|
||||
let l = build(&d, &Space::Model, &Options::default());
|
||||
let mut b = Bounds2::EMPTY;
|
||||
for p in &l.prims {
|
||||
for q in l.points(p) {
|
||||
b.add(*q);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn same(a: &Bounds2, b: &Bounds2) -> bool {
|
||||
(a.min.x - b.min.x).abs() < 1e-9 && (a.min.y - b.min.y).abs() < 1e-9 && (a.max.x - b.max.x).abs() < 1e-9 && (a.max.y - b.max.y).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_orientation_ignores_the_stored_rotation() {
|
||||
let upright = bounds(0.0, None);
|
||||
assert!(!upright.is_empty());
|
||||
assert!(same(&bounds(std::f64::consts::PI, Some("1")), &upright));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_rotation_applies_without_the_flag() {
|
||||
let upright = bounds(0.0, None);
|
||||
assert!(!same(&bounds(std::f64::consts::PI, None), &upright));
|
||||
assert!(!same(&bounds(std::f64::consts::PI, Some("0")), &upright));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod aci7_display;
|
||||
mod annotative_orientation;
|
||||
mod annotative_viewport;
|
||||
mod dim_angular_centre;
|
||||
mod dim_post_tolerance;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Autosave and Drawing Recovery in the app. The engine decides what to write and writes it
|
||||
//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME and SAVEFILEPATH with
|
||||
//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME, SAVEFILEPATH and ISAVEBAK with
|
||||
//! the preferences, and shows the Drawing Recovery list after a crash: each autosaved drawing can
|
||||
//! be opened (as an unsaved copy) or discarded. On the web there is no autosave yet.
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::theme::Tokens;
|
||||
/// The host storage key for [`prefs_json`].
|
||||
pub const PREFS_KEY: &str = "cadcraft.autosave";
|
||||
|
||||
/// SAVETIME and SAVEFILEPATH as JSON for the host's storage.
|
||||
/// SAVETIME, SAVEFILEPATH and ISAVEBAK as JSON for the host's storage.
|
||||
pub fn prefs_json() -> String {
|
||||
json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath() }).to_string()
|
||||
json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath(), "backup": autosave::isavebak() }).to_string()
|
||||
}
|
||||
|
||||
/// Restore what [`prefs_json`] saved; malformed values are ignored.
|
||||
@@ -27,6 +27,9 @@ pub fn load_prefs(json: &str) {
|
||||
if let Some(p) = v.get("saveFilePath").and_then(Value::as_str) {
|
||||
autosave::set_savefilepath(p);
|
||||
}
|
||||
if let Some(b) = v.get("backup").and_then(Value::as_bool) {
|
||||
autosave::set_isavebak(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the GPU error notice says (see [`gpu_notice`]).
|
||||
|
||||
@@ -443,7 +443,9 @@ pub fn submit(app: &mut CadApp) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.cmdline(&text);
|
||||
// A partly typed command name runs the highlighted (first) AutoComplete suggestion.
|
||||
let completion = if app.session.prompting() { None } else { suggestions(text.trim()).into_iter().next() };
|
||||
app.cmdline_or(&text, completion.map(|s| s.id.to_ascii_lowercase()).as_deref());
|
||||
}
|
||||
|
||||
fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {
|
||||
@@ -924,4 +926,39 @@ mod autocomplete_tests {
|
||||
tab(&mut app);
|
||||
assert_eq!(app.cmd.buffer, "RECTANG");
|
||||
}
|
||||
|
||||
fn app() -> CadApp {
|
||||
let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default());
|
||||
app.start("ui.sample");
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_partial_name_runs_the_highlighted_suggestion() {
|
||||
let mut app = app();
|
||||
app.cmd.buffer = "RECT".to_string();
|
||||
submit(&mut app);
|
||||
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("rectang"));
|
||||
assert!(!app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_keeps_exact_names_and_reports_names_nothing_matches() {
|
||||
let mut app = app();
|
||||
// An exact alias is not replaced by a longer suggestion.
|
||||
app.cmd.buffer = "l".to_string();
|
||||
submit(&mut app);
|
||||
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line"));
|
||||
app.session.cancel();
|
||||
// A system variable name still acts as SETVAR.
|
||||
app.cmd.buffer = "orthomode".to_string();
|
||||
submit(&mut app);
|
||||
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("setvar"));
|
||||
app.session.cancel();
|
||||
// No suggestion: still an unknown command.
|
||||
app.cmd.buffer = "zzqx".to_string();
|
||||
submit(&mut app);
|
||||
assert!(app.session.running.is_none());
|
||||
assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +500,8 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
||||
let Some(path) = s("path") else { return err("missing path") };
|
||||
wrap(app.run("open", json!({"path": path})))
|
||||
}
|
||||
"app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, p.clone())),
|
||||
// Never `null` params: those mean a user's Save, which may pick a file or ask.
|
||||
"app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, if p.is_null() { json!({}) } else { p.clone() })),
|
||||
"app.quit" => {
|
||||
// Programmatic calls never open dialogs: quit without the unsaved-changes prompt.
|
||||
app.quit_confirmed = true;
|
||||
|
||||
@@ -45,10 +45,12 @@ pub enum Preference {
|
||||
Hungarian,
|
||||
#[serde(rename = "tr")]
|
||||
Turkish,
|
||||
#[serde(rename = "bs")]
|
||||
Bosnian,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 17] = [
|
||||
pub const ALL: [Self; 18] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Greek,
|
||||
@@ -66,6 +68,7 @@ impl Preference {
|
||||
Self::Arabic,
|
||||
Self::Hungarian,
|
||||
Self::Turkish,
|
||||
Self::Bosnian,
|
||||
];
|
||||
|
||||
/// Every preference in the order the Interface Language dialog lists them: Auto, then English
|
||||
@@ -113,6 +116,7 @@ impl Preference {
|
||||
Self::Arabic => "ar",
|
||||
Self::Hungarian => "hu",
|
||||
Self::Turkish => "tr",
|
||||
Self::Bosnian => "bs",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +145,7 @@ impl Preference {
|
||||
Self::Arabic => "العربية",
|
||||
Self::Hungarian => "Magyar",
|
||||
Self::Turkish => "Türkçe",
|
||||
Self::Bosnian => "Bosanski",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +176,7 @@ impl Preference {
|
||||
Self::Arabic => "ar",
|
||||
Self::Hungarian => "hu",
|
||||
Self::Turkish => "tr",
|
||||
Self::Bosnian => "bs",
|
||||
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
|
||||
}
|
||||
}
|
||||
@@ -187,7 +193,7 @@ pub struct Language {
|
||||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 16] = [
|
||||
pub static LANGUAGES: [Language; 17] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Greek: one for exactly 1; other for zero and all other integer counts.
|
||||
Language {
|
||||
@@ -234,6 +240,9 @@ pub static LANGUAGES: [Language; 16] = [
|
||||
Language { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() },
|
||||
// Turkish keeps a noun singular after a number ("3 commit"), so like Korean it has one form.
|
||||
Language { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() },
|
||||
// Bosnian (Latin script, Ijekavian): one (1, 21), few (2–4, 22–24, not 12–14), other — the
|
||||
// Ukrainian rule for integer counts.
|
||||
Language { code: "bs", name: "Bosanski", source: include_str!("i18n/bs.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
|
||||
@@ -437,6 +446,7 @@ mod picker_order_tests {
|
||||
assert!(pos(Preference::Czech) < pos(Preference::German), "Čeština sorts under C");
|
||||
assert!(pos(Preference::Italian) < pos(Preference::Hungarian), "Magyar sorts under M");
|
||||
assert!(pos(Preference::Turkish) < pos(Preference::Greek), "Latin names come before Greek");
|
||||
assert!(pos(Preference::Bosnian) < pos(Preference::Czech), "Bosanski sorts under B");
|
||||
assert_eq!(name_sort_key("Türkçe"), "turkce");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,8 @@
|
||||
AutoConstrain Restricciones automáticas
|
||||
Automatic save every Guardado automático cada
|
||||
Automatic save file location Ubicación de los archivos de guardado automático
|
||||
Backup copy Copia de seguridad
|
||||
Create backup copy with each save Crear una copia de seguridad en cada guardado
|
||||
Axis, End Eje, extremo
|
||||
B N
|
||||
Base Base
|
||||
|
||||
|
@@ -186,6 +186,7 @@ fn assert_additional_locale_aliases() {
|
||||
("sv", &["sv", "sv-SE", "sv_SE.UTF-8", "SV-se", " sv-SE ", "sv-FI"]),
|
||||
("hu", &["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu", " hu-HU "]),
|
||||
("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", " tr-TR ", "tr-CY"]),
|
||||
("bs", &["bs", "bs-BA", "bs_BA.UTF-8", "BS-ba", " bs-BA ", "bs-Latn-BA"]),
|
||||
("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]),
|
||||
("zh", &["zh", "zh-CN"]),
|
||||
] {
|
||||
@@ -556,3 +557,12 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() {
|
||||
assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace");
|
||||
});
|
||||
}
|
||||
|
||||
/// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`.
|
||||
#[test]
|
||||
fn bosnian_counts_take_one_few_other() {
|
||||
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 111].into_iter().map(plural_uk).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 2]);
|
||||
assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian));
|
||||
assert_eq!(Preference::Bosnian.native_name(), "Bosanski");
|
||||
}
|
||||
|
||||
@@ -1283,6 +1283,8 @@
|
||||
Automatic save every Автозбереження кожні
|
||||
min хв
|
||||
Automatic save file location Папка файлів автозбереження
|
||||
Backup copy Резервна копія
|
||||
Create backup copy with each save Створювати резервну копію під час кожного збереження
|
||||
Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження.
|
||||
Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню
|
||||
Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню.
|
||||
|
||||
|
+46
-15
@@ -56,6 +56,7 @@ pub mod palettes;
|
||||
pub mod parametric;
|
||||
pub mod plotstyles;
|
||||
pub mod quick;
|
||||
pub mod save_loss;
|
||||
pub mod shortcut_menus;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -118,7 +119,7 @@ pub struct UiState {
|
||||
/// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS
|
||||
/// setting), "on" or "off".
|
||||
pub animations: animation::AnimationPref,
|
||||
/// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg".
|
||||
/// The format Save As suggests: "dxf" or "dwg", for new drawings and opened files alike.
|
||||
pub save_format: SaveFormat,
|
||||
/// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a
|
||||
/// drawing opened from a file keeps that file's version.
|
||||
@@ -132,8 +133,8 @@ pub struct UiState {
|
||||
/// A DXF/DWG file version (2000 … 2018).
|
||||
pub use cadcraft_engine::cmd::file::Version as SaveVersion;
|
||||
|
||||
/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as".
|
||||
/// A drawing opened from a file keeps that file's format.
|
||||
/// The default save format, like AutoCAD's Options > Open and Save > "Save as": the format Save As
|
||||
/// suggests for every drawing, an opened DWG or DXF too. Save (QSAVE) of an opened file keeps its file.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SaveFormat {
|
||||
@@ -158,13 +159,19 @@ impl SaveFormat {
|
||||
SaveFormat::ALL.into_iter().find(|f| f.as_str().eq_ignore_ascii_case(s.trim().trim_start_matches('.')))
|
||||
}
|
||||
|
||||
/// The name Save As suggests: a title that already has an extension (a drawing opened from a
|
||||
/// file) keeps it; an untitled drawing gets this format's extension.
|
||||
/// The name Save As suggests: this format's extension, replacing a drawing's own (DWG, DXF or
|
||||
/// DWT, from a drawing opened from a file); any other extension is kept.
|
||||
pub fn suggested_name(self, title: Option<&str>) -> String {
|
||||
match title.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) if std::path::Path::new(t).extension().is_some() => t.to_owned(),
|
||||
Some(t) => format!("{t}.{}", self.as_str()),
|
||||
None => format!("Drawing.{}", self.as_str()),
|
||||
let Some(t) = title.map(str::trim).filter(|t| !t.is_empty()) else { return format!("Drawing.{}", self.as_str()) };
|
||||
let path = std::path::Path::new(t);
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some(e) if ["dwg", "dxf", "dwt"].iter().any(|d| d.eq_ignore_ascii_case(e)) => {
|
||||
// the extension is ASCII, so this is a char boundary
|
||||
let stem = t.get(..t.len().saturating_sub(e.len() + 1)).unwrap_or(t);
|
||||
format!("{stem}.{}", self.as_str())
|
||||
}
|
||||
Some(_) => t.to_owned(),
|
||||
None => format!("{t}.{}", self.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +291,10 @@ pub struct CadApp {
|
||||
pub quit_confirmed: bool,
|
||||
/// A close request waiting for "Save changes?" answers ([`closing`]).
|
||||
pub closing: Option<closing::Closing>,
|
||||
/// A save waiting for an answer about the content the file can't keep ([`save_loss`]).
|
||||
pub save_loss: Option<save_loss::Pending>,
|
||||
/// The loss the user last agreed to, so saving again doesn't ask again ([`save_loss`]).
|
||||
pub save_loss_accepted: Option<save_loss::Accepted>,
|
||||
/// Autosave (desktop; `None` until [`Self::enable_autosave`]).
|
||||
pub autosave: Option<cadcraft_engine::autosave::Autosaver>,
|
||||
/// Drawing Recovery: autosave files a crashed app left behind.
|
||||
@@ -344,6 +355,8 @@ impl CadApp {
|
||||
quit_requested: false,
|
||||
quit_confirmed: false,
|
||||
closing: None,
|
||||
save_loss: None,
|
||||
save_loss_accepted: None,
|
||||
autosave: None,
|
||||
recovery: autosave::Recovery::default(),
|
||||
gpu_faults: std::sync::Arc::default(),
|
||||
@@ -409,6 +422,13 @@ impl CadApp {
|
||||
/// Submit a line of command-line text. A refused line is echoed to the history; the error is
|
||||
/// also returned.
|
||||
pub fn cmdline(&mut self, text: &str) -> Option<String> {
|
||||
self.cmdline_or(text, None)
|
||||
}
|
||||
|
||||
/// [`CadApp::cmdline`], but a command name nothing knows (no command, alias, UI command, LISP
|
||||
/// command or system variable) runs `fallback` instead: Enter on a partly typed name accepts
|
||||
/// the highlighted AutoComplete suggestion.
|
||||
pub fn cmdline_or(&mut self, text: &str, fallback: Option<&str>) -> Option<String> {
|
||||
if !self.session.prompting() {
|
||||
let first = text.split_whitespace().next().unwrap_or("");
|
||||
// `cmd {json}` is a programmatic call: the engine runs it with those parameters.
|
||||
@@ -417,7 +437,11 @@ impl CadApp {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let e = self.session.cmdline(text).err()?.to_string();
|
||||
let e = match (self.session.cmdline(text), fallback) {
|
||||
(Ok(()), _) => return None,
|
||||
(Err(cadcraft_engine::EngineError::UnknownCommand(_)), Some(f)) => return self.cmdline(f),
|
||||
(Err(e), _) => e.to_string(),
|
||||
};
|
||||
self.session.echo(e.clone());
|
||||
Some(e)
|
||||
}
|
||||
@@ -685,11 +709,11 @@ impl CadApp {
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
|
||||
/// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The
|
||||
/// Whether a modal dialog is up: the "Save changes?" prompt, the lossy-save warning or Drawing Recovery. The
|
||||
/// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the
|
||||
/// main window). The app's other dialogs are ordinary windows that leave the drawing usable.
|
||||
pub fn shows_modal(&self) -> bool {
|
||||
self.close_prompt().is_some() || !self.recovery.items.is_empty()
|
||||
self.close_prompt().is_some() || self.save_loss.is_some() || !self.recovery.items.is_empty()
|
||||
}
|
||||
|
||||
/// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels'
|
||||
@@ -869,6 +893,7 @@ impl CadApp {
|
||||
dialogs::show(self, ui.ctx());
|
||||
palettes::patterns::prompt_picker(self, ui.ctx());
|
||||
closing::prompt(self, ui.ctx());
|
||||
save_loss::prompt(self, ui.ctx());
|
||||
autosave::show(self, ui.ctx());
|
||||
autosave::gpu_notice(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
@@ -1142,11 +1167,17 @@ mod tests {
|
||||
b.start("ui.saveformat.dwg");
|
||||
b.start("ui.resetpalettes");
|
||||
assert_eq!(b.ui.save_format, SaveFormat::Dwg);
|
||||
// Untitled drawings get the format's extension; files keep their own.
|
||||
// Untitled drawings get the format's extension, and so do opened drawings (#2199).
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("Drawing1")), "Drawing1.dwg");
|
||||
assert_eq!(SaveFormat::Dxf.suggested_name(Some("Drawing1")), "Drawing1.dxf");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf");
|
||||
assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.DWG");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dwg");
|
||||
assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.dxf");
|
||||
assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("Title block.dwt")), "Title block.dwg");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("v1.2 plan.Dxf")), "v1.2 plan.dwg");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some("平面図.dxf")), "平面図.dwg");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some(".dxf")), ".dxf.dwg", "a dot file has no extension");
|
||||
assert_eq!(SaveFormat::Dxf.suggested_name(Some("logo.svg")), "logo.svg", "other files keep their type");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(None), "Drawing.dwg");
|
||||
assert_eq!(SaveFormat::Dwg.suggested_name(Some(" ")), "Drawing.dwg");
|
||||
let ui: UiState = serde_json::from_value(json!({ "saveFormat": "dwg" })).unwrap_or_default();
|
||||
|
||||
@@ -292,6 +292,16 @@ fn should_pick_file_path(id: &str, params: &Value) -> bool {
|
||||
if id.starts_with("ui.") { params.is_null() || (params.get("path").is_none() && params.get("data").is_none()) } else { params.is_null() }
|
||||
}
|
||||
|
||||
/// A user's QSAVE: a drawing with no file yet picks one, and saving over a file that would lose
|
||||
/// content asks first. `None` lets the engine save. JSON calls carry params and never get here;
|
||||
/// they report the loss in their result instead.
|
||||
fn user_qsave(app: &mut CadApp) -> Option<Result<Value, String>> {
|
||||
if app.session.state().is_ok_and(|s| s.path.is_none()) {
|
||||
return run_ui_command(app, "ui.saveas", &Value::Null);
|
||||
}
|
||||
app.hold_lossy_save().then_some(Ok(Value::Null))
|
||||
}
|
||||
|
||||
fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if let Some(result) = route_ui_subsystems(app, id, params) {
|
||||
return Some(result);
|
||||
@@ -370,7 +380,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas", &Value::Null),
|
||||
"qsave" if no_path => return user_qsave(app),
|
||||
// EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot
|
||||
// dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry
|
||||
// params, never get here and never open a dialog.
|
||||
@@ -520,7 +530,7 @@ fn saveas_params(ui: &crate::UiState, session: &cadcraft_engine::Session, path:
|
||||
|
||||
fn set_save_format(app: &mut CadApp, format: crate::SaveFormat) -> Value {
|
||||
app.ui.save_format = format;
|
||||
app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase()));
|
||||
app.session.echo(format!("Save As will suggest {}", format.as_str().to_ascii_uppercase()));
|
||||
json!({ "saveFormat": format.as_str() })
|
||||
}
|
||||
|
||||
@@ -780,18 +790,20 @@ pub fn run_transparent(app: &mut CadApp, id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard shortcuts and function keys.
|
||||
pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
/// CADCraft's keyboard shortcuts and function keys, each with the command it runs. Every shortcut a
|
||||
/// command shows (its registry `key`, or a UI command's) must be here, or pressing it does nothing.
|
||||
pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)> {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k);
|
||||
let cmd = Modifiers::COMMAND;
|
||||
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
let pairs: &[(KeyboardShortcut, &str)] = &[
|
||||
vec![
|
||||
(sc(cmd_shift, Key::A), "pickstyle"),
|
||||
(sc(cmd_shift, Key::Z), "redo"),
|
||||
(sc(cmd, Key::Z), "u"),
|
||||
(sc(cmd, Key::Y), "redo"),
|
||||
(sc(cmd, Key::N), "new"),
|
||||
(sc(cmd, Key::W), "close"),
|
||||
(sc(cmd, Key::O), "ui.open"),
|
||||
(sc(cmd_shift, Key::S), "ui.saveas"),
|
||||
(sc(cmd, Key::S), "qsave"),
|
||||
@@ -816,7 +828,15 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
(sc(Modifiers::NONE, Key::F10), "polar"),
|
||||
(sc(Modifiers::NONE, Key::F11), "otrack"),
|
||||
(sc(Modifiers::NONE, Key::F12), "dynmode"),
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
/// Keyboard shortcuts and function keys.
|
||||
pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k);
|
||||
let cmd = Modifiers::COMMAND;
|
||||
let pairs = builtin_shortcuts();
|
||||
crate::clipboard::mirror(app, ctx);
|
||||
if app.closing.is_some() {
|
||||
return;
|
||||
@@ -831,7 +851,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
// Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses.
|
||||
let mut fire = crate::clipboard::shortcut_commands(app, ctx);
|
||||
ctx.input_mut(|i| {
|
||||
for (s, id) in pairs {
|
||||
for (s, id) in &pairs {
|
||||
if i.consume_shortcut(s) && !fire.contains(id) {
|
||||
fire.push(*id);
|
||||
}
|
||||
@@ -885,6 +905,46 @@ mod tests {
|
||||
(CadApp::new(Session::new(), services), asked)
|
||||
}
|
||||
|
||||
/// A registry shortcut ("Cmd+Shift+Z", "F8") as egui reads it.
|
||||
fn parse_shortcut(s: &str) -> Option<egui::KeyboardShortcut> {
|
||||
let mut m = egui::Modifiers::NONE;
|
||||
let (mods, key) = s.rsplit_once('+').unwrap_or(("", s));
|
||||
for part in mods.split('+').filter(|p| !p.is_empty()) {
|
||||
match part {
|
||||
"Cmd" => m |= egui::Modifiers::COMMAND,
|
||||
"Shift" => m |= egui::Modifiers::SHIFT,
|
||||
"Alt" => m |= egui::Modifiers::ALT,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(egui::KeyboardShortcut::new(m, egui::Key::from_name(key)?))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shown_shortcut_runs_its_command() {
|
||||
// Handled outside the table: Delete by the command line (it erases the selection only when
|
||||
// the line is empty), Cmd+8 before it (QuickCalc's own field keeps focus), Cmd+Q by the
|
||||
// macOS app menu, the only place that shows it.
|
||||
let elsewhere = [("Delete", "erase.selection"), ("Cmd+8", "ui.toggle.quickcalc"), ("Cmd+Q", "ui.quit")];
|
||||
let table = super::builtin_shortcuts();
|
||||
let shown = cadcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter_map(|c| Some((c.shortcut?, c.id)))
|
||||
.chain(super::UI_COMMANDS.iter().filter_map(|(id, _, _, sc)| Some(((*sc)?, *id))));
|
||||
let mut missing = Vec::new();
|
||||
for (key, id) in shown {
|
||||
if elsewhere.contains(&(key, id)) {
|
||||
continue;
|
||||
}
|
||||
let sc = parse_shortcut(key).unwrap_or_else(|| panic!("{id}: unreadable shortcut {key}"));
|
||||
// The table may run a command's UI form (`ui.open` for `open`), which does the same.
|
||||
if !table.iter().any(|(s, i)| *s == sc && (*i == id || i.strip_prefix("ui.") == Some(id))) {
|
||||
missing.push(format!("{key} {id}"));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "shown but not run: {missing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_and_print_open_the_plot_dialog_and_exportpdf_asks_for_a_file() {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) });
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
//! |---|---|
|
||||
//! | `theme` | `"system"`, `"light"` or `"dark"` |
|
||||
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"` or `"el"` |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` or `"el"` |
|
||||
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
|
||||
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
|
||||
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
|
||||
//! | `SAVEFILEPATH` | the folder automatic saves go to |
|
||||
//! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `<name>.bak` (default); 0: no backup |
|
||||
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
|
||||
//! | `systemCursor` | keep the system cursor visible over the drawing area |
|
||||
//! | `historyLines` | command-line history lines, 0–12 |
|
||||
@@ -23,7 +24,7 @@
|
||||
//! | `snapMarkerSize` | object snap marker size in points, 5–31 |
|
||||
//! | `interfaceScale` | the interface's size, 0.5–3 (1: the system's own) |
|
||||
//!
|
||||
//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state
|
||||
//! SAVETIME, SAVEFILEPATH and ISAVEBAK are the engine's save settings, kept with the autosave state
|
||||
//! rather than in the profile.
|
||||
//!
|
||||
//! The profile variables and the interface preferences survive restarts: the host saves
|
||||
@@ -147,6 +148,7 @@ struct UiChanges {
|
||||
save_version: Option<crate::SaveVersion>,
|
||||
savetime: Option<u32>,
|
||||
savefilepath: Option<String>,
|
||||
isavebak: Option<bool>,
|
||||
time_sensitive_right_click: Option<bool>,
|
||||
interface_scale: Option<f32>,
|
||||
}
|
||||
@@ -203,6 +205,10 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
|
||||
let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control));
|
||||
c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string());
|
||||
}
|
||||
k if k.eq_ignore_ascii_case("ISAVEBAK") => {
|
||||
let on = v.as_bool().or_else(|| v.as_i64().filter(|n| matches!(n, 0 | 1)).map(|n| n == 1));
|
||||
c.isavebak = Some(on.ok_or("ISAVEBAK must be 0 or 1")?);
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
@@ -226,6 +232,7 @@ fn ui_json(app: &CadApp) -> Map<String, Value> {
|
||||
m.insert("saveVersion".into(), json!(app.ui.save_version));
|
||||
m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime()));
|
||||
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
|
||||
m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak())));
|
||||
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
|
||||
m
|
||||
}
|
||||
@@ -297,6 +304,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
if let Some(p) = &ui.savefilepath {
|
||||
cadcraft_engine::autosave::set_savefilepath(p);
|
||||
}
|
||||
if let Some(b) = ui.isavebak {
|
||||
cadcraft_engine::autosave::set_isavebak(b);
|
||||
}
|
||||
if let Some(b) = ui.time_sensitive_right_click {
|
||||
app.options.time_sensitive_right_click = b;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,8 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
ui.end_row();
|
||||
autosave_row(ui, changes);
|
||||
ui.end_row();
|
||||
backup_row(ui, changes);
|
||||
ui.end_row();
|
||||
savefile_row(ui, changes);
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -285,6 +287,15 @@ fn autosave_row(ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// ISAVEBAK: keep the previous file as `<name>.bak` when saving over a drawing.
|
||||
fn backup_row(ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
ui.label(crate::tl!("Backup copy"));
|
||||
let mut bak = cadcraft_engine::autosave::isavebak();
|
||||
if ui.checkbox(&mut bak, crate::tl!("Create backup copy with each save")).changed() {
|
||||
changes.insert("ISAVEBAK".into(), json!(i32::from(bak)));
|
||||
}
|
||||
}
|
||||
|
||||
fn savefile_row(ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
let savefile_label = ui.label(crate::tl!("Automatic save file location"));
|
||||
// Edited as text; the folder changes when the field loses focus.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Asking before a save throws content away. DWG can't yet hold everything a drawing read from
|
||||
//! another program keeps (`cadcraft_engine::cmd::file::not_saved`), and QSAVE replaces the file
|
||||
//! that still has it, so File ▸ Save, its shortcut or typing QSAVE says what would go first:
|
||||
//! Save Anyway / Save As... / Cancel. Agreeing lasts for that same loss on that same file, so
|
||||
//! later saves in the session don't ask again; the file no longer holds what was agreed to.
|
||||
//!
|
||||
//! Programmatic saves (`engine.execute`, the control channel, MCP, scripts) never ask: they keep
|
||||
//! reporting the loss in the result's `notSaved` and on the command line.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use cadcraft_engine::autosave::isavebak;
|
||||
use cadcraft_engine::cmd::file::{backup_path, loss_on_save, save_keeps_current};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// How many object types the warning lists; a file can name as many as it likes.
|
||||
const MAX_LISTED: usize = 8;
|
||||
/// How many characters of a type name the warning shows; a file chooses these too.
|
||||
const MAX_NAME_CHARS: usize = 40;
|
||||
|
||||
/// The user's answer to the warning.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
SaveAnyway,
|
||||
SaveAs,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A loss the user agreed to: this drawing, this file, these objects.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Accepted {
|
||||
uid: u64,
|
||||
path: String,
|
||||
lost: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
/// A save held back until the user answers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
loss: Accepted,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// The file the save would replace.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.loss.path
|
||||
}
|
||||
|
||||
/// The objects the file wouldn't keep, as "2 MLINE, 1 3DSOLID".
|
||||
pub fn summary(&self) -> String {
|
||||
let lost = &self.loss.lost;
|
||||
let name = |t: &str| t.chars().take(MAX_NAME_CHARS).collect::<String>();
|
||||
let mut parts: Vec<String> = lost.iter().take(MAX_LISTED).map(|(t, n)| format!("{n} {}", name(t))).collect();
|
||||
if lost.len() > MAX_LISTED {
|
||||
parts.push(crate::tf!("{count} more types", count = lost.len() - MAX_LISTED));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
impl CadApp {
|
||||
/// Hold a user's QSAVE of the active drawing if it would lose content the user hasn't agreed
|
||||
/// to lose, and ask. Returns whether it was held.
|
||||
pub fn hold_lossy_save(&mut self) -> bool {
|
||||
let Ok(st) = self.session.state() else { return false };
|
||||
let (uid, Some(path)) = (st.uid, st.path.clone()) else { return false };
|
||||
let lost = loss_on_save(&self.session, &path);
|
||||
if lost.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let loss = Accepted { uid, path, lost };
|
||||
if self.save_loss_accepted.as_ref() == Some(&loss) {
|
||||
return false;
|
||||
}
|
||||
self.save_loss = Some(Pending { loss });
|
||||
true
|
||||
}
|
||||
|
||||
/// Answer the open warning (no-op without one).
|
||||
pub fn answer_save_loss(&mut self, choice: Choice) {
|
||||
let Some(Pending { loss }) = self.save_loss.take() else { return };
|
||||
// The drawing can go away, or stop being the active one, while the warning is up.
|
||||
let i = self.session.docs.iter().position(|d| d.uid == loss.uid);
|
||||
match (choice, i) {
|
||||
(Choice::Cancel, _) | (_, None) => self.session.echo(crate::tf!("Save cancelled: {path} was not changed.", path = loss.path)),
|
||||
(Choice::SaveAs, Some(i)) => {
|
||||
self.activate_doc(i);
|
||||
let _ = self.run("ui.saveas", Value::Null);
|
||||
}
|
||||
(Choice::SaveAnyway, Some(i)) => {
|
||||
self.activate_doc(i);
|
||||
self.save_loss_accepted = Some(loss);
|
||||
let _ = self.run("qsave", json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_doc(&mut self, i: usize) {
|
||||
if self.session.active != i {
|
||||
let _ = self.run("document.switch", json!({ "index": i }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning, while a save waits for an answer. Cancel is what Escape does.
|
||||
pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {
|
||||
let Some(pending) = app.save_loss.clone() else { return };
|
||||
let t = Tokens::get();
|
||||
let path = std::path::Path::new(pending.path());
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| pending.path().to_string());
|
||||
let backup = backup_path(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let mut choice = None;
|
||||
crate::modal::instant(ctx, egui::Id::unique("cc_save_loss_prompt")).show(ctx, |ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.label(RichText::new(crate::tf!("Saving {name} will remove content", name = file)).strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label(crate::tf!("This format can't keep these objects yet: {objects}.", objects = pending.summary()));
|
||||
ui.label(RichText::new(crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.")).color(t.text_dim));
|
||||
let on_disk = if save_keeps_current(path) {
|
||||
crate::tf!("The current file is kept as {name} until the next save.", name = backup)
|
||||
} else if isavebak() {
|
||||
crate::tf!("The current file doesn't look whole, so {name} stays as it is and the current file will be replaced.", name = backup)
|
||||
} else {
|
||||
crate::tl!("Backup copies are off, so the current file will be replaced.").to_string()
|
||||
};
|
||||
ui.label(RichText::new(on_disk).color(t.text_dim));
|
||||
ui.add_space(10.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let size = vec2(96.0, 22.0);
|
||||
let save_as = ui.add(egui::Button::new(RichText::new(crate::tl!("Save As...")).color(t.text)).fill(t.accent).min_size(size));
|
||||
if save_as.clicked() {
|
||||
choice = Some(Choice::SaveAs);
|
||||
}
|
||||
if ui.add(egui::Button::new(crate::tl!("Cancel")).min_size(size)).clicked() {
|
||||
choice = Some(Choice::Cancel);
|
||||
}
|
||||
if ui.add(egui::Button::new(crate::tl!("Save Anyway")).min_size(size)).clicked() {
|
||||
choice = Some(Choice::SaveAnyway);
|
||||
}
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
choice = Some(Choice::Cancel);
|
||||
}
|
||||
});
|
||||
if let Some(c) = choice {
|
||||
app.answer_save_loss(c);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Saving over a file that would lose content asks first, and only when a user saves. These run
|
||||
//! in their own test binary because the engine's I/O hooks are set once per process, and the
|
||||
//! library's unit tests register a writer that always fails.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::{Common, Drawing, EntityKind, RawTag, Space, Unknown};
|
||||
use serde_json::json;
|
||||
|
||||
use cadcraft_ui_egui::save_loss::{Choice, Pending};
|
||||
use cadcraft_ui_egui::{CadApp, Services, menus};
|
||||
|
||||
const ORIGINAL: &[u8] = b"original";
|
||||
|
||||
fn unknown(kind: &str) -> EntityKind {
|
||||
EntityKind::Unknown(Unknown { dxf_type: kind.into(), tags: vec![RawTag { code: 8, value: "0".into() }] })
|
||||
}
|
||||
|
||||
/// A drawing with objects of `types`, which DWG can't keep, opened from a DWG file on disk.
|
||||
fn opened(tag: &str, types: &[&str]) -> (CadApp, PathBuf) {
|
||||
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-loss-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Plan.dwg");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let mut d = Drawing::new_metric();
|
||||
for t in types {
|
||||
d.add(&Space::Model, Common::default(), unknown(t)).unwrap();
|
||||
}
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "Plan.dwg", Some(path.to_string_lossy().into_owned()));
|
||||
(CadApp::new(s, Services::default()), path)
|
||||
}
|
||||
|
||||
fn cleanup(path: &Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_users_save_asks_before_the_file_changes_and_cancel_leaves_it() {
|
||||
let (mut app, path) = opened("cancel", &["3DSOLID", "MLINE", "MLINE"]);
|
||||
menus::activate(&mut app, "qsave");
|
||||
let pending = app.save_loss.clone().expect("the save waits for an answer");
|
||||
assert_eq!(pending.summary(), "1 3DSOLID, 2 MLINE");
|
||||
assert!(app.shows_modal());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL, "nothing is written before the answer");
|
||||
|
||||
app.answer_save_loss(Choice::Cancel);
|
||||
assert!(app.save_loss.is_none());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_anyway_saves_and_the_same_loss_is_not_asked_again() {
|
||||
let (mut app, path) = opened("anyway", &["MLINE"]);
|
||||
menus::activate(&mut app, "qsave");
|
||||
app.answer_save_loss(Choice::SaveAnyway);
|
||||
assert!(app.save_loss.is_none());
|
||||
assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL);
|
||||
|
||||
menus::activate(&mut app, "qsave");
|
||||
assert!(app.save_loss.is_none(), "already agreed to");
|
||||
|
||||
// A new kind of loss is a new question.
|
||||
app.session.doc_mut().unwrap().add(&Space::Model, Common::default(), unknown("3DSOLID")).unwrap();
|
||||
menus::activate(&mut app, "qsave");
|
||||
assert_eq!(app.save_loss.as_ref().map(Pending::summary).as_deref(), Some("1 3DSOLID, 1 MLINE"));
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_that_loses_nothing_does_not_ask() {
|
||||
let (mut app, path) = opened("clean", &[]);
|
||||
menus::activate(&mut app, "qsave");
|
||||
assert!(app.save_loss.is_none());
|
||||
assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// Programmatic calls never open dialogs: the loss is in the result instead.
|
||||
#[test]
|
||||
fn programmatic_saves_never_ask() {
|
||||
let (mut app, path) = opened("json", &["MLINE"]);
|
||||
let r = app.run("qsave", json!({})).unwrap();
|
||||
assert!(app.save_loss.is_none());
|
||||
assert_eq!(r["notSaved"], json!({ "MLINE": 1 }));
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// Type names come from the file: however many, however long, the warning stays readable.
|
||||
#[test]
|
||||
fn a_hostile_list_of_types_is_capped() {
|
||||
let long = "X".repeat(5000);
|
||||
let many: Vec<String> = (0..50).map(|i| format!("{long}{i}")).collect();
|
||||
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
|
||||
let (mut app, path) = opened("hostile", &refs);
|
||||
menus::activate(&mut app, "qsave");
|
||||
let s = app.save_loss.as_ref().unwrap().summary();
|
||||
assert!(s.chars().count() < 500, "{}", s.len());
|
||||
assert!(s.ends_with("42 more types"), "{s}");
|
||||
cleanup(&path);
|
||||
}
|
||||
@@ -44,3 +44,37 @@ fn default_save_version_applies_to_new_drawings_and_persists() {
|
||||
b.start("ui.resetpalettes");
|
||||
assert_eq!(b.ui.save_version, "2004");
|
||||
}
|
||||
|
||||
/// The default save format (#2199) is what Save As suggests for an opened DWG or DXF too, as
|
||||
/// AutoCAD's Options > Open and Save > "Save as" does; Save keeps the opened file.
|
||||
#[test]
|
||||
fn default_save_format_applies_to_opened_drawings() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"plain".to_vec()), plot: None });
|
||||
set_write_as(with_options);
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-ui-save-format-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let suggested = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let (seen, to) = (suggested.clone(), dir.clone());
|
||||
let pick = move |name: &str| {
|
||||
seen.lock().unwrap().push(name.to_string());
|
||||
Some(to.join(name).to_string_lossy().to_string())
|
||||
};
|
||||
let mut app = CadApp::new(Session::empty(), Services { pick_save: Some(Box::new(pick)), ..Services::default() });
|
||||
for (opened, format, expected) in
|
||||
[("Plan.dwg", "dxf", "Plan.dxf"), ("Plan.DXF", "dwg", "Plan.dwg"), ("Plan.dxf", "dxf", "Plan.dxf"), ("Sheet.dwt", "dwg", "Sheet.dwg")]
|
||||
{
|
||||
let file = dir.join(opened);
|
||||
std::fs::write(&file, b"0\nEOF\n").unwrap();
|
||||
app.run("open", json!({ "path": file.to_string_lossy() })).unwrap();
|
||||
app.run("ui.saveformat", json!({ "format": format })).unwrap();
|
||||
// the picker's path decides the format, so the echo promises only the suggestion
|
||||
let echo = format!("Save As will suggest {}", format.to_ascii_uppercase());
|
||||
assert_eq!(app.session.log.last(), Some(&echo), "{opened} with {format}");
|
||||
app.run("ui.saveas", Value::Null).unwrap();
|
||||
assert_eq!(suggested.lock().unwrap().last().map(String::as_str), Some(expected), "{opened} with {format}");
|
||||
let path = app.session.state().unwrap().path.clone().unwrap_or_default();
|
||||
assert!(path.ends_with(expected), "{opened}: saved as {path}");
|
||||
assert!(dir.join(expected).exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+5
-2
@@ -73,14 +73,17 @@ reached", malformed source is a reader error, and every built-in checks its argu
|
||||
- **Output and files:** `princ prin1 print prompt terpri alert open close read-line write-line
|
||||
findfile`
|
||||
- **Errors:** `*error*`, `vl-catch-all-apply vl-catch-all-error-p vl-catch-all-error-message`
|
||||
- **Drawing:** `command` (also `command-s`, `vl-cmdf`) with `pause`, a command left open for the
|
||||
next call, and `(command)` to cancel; `getvar setvar`; `entget entmake entmod entdel entlast
|
||||
- **Drawing:** `command` (also `command-s`, `vl-cmdf`) with `pause`, entity names and `entsel`
|
||||
lists at prompts picking one object (a bare name picks at its DXF group 10 point, as in AutoCAD),
|
||||
a command left open for the next call, and `(command)` to cancel; `getvar setvar`; `entget entmake entmod entdel entlast
|
||||
entnext entupd handent`; `ssget` (modes X, L, P, I, W, C, F, WP, CP, a point, or the user
|
||||
selecting; filters with wildcards and `-4` operators) `ssadd ssdel sslength ssname ssmemb
|
||||
sssetfirst ssgetfirst`; `tblsearch tblnext` (LAYER, LTYPE, STYLE, BLOCK, DIMSTYLE, APPID)
|
||||
- **Input:** `initget getpoint getcorner getdist getangle getorient getreal getint getstring
|
||||
getkword entsel nentsel`
|
||||
- **Units:** `rtos angtos distof angtof`
|
||||
- **Angle variables:** `getvar` and `setvar` give and take ANGBASE, SNAPANG, POLARANG, HPANG,
|
||||
CHAMFERD and DIMJOGANG in radians, as in AutoCAD; the SETVAR command shows them in degrees.
|
||||
- **No-ops:** `vl-load-com gc redraw`; `ver`
|
||||
|
||||
Entity lists use the DXF group codes of CADCraft's DXF files: what `entget` returns is what a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (R2000 DXF CLASS records without instance counts, so AutoCAD LT 2026 opens them) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
|
||||
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
|
||||
@@ -64,7 +64,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
|---|---|---|---|
|
||||
| R12 (AC1009), ASCII | read, write | yes: no version gating; old-style POLYLINE/VERTEX read (polyface meshes flattened to 3D polylines) | **no** |
|
||||
| R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no |
|
||||
| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) |
|
||||
| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure with that `$ACADVER`; UTF-8 text from 2007; CLASS instance counts (91) from 2004 only (#635; with them, AutoCAD LT 2026 stops responding on an R2000 file) |
|
||||
| R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) |
|
||||
| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | writes `\U+` escapes (#169), which is correct; declares `ANSI_1252` |
|
||||
| `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes |
|
||||
@@ -140,6 +140,7 @@ our output in AutoCAD black-box.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) |
|
||||
| 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. |
|
||||
| 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use |
|
||||
| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
|
||||
|
||||
+7
-6
@@ -1,10 +1,10 @@
|
||||
# Where CADCraft falls short of AutoCAD
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (BEDIT in the Block Editor switches blocks: F3 updated) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 updated) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
||||
that closes it. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours
|
||||
list: agents pick from here (top of a section first). A PR names the rows it closes in its
|
||||
description, and maintainers update them in batches. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours
|
||||
are Opus 5.5 agent-hours, calibrated as explained there. "Est." means a judgement from the cited
|
||||
evidence. Rows marked **[alpha blocker]** are what fails the [alpha gate](roadmap.md#alpha-gate);
|
||||
do them first.
|
||||
@@ -13,7 +13,7 @@ do them first.
|
||||
|
||||
| # | Gap | Why it ranks here | Est. | Doc |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) |
|
||||
| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) |
|
||||
| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) |
|
||||
| 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
|
||||
| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) |
|
||||
@@ -41,7 +41,7 @@ do them first.
|
||||
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
|
||||
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
|
||||
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export Open and Save has the backup copy checkbox (ISAVEBAK). | `ui-egui/src/options/` | Low | 2–4 |
|
||||
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
|
||||
| F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 |
|
||||
| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 |
|
||||
@@ -73,7 +73,7 @@ Detail: [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
| # | Gap | User impact | Est. |
|
||||
|---|---|---|---|
|
||||
| FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data | High | 6–10 |
|
||||
| FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data A user's QSAVE now asks before such a loss and keeps the previous file as `.bak`; the loss itself remains. | High | 6–10 |
|
||||
| FF2 | Multileader leftovers (native MULTILEADER read/write landed, #459): several landings per multileader (extra leader roots are folded into the lines), per-object overrides (leader colour and linetype, arrowhead symbol, text frame), annotative scale contexts; check the text placement of our MULTILEADER output in AutoCAD | Medium | 3–5 |
|
||||
| FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 |
|
||||
| FF4 | Code-page leftovers (pre-2007 DXF read in their `$DWGCODEPAGE` landed, #505): Johab (ANSI_1361) and DOS 855/857/860–865/869 read as ANSI_1252; saving declares `ANSI_1252` instead of the file's code page (the text is pure ASCII, so still correct); acadrust approximates DOS code pages in pre-2007 DWG (DOS437 as 866, DOS850 as 1252) | Low | 1–2 |
|
||||
@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | QSAVE asks before replacing a file with content the DWG writer can't keep, and ISAVEBAK keeps the previous file as `.bak`: row 1 narrowed, FF1 and F20 updated |
|
||||
| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles |
|
||||
| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated |
|
||||
| 2026-10-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Bosnian added; previously minor: the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
|
||||
How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in
|
||||
[ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization).
|
||||
@@ -74,9 +74,10 @@ plus native review by a human; extending the catalog to the remaining English-on
|
||||
| Italian | it | 1,227 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings (Visualizza / Vista, Edita / Modifica); command reference searchable in Italian | Latin: yes (bundled egui fonts; accented letters tested) | AI-assisted by Emanuele Bottelli (Claude), written from the English strings with Italian desktop-CAD vocabulary; no native proofreading pass yet | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work |
|
||||
| Hungarian | hu | 1,326 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings; command reference searchable in Hungarian | Latin: yes (bundled egui proportional and monospace fonts; ő ű Ő Ű and the other accented letters tested) | translated by @Skaywalker from the English strings; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work |
|
||||
| Turkish | tr | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings (Ölçülendirme / Ölçü); command reference searchable in Turkish | Latin: yes (bundled egui proportional and monospace fonts; Ç Ğ İ Ö Ş Ü ç ğ ı ö ş ü tested) | AI-assisted by @ogzhnklvz, written from the English strings with Turkish desktop-CAD vocabulary; the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work |
|
||||
| Bosnian | bs | 1,442 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes | Latin: yes (bundled egui fonts) | AI-assisted; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work |
|
||||
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian and Turkish.
|
||||
Czech, Swedish, Hungarian and Brazilian Portuguese cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian, Turkish and Bosnian.
|
||||
Czech, Swedish, Hungarian, Brazilian Portuguese and Bosnian (1,442 rows, AI-assisted, not yet native-reviewed) cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text
|
||||
remain English. AutoCAD for Mac ships 7
|
||||
non-English languages (zh-Hans, es, fr, ja, de, ko, it); of those, CADCraft now ships German and Korean,
|
||||
and additionally Ukrainian, which AutoCAD for Mac doesn't ship.
|
||||
@@ -89,6 +90,7 @@ each language, which needs humans. Translations parallelise fully once the catal
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Latin, Ijekavian) added: 1,442 rows, one/few/other plurals, `bs-*` locales; AI-assisted, native proofreading pending |
|
||||
| 2026-10-22 | minor | Command line IME: enabled while a prompt collects text, composition shown but not saved, commit inserted once; single-line TEXT edits accept CJK input (#1761) |
|
||||
| 2026-10-19 | minor | Brazilian Portuguese: row brought up to date (it still read 0%); native-speaker terminology pass on 69 rows (Bind as Integrar, prompt keywords and SPDX kept in English, Thaw Layer, Snap type, Revolve fixed; snap, viewport, grip, offset, layer on/off and multiline wording made consistent with Brazilian usage) |
|
||||
| 2026-10-15 | minor | Greek (`el`) catalog: 1,330 rows, context/plural/locale/font tests; review pending |
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Parity checklist
|
||||
|
||||
> **Generated:** 2026-10-29 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-28 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ runtime-version: '26.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: cadcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
|
||||
@@ -18,6 +18,7 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: cadcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
|
||||
Reference in New Issue
Block a user