Compare commits

..
Author SHA1 Message Date
dependabot[bot] 751aa33812 chore(deps): bump the cargo-dependencies group across 1 directory with 6 updates
Bumps the cargo-dependencies group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [log](https://github.com/rust-lang/log) | `0.4.34` | `0.4.35` |
| [skrifa](https://github.com/googlefonts/fontations) | `0.48.0` | `0.49.0` |
| [harfrust](https://github.com/harfbuzz/harfrust) | `0.14.0` | `0.15.0` |
| [encoding_rs](https://github.com/hsivonen/encoding_rs) | `0.8.42` | `0.8.44` |
| [acadrust](https://github.com/hakanaktt/acadrust) | `0.6.6` | `0.6.8` |
| [ron](https://github.com/ron-rs/ron) | `0.12.2` | `0.12.3` |



Updates `log` from 0.4.34 to 0.4.35
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.34...v0.4.35)

Updates `skrifa` from 0.48.0 to 0.49.0
- [Release notes](https://github.com/googlefonts/fontations/releases)
- [Changelog](https://github.com/googlefonts/fontations/blob/main/docs/RELEASE.md)
- [Commits](https://github.com/googlefonts/fontations/compare/skrifa-v0.48.0...skrifa-v0.49.0)

Updates `harfrust` from 0.14.0 to 0.15.0
- [Release notes](https://github.com/harfbuzz/harfrust/releases)
- [Changelog](https://github.com/harfbuzz/harfrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/harfbuzz/harfrust/compare/0.14.0...0.15.0)

Updates `encoding_rs` from 0.8.42 to 0.8.44
- [Commits](https://github.com/hsivonen/encoding_rs/compare/v0.8.42...v0.8.44)

Updates `acadrust` from 0.6.6 to 0.6.8
- [Release notes](https://github.com/hakanaktt/acadrust/releases)
- [Changelog](https://github.com/hakanaktt/acadrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hakanaktt/acadrust/compare/v0.6.6...v0.6.8)

Updates `ron` from 0.12.2 to 0.12.3
- [Release notes](https://github.com/ron-rs/ron/releases)
- [Changelog](https://github.com/ron-rs/ron/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ron-rs/ron/compare/v0.12.2...v0.12.3)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.35
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: skrifa
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: harfrust
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: encoding_rs
  dependency-version: 0.8.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: acadrust
  dependency-version: 0.6.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: ron
  dependency-version: 0.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-02 22:05:38 +00:00
Books 2ac500925b DWG: a table cell's text height is an override only when the file flags it (part of #2501) (#2588)
* DWG: a table cell's text height is an override only when the file flags it (#2501)

acadrust's DXF writer writes group 140 for any cell with a style record, even a
default 0.18 with no override flag, and CADCraft read it as a per-cell height,
so cells ignored the table style's height. The bridge now clears the height of a
cell style that has neither the TEXT_HEIGHT property flag nor the content's
height override bit (0 reads as no override).

* Parity checklist: regenerated after merging main

* Tests: inherited table cell heights must stay unset (review on #2588)

A cell that inherits its height must read back as None, not an explicit 0.0625 override.

* Parity checklist: regenerated after merging main

* Test: a cell style with the TEXT_HEIGHT flag, content override bit 128 and a cell with no style record keep the right height (review on #2588)

* DWG: a table cell's content height override (group 144) is kept (review on #2588)

* Parity checklist: regenerated after merging main

* DWG: table cell height takes the first content's override and the style's override flags (review on #2588)

Only the first content's bit-128 height stands for the cell (one height for its text); an
invalid content height no longer keeps the writer's default 0.18; a height marked only in
the style's override flags (newer files) is kept.
2026-11-02 15:01:57 -07:00
STE1200andClaude Opus 5.5 5ec0d215d8 CLI: info and convert report what a damaged DWG left out (read notes, #859) (#2337)
A damaged DWG opens partially since #859 and the drawing's read notes
say how many object records were left out. OPEN echoes them and MCP
`open` returns them as `warnings`, but `cadcraft-cli info` and
`convert` stayed silent: a corpus round trip wrote thousands of skipped
records away without a word.

- convert: each read note goes to stderr as `IN: <note>`, next to the
  skipped-records warning (#872). The exit code stays 0.
- drawing.inspect (and so info, MCP inspect_drawing and the control
  channel): new field `readNotes`, next to `skipped`. Existing fields
  are unchanged.
- run already prints OPEN's notes with the session log.
- README and docs/control-protocol.md say where the notes appear.

Part of #1039.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:01:51 -07:00
midasmatterhorn 3f57f8d3d4 render: a display list past the block-expansion budget says what it left out (#1278)
* render: a display list past the block-expansion budget says what it left out

* Report viewport truncation only after the expansion budget is exceeded

* fmt: keep ui-egui control.rs rustfmt-clean
2026-11-02 15:01:46 -07:00
NetterSilverandClaude Haiku 5.5 4e874d089a Persist current table style (CTABLESTYLE) in DXF header (#2117)
Found while testing crates/io/src/dxf_write.rs:1964-1970 (xrecords written) and crates/io/src/dxf_read.rs:2049-2052 (apply)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 15:01:41 -07:00
Newton Kilgore Sightsinger 3bb515485d Xrefs: a saved path is found regardless of letter case on case-sensitive file systems (#1982) 2026-11-02 15:01:36 -07:00
e4cc7ffc77 DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.6) (#1725)
* DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.5)

AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with one byte after its
three connections that DXF doesn't carry. acadrust 0.6.4 neither read nor
wrote it, so AutoCAD read past the object and refused every DWG CADCraft
wrote with a dynamic block that scales ("Error Decrypting Data"), even an
unchanged one. acadrust 0.6.5 keeps the byte (dwg_tail_byte).

0.6.5 also maps the dimension flags byte's bit 0 to text_user_positioned
both ways, so the bridge no longer inverts it (it would now double-invert);
it still sets bit 1, which acadrust keeps no DXF source for.

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

* DWG: acadrust 0.6.6; multi-layout DWGs get unique paper space names

0.6.6 keeps the scale action byte and the dimension flag mapping this branch
relies on, and writes only the active layout as *Paper_Space (others get
unique *Paper_SpaceN names; hakanaktt/acadrust#143), so ACadSharp opens a
DWG CADCraft saves with more than one layout.

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

* io tests: a scale action survives CADCraft's own DWG save

The cadcraft-dwg test builds its DXF with acadrust's writer; this one goes
through cadcraft_io::write (the bridge DXF, then acadrust) with a scale
action added to the synthetic Door fixture, and checks the DWG holds it
with its connections and reads back as the same action.

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

* ui: block description field ids from Ui::scope_id (egui 0.37 deprecates Ui::id)

main's gate fails clippy -D warnings on the one Ui::id() call (#2113);
Ui::scope_id is its new name.

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

* DWG: the scale action byte doc names acadrust 0.6.6, the version in use

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

* Docs: leave the parity docs to the maintainers (AGENTS.md: PRs don't edit them)

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

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:01:31 -07:00
Rahulandrahul05ranjan 03101e8d77 fix(io): a moved DIMENSION draws its block at its insertion point (group 12) (#2634) (#2636)
A DIMENSION moved by an older AutoCAD keeps its *D block contents where
they were and the move in DXF group 12, and is drawn translated by the
insertion point. Group 12 was never read into Dimension, so the block
rendered at its pre-move position and the extents reached there too.

Read group 12 into a new Dimension::insertion_point, draw the block
translated by it, move its bounds with it in the extents, test the
block's text where it draws, and write group 12 back whenever the block
written is the one read from the file. Regenerated geometry (edits drop
the block link) sits at its definition points, so no offset is kept or
written for it.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 15:01:26 -07:00
ZaxaerithandZaxaerith 0bc025965a fix(dwg): preserve MTEXT caret-adjacent whitespace (#2250) (#2314)
* fix(dwg): preserve literal spaces after MTEXT carets (#2250)

* test(dwg): fix MTEXT fixture ownership and boxed entity

---------

Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
2026-11-02 15:01:21 -07:00
Tanish Manku 03db6261e4 Merge pull request #2662 from storytold/land/1978
Plot style and plotter files save atomically (#1978)
2026-11-02 15:01:16 -07:00
Tanish Manku cd19b205b7 Merge remote-tracking branch 'origin/main' into land-1978 2026-11-02 15:01:09 -07:00
Tanish Manku 69e946586f Merge pull request #2661 from storytold/land/2474
DXF: leave SAT-text solids out of 2013+ files, and ask before a save does (#2474)
2026-11-02 14:37:04 -07:00
Tanish Manku fbc47b48e8 Merge remote-tracking branch 'origin/main' into land-2474 2026-11-02 14:36:58 -07:00
Tanish Manku 86f3fdc9e9 Test: the DWG note's wording, and one-line fake bytes 2026-11-02 14:36:58 -07:00
Tanish Manku 7690d36bb0 fmt 2026-11-02 14:36:58 -07:00
7bc76f94ca OVERKILL: objects within the tolerance are duplicates wherever their numbers fall (#2653)
* OVERKILL: objects within the tolerance are duplicates wherever their numbers fall

OVERKILL compared objects by rounding every number to a multiple of the
tolerance. Two numbers closer than the tolerance on either side of a
rounding step round apart, so the objects were kept: at 0.01, circles at
x = 80.001 and 80.003 were merged but circles at 50.004 and 50.006 were
not. Arcs on one circle were bucketed the same way.

Duplicates are now found by shape (the JSON without its floating-point
numbers; counts and flags must match exactly) and a weighted sum of the
numbers on a grid of the tolerance, looking in the neighbouring cells too,
then compared pairwise to within the tolerance. Arcs and circles look in
the neighbouring cells of centre and radius and are grouped with a
union-find. Lines were already grouped by sorting and are unchanged.

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

* OVERKILL: within the tolerance doesn't chain

Arcs and circles were grouped with a union-find over pairwise matches,
so A ~ B and B ~ C put A and C in one group even when A and C are
farther apart than the tolerance: a circle C was deleted for matching
only the arc between it and the kept circle A, and arcs were combined
across the chain. Each object now joins the first group whose every
member is within the tolerance of it, or starts its own.

In the duplicates pass, a followed object displaced an earlier keeper
that later objects had already matched, leaving them deleted up to twice
the tolerance from what stays. Followed objects are now looked at first,
so every deletion is within the tolerance of an object that stays.

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:53 -07:00
RicSchonfelder 6660d88785 i18n(pt-BR): translate the rows the sibling catalogs cover (#2581)
* i18n(pt-BR): translate the rows the sibling catalogs cover

The Brazilian Portuguese catalog covered the reference catalog and every marked UI literal, but 191
strings that the German, French, Swedish and Ukrainian catalogs already translate still fell back to
English: the Hatch and Gradient dialog, the Block Editor and dynamic-block grips, the plot and drawing
units dialogs, multileader editing, selection preview settings and the unit names.

Rows follow the catalog's vocabulary (dimension = cota, hatch = hachura, grip = pinça, layout =
apresentação, multileader = multilinha, xref = referência externa), keep the prompt keywords, SPDX
tokens and technology names in English, and are appended after the existing entries.

* docs: leave localization parity updates to maintainers
2026-11-02 14:36:48 -07:00
2ez4gcxandClaude Opus 5.5 beb6c48a59 AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them (#2552)
* feat(inquiry): AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them

AREA, DIST and MEASUREGEOM now leave AREA, PERIMETER and DISTANCE for
getvar, read-only and not saved: the area just measured (the running
total in Add and Subtract mode) with the last area's perimeter, and the
distance (the total of Multiple points). After DIST's two points
LASTPOINT stays at the first point, as measured. AREA's Object option
measures splines, closed by their chord like open polylines.

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

* Address review: spline area over narrow spans, finite distances, list order

- AREA of a spline samples it span by span (Spline::tessellate_spans), so
  a narrow knot span no longer drops out of the area. The spread over the
  spans is one helper shared with Spline::bounds, whose boxes are
  unchanged.
- DIST refuses a distance that isn't finite on all three paths and leaves
  DISTANCE as it was.
- sysvars::list puts the session's values after the drawing's header, so
  it shows what get returns (AREA, ORTHOMODE, LWDISPLAY...).
- LASTPOINT after DIST's Multiple points is pinned to AutoCAD 2024's
  measured behaviour: the last point entered, also after Length and Undo.

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

* Address review: measure every knot span of a spline in AREA

A spline with more knot spans than half the sample cap fell back to an
even grid over the domain, which can step over a narrow span and measure
the wrong area: a 100 x 100 square as a degree-1 spline, its right and
top edges two spans a millionth wide after 4,500 spans along the bottom,
measured 4,998.75.

Spread the samples over the spans at any span count: at least one
segment per span, within the cap up to MAX_SAMPLES spans and one each
past it, as Spline::length integrates every span. One evaluation per
span costs 85-285 ms on a million spans in a release build, against
32-52 s for the length AREA already takes.

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

* Address review: share AREA's spline samples by how much each span bends

Past the sample cap, tessellate_spans gave each knot span one segment, so
a bent span among many straight ones was measured by its chord. Example:
a quadratic spline along the x-axis, one control point 1000 above it,
past 4,500 spans. It measured 750; the area is 1750/3.

Each span now keeps one segment. A span whose control points are
collinear is straight, so its chord is exact. tessellate's count is
shared among the spans that bend, by the cube root of a measure of their
control points' convex hull, which holds the span and its chord. The
shares are rounded on the running total, so they use the whole count
however many spans share it. The example now measures 583.33343, below
the cap and past it.

The spreading by parameter width that this replaces goes, and
span_params with it: bounds is main's code again.

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

* DIST: a measurement refused as too large leaves LASTPOINT at the first point

Session::feed sets LASTPOINT to the second point before DIST checks the
distance, and the refusal returned before DIST put the first point back,
as it does after a measurement. It now does so before refusing.

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

* AREA: a spline too long to measure is too large, not a perimeter of 0

Spline::length reads a length past f64's range as 0, and AREA took that
as the spline's perimeter. Spline::checked_length returns None there,
and AREA reports the perimeter too large, as it does a polyline's.

The overflow was hidden twice: in the sum, and before it in the
derivative, which read as zero speed once past f64's range. The length
integral now takes the derivative before that (Spline::velocity), so an
overflowing speed counts too. param_at_length keeps the speed it had,
and finite lengths are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:42 -07:00
2ez4gcxandClaude Opus 5.5 990ff2ce9f Say "or" once in prompts that offer options (#2563)
* Say "or" once in prompts that offer options

Twelve prompt messages ended in "or", and the command line adds "or"
before a prompt's options, so they read "or or": HELIX height, XLINE angle
and offset, BLEND, four LENGTHEN prompts, two NCOPY prompts, PEDIT and
table breaks. They now read as AutoCAD's do.

The interface catalogs are keyed by these messages: each key loses its
"or" and each translation its trailing word for "or"; where the shorter
key already had a translation, the longer row is removed.

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

* Say "or" once in HELIX's radius prompts; rename the Vietnamese keys

HELIX's base and top radius prompts ended in "or" before their Diameter
option too, so they read "Specify base radius or or [Diameter]". They
now read "Specify base radius or [Diameter] <1.0000>:", and their keys
lose "or" in all 17 catalogs, as the other twelve did.

The Vietnamese catalog, merged from main, gets the same renames as the
other catalogs; where the shorter key already had a translation, the
longer row goes. The unused "Specify rotation angle or" row goes from
every catalog too: RECTANG has asked "Specify rotation angle" since
#2554.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:37 -07:00
2ez4gcxandClaude Opus 5.5 8de925e1d3 Write option prompts without "or", as AutoCAD does (#2540)
* Write option prompts without "or", as AutoCAD does

AutoCAD writes " or [" only when a prompt asks for something besides its keywords ("Enter block
name or [?]"); a prompt asking for one of them has none ("Enter an option [...]", "Enter layout
option [...]", "New color [Truecolor/COlorbook]"). The 27 messages read without "or" from
AutoCAD 2024's command line are listed in NO_OR and displayed that way.

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

* Address review: the window's command line drops "or" as the prompt does

The command line in the window draws a prompt's keywords itself and
wrote " or [" after every message, so -LAYER still read "Enter an option
or [?/Make/...]" there, and questions such as AUDIT's had "or" too. Only
the prompt text (the control channel, the state JSON, the tests) had
dropped it.

Prompt::keyword_lead gives what goes before the keywords; display() and
the command line both use it, and the command line translates only
" or [". A render test reads what the command line draws, in English and
German, for -LAYER, LINE and AUDIT. Suggested in review.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:32 -07:00
Tanish Manku 97679d8cff Ask before a save drops solids from a 2013+ DXF (#2334)
The interactive save only knew about DWG, so overwriting a 2013+ DXF lost SAT-text
solids on a command-line note. loss_on_save now lists them, and EXPORT reports them
with the DXF wording.
2026-11-02 14:30:30 -07:00
Tanish Manku 9972a739c5 Merge remote-tracking branch 'origin/main' into land-2474
# Conflicts:
#	crates/doc/src/foreign.rs
#	crates/engine/tests/save_reports_not_saved.rs
#	crates/io/src/dxf_raw.rs
#	crates/io/src/dxf_write/sections.rs
2026-11-02 14:29:22 -07:00
Tanish Mankuandassamaral 0f56c16d00 EXPORT and WBLOCK say what a DWG file leaves out (#2240) (#2660)
Co-authored-by: assamaral <217119886+assamaral@users.noreply.github.com>
2026-11-02 14:27:45 -07:00
Tanish MankuandMarekSpodBiedry a60d437f0a WBLOCK: new handles start above those of the copied blocks (#1996) (#2659)
Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
2026-11-02 14:27:33 -07:00
Rahulandrahul05ranjan f2cb7e6e8f i18n(zh): translate the 74 strings the report listed as missing (#2638)
The Chinese catalog lacked 74 interface strings: the block editor
prompts and dialog, the alias editor sentences, the selection visual
effects and preview filter, units of new drawings, and the menu names
Dimension, Draw, Modify and View.

Adds them in the catalog's TSV format with the AutoCAD zh-CN vocabulary
the catalog already uses (夹点, 图案填充, 外部参照, 多行文字, 孤岛).
After this the report lists nothing missing for zh.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 14:26:59 -07:00
0e7c71759c test(ui): the IME double-click test passes without craft-fonts (#2652)
`double_clicked_text_takes_ime_composition_and_commit` commits 中文 at the
command line. Without a CJK font (no CRAFT_FONTS_DIR, none installed, as on
a plain Linux CI image) the kittest harness panics on the first missing
glyph: "No glyph for 中 in Proportional". Code and tests must work without
craft-fonts.

The test now asks the harness's own fonts whether they can draw 中文 and,
only when they can't, lets the command line draw boxes for it. The IME
path it checks is unchanged: the pre-edit is shown and not saved, the
commit is inserted once, Enter stores 中文 in the text. With a CJK font
the harness still fails on any missing glyph.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:53 -07:00
SoldierPePe facae4a9a6 plot and exportpdf help name the device parameter (#2557) (#2567)
* plot and exportpdf help name the `device` parameter (#2557)

`cadcraft-cli commands plot` didn't list `device`, so the only way to find it was the `unknown plotter` error.

PLOT's help now lists `device` with the built-in devices (CADCraft PDF, PNG, SVG), PLOTTERMANAGER plotters and "None". It also says the default is the page setup's device and that the device picks the output format and the papers. EXPORTPDF's help names `device` too, and notes that its output stays PDF. A test checks that both help texts name `device` and that PLOT's names every built-in device.

* plot help: say what Model, "None" and an omitted paper do

In model space, and with "None", no device is used and the plot is a PDF. When `paper` is omitted, a layout keeps its page setup's paper and model space takes the device's default.
2026-11-02 14:26:48 -07:00
2ez4gcxandClaude Opus 5.5 ea127d4702 Dynamic blocks: a point on a frame edge of any length is on it (#2640)
* Dynamic blocks: a point on a frame edge of any length is on it

The frame edge test used Line::dist, which takes a segment shorter than
EPS (1e-9) for its first point: a point halfway along a frame edge that
short was neither on the edge nor, by the even-odd test, inside, so its
line moved whole instead of stretching. The distance to an edge is now
the exact one, clamped to the segment.

Tests also cover two rules from #2627 that had none: a point on an
edge's line but past its end is outside the frame, and a polyline
tilted by only 5e-10 (about a unit off at an elevation of 2e9) keeps its
stored vertex list.

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

* Dynamic blocks: measure a frame edge's residual from its start

segment_dist added the projection back to the edge's start before
measuring, so near x = 36 the residual was rounded to that magnitude's
spacing (7e-15). A point 1.4e-15 off an edge 5e-10 long, outside the
frame, then came out 4e-20 away, within the edge's 5e-19 tolerance, and
was taken as on it. The residual is now measured from the edge's start,
where those digits are kept.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:43 -07:00
Ashok Ramandashokram-si ad95791c20 SVG import: a transform on a shape element moves the shape (#2602)
SVG 1.1 §7.5 lets every graphics element carry a transform. Only groups'
transforms were applied, so a rect, circle, path or other shape with its
own transform came in where it would be without one. on_element now
composes the element's transform after its groups' for that element
only, the way a group around it would.

https://www.w3.org/TR/SVG11/coords.html#TransformAttribute

Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com>
2026-11-02 14:26:38 -07:00
NetterSilverandClaude Haiku 5.5 e6352d955e Reject control characters in layer names (#2569)
Found while testing crates/engine/src/cmd/layer.rs:184 (valid_name, used by run_new)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 14:26:33 -07:00
NetterSilverandClaude Haiku 5.5 4a17ee78c2 Reject invalid fill angles in JSON arraypolar (#2655)
Found while testing crates/engine/src/cmd/array.rs:380 (run_arraypolar, `fill: f64_or(p, "angle", 360.0)`)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 14:26:28 -07:00
c0306b1af3 Remember the main window's size, position and maximized state between sessions (#2595) (#2604)
* Remember the main window's size, position and maximized state between sessions (#2595)

The main window opened at its default size, centred, on every launch: eframe's window
persistence was turned off (persist_window: false), and `centered` is applied over any saved
position. The window state is now saved; on launch, if app.ron holds a saved window, it is
restored (eframe keeps it on the connected monitors) and not centred. Only the first launch,
with nothing saved, opens centred (#1129). Wayland places windows itself, so only size and
maximized state return there.

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

* Docs: leave the parity docs to the maintainers

The affected rows are named in the PR description instead (CLAUDE.md: PRs do not edit the parity docs).

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

---------

Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:23 -07:00
25ca905815 fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500) (#2618)
* fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500)

A TEXT "- פרוייקט" took its direction from its first strong character
(Hebrew), so the leading dash went to the right of the word; AutoCAD
shows it on the left. complex::shape now takes the base direction
(shape_stroke_dir, ttf::shape_dir and shape_line_dir pass it on), and
TEXT's layout (fonts::place and its frame) asks for left to right.
MTEXT and the existing entry points keep taking the direction from the
text.

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

* fix(fonts): a right-to-left base reaches LTR-only lines; Fit TEXT is measured as placed

From the review of #2618: shape_stroke_dir and ttf::shape_dir took the
simple, left-to-right path whenever a line had no right-to-left letter,
so an explicit right-to-left base did nothing there; such a line now
takes the bidi path. And Fit/Aligned TEXT was measured with the
direction from its content but placed left to right, so a line whose
last glyph changed (a stroke-font gap) missed its second point; it is
measured with the direction it is placed in.

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:18 -07:00
CAandClaude Fable 5.1 fbd6e676f8 DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved (#2582)
* DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved

acadrust reads OLE2FRAME from our DXF into its own entity and writes it to
DWG of every version we save, frame and embedded data unchanged; only the
"Not saved in the DWG file" warning (and the QSAVE loss prompt) listed it.
not_saved_as now skips the types the DWG bridge writes (DWG_WRITES).

Tests: a synthetic OLE2FRAME through DWG (resaved, and as 2000 and 2010
DWG) keeps every byte; OLE objects are not reported at any version.

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

* DWG_WRITES matches the type as stored, not case-insensitively

The DXF given to the DWG bridge carries an unknown entity's type as stored, and
acadrust reads only "OLE2FRAME" into its OLE entity: any other spelling becomes
its Unknown and is dropped, so it must stay in the "Not saved" report. The test
adds a lower-case "ole2frame" that is still listed. (Review comment on #2582.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:14 -07:00
whaslbeckandWalter Haslbeck 0674391100 fix(io): read drawings with many groups in linear time (#862) (#2645)
* fix(io): read drawings with many groups in linear time (#862)

Drawings can carry hundreds of thousands of empty anonymous groups
(*A1, *A2, ...). Two places in the DXF reader were quadratic in their
number:

- apply_groups looked up each group by name with a linear search; it now
  uses an index keyed by the lower-case name, so the case-insensitive
  merge (the later definition wins) is unchanged.
- retained_objects called is_xdict(parent) for every object before
  consulting its cache; for a group the parent is the ACAD_GROUP
  dictionary, which head() walks completely. is_xdict is now memoized
  per object index.

Two drawings with 179,124 and 124,362 empty groups go from 388 s and
165 s (DWG to PDF) to 7.3 s and 8.0 s, with byte-identical output.

* test(io): check that reading groups scales linearly (#862)

The content test passes on the quadratic code as well, so add a scaling
check: 16 times the groups (2,500 vs 40,000, best of three reads each)
must take less than 64 times as long. With both fixes the ratio is
about 21; without the name index it is about 218, without the is_xdict
cache about 180. No wall-clock limit is involved.

Also document apply_groups.

---------

Co-authored-by: Walter Haslbeck <8794773+whaslbeck@users.noreply.github.com>
2026-11-02 14:26:09 -07:00
f4c68e18fc macOS: Quit (Cmd+Q), the Dock's Quit and logout ask to save unsaved drawings (#2157) (#2620)
Quit CADCraft in the app menu, the Dock's Quit and logout end the app through AppKit's
terminate, which skipped the "Save changes to ...?" prompt, so unsaved work was lost. The
event loop now turns termination into a close request for the window (the storytold winit
fork's opt-in, as DesignCraft does), so it asks first, as the close button does. Cancel keeps
the app open; Force Quit can't be intercepted.

Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:04 -07:00
ed08716f30 fix(io): the paper viewport a save adds is the sheet at the layout's plot scale (#2108) (#2624)
A layout without a paper-space viewport (id 1) gets one on save, sized
by the page alone; a custom plot scale (142/143) wasn't applied, so an
A3 layout at 49.6 mm per unit got a 420 x 297 viewport around its
8.4 x 6 unit sheet, and its extents went wrong. paper_view divides the
sheet by the layout's plot scale; a layout scaled to fit, at 1:1 or with
an unusable scale writes what it did.

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:00 -07:00
ab15e42c6c fix(io): a layout keeps its limits and paper image origin through a save (#2109) (#2623)
Every LAYOUT was written with limits 0,0 to 12,9 (AcDbLayout groups
10/11) and paper image origin 0,0 (groups 148/149), whatever the file
had, so another program drew the sheet elsewhere and a Layout plot came
out shifted. Layout::limits and PageSetup::image_origin keep what the
file had (finite, within 1e12) and a save writes it back; a layout
without them writes the old defaults, which read back as none.

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:55 -07:00
assamaralandClaude Fable 5.1 c4ac8761e8 fix(io): a ByBlock plot style (group 380 = 1) is written and read (#2242) (#2566)
* test(io): a ByBlock plot style (group 380 = 1) survives a DXF round trip (#2242)

Red: the writer leaves group 380 out and the reader maps it to ByLayer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(io): a ByBlock plot style is group 380 = 1, written and read (#2242)

The writer only knew named styles (group 390) and left ByBlock out like ByLayer; the reader
mapped a record without 390 to ByLayer. Both now use the plot style name type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:25:51 -07:00
a277346c2f fix(io): a viewport looks at its view target plus its centre (#2492) (#2508)
* fix(io): a viewport looks at its view target plus its centre (#2492)

A DXF stores a view's centre in display coordinates, whose origin is the
view target (DXF Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and
17/27/37, VIEW 10/20 and 12/22/32). The readers took the centre alone, so
a file that keeps the location in the target with a centre of 0,0 (as
ArchiCAD's AutoCAD 2018 export does) showed model point 0,0 in every
layout viewport, on screen and when plotted.

The VIEWPORT entity, VPORT tiles and named views now read the target plus
the centre. A sum that overflows keeps the stored centre. The writers
already store the whole location as the centre with a target of 0, so a
save and reopen keeps the view, through DXF and DWG.

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

* test(io): a viewport looking along another direction centres on its target

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:46 -07:00
3146b47656 fix(engine): WBLOCK writes only what the block or selection uses (#2423) (#2621)
* fix(engine): WBLOCK writes only what the block or selection uses (#2423)

WBLOCK of a block, or of selected objects, copied the source drawing's
whole layer, linetype, style and block tables into the new file, so a
5-object symbol carried 40 unused layers and 20 blocks. The written
drawing is now purged of what nothing in it uses, as AutoCAD's WBLOCK
writes it: purge::purge_unused runs PURGE All's scan on a plain Drawing
until a pass removes nothing (a block can free its layer), keeping the
defaults and the current ones. Writing the entire drawing is unchanged.

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

* fix(engine): PURGE's scan counts leaders' dimension styles and styles used by other styles

From the review of #2621: PURGE (and now WBLOCK, which runs its scan)
took as unused a dimension style only a LEADER used, a linetype only a
dimension style or a dimension's override named (DIMLTYPE, DIMLTEX1,
DIMLTEX2), and a text style only a complex linetype's text used, and
removed them while the objects still named them. The scan counts all
three now; text_styles_in_use, which STYLE's delete also asks, includes
complex linetypes' text styles.

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

* fix(engine): PURGE's scan counts multiline styles' linetypes and table styles' text styles

From the review of #2621: a linetype only a multiline style's element
used, and a text style only a table style's cell style used, were taken
as unused and purged while the styles still named them.

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:41 -07:00
FredApps dae4b7b2f5 Merge pull request #2335 from starise/codex/it-chamfer
fix(it): correct Chamfer terminology
2026-11-02 18:23:14 +01:00
ebc67fa81d Dynamic blocks: stretch the points inside the frame, as AutoCAD does (#2627)
* Dynamic blocks: stretch the points inside the frame, as AutoCAD does

A dynamic block made by a third-party tool came out distorted when a
distance changed: its stretch action has distance multiplier 0 with
angle offset 1, and stored vertex lists that don't match its objects.
apply turned the 0 into 1 but kept the angle (a 57 degree slant), and
moved exactly the listed points.

AutoCAD 2024, on a copy of that drawing, moved the block straight along
the parameter, and every point of its lines and polylines exactly when
it was inside the frame, whatever the lists said. So:

- a zero multiplier moves as 1, without its angle offset;
- a line's or lightweight polyline's moving points are those inside
  the frame where the dynamic block has them; a selected one wholly
  outside the frame moves whole, as in AutoCAD's own stretched copies;
- a frame of fewer than 2 or more than 1,000 points keeps the stored
  lists, so a huge frame can't make every point test every edge.

Against AutoCAD's own anonymous copies in two library drawings (556
references set back to their block's distances), 249 come back exactly
on main and 258 with this change; none stops matching.

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

* Dynamic blocks: a point on a polygon frame's edge is inside it

in_frame counted a point on a two-corner frame's edge as inside, but
the even-odd test of a polygon frame put one on its right edge outside.
A line whose end lay there, with its other end outside, then had no
point inside the frame and moved whole instead of its end alone.

A polygon frame now counts a point on an edge (within 1e-9, relative
to the edge's length) as inside too, as the two-corner frame does.

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

* Dynamic blocks: frame edge tolerance by the edge's length; tilted polylines keep their lists

A point counted as on a polygon frame's edge within 1e-9 * (1 + the
edge's length), so never less than 1e-9 drawing units: in a tiny frame,
a point visibly outside was taken as inside. The tolerance is now a
billionth of the edge's length.

A lightweight polyline's vertices are in its own plane. The reader
flips a mirrored one (extrusion along -z) into the block's plane, but
leaves a tilted one's in its OCS, which the frame (block coordinates)
can't be tested against. A tilted polyline keeps the stored vertex
list, as before the frame test.

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

* dynblock: only an exactly untilted polyline is tested against the frame; a tilted one keeps its stored list

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

* fmt

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 05:31:22 -05:00
fa03bf2a29 feat(engine): INSERT of a drawing file keeps an attribute definition's field (#2619)
* feat(engine): INSERT of a drawing file keeps an attribute definition's field

The attribute definitions of a drawing file read by INSERT name=file lost their
extension dictionary, so the ACAD_FIELD dictionary and the FIELD objects of an attribute
whose value is a field (the file name in a title block) were gone from the block.
adopt() now copies that object tree to the drawing under new handles, with the FIELD
class and a field list if the drawing has none.

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

* foreign: index the kept objects once when walking and copying a dictionary tree

A file can keep many objects; a linear lookup per pointer grew with their product.

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

---------

Co-authored-by: andrewbrake <6925387+andrewbrake@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 05:31:18 -05:00
2ez4gcxandClaude Opus 5.5 b006110dc5 OOPS: bring back what -WBLOCK erased; keep the last erase through REFEDIT; the Block Editor (#2625)
* OOPS brings back what -WBLOCK erased

-WBLOCK erases the objects it writes, and OOPS brings back what the
last ERASE or BLOCK removed; the two landed separately, so OOPS never
saw what -WBLOCK erased. -WBLOCK now erases through erase_kept, as ERASE
and BLOCK do.

As AutoCAD 2024 does (accoreconsole, the drawing's entnext order read
back): OOPS after -WBLOCK brings its objects back, each where it was and
with its handle; only the last erase counts, -WBLOCK's or ERASE's; and
-WBLOCK * erases nothing, so OOPS still brings back the erase before it.

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

* OOPS: keep the last erase through a refused REFEDIT command; Block Editor

During REFEDIT, a command that removes an object outside the working set
is undone (refedit::after_command), but erase_kept had already replaced
what OOPS kept: the objects came back, and the erase before them was
lost. erase_kept now leaves OOPS as it was when an object it removes is
outside the working set. That covers BLOCK, -WBLOCK, ERASE and the
Delete key.

In the Block Editor, its objects keep the handles they have in the block
definition, so in_blocks found them there and OOPS brought back nothing
erased in the editor. in_blocks now skips the block open there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:31:14 -05:00
2034d6899e Polyface meshes stay one object through DXF and DWG (#2600)
* Polyface meshes stay one object through DXF and DWG

A polyface mesh (POLYLINE flag 64) was read as loose 3D faces. It is now
read, drawn, exploded into its 3D faces and written back as a POLYLINE.
Hidden edges aren't drawn. A mesh too large for the 16-bit groups 71-74
is saved as 3D faces.

Split from #1005.

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

* A polyface face ends at its first zero index

A face record with a gap ([1, 2, 0, 3]) has two corners and is dropped,
not read as a triangle.

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

* Polyface meshes: write only faces that name their vertices; ID gives their Z

The writer drops the faces the reader would drop, so no record names a
missing vertex, and the 16-bit check only needs the counts. ID and DIST
give a mesh the Z of its nearest visible edge, as they do for 3D faces.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:40:30 -05:00
e8bcc38e34 OOPS: bring back the last erase, each object where it was (#2605)
There was no OOPS. It now brings back what the last ERASE removed (the
Delete key too), or BLOCK deleting the objects it makes a block of, as
AutoCAD 2024 does (accoreconsole, the drawing's entnext order read
back):

- Each object keeps its handle and goes back directly above the object
  that was under it, or under the one that was above it when that is
  gone. An erase records both neighbours (EntityStore::neighbours).
- Only the last erase counts; entdel doesn't. OOPS says nothing and is
  one undo step.
- An object stays out when it is already in the drawing, when an undo
  took the drawing back to before it was made, when its layer, block or
  layout is gone, and, during REFEDIT, when it isn't in the working set.
  REFCLOSE forgets the working copies; the Block Editor has an OOPS of
  its own.
- The command's label is in the 17 interface catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:40:15 -05:00
152617f4df Plot: a layout's saved page setup is used by default (part of #2493) (#2586)
* Plot: a layout's saved page setup is used by default (#2493)

A layout whose page setup says plot window, scale to fit or a plot scale was plotted 1:1 from
the sheet origin unless the caller passed fit. plot, exportpdf, the PNG device and preview now
read the layout's saved window, scale to fit, scale and centring when the options don't say
(render::paper::saved_plot); explicit options win, and fit: false without a scale is still the
plain 1:1 sheet. Tests: saved_setup_tests in pdf.rs (four), and
preview_uses_the_layouts_saved_page_setup_by_default.

* Parity checklist: regenerated after merging main

* Parity checklist: regenerated after merging main

* Plot: saved page setup in preview and PNG matches the PDF (review on #2586)

Preview honours the saved centre when fitting a window and an explicit scale beats a saved scale to fit (PDF too); PNG places a saved window without fit and clips to the printable area.

* Preview: a saved window clips to the printable area (review on #2586)

The preview drew the placed plot over the margins while the PDF and PNG blank them. The blanking helper now lives in the render crate and both the PNG plot and the preview use it. Test: preview_clips_a_saved_window_to_the_printable_area.

* Preview: pin hostile saved page setups and the default setup (review on #2586)

Degenerate or far-away saved windows and saved scales of 0 or not finite already fall back to the plain plot in the PDF, PNG and preview; tests now pin that, and that a layout with no saved setup plots as before.

* Preview: the printable clip follows the view's fit (review on #2586)

The clip assumed the sheet filled the image; when the height cap applies
View::fit scales and centres it. The printable corners now go through
View::to_screen. Test: preview_clip_follows_the_view_when_the_height_cap_applies.

* docs: leave the generated parity checklist to maintainers

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:34 -05:00
ccbc4fa854 DXF: link dimension sources to their associations (fixes #2221) (#2589)
* DXF: link dimension sources to their associations (#2221)

Source objects lacked the persistent reactor back to their DIMASSOC.
Register validated source and intersection references once through the existing
reactor list, keeping a self-link from duplicating the dimension's own reactor.
Synthetic regressions cover missing links, repeated references and self-links.

* Parity checklist: regenerated for dimension reactor gate

* Parity checklist: regenerated after merging main

* DXF: a dimension source keeps its other reactors next to the DIMASSOC (review on #2589)

The DIMASSOC reactor made the writer emit an ACAD_REACTORS group, so the raw
passthrough skipped the source's retained foreign reactors. They are now merged,
deduplicated, into that one group. Test: a line reactor-linked to a kept object keeps
both links after an associative dimension is added.

* DXF: an unmodelled DIMASSOC source gets the DIMASSOC reactor (review on #2589)

Unknown entities are written by the raw passthrough, which never saw the planned
reactors. The map is now passed to dxf_raw::finish and merged into the entity's
reactor group. Test: an unknown entity with a dimension snapped to it names the DIMASSOC.

* Docs: leave the file-format parity document to maintainers (review on #2589)

* DXF: pass the unknown-entity reactors as one argument (review on #2589)

* DXF: collapse the extension dictionary condition (review on #2589)

* Test: pin the reactor merge for dimension sources (review on #2589)

* DXF: a kept extension dictionary follows the reactors group (review on #2589)

When a written record already had its reactors group, the kept extension dictionary was
inserted right after the handle, ahead of it; the DXF reference puts the reactors first.
The reactor tests now find each group by name, so either order can no longer pass.

* docs: leave the generated parity checklist to maintainers

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:31 -05:00
f0b46e34dc Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#2603)
* Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#1285)

Every mouse move hit-tests the block references under the cursor, and
hit_polylines built them with a full-detail display list, one primitive per
dash. A fine pattern on long lines in a block inserted at 0.001 made millions
of dashes per move.

Options::linetypes off draws every line whole and continuous, and
hit_polylines uses it for blocks, dimensions and multileaders. A dashed line in
a block is now hit across its gaps, as one outside a block already was.

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

* render: with linetypes off, a lightweight polyline is one continuous stroke

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

---------

Co-authored-by: Steffen <camperking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:38:27 -05:00
06c5760ea6 Persist QuickCalc history, variables and last answer (#2572)
* Persist QuickCalc history, variables and last answer

Store bounded calculator data in app preferences for desktop and web. Restore finite numeric and point variables, normalize names and ignore malformed entries without resuming transient drawing input.

Covers gaps F22. Validation: 3079 workspace tests passed; wasm, fmt, assets, layers and i18n passed; UI screenshot inspected. Strict CI remains blocked by pre-existing nonminimal_bool lints in DXF and UI code; scoped UI Clippy passes with only that lint disabled.

* quickcalc: apply the variable limit after validation

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:24 -05:00
Andrea FicarraandClaude Opus 5.5 2d02a5faa5 DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings (#2574)
* DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings

AutoCAD reads `^x` in a DXF string as a control character, so a TEXT of
`10^2` opened in AutoCAD showed a control character instead of the caret.
The DXF Reference writes a caret as `^` and a space: the writer now does
for the text (group 1) of TEXT, ATTRIB, ATTDEF and DIMENSION and an
ATTDEF's prompt (group 3), and the reader turns it back. A bare `^x` from
older files stays as it is; single-line strings keep `^J` literal (they
hold no line feeds). AutoLISP's entget shows the plain caret, and a
DIMENSION's style name (also group 3) is left as written.

Tests: crates/io/tests/it/text_carets_roundtrip.rs.

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

* entget gives TEXT strings as the file has them, like MTEXT

Decoding the carets in entity_groups broke entmod (it reads the groups
as a file, so `A^ B` came back as `A^B`) and reached into an ATTDEF's
embedded MTEXT. entity_groups is left as on main: it gives the file's
groups, as it already does for MTEXT, and entmod reads them back
unchanged (test entmod_of_entget_keeps_the_strings).

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

* entget unescapes carets, entmake and entmod escape them

e61b4dd left entget with the file's escapes, but entmake still read a
plain `p^ q` as `p^q`. Now one rule, map_carets, picks the groups that
hold an escaped string (the text, 1, and an ATTDEF's prompt, 3, up to an
embedded MTEXT at group 101): entity_groups unescapes them and
entity_from_groups escapes them before reading, so entget is plain,
entmake takes plain strings and entmod of entget changes nothing.

Tests: entget_shows_plain_carets_and_entmake_takes_them,
entmod_leaves_the_embedded_text_alone.

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

* Leave the parity docs to the maintainers

CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in
the PR description instead.

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

* Single-line strings read like MTEXT: a tab from AutoCAD survives

AutoCAD writes a tab in a TEXT as `^I`. The reader left it as two
characters and the writer then saved `^ I`, so the tab was lost. Single-line
strings are now read with decode_contents, as MTEXT is (`^ `, `^I`, `^J`;
other bare carets stay), and encode_line writes a caret as `^ ` and a tab
as `^I`. unescape_carets is gone.

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

* Rename map_carets to map_text_groups (it encodes tabs too)

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

* Single-line strings keep a bare ^J; entity_from_groups doc

A TEXT holding `A^JB` (from a file saved before this change, which wrote
carets bare) was read as a line feed and saved as `A B`. Single-line
strings now decode only `^ ` and `^I` (decode_line, decode_contents
without line feeds), so `^J` stays two characters. entity_from_groups
documents that it takes plain strings, as entity_groups gives them.
The caret test also goes through a DWG.

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

* Test a caret before a space and at the end of a TEXT

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:21 -05:00
Andrea FicarraandClaude Opus 5.5 5cc342b033 DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF) (#2575)
* DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF)

AutoCAD 2004 and later, and the DWG converter, read a VIEWPORT's frozen
layers from group 331; CADCraft wrote only group 341 (the DXF 2000 form),
so a DWG or 2004+ DXF lost the freeze outside CADCraft, and a file frozen
only in group 331 (as AutoCAD 2026 and ezdxf write it) lost it on the
first save. Now: group 341 in an R2000 DXF, 331 otherwise and for the DWG
bridge; both are read, each layer once. CADCraft's own xdata still wins.

Tests in crates/io/src/tests.rs, including
a_331_only_viewport_freeze_survives_open_and_save.

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

* Leave the parity docs to the maintainers

CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in
the PR description instead.

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

* Viewport frozen layers: group 331 wins over stale xdata

AutoCAD now sees the freeze in group 331, edits it, and keeps CADCraft's
VPFROZEN xdata as it was. Reading preferred the xdata, so a layer thawed
in AutoCAD came back frozen. The handles now win; the xdata still gives
names that have no LAYER record, and the whole list for records without
handles (entget/entmod, older files). The handles are written before
group 90, as in the DXF Reference.

Test: viewport_frozen_331_wins_over_the_xdata.

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

* Viewport frozen layers: handles for real layers, xdata only for the rest

Simpler and safe against edits in AutoCAD. A layer with a LAYER record is
written only as a handle (331, or 341 in an R2000 DXF); CADCraft's
VPFROZEN xdata now holds only the names with no record (and the whole
list for entget, which writes no handles). Reading takes the handles,
then the xdata names, each layer once: no precedence, and a viewport
thawed in AutoCAD (even its last frozen layer) stays thawed, since no
stale list is left behind. The reader no longer builds a layer-name set
per viewport.

Tests: viewport_frozen_layers_read_from_331_or_341_once_each,
viewport_freeze_edited_in_autocad_is_read_as_edited.

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

* Viewport frozen layers: skip 102 application groups

A group 331 inside another application's `102 {…}` group isn't a frozen
layer.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:17 -05:00
2ez4gcxandClaude Opus 5.5 fb05c78590 Read bearings and compass points as AutoCAD does (#2590)
Measured in AutoCAD 2024 (accoreconsole):
- N, E, S and W are taken on their own in any angle format (LINE to
  @10<N goes north at AUNITS 0, 1 and 4).
- A bearing or a compass point is an angle like a number, measured from
  ANGBASE in the ANGDIR sense: with ANGBASE 90 and ANGDIR 1, @10<N45dE
  draws at 45 degrees and @10<N east; (angtof "N") is 0 there.
- <<< takes them from east counterclockwise; << (degrees) refuses them.

main took the letters in surveyor's units only, and read them and
bearings as absolute directions. One cardinal() now serves every angle
format, and the test that pinned the surveyor-only reading expects the
measured values. -UNITS uses the shared parser.

Also write the -UNITS tests' scripts with \n escapes again; they had
become multi-line strings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:14 -05:00
2ez4gcxandClaude Opus 5.5 5e9c478bfb U inside a LISP routine takes back the routine's last command (#2610)
Inside a routine, commands recorded no undo step (the routine becomes
one when it ends), so U inside a routine took back the step from before
the routine: after drawing a line D, ERASE B then U in one routine
brought B back and removed D.

As AutoCAD 2024 does (accoreconsole, the drawing read back after one
routine), U inside a routine now takes back the routine's last command,
with what the routine did after it (entdel, entmake); U again, UNDO n
and REDO go on through its commands. With none left, U takes back the
drawing's step before the routine, as before, and the routine's step
then starts from there. Once the routine ends it is still one undo step.

The routine's steps live in the session's LISP state (RoutineSteps), not
in the drawing's undo stack, so a long routine doesn't push the drawing's
earlier steps out. A command inside the routine ends what REDO could
bring back; UNDO Control One and None apply. DocState::swap_in is the
one restore step undo, redo and the routine share.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:10 -05:00
SoldierPePe e187c5fecb i18n(ar): translate 129 new strings, use the catalog's terms in 3 (#2564)
The Arabic catalog showed 129 interface strings in English: the Block Editor and REFEDIT, dynamic blocks, multileader editing, hatch islands, origin and boundaries, selection and grip settings, visual effects, command aliases, backup and units options, and the MTEXT ruler. `cargo xtask i18n` now reports `ar: nothing missing`.

Hatch, Leader and Multileader now use the terms the rest of the catalog uses (تهشير, خط الإشارة) instead of تظليل and قائد.
2026-11-01 14:38:07 -05:00
Andrea FicarraandClaude Opus 5.5 c72c0eebfb SAVEAS portableFonts: Windows fonts for Linux text style fonts (#2576)
* SAVEAS portableFonts: Windows fonts for Linux text style fonts

A text style using a Linux TrueType font (Liberation, DejaVu, Open Sans)
shows in AutoCAD on Windows with a substitute it picks. A save now names
those styles on the command line (and as `nonPortableFonts` in the
result), and SAVEAS {"portableFonts": true} writes the Windows equivalent
in the file (Liberation -> Arial, Times New Roman, Courier New; DejaVu
Sans -> Verdana; Open Sans -> Arial) while the drawing keeps its own
fonts. Narrow and condensed families have no Windows stand-in (Arial
Narrow comes with Office) and are kept and named. QSAVE takes no SAVEAS
options.

Tests: stand_in_tests in cmd/file.rs, tests/save_portable_fonts.rs.

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

* Portable fonts kept in the file, applied by the writer

Reworked after review. SAVEAS `portableFonts` writes a copy of the drawing
with `Drawing::portable_fonts` set, and the DXF/DWG writer turns each
style's Linux font into its Windows font, keeping the style's own font in
the CADCraft FONT xdata already used for the built-in font. Reading the
file gives the Linux fonts back (unless the font was changed since, in
AutoCAD); the drawing itself is never changed.

It applies to that save: a later QSAVE writes the drawing's fonts and
says so, naming the styles and SAVEAS portableFonts. The family table
moves to `cadcraft_fonts::windows`, used by the writer and the note.
Styles from an xref aren't saved and aren't named. The parity docs are
left to the maintainers (CLAUDE.md).

Tests: crates/io/tests/it/portable_fonts.rs (DXF, DWG, a font changed in
AutoCAD), crates/engine/tests/save_portable_fonts.rs, windows.rs tests.

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

* Portable fonts: match the Windows font by file name

A STYLE whose Windows font carries a path (C:\Windows\Fonts\…) still
gets its own font back.

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

* Portable fonts: say the font list again only when it changes

A save still returns nonPortableFonts, but the command line note isn't
repeated on every QSAVE of the same drawing.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:04 -05:00
NateMarida 4a9371ad2c Thai text: stacked marks, Thai fonts first, MTEXT word wrap (#2591)
* Thai text: stacked marks, Thai fonts first, MTEXT word wrap, Windows fonts under WSL

Thai text in DWG/DXF files decoded correctly but laid out badly. Vowels and tone marks
stacked on one consonant collided (ดิ์), because only right-to-left text went through the
shaper.

- Thai lines go through the harfrust shaper (complex::needed). Shaped left-to-right glyphs
  keep their characters (TextGlyph) for PDF text.
- Thai fallback fonts: Cordia, Angsana and Browallia first (Thai drawings are laid out with
  their widths), then IBM Plex Sans Thai, Noto Sans/Serif Thai and the fonts each OS ships.
- The web build embeds the craft-fonts Thai regular faces.
- MTEXT wraps a run of Thai between words when the run is too wide for the column by itself.
  Text that fits at its spaces keeps those line breaks, so existing drawings lay out as
  before. Word breaks come from icu_segmenter's LSTM model (about 410 KB). ICU's dictionary
  segmenter was not used: after loanwords it breaks inside words, even between a consonant and
  its tone mark. Breaks inside a character cluster are dropped, and results are cached.
- Under WSL the font scan also reads /mnt/c/Windows/Fonts, at the lowest priority, so drawings
  find the Windows fonts they name (Cordia New). The scan reads entry types from the
  directory listing instead of a stat per file.

* Thai fallback: match older Angsana and Browallia file names; document touched functions

Today's Windows ships Angsana New and Browallia New as angsana.ttc and browalia.ttc. Older
Windows used single faces (angsa.ttf, angsab.ttf, browa.ttf, ...), which still turn up when
fonts are copied to Linux machines. The Thai fallback list now matches the shorter stems
`angsa` and `browa`, which cover both. Review feedback on #2591.

Doc comments for scan, plain_lines and the paragraph layout's word_width and place.

* MTEXT: wrap a Thai run after a first-line indent or a tab

A Thai run that fit a line at the left indent, but not the room left after a first-line indent
or a tab stop, wasn't split between its words: the first line of a paragraph can't wrap, so it
ran out of the column. An empty line now measures the run from where it starts. Review feedback
on #2591.

Also restores the space in `image = {` in Cargo.toml.

* Font scan: take the WSL font folder out of this change

Thai text doesn't depend on it: with no Windows or system fonts, the craft-fonts Thai faces
(Noto Sans Thai, IBM Plex Sans Thai) stack marks and wrap MTEXT the same way. Reading the
Windows host's /mnt/c/Windows/Fonts under WSL is a separate decision, so the scan is back to
main's: Linux system and user font folders only.
2026-11-01 14:38:00 -05:00
2ez4gcxandClaude Opus 5.5 0e2bff3b2b LEADER at the command line, as AutoCAD runs it (#2596)
LEADER had only its JSON form, so a script's LEADER drew nothing. This
adds the command-line command with AutoCAD 2024's prompts: the points,
Format (Spline/STraight/Arrow/None) and Undo, then annotation lines, or
the options None, Block, Copy, and Mtext and Tolerance (which come back
to the first line at the command line, as in accoreconsole).

Measured, and followed:
- A hook line, a horizontal last segment DIMASZ long, is added when the
  last segment is straight and more than 15 degrees off horizontal and
  text follows: 14.9 and 15 degrees keep the end, 15.1 hooks. A spline,
  no annotation and a block take none.
- The text sits DIMGAP past the end on the side the leader runs to:
  above it, bottom-attached, with DIMTAD on; centred on it with DIMTAD
  0. Sizes are DIMSCALE times the style's, with SETVAR overrides.
- A copied text keeps its contents, height and width, is centred on its
  line, and with DIMTAD on the leader ends half the text height lower.

The JSON form now draws through the same code. The drawing model has no
link from a leader to its annotation, so the two are separate objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:56 -05:00
2ez4gcxandClaude Opus 5.5 210cfc2a1d -WBLOCK at the command line, as AutoCAD runs it (#2594)
* -WBLOCK at the command line, as AutoCAD runs it

WBLOCK had only its JSON form, so a script's -WBLOCK did nothing: the
objects it should write and erase stayed in the drawing. This adds the
command-line command, with AutoCAD 2024's prompts:

- "Current settings: Object conversion=Delete", then the output file. A
  name without .dwg or .dxf gets .dwg. A DXF file asks for its decimal
  places first. A file that exists asks "... already exists, do you want
  to replace it? [Yes/No] <N>"; No ends the command.
- An existing block by name, "=" (the block named as the file), "*" (the
  whole drawing), or Enter for objects. A name that isn't a block is
  answered Block "NAME" not found. and asked again.
- For objects: the base point (with mOde), then the objects, which are
  written and erased. AutoCAD erases them whatever mOde says, and keeps
  Delete as the setting. Nothing selected writes nothing.

Its JSON form, -wblock, erases what it writes. WBLOCK's JSON form moves
into the new module unchanged, sharing the writer. The web build refuses
-WBLOCK rather than erase objects it can't save.

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

* -WBLOCK's JSON form: nothing to write writes no file

With no handles, an empty selection or only handles of objects that
aren't there, the JSON form wrote an empty drawing. It now refuses with
"no objects selected" before writing or erasing anything, as the command
line writes nothing when nothing is selected.

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

* -WBLOCK: only Yes replaces an existing file

At "... already exists, do you want to replace it? [Yes/No] <N>", any
text starting with "y" counted as Yes, so "yodel" replaced the file, and
any other text ended the command. Now only the Yes keyword replaces it,
No and Enter end the command, and anything else gets "Invalid option
keyword." and the prompt again.

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

* WBLOCK and -WBLOCK write their file through a temporary file

Replacing an existing file truncated it first, so a write that failed
part way lost it. The file now goes through atomic_write::write_atomic,
as SAVE, EXPORT and autosave write theirs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:52 -05:00
2ez4gcxandClaude Opus 5.5 520394858c -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts (#2597)
* -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts

-ATTDEF asked for the tag first. A script's mode answer ("_V", "_C",
"_P") became the tag; the attribute kept no mode; AFLAGS never changed.
The definition was made at the start point, so the height and rotation a
script gives next ran as commands. It now asks as AutoCAD 2024 does:

- "Current attribute modes:" from AFLAGS, then "Enter an option to
  change [Invisible/Constant/Verify/Preset/Lock position/Annotative/
  Multiple lines] <done>", each answer toggling a mode, kept in AFLAGS.
- The tag (spaces taken, upper-cased; an empty one gets "Tag cannot be
  null"), the prompt and default value, or a constant attribute's value.
- "Current text style: ...", the point with Justify and Style, the
  height and the rotation.

The definition takes Invisible, Constant, Verify, Preset and Lock
position from the modes; a constant attribute stays in its block.

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

* -ATTDEF: a typed height is a distance, as TEXT takes it

The height was read with str::parse, so 1'6" (or any distance TEXT
takes) was refused as "Requires a positive height." It now goes through
the shared distance parser, `number`.

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

* -ATTDEF: a wrong answer at Justify is asked again

A wrong answer at "Enter an option [Left/Center/...]", or Align or Fit
(not available here), went on to the point prompt with the justification
unchanged, so a script's next answers landed one prompt off. Only a
justification goes on to its point now; anything else is asked again.

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

* -ATTDEF test: the modes start from AFLAGS, not from the default

The other cases start from AFLAGS 16, which is also the default, so
reading the modes from the drawing went untested. This starts from 0.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:49 -05:00
Andrea Brandi 020d81a27e chore: merge main into Italian terminology fix
Keep all contributor entries and align progress docs with main.
2026-11-01 20:18:39 +01:00
SBaksa 57e6a89d10 Parity checklist: regenerated after merging main 2026-10-31 12:31:34 +11:00
SBaksa 282ad11e60 Save note: a DWG loses SAT-text solids from 2013 on, and the note says so (#2334)
A real DWG write and read-back keeps a SAT-text REGION's groups up to 2010 and
returns it without them at 2013 and 2018, so the SAT-text skip in not_saved_as
applies before 2013 only. The DWG note now says solids are kept in a DXF only
when saved as 2010 or earlier.
2026-10-31 12:28:21 +11:00
SBaksa 768d25bb7d Merge origin/main into dxf2000-region 2026-10-31 11:57:51 +11:00
SBaksa 7baa970381 Save note: a data store record stores its first entity; a DWG keeps SAT-text solids (#2334)
The writer keeps an ACDSRECORD by its first 320 only, so data_store_entities now
credits that handle alone (one shared rule, data_store_owner). The DWG writer
converts a solid's SAT text itself at any version, so not_saved_as no longer
lists solids that hold SAT text as left out of a DWG.
2026-10-30 19:18:41 +11:00
SBaksa 590b97a321 Save note: QSAVE reports by the version it writes, not a version it ignores (#2334)
QSAVE takes no version and writes the drawing's own, but the SAT-text note read a `version`
passed to it, so `qsave {version: 2010}` on a 2018 drawing skipped the note while still
leaving the solids out. Only SAVEAS's requested version now picks the reporting branch.
Test: saving_sat_text_solids_as_dxf_2013_reports_them (qsave case fails without the fix).
2026-10-30 18:42:24 +11:00
SBaksa 86d010eaac DXF: SAT text is groups 1 and 3 of the modeler subclass; only ASM_Data records keep geometry (#2334)
sat_text_acis now looks for groups 1 and 3 inside AcDbModelerGeometry only (all
groups when a file has no subclass markers), and the data store counts an
entity as stored only when its ACDSRECORD holds ASM_Data. The writer and the
reader share one record splitter (data_store_records).
2026-10-30 14:59:16 +11:00
SBaksa fc8270d8c5 Parity checklist: regenerated after merging main 2026-10-30 12:47:03 +11:00
SBaksa b301d5782d Merge origin/main into dxf2000-region 2026-10-30 12:43:43 +11:00
SBaksa 1013b482a8 DXF: SAT-text note only for DXF output, from the host drawing, without xref contents (#2334)
The DWG bridge writes R2000 text for the DWG writer, which keeps SAT-text
solids: the skip is now keyed on the DXF's declared version, not the DWG
target. The save note reads the version and count from the host drawing while
the Block Editor is open, and leaves out xref blocks (path or xref| name), which
the DXF writer empties anyway; the xref name rule is shared with the writer.
2026-10-30 12:26:09 +11:00
SBaksa 89771962fc Parity checklist: regenerated after merging main 2026-10-30 11:26:25 +11:00
SBaksa 49fdbff529 Test: saving SAT-text solids as DXF 2013 reports them, 2010 does not (#2334) 2026-10-30 11:23:09 +11:00
SBaksa 2eecaef6c2 Parity checklist: regenerated after merging main 2026-10-30 10:41:07 +11:00
SBaksa b6c1ab74f9 DXF: leave SAT-text solids out of files of 2013 and later, with a save note (#2334)
REGION, 3DSOLID and BODY read from a DXF 2000 hold SAT text; DXF 2013 and later
keep ACIS geometry in the data store section, so writing the text there made a
file other readers reject. Those entities are now left out of 2013+ DXF output
and counted in the save note; 2010 and earlier, and DWG, are unchanged.
Test: sat_text_solids_are_left_out_of_dxf_2013_and_later.
2026-10-30 10:38:00 +11:00
Andrea Brandi 4398a1aab1 fix(it): correct CADCraft chamfer terminology 2026-10-27 18:47:15 +01:00
MrBeeboh 6ed822ec30 Plot style and plotter files save atomically through write_atomic (#1928 follow-up) 2026-10-22 20:22:09 -07:00
212 changed files with 9961 additions and 1021 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ Generated-in-code assets are original and have no file to list:
| `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 |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
Generated
+57 -19
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "acadrust"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2"
checksum = "eb201e11e1c75584e4deefc2b30afb83e2f34d16338733439d38664cb9599929"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -632,6 +632,7 @@ dependencies = [
"muda",
"objc2-app-kit 0.3.2",
"rfd",
"ron",
"serde_json",
"sys-locale",
"thiserror 2.0.21",
@@ -727,8 +728,9 @@ name = "cadcraft-fonts"
version = "0.16.0"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
"skrifa 0.48.0",
"harfrust 0.15.0",
"icu_segmenter",
"skrifa 0.49.0",
"unicode-bidi",
]
@@ -1115,6 +1117,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1548,9 +1559,9 @@ dependencies = [
[[package]]
name = "encoding_rs"
version = "0.8.42"
version = "0.8.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
dependencies = [
"cfg-if",
"core_detect",
@@ -2118,14 +2129,15 @@ dependencies = [
[[package]]
name = "harfrust"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb"
checksum = "865dcc4105d51c323d8fedbb2d75b5e9c8ecddf5613f74c82f851d0afbc97114"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"read-fonts 0.45.1",
"read-fonts 0.46.0",
"smallvec",
"yoke",
]
[[package]]
@@ -2435,6 +2447,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -2776,9 +2811,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.34"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "lyon_geom"
@@ -3870,14 +3905,15 @@ dependencies = [
[[package]]
name = "read-fonts"
version = "0.45.1"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
checksum = "bdaa4fffe9ff3ca2c353fa9729792805b9b3e324695ff5a081f860612e81b330"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"font-types 0.12.7",
"once_cell",
"smallvec",
"yoke",
]
@@ -3963,12 +3999,11 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "ron"
version = "0.12.2"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
dependencies = [
"bitflags 2.13.2",
"once_cell",
"serde",
"serde_derive",
"typeid",
@@ -4334,12 +4369,15 @@ dependencies = [
[[package]]
name = "skrifa"
version = "0.48.0"
version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
checksum = "a7459fb60888ad9a5f367dd293fa2fb9df142cb451aa6bfd6cd66a7d6a9b739d"
dependencies = [
"bytemuck",
"read-fonts 0.45.1",
"once_cell",
"read-fonts 0.46.0",
"smallvec",
"yoke",
]
[[package]]
+5 -2
View File
@@ -46,11 +46,14 @@ rayon = "1"
# rendering / formats
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.48"
skrifa = "0.49"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
harfrust = "0.15"
unicode-bidi = "0.3.18"
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
+5
View File
@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
When reading a file leaves something out (records a DXF reader can't place, object records of a
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
`warnings`.
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
+3 -2
View File
@@ -1,6 +1,6 @@
use super::args::ConvertArgs;
use super::error::Result;
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
use super::{open, unicode};
use cadcraft_engine::Session;
@@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
let framing = a.framing.framing();
let mut s = Session::empty();
open(&mut s, &a.input)?;
if let Some(m) = skipped_message(s.doc()?, i) {
let d = s.doc()?;
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
io.errln(format_args!("{m}"));
}
if cadcraft_io::is_image(&a.output)
+23
View File
@@ -165,6 +165,12 @@ pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
/// #859), one warning line each; empty for a complete read.
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
@@ -175,3 +181,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
#[cfg(test)]
mod tests {
use super::read_note_messages;
#[test]
fn read_notes_become_one_warning_each() {
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
assert!(read_note_messages(&d, "a.dwg").is_empty());
d.read_notes =
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
assert_eq!(
read_note_messages(&d, "a.dwg"),
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
);
}
}
+69
View File
@@ -0,0 +1,69 @@
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
//! the loss. The exit code stays 0.
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
for i in 0..200 {
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
}
dxf += "0\nENDSEC\n0\nEOF\n";
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
.step_by(97)
.map(|at| {
let mut b = dwg.clone();
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
b
})
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
.expect("no damaged copy read partially");
(dwg, damaged)
}
#[test]
fn info_lists_and_convert_warns_about_read_notes() {
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (whole, damaged) = dwg_and_damaged_copy();
let path = dir.join("damaged.dwg");
std::fs::write(&path, &damaged).unwrap();
let path = path.to_str().unwrap();
let out = cli(&["info", path]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let notes = v["readNotes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{v}");
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
let copy = dir.join("copy.dxf");
let out = cli(&["convert", path, copy.to_str().unwrap()]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
assert_eq!(warnings.len(), 1, "{err}");
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
assert!(copy.exists());
// A complete read: no notes, and no warning.
let intact = dir.join("whole.dwg");
std::fs::write(&intact, &whole).unwrap();
let out = cli(&["info", intact.to_str().unwrap()]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["readNotes"], serde_json::json!([]));
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
+6
View File
@@ -24,6 +24,9 @@ log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
# Already in Cargo.lock through eframe; same version so nothing is built twice.
ron = "0.12"
[build-dependencies]
winresource = "0.1"
@@ -50,6 +53,9 @@ muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
winit = { version = "0.30", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
+59 -12
View File
@@ -127,7 +127,8 @@ impl eframe::App for App {
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
/// (`persist_window`); egui state is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
@@ -194,8 +195,20 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters
}
/// The main window: its size, title bar, placement and rendering needs.
fn window_options() -> eframe::NativeOptions {
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
/// state. An unreadable or malformed file counts as none, as it does for eframe.
fn saved_window(app_ron: &str) -> bool {
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
}
/// The window state from the last session is saved and will be restored (#2595).
fn restores_window() -> bool {
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
}
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
/// saved by the last session is restored instead of opening centred.
fn window_options(restoring: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
@@ -206,10 +219,13 @@ fn window_options() -> eframe::NativeOptions {
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
// restored window on the monitors that are connected now.
persist_window: true,
// The first launch opens centred on its screen, not cascaded from the top-left corner
// (#1129); later launches go back where the window was (eframe would centre over that).
// Wayland compositors place windows themselves.
centered: true,
centered: !restoring,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
@@ -221,6 +237,9 @@ fn window_options() -> eframe::NativeOptions {
const APP_ID: &str = "ai.storyteller.cadcraft";
/// The key eframe saves the main window state under in its storage.
const WINDOW_KEY: &str = "window";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
const STENCIL_BITS: u8 = 8;
@@ -281,7 +300,7 @@ fn main() -> eframe::Result {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
..window_options()
..window_options(restores_window())
};
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
@@ -310,6 +329,16 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
// close request for the window instead, so it asks first, as the close button does.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_close_requested_on_terminate(true);
}));
}
eframe::run_native(
"CADCraft",
options,
@@ -393,7 +422,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{filters_for_save, follow_desktop, window_options};
use super::{filters_for_save, follow_desktop, saved_window, window_options};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -411,12 +440,30 @@ mod tests {
}
}
/// #1129: the window opens centred, not cascaded from the top-left corner.
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
#[test]
fn the_window_opens_centred() {
let o = window_options();
fn the_first_launch_opens_centred() {
let o = window_options(false);
assert!(o.centered, "centred on launch");
assert!(!o.persist_window, "no saved position to restore instead");
assert!(o.persist_window, "the window state is saved for the next launch");
}
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
#[test]
fn a_saved_window_reopens_where_it_was() {
let o = window_options(true);
assert!(!o.centered, "centring would move a restored window");
assert!(o.persist_window);
}
/// Only a readable `app.ron` with a window entry counts as a saved window state.
#[test]
fn a_saved_window_is_read_from_app_ron() {
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
assert!(!saved_window(""));
assert!(!saved_window("{\"window\": "), "truncated");
assert!(!saved_window("not ron at all"));
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
+3
View File
@@ -99,3 +99,6 @@ sync_github_name = true
[people.GULERTOLGA]
sync_github_name = true
[people.starise]
real_name = "Andrea Brandi"
+3 -1
View File
@@ -194,7 +194,9 @@ pub struct Action {
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ActionKind {
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
/// file lists for each object, used only when the frame can't be tested (see `apply`).
Stretch {
frame: Vec<Vec2>,
#[serde(serialize_with = "hex_vertices")]
+46 -3
View File
@@ -403,7 +403,31 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
}
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame
/// is two corners or a short polygon; testing every point of every object against every edge of a
/// longer one would grow with their product, so the action's own vertex lists are used instead.
const MAX_FRAME_POINTS: usize = 1000;
/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the
/// action selects that lies outside its frame moves whole.
fn inside(frame: &[Vec2], points: impl Iterator<Item = Vec2>) -> Option<Vec<i32>> {
let idx: Vec<i32> = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect();
(!idx.is_empty()).then_some(idx)
}
/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than
/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps
/// the digits rounding there would drop.
fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 {
let d = b - a;
let w = p - a;
let l2 = d.len2();
let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(w - d * t).len()
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is
/// inside, for a polygon as for two corners: within a billionth of the edge's length of it.
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
match frame {
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
@@ -412,6 +436,9 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
let mut j = f.len() - 1;
for i in 0..f.len() {
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) {
return true;
}
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
inside = !inside;
}
@@ -464,7 +491,10 @@ fn apply(
_ => 0.0,
})
};
let mult = if mult == 0.0 { 1.0 } else { mult };
// A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the
// parameter's change and leaves its angle offset out (measured on a stretch action written
// with multiplier 0, angle offset 1 and group 280 = 1).
let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) };
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
@@ -473,6 +503,8 @@ fn apply(
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
// Whether the frame (two corners or a polygon) is tested point by point.
let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len());
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection {
if !handled.insert(*h) {
@@ -502,7 +534,18 @@ fn apply(
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
// The points of a line or polyline that move are those inside the frame where the
// dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It
// doesn't go by the vertex lists the action stores, which a block from another program
// can get wrong. A polyline's vertices are in its own plane: in the block's when its
// extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however
// small, moves them by as much as the elevation times the tilt, so a tilted one keeps the
// stored list.
let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) {
Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()),
Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)),
_ => vtx_by_handle.get(h).map(|i| (*i).clone()),
};
moves.entry(c).or_default().push((idx, v));
}
}
+11 -5
View File
@@ -48,7 +48,7 @@ mod wipeout;
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, Polyline3d, RayLine, Solid};
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
pub use fill::{Gradient, Hatch, HatchLoop};
pub use layout::{Image, Table, TableCell, Viewport};
@@ -86,6 +86,7 @@ pub enum EntityKind {
Solid(Solid),
Trace(Solid),
Face3d(Face3d),
PolyfaceMesh(PolyfaceMesh),
Region(Region),
Viewport(Viewport),
Image(Image),
@@ -140,6 +141,7 @@ impl EntityKind {
EntityKind::Solid(_) => "2D Solid",
EntityKind::Trace(_) => "Trace",
EntityKind::Face3d(_) => "3D Face",
EntityKind::PolyfaceMesh(_) => "Polyface Mesh",
EntityKind::Region(_) => "Region",
EntityKind::Viewport(_) => "Viewport",
EntityKind::Image(_) => "Raster Image",
@@ -175,6 +177,7 @@ impl EntityKind {
EntityKind::Solid(_) => "SOLID",
EntityKind::Trace(_) => "TRACE",
EntityKind::Face3d(_) => "3DFACE",
EntityKind::PolyfaceMesh(_) => "POLYLINE",
EntityKind::Region(_) => "REGION",
EntityKind::Viewport(_) => "VIEWPORT",
EntityKind::Image(_) => "IMAGE",
@@ -218,10 +221,10 @@ impl EntityKind {
let c = &s.corners;
vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
}
EntityKind::Face3d(f) => {
let c = &f.corners;
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(),
// Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn).
EntityKind::PolyfaceMesh(m) => {
m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect()
}
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
EntityKind::Hatch(h) => {
@@ -424,6 +427,7 @@ impl EntityKind {
}
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3),
EntityKind::Region(r) => {
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
v.p = m.apply(v.p);
@@ -542,6 +546,7 @@ impl EntityKind {
EntityKind::Hatch(h) => scale(&mut h.elevation),
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
EntityKind::Viewport(v) => z(&mut v.center),
EntityKind::Image(i) => z(&mut i.insert),
EntityKind::Table(t) => z(&mut t.insert),
@@ -611,6 +616,7 @@ impl EntityKind {
}
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(),
EntityKind::Region(r) => vec![r.bounds().center()],
EntityKind::Viewport(v) => {
let c = v.center.xy();
+46
View File
@@ -69,6 +69,52 @@ pub struct Face3d {
pub hidden_edges: u8,
}
impl Face3d {
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
/// without the zero-length side of a triangle.
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
let c = self.corners;
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
}
}
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
/// that starts at that vertex).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PolyfaceMesh {
pub vertices: Vec<Vec3>,
pub faces: Vec<[i32; 4]>,
}
impl PolyfaceMesh {
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
/// index outside the vertex list are skipped.
pub fn faces3d(&self) -> Vec<Face3d> {
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
}
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
/// or one outside the vertex list.
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
let mut hidden = 0u8;
for &ix in f.iter().take_while(|&&ix| ix != 0) {
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
let p = *at.and_then(|n| self.vertices.get(n))?;
if ix < 0 {
hidden |= 1 << corners.len();
}
corners.push(p);
}
match corners.as_slice() {
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
+5
View File
@@ -52,6 +52,11 @@ pub struct Dimension {
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
/// Zero once the geometry is regenerated from the definition points.
#[serde(default)]
pub insertion_point: Vec3,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
+4
View File
@@ -212,6 +212,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
inner
}
};
// The block draws translated by the insertion point (group 12), so its bounds
// move with it (#2634); the cache above keeps the block's own bounds.
let off = dm.insertion_point.xy();
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
b = b.union(&inner);
}
b
+165 -3
View File
@@ -6,7 +6,7 @@
//! named object dictionary, a table record, a block, an entity); references to objects that are
//! gone are dropped. Commands don't edit it.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use crate::{Handle, RawTag};
@@ -89,8 +89,170 @@ impl Foreign {
&& self.data_store.is_empty()
}
/// The entities the data store has records for (their 320 handles).
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
let mut out = std::collections::BTreeSet::new();
for rec in data_store_records(&self.data_store) {
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
}
}
out
}
}
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
}
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
let stored = d.foreign.data_store_entities();
let mut out = BTreeMap::new();
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let crate::EntityKind::Unknown(u) = &e.kind
&& sat_text_acis(u, e.handle, &stored)
{
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
*n = n.saturating_add(1);
}
}
out
}
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
pub fn xref_dependent(name: &str) -> bool {
name.contains('|')
}
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
/// (the section's own groups before the first record are not part of one).
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
let mut out = Vec::new();
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
while start < tags.len() {
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
out.push(tags.get(start..end).unwrap_or(&[]));
start = end;
}
out
}
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
/// groups (before the extended data) are the entity's.
pub fn has_sat_text(tags: &[RawTag]) -> bool {
let own = tags.iter().take_while(|r| r.code < 1000);
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
let mut in_modeler = !marked;
for r in own {
if r.code == 100 {
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
} else if in_modeler && (r.code == 1 || r.code == 3) {
return true;
}
}
false
}
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
/// or dropped by this handle alone, in the writer and wherever the store is read.
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
record.iter().find(|t| t.code == 320)
}
impl Foreign {
/// The kept objects by handle (the first of any that share one).
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
let mut m = BTreeMap::new();
for o in &self.objects {
m.entry(o.handle).or_insert(o);
}
m
}
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
/// and child pointers, groups 350 and 360), `root` first.
pub fn tree(&self, root: Handle) -> Vec<Handle> {
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
// their product.
let by_handle = self.object_index();
let mut out = vec![root];
let mut seen = BTreeSet::from([root]);
let mut i = 0;
while let Some(&h) = out.get(i) {
i += 1;
let Some(o) = by_handle.get(&h) else { continue };
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
out.push(c);
}
}
}
out
}
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
/// root's owner. References to objects outside the tree become null. Fields keep a field
/// list: this one gets a copy of the source's if it has none.
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
if fresh.len() < tree.len() + 2 {
return;
}
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
let by_handle = src.object_index();
let mut kinds = Vec::new();
for (n, h) in tree.iter().enumerate() {
let Some(o) = by_handle.get(h) else { continue };
let mut c = (*o).clone();
let Some(&new) = fresh.get(n) else { continue };
c.handle = new;
for t in &mut c.tags {
if t.code == 5 {
t.value = new.hex();
} else if matches!(t.code, 320..=369 | 390..=399) {
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
}
}
kinds.push(c.kind.clone());
self.objects.push(c);
}
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
let mut c = list.clone();
c.handle = h;
for t in &mut c.tags {
match t.code {
5 => t.value = h.hex(),
330 => t.value = "0".to_string(),
_ => {}
}
}
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
t.value = nod.hex();
}
self.targets.insert(nod, ("NOD".to_string(), String::new()));
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
kinds.push(c.kind.clone());
self.objects.push(c);
}
for k in kinds {
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
if !self.classes.iter().any(named) {
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
}
}
}
}
+5
View File
@@ -132,6 +132,10 @@ pub struct Drawing {
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
/// drawing reopens with it. Never set on an open drawing.
pub portable_fonts: bool,
}
impl Default for Drawing {
@@ -191,6 +195,7 @@ impl Drawing {
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
}
}
+35
View File
@@ -189,6 +189,19 @@ impl EntityStore {
None => false,
}
}
/// The objects directly under and above `h` in the draw order (None at the bottom or the
/// top), when `h` is here.
pub fn neighbours(&self, h: Handle) -> Option<(Option<Handle>, Option<Handle>)> {
let pos = self.chunk_pos(self.chunk_id(h)?)?;
let items = &self.chunks.get(pos)?.items;
let i = items.iter().position(|e| e.handle == h)?;
let below = match i.checked_sub(1) {
Some(j) => items.get(j),
None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()),
};
let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first()));
Some((below.map(|e| e.handle), above.map(|e| e.handle)))
}
/// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing
/// moves) when either is absent or they are the same object.
pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool {
@@ -364,4 +377,26 @@ mod tests {
assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));
}
}
#[test]
fn neighbours_across_chunks() {
let mut s = EntityStore::new();
for h in 1..=3000 {
s.push(ent(h));
}
assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501)))));
// The first and last objects of a chunk look into the chunks next to it.
let k = CHUNK_MAX as u64;
assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1)))));
assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None)));
assert_eq!(s.neighbours(Handle(9999)), None);
assert!(s.move_next_to(Handle(3000), Handle(1), false));
assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1)))));
let mut one = EntityStore::new();
one.push(ent(7));
assert_eq!(one.neighbours(Handle(7)), Some((None, None)));
}
}
+10 -1
View File
@@ -799,6 +799,10 @@ pub struct PageSetup {
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
#[serde(skip_serializing_if = "Option::is_none")]
pub image_origin: Option<Vec2>,
}
impl Default for PageSetup {
@@ -820,6 +824,7 @@ impl Default for PageSetup {
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
image_origin: None,
}
}
}
@@ -835,11 +840,15 @@ pub struct Layout {
/// Saved paper-space view (centre, height).
#[serde(default)]
pub view: Option<(Vec2, f64)>,
/// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had
/// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<(Vec2, Vec2)>,
}
impl Layout {
pub fn new(name: &str, tab_order: u32) -> Self {
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }
}
}
+39
View File
@@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
}
/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and
/// the move in the insertion point, and the block draws translated by it. Extents follow the
/// drawing, not the stale block contents (#2634).
#[test]
fn dimension_insertion_point_moves_its_block_in_extents() {
let moved = |ins: Vec3| {
let mut d = Drawing::new_metric();
let mut blk = Block::new("*D1");
blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
let dm = Dimension {
kind: DimKind::Aligned,
defpt: Vec3::new(5.0, 1.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
insertion_point: ins,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
d
};
// Without the offset the block's far-away contents reach the extents.
assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0);
// With it, the block draws at the definition points and the extents stay at the drawing.
assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.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.
@@ -637,6 +675,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
overrides: Default::default(),
assoc: vec![],
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
)
.unwrap();
@@ -0,0 +1,19 @@
//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to
//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn.
use cadcraft_doc::{EntityKind, Face3d, Prim};
use cadcraft_geom::Vec3;
fn sides(f: Face3d) -> usize {
EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count()
}
#[test]
fn hidden_edges_are_not_drawn() {
let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)];
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0);
let triangle = [quad[0], quad[1], quad[2], quad[2]];
assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3);
}
+1
View File
@@ -1,2 +1,3 @@
mod face3d_hidden_edges;
mod lin_pat_parse;
mod sub_segment_alias;
+1 -1
View File
@@ -13,4 +13,4 @@ workspace = true
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.5"
acadrust = "0.6.6"
+208 -3
View File
@@ -110,8 +110,11 @@ mod native {
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
}
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
let written = std::panic::catch_unwind(AssertUnwindSafe(|| {
acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?;
mtext_contents(&doc, &mut out)
}))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
if out.exceeded {
return Err(format!(
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
@@ -124,6 +127,94 @@ mod native {
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already
/// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents
/// from the native values instead, using its lossless string writer. All other groups,
/// including embedded-object data, remain byte-for-byte as the DXF writer emitted them.
fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> {
use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter};
let contents: std::collections::HashMap<_, _> = doc
.entities()
.filter_map(|e| match e {
acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())),
_ => None,
})
.collect();
if contents.is_empty() {
return Ok(());
}
let dxf = std::mem::take(&mut out.buf);
let mut lines = dxf.split_inclusive(|b| *b == b'\n');
let (mut mtext, mut text, mut written) = (false, None, false);
while let Some(code) = lines.next() {
let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?;
match code.trim_ascii() {
b"0" => {
mtext = value.trim_ascii() == b"MTEXT";
text = None;
written = false;
}
b"5" if mtext => {
text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h));
}
b"101" => mtext = false,
b"1" | b"3" if mtext && text.is_some() => {
if let Some(&text) = text
&& !written
{
let mut writer = DxfTextWriter::new(&mut *out);
if doc.version < acadrust::DxfVersion::AC1021
&& let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page)
{
writer.set_encoding(encoding);
}
// Each character can expand to two caret-escape characters. Keep
// those pairs together and each group within 250 characters.
let mut rest = text;
while let Some((end, _)) = rest.char_indices().nth(125) {
let (chunk, tail) = rest.split_at(end);
writer.write_xrecord_string(3, chunk)?;
rest = tail;
}
writer.write_xrecord_string(1, rest)?;
written = true;
}
continue;
}
_ => {}
}
out.write_all(code)?;
out.write_all(value)?;
}
Ok(())
}
#[cfg(test)]
#[test]
fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> {
for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] {
let mut doc = acadrust::CadDocument::new();
doc.version = version;
doc.header.code_page = "ANSI_1252".into();
let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO);
t.common.handle = acadrust::Handle::new(0x10);
doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?;
// Only the MTEXT's own 1/3 groups change. The embedded object's groups and the
// following TEXT record must survive byte-for-byte, including their spaces.
let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n";
let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n";
let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat();
let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false };
mtext_contents(&doc, &mut out)?;
let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() };
assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat());
let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false };
assert!(mtext_contents(&doc, &mut limited).is_err());
assert!(limited.exceeded);
}
Ok(())
}
/// Most record types named in the skipped-records note.
const NOTE_TYPES: usize = 6;
@@ -241,12 +332,51 @@ mod native {
/// can cover rows × cols cells, so the total is bounded too.
const MAX_MERGE_CELLS: usize = 1 << 24;
/// A table cell's own text height only when the file says it overrides the table style's.
/// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a
/// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an
/// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the
/// content's format override bit 128; without either the height is cleared (0 is no
/// override), so the table style's height applies.
fn table_cell_heights(doc: &mut acadrust::CadDocument) {
use acadrust::entities::table::CellStylePropertyFlags;
const CONTENT_TEXT_HEIGHT: i32 = 128;
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
// The first content's own height (group 144) when its format override sets bit 128.
// CADCraft keeps one height for the cell's text, so a later content's override
// must not stand in for the first content's.
let content_height = cell
.contents
.first()
.filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0)
.map(|c| c.text_height)
.filter(|h| h.is_finite() && *h > 0.0);
if let Some(h) = content_height {
// CADCraft reads only the style's height (group 140), so carry it there.
cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h;
continue;
}
// A style overrides through its property flags or, in newer files, its override
// flags; anything else is the writer's default and is cleared.
if let Some(style) = cell.style.as_mut()
&& !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT)
&& style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0
{
style.text_height = 0.0;
}
}
}
}
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
/// components were dropped. The document comes from a third-party reader and is hostile.
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
super::fail_in_test("post_process");
current_style_names(doc);
table_merges(doc);
table_cell_heights(doc);
table_style_rows(doc, false);
keep_sort_handles(doc);
drop_orphaned_dgn_components(doc)
@@ -344,7 +474,7 @@ mod native {
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// acadrust 0.6.5 reads and writes itself.
/// acadrust 0.6.6 reads and writes itself.
fn dimension_flags(doc: &mut acadrust::CadDocument) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
@@ -730,6 +860,34 @@ mod native {
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
}
/// A height marked only in the style's override flags (as newer files store it) is kept,
/// and the same height with no flag at all is cleared (review on #2588). Tested on the
/// read document because acadrust's DWG writer doesn't write the override flags.
#[test]
fn a_style_override_flag_keeps_the_cell_height() {
use acadrust::entities::table::{CellStyle, CellStylePropertyFlags};
let height = |override_flags: i32| {
let mut doc = doc_with_table(1, 1, 0);
for e in doc.entities_mut() {
if let EntityType::Table(t) = e {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
let mut s = CellStyle::new();
s.text_height = 0.5;
s.override_flags = override_flags;
cell.style = Some(s);
}
}
}
table_cell_heights(&mut doc);
doc.entities().find_map(|e| match e {
EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height),
_ => None,
})
};
assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5));
assert_eq!(height(0), Some(0.0));
}
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
#[test]
fn a_panic_in_post_processing_is_an_error() {
@@ -1076,4 +1234,51 @@ mod tests {
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
/// from DXF is written whole, with the byte AutoCAD writes (0).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
use acadrust::objects::*;
let scale_action = |doc: &acadrust::CadDocument| {
doc.objects.values().find_map(|o| match o {
ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()),
_ => None,
})
};
let mut doc = acadrust::CadDocument::new();
let h = doc.allocate_handle();
let conn = |code, name: &str| BlockConnection { code, name: name.into() };
let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction");
action.handle = h;
action.owner = doc.header.named_objects_dict_handle;
let mut a = BlockBasePointAction::default();
a.action.action.element.name = "Scale".into();
a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")];
a.action.dependent = true;
a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")];
a.dwg_tail_byte = 7;
action.data = DynamicBlockData::ScaleAction(a.clone());
doc.objects.insert(h, ObjectType::DynamicBlock(action));
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written");
let mut dxf = Vec::new();
acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap();
for version in ["AC1018", "AC1032"] {
let dwg = dxf_to_dwg_as(&dxf, version).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written"));
assert_eq!(read.connections, a.connections, "{version}");
assert_eq!(read.action.connections, a.action.connections, "{version}");
assert_eq!(read.dwg_tail_byte, 0, "{version}");
}
}
}
+3 -1
View File
@@ -268,11 +268,12 @@ impl DocState {
}
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
/// undo history, nothing selected, model space.
/// undo history, nothing selected, nothing for OOPS, model space.
fn enter_block_editor(&mut self, be: BlockEdit) {
self.undo.clear();
self.redo.clear();
self.undo_ctl = Default::default();
self.erased = Default::default();
self.selection.clear();
self.previous_selection.clear();
self.space = cadcraft_doc::Space::Model;
@@ -338,6 +339,7 @@ impl DocState {
self.undo = host.undo;
self.redo = host.redo;
self.undo_ctl = host.undo_ctl;
self.erased = host.erased;
// The drawing comes back with nothing selected; Previous is what was selected before.
self.selection.clear();
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
+1
View File
@@ -25,6 +25,7 @@ mod lisp;
mod mleader_content;
mod mleader_edit;
mod modify;
pub(crate) use modify::Erased;
mod modify2;
mod mtext_opts;
mod overkill;
+5 -1
View File
@@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions
mod angular;
mod common;
mod edit;
mod leader;
mod linear;
mod mleader;
mod radial;
@@ -88,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["mld"])
.params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
CommandSpec::new("leader", "Leader", run_leader)
.alias(&["lead"])
.params("{points: [[x,y]...], text?}")
.interactive(|_| Ok(Box::new(leader::LeaderM::default()))),
CommandSpec::new("dimstyle.update", "Update", run_update)
.menu(&["Dimension", "Update"])
.params("{handles?}")
+1
View File
@@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
})
}
+330
View File
@@ -0,0 +1,330 @@
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the
//! points, Format and Undo, then annotation text, no annotation, or a block.
//!
//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is
//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the
//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to.
//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the
//! drawing model has no link from a leader to its annotation.
use super::common::style;
use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3};
use crate::cmd::curves::KW;
/// Where LEADER is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Points,
Format,
FirstLine,
NextLine,
Option,
/// Copy: the text or multiline text to copy.
Copy,
BlockName,
BlockPoint,
BlockX,
BlockY,
BlockRotation,
}
#[derive(Default)]
pub(super) struct LeaderM {
ask: Ask,
pts: Vec<Vec2>,
spline: bool,
no_arrow: bool,
lines: Vec<String>,
block: String,
block_at: Vec2,
block_scale: Vec2,
}
/// What follows the leader.
enum Annotation {
Text(Vec<String>),
/// A copy of a text or multiline text: its contents, height, width and style.
Copy(MText),
None,
Block {
name: String,
at: Vec2,
scale: Vec2,
rotation: f64,
},
}
impl Interactive for LeaderM {
fn name(&self) -> &'static str {
"LEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
let last = self.pts.last().copied();
match self.ask {
Ask::Points => match self.pts.len() {
0 => Prompt::new("Specify leader start point", Accept::POINT),
1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last),
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last),
},
Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"),
// "or <options>": the default is the options, not a keyword list.
Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"),
Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT),
Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT),
Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"),
Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"),
Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Points => match i {
Input::Point(p) => self.pts.push(p),
// Enter after one point: no leader.
Input::Enter if self.pts.len() < 2 => return Ok(Step::Done),
Input::Enter => self.ask = Ask::FirstLine,
Input::Keyword(k) => match k.as_str() {
"Annotation" => self.ask = Ask::FirstLine,
"Format" => self.ask = Ask::Format,
_ => {
self.pts.pop();
}
},
_ => {}
},
Ask::Format => {
// Enter, or Exit typed, changes nothing; either way back to the points.
if let Input::Keyword(k) = &i {
match k.as_str() {
"Spline" => self.spline = true,
"STraight" => self.spline = false,
"Arrow" => self.no_arrow = false,
_ => self.no_arrow = true,
}
}
self.ask = Ask::Points;
}
Ask::FirstLine | Ask::NextLine => match i {
Input::Text(t) if !t.is_empty() => {
self.lines.push(t);
self.ask = Ask::NextLine;
}
_ if self.ask == Ask::NextLine => {
let lines = std::mem::take(&mut self.lines);
return self.finish(s, Annotation::Text(lines));
}
_ => self.ask = Ask::Option,
},
Ask::Option => match &i {
Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None),
Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName,
Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy,
Input::Text(_) => s.echo("Invalid option keyword."),
// Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the
// command line, as in accoreconsole, they come back to the first line.
_ => self.ask = Ask::FirstLine,
},
Ask::Copy => {
let picked = match &i {
Input::Pick(hs) => hs.first().copied(),
Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()),
_ => None,
};
let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind {
EntityKind::Text(t) => Some(MText {
insert: t.insert,
height: t.height,
width: 0.0,
attach: 4,
rotation: 0.0,
style: t.style.clone(),
contents: t.value.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
EntityKind::MText(m) => Some(m.clone()),
_ => None,
});
match text {
Some(m) => return self.finish(s, Annotation::Copy(m)),
None if picked.is_some() => s.echo("The object is not text."),
None => {}
}
}
Ask::BlockName => {
let name = match i {
Input::Text(t) => t.trim().to_string(),
Input::Keyword(k) => k,
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
} else if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
} else {
self.block = name;
self.ask = Ask::BlockPoint;
}
}
Ask::BlockPoint => match i {
Input::Point(p) => {
self.block_at = p;
self.ask = Ask::BlockX;
}
Input::Keyword(_) => s.echo("This option is not available here yet."),
_ => {}
},
Ask::BlockX => match scale(&i) {
Some(x) => {
self.block_scale = Vec2::new(x, x);
self.ask = Ask::BlockY;
}
None => s.echo("Requires a nonzero number."),
},
Ask::BlockY => {
match scale(&i) {
Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y,
Some(_) => {}
None => {
s.echo("Requires a nonzero number.");
return Ok(Step::Continue);
}
}
self.ask = Ask::BlockRotation;
}
Ask::BlockRotation => {
let rotation = match &i {
Input::Enter => 0.0,
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires a valid angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation };
return self.finish(s, a);
}
}
Ok(Step::Continue)
}
}
/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number.
fn scale(i: &Input) -> Option<f64> {
match i {
Input::Enter => Some(1.0),
Input::Text(t) => t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v != 0.0),
_ => None,
}
}
impl LeaderM {
fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result<Step> {
let pts = std::mem::take(&mut self.pts);
add_leader(s, &pts, self.spline, !self.no_arrow, ann)?;
Ok(Step::Done)
}
}
/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE.
struct Sizes {
arrow: f64,
gap: f64,
text: f64,
above: bool,
text_style: String,
}
fn sizes(s: &Session) -> Sizes {
// The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets).
let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default();
let k = if st.scale > 0.0 { st.scale } else { 1.0 };
let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() };
Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style }
}
/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at
/// `\n`) placed as the command line places it. The handles of the leader and its text.
pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result<Vec<Handle>> {
let ann = match text {
Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()),
_ => Annotation::None,
};
add_leader(s, pts, false, true, ann)
}
/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles.
fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result<Vec<Handle>> {
let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) };
let z = sizes(s);
// The side the leader runs to: right unless its last segment runs left.
let dir = if end.x < before.x { -1.0 } else { 1.0 };
let mut vertices = pts.to_vec();
if let Annotation::Text(_) | Annotation::Copy(_) = ann {
let d = end - before;
// A copied text is centred on its line, and with DIMTAD on that line is above the leader:
// the leader ends half the text height lower.
if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) {
last.y -= z.text / 2.0;
}
let end = vertices.last().copied().unwrap_or(end);
// More than 15° off horizontal: a hook line DIMASZ long into the end.
if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() {
let at = vertices.len() - 1;
vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y));
}
}
let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: vertices.iter().map(|p| v3(*p)).collect(),
arrow,
spline,
style: style(s),
}))?;
let annotation = match ann {
Annotation::Text(lines) => {
let (attach, lift) = match (z.above, dir > 0.0) {
(true, true) => (7, z.gap),
(true, false) => (9, z.gap),
(false, true) => (4, 0.0),
(false, false) => (6, 0.0),
};
Some(s.add_entity(EntityKind::MText(MText {
insert: v3(end + Vec2::new(dir * z.gap, lift)),
height: z.text,
width: 0.0,
attach,
rotation: 0.0,
style: z.text_style,
contents: lines.join("\\P"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?)
}
Annotation::Copy(m) => {
let lift = if z.above { z.gap } else { 0.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?)
}
Annotation::Block { name, at, scale, rotation } => {
match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) {
Ok(h) => Some(h),
Err(e) => {
s.doc_mut()?.remove_entity(leader);
return Err(e);
}
}
}
Annotation::None => None,
};
Ok(std::iter::once(leader).chain(annotation).collect())
}
+2 -28
View File
@@ -1,4 +1,3 @@
use super::common::style;
use super::{
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
bad, json, points_param, str_param, v3,
@@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Err(bad("leader", "need 2+ points"));
}
let st = style(s);
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: pts.iter().map(|q| v3(*q)).collect(),
arrow: true,
spline: false,
style: st,
}))?;
let mut out = vec![h.hex()];
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
out.push(
s.add_entity(EntityKind::MText(MText {
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
height: th,
width: 0.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
}
Ok(json!({ "handles": out }))
let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?;
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// The MLEADER Options prompt being answered.
+5 -1
View File
@@ -374,10 +374,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
let row_spacing = f64_or(p, "rowSpacing", 1.0);
let fill = f64_or(p, "angle", 360.0);
if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) {
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
}
let kind = ArrayKind::Polar(PolarArray {
center: c - base,
items: count32(n),
fill: f64_or(p, "angle", 360.0),
fill: fill.clamp(-360.0, 360.0),
rotate: bool_or(p, "rotate", true),
rows: count32(rows),
row_spacing,
+16 -107
View File
@@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge;
use std::sync::Arc;
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::helpers::v3;
@@ -17,12 +17,14 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po
use super::has_doc;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attdef;
mod attedit;
mod burst;
mod description;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
mod wblock;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -45,10 +47,11 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Draw", "Block", "Define Attributes..."])
.alias(&["att", "-attdef"])
.params("{tag, prompt?, default?, at, height?, invisible?}")
.interactive(|_| Ok(Box::new(AttdefM::default()))),
.interactive(|_| Ok(Box::new(attdef::AttdefM::default()))),
attedit::attedit_spec(),
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
wblock::wblock_spec(),
wblock::dash_wblock_spec(),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
@@ -184,9 +187,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
b.entities.push(e);
}
doc.blocks.insert(name.to_string(), Arc::new(b));
if keep != Keep::Retain {
for h in hs {
doc.remove_entity(*h);
match keep {
Keep::Retain => {}
Keep::Convert => {
for h in hs {
doc.remove_entity(*h);
}
}
// As after ERASE, OOPS brings them back.
Keep::Delete => {
super::modify::erase_kept(s, hs)?;
}
}
s.set_selection(Vec::new());
@@ -361,61 +371,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let src = s.doc()?;
let mut d = cadcraft_doc::Drawing::new_imperial();
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
// objects and the block's base point for a block (`base` is then in block coordinates).
let base = point_param(p, "base");
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
b.entities
.iter()
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
} else {
targets(s, p)?
.iter()
.filter_map(|h| src.entity(*h))
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
};
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
}
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("base", p, "at")?;
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
@@ -983,52 +938,6 @@ impl Interactive for InsertM {
}
}
#[derive(Default)]
struct AttdefM {
tag: Option<String>,
prompt: Option<String>,
default: Option<String>,
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
_ => Prompt::new("Specify start point of text", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match &i {
Input::Text(t) => Some(t.clone()),
Input::Enter => Some(String::new()),
_ => None,
};
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => {
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
self.tag = Some(t.to_ascii_uppercase());
}
}
(Some(_), None, _) => self.prompt = text,
(Some(_), Some(_), None) => self.default = text,
(Some(tag), Some(pr), Some(def)) => {
if let Input::Point(p) = i {
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
let k = attdef_kind(s, tag, pr, def, p, h, false);
s.add_entity(k)?;
return Ok(Step::Done);
}
}
}
Ok(Step::Continue)
}
}
mod attribs;
mod battman;
mod blockreplace;
+293
View File
@@ -0,0 +1,293 @@
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes
//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead),
//! then the text's point, justification, style, height and rotation.
use cadcraft_doc::{AttribModes, HAlign, VAlign};
use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3};
use crate::cmd::curves::KW;
use crate::cmd::draw::justify;
/// AFLAGS bits, as AutoCAD numbers the modes.
const INVISIBLE: i64 = 1;
const CONSTANT: i64 = 2;
const VERIFY: i64 = 4;
const PRESET: i64 = 8;
const LOCK: i64 = 16;
const MULTILINE: i64 = 32;
const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"];
const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
/// Where -ATTDEF is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Modes,
Tag,
Prompt,
Value,
Point,
Justify,
Style,
Height,
Rotation,
}
pub(super) struct AttdefM {
ask: Ask,
flags: i64,
tag: String,
prompt: String,
value: String,
justify: (HAlign, VAlign),
style: String,
at: Vec2,
height: f64,
}
impl Default for AttdefM {
fn default() -> Self {
AttdefM {
ask: Ask::Modes,
flags: LOCK,
tag: String::new(),
prompt: String::new(),
value: String::new(),
justify: (HAlign::Left, VAlign::Baseline),
style: "Standard".into(),
at: Vec2::ZERO,
height: 0.0,
}
}
}
impl AttdefM {
fn echo_modes(&self, s: &mut Session) {
let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" };
s.echo("Current attribute modes:");
s.echo(format!(
"Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}",
yn(INVISIBLE),
yn(CONSTANT),
yn(VERIFY),
yn(PRESET),
yn(LOCK),
yn(MULTILINE)
));
}
fn echo_style(&self, s: &mut Session) -> Result<()> {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify)));
Ok(())
}
fn point_prompt(&self) -> &'static str {
match self.justify {
(HAlign::Center, VAlign::Baseline) => "Specify center point of text",
(HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline",
(HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text",
(HAlign::Left, VAlign::Top) => "Specify top-left point of text",
(HAlign::Center, VAlign::Top) => "Specify top-center point of text",
(HAlign::Right, VAlign::Top) => "Specify top-right point of text",
(HAlign::Left, VAlign::Middle) => "Specify middle-left point of text",
(HAlign::Right, VAlign::Middle) => "Specify middle-right point of text",
(HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text",
(HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text",
(HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text",
_ => "Specify start point of text",
}
}
fn finish(&mut self, s: &mut Session, rotation: f64) -> Result<Step> {
let (h, v) = self.justify;
let left = h == HAlign::Left && v == VAlign::Baseline;
s.add_entity(EntityKind::AttDef(Attrib {
tag: self.tag.to_uppercase(),
text: Text {
insert: v3(self.at),
align_pt: if left { None } else { Some(v3(self.at)) },
height: self.height,
value: std::mem::take(&mut self.value),
rotation,
width_factor: 1.0,
oblique: 0.0,
style: self.style.clone(),
halign: h,
valign: v,
},
invisible: self.flags & INVISIBLE != 0,
constant: self.flags & CONSTANT != 0,
prompt: std::mem::take(&mut self.prompt),
props: Default::default(),
multiline: None,
modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 },
}))?;
Ok(Step::Done)
}
}
/// The justification's name in "Current text style: … Justify: …".
fn justify_name(j: (HAlign, VAlign)) -> &'static str {
JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left")
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let d = s.doc()?;
self.flags = d.header.i64("AFLAGS", LOCK);
self.style = d.header.str("TEXTSTYLE", "Standard");
self.echo_modes(s);
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.ask {
Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"),
Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT),
Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT),
Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT),
Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT),
// Once a justification is chosen AutoCAD asks for its point alone.
Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => {
Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"])
}
Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT),
Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY),
Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()),
Ask::Height => {
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at)
}
Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Modes => match &i {
Input::Keyword(k) => {
let bit = match k.as_str() {
"Invisible" => INVISIBLE,
"Constant" => CONSTANT,
"Verify" => VERIFY,
"Preset" => PRESET,
"Lock position" => LOCK,
_ => {
s.echo(format!("{k} attributes are not available yet."));
return Ok(Step::Continue);
}
};
self.flags ^= bit;
s.doc_mut()?.header.set_i64("AFLAGS", self.flags);
self.echo_modes(s);
}
Input::Enter => self.ask = Ask::Tag,
Input::Text(_) => s.echo("Invalid option keyword."),
_ => {}
},
Ask::Tag => match &i {
// AutoCAD takes spaces in a tag here, and upper-cases it.
Input::Text(t) if !t.trim().is_empty() => {
self.tag = t.trim().to_string();
self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt };
}
Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"),
_ => {}
},
Ask::Prompt | Ask::Value => {
let t = match i {
Input::Text(t) => t,
Input::Enter => String::new(),
_ => return Ok(Step::Continue),
};
if self.ask == Ask::Prompt {
self.prompt = t;
self.ask = Ask::Value;
} else {
self.value = t;
self.echo_style(s)?;
self.ask = Ask::Point;
}
}
Ask::Point => match i {
Input::Point(p) => {
self.at = p;
self.ask = Ask::Height;
}
Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify,
Input::Keyword(_) => self.ask = Ask::Style,
_ => {}
},
Ask::Justify => {
// A justification goes on to its point; anything else is asked again.
if let Input::Keyword(k) | Input::Text(k) = &i {
match justify(k) {
// Aligned and fitted text take two points: not here yet.
Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."),
Some(j) => {
self.justify = j;
self.ask = Ask::Point;
}
None => s.echo("Invalid option keyword."),
}
}
}
Ask::Style => {
let name = match i {
Input::Enter => self.style.clone(),
Input::Text(t) | Input::Keyword(t) => t.trim().to_string(),
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect();
s.echo(format!("Text styles: {}", names.join(", ")));
return Ok(Step::Continue);
}
match s.doc()?.text_style(&name).map(|st| st.name.clone()) {
Some(n) => {
self.style = n;
self.echo_style(s)?;
self.ask = Ask::Point;
}
None => s.echo(format!("Cannot find text style \"{name}\".")),
}
}
Ask::Height => {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
let h = match &i {
Input::Enter => th,
Input::Point(p) => self.at.dist(*p),
// A distance, as TEXT takes its height (1'6" in feet and inches, too).
Input::Text(t) => number(t).unwrap_or(0.0),
_ => return Ok(Step::Continue),
};
if !(h.is_finite() && h > 0.0) {
s.echo("Requires a positive height.");
return Ok(Step::Continue);
}
self.height = h;
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
self.ask = Ask::Rotation;
}
Ask::Rotation => {
let r = match &i {
Input::Enter => 0.0,
Input::Point(p) => self.at.angle_to(*p),
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires an angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
return self.finish(s, r);
}
}
Ok(Step::Continue)
}
}
+15 -4
View File
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
}
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
fn stem(path: &str) -> String {
pub(super) fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
@@ -109,7 +109,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
@@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
let mut ents = Vec::new();
for e in src.model.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
d.ensure_layer(&e.common.layer);
ents.push(e);
}
@@ -183,9 +183,20 @@ fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, S
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
let old = e.handle;
e.handle = d.new_handle();
// An attribute definition keeps its field: its extension dictionary and the objects in it.
if matches!(e.kind, EntityKind::AttDef(_))
&& let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict)
{
let tree = src.foreign.tree(root);
let fresh: Vec<Handle> = (0..tree.len() + 2).map(|_| d.new_handle()).collect();
let foreign = Arc::make_mut(&mut d.foreign);
foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle);
foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied();
}
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
+51 -1
View File
@@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.collect()
}
Kind::Linetypes => {
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
// Objects and layers use linetypes, and so do dimension styles and a dimension's own
// overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements.
let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]);
let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| {
["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string))
});
let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone()));
let used = lower(
&mut entities(d)
.map(|e| e.common.linetype.clone())
.chain(d.layers.iter().map(|l| l.linetype.clone()))
.chain(styles)
.chain(overrides)
.chain(mlines),
);
let cur = d.header.str("CELTYPE", "ByLayer");
d.linetypes
.iter()
@@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.style.clone()),
EntityKind::Tolerance(t) => Some(t.style.clone()),
EntityKind::Leader(l) => Some(l.style.clone()),
_ => None,
}));
let cur = d.header.str("DIMSTYLE", "Standard");
@@ -299,6 +314,41 @@ fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
v.len() != before
}
/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested
/// block, its layer, that layer's linetype).
const MAX_PASSES: usize = 64;
/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included;
/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one
/// being edited: nothing goes through the session or its undo.
pub(in crate::cmd) fn purge_unused(d: &mut Drawing) {
for _ in 0..MAX_PASSES {
let mut removed = false;
for kind in ALL {
for name in candidates(d, *kind) {
removed |= match kind {
Kind::Blocks => {
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned();
key.is_some_and(|k| d.blocks.remove(&k).is_some())
}
Kind::Layers => retain(&mut d.layers, |l| &l.name, &name),
Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name),
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name),
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name),
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name),
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name),
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name),
Kind::Groups => retain(&mut d.groups, |g| &g.name, &name),
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
};
}
}
if !removed {
break;
}
}
}
/// Model-space and layout objects with no length, or text with no characters.
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
let zero = |e: &EntityKind| match e {
+2
View File
@@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result<Value> {
}
nd.end_refedit_discard(&r);
*s.doc_mut()? = nd;
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
s.state_mut()?.erased.forget(|h| r.owns(h));
s.set_selection(Vec::new());
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
}
+349
View File
@@ -0,0 +1,349 @@
//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing
//! written to a drawing file.
use std::collections::BTreeMap;
use cadcraft_doc::{Drawing, Entity, Handle};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use serde_json::{Value, json};
use super::super::curves::KW;
use super::super::file::{drops_unknown, not_saved, not_saved_message, report_not_saved};
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::modify::erase_kept;
use super::super::{CommandSpec, bad, point_param, str_param, targets};
use super::insert_file::stem;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn wblock_spec() -> CommandSpec {
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}")
}
/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and
/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE.
pub(super) fn dash_wblock_spec() -> CommandSpec {
CommandSpec::new("-wblock", "Write Block", run_dash_wblock)
.params("{path, name? | handles?, base?} (the objects written are erased)")
.interactive(|_| Ok(Box::new(DashWblockM::default())))
}
/// The web build has no file system: -WBLOCK would erase objects it can't save.
const NO_FILES: &str = "files can't be written in the web build";
/// What WBLOCK writes.
enum Source<'a> {
/// These objects, `base` becoming the written drawing's origin.
Objects(&'a [Handle], Vec2),
/// A block's objects, from its base point or `base` (in block coordinates).
Block(&'a str, Option<Vec2>),
/// The whole drawing.
Whole,
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_not_saved(s, &mut r, &lost);
Ok(r)
}
fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
if cfg!(target_arch = "wasm32") {
return Err(bad("-wblock", NO_FILES));
}
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
None => {
let hs = targets(s, p)?;
// As at the command line, nothing selected writes no file.
if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) {
return Err(bad("-wblock", "no objects selected"));
}
let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?;
erase(s, &hs)?;
written
}
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_not_saved(s, &mut r, &lost);
Ok(r)
}
/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a
/// DWG file left out, for the caller to report as a save does. The
/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units
/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and
/// scaled on insert, and its defaults (text size, current styles, annotation scales).
fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap<String, usize>)> {
let src = s.doc()?;
// The base point becomes the written drawing's origin: by default 0,0 for objects and the
// block's base point for a block.
let d = match what {
Source::Whole => src.clone(),
Source::Block(name, base) => {
let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
with_objects(src, b.entities.iter().map(|e| moved(e, &m)))
}
Source::Objects(hs, base) => {
let m = Mat3::translate(-base);
with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m)))
}
};
let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
#[cfg(not(target_arch = "wasm32"))]
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
Ok((d.model.len(), bytes.len(), lost))
}
/// Say on the command line what the written DWG file left out.
fn echo_not_saved(s: &mut Session, lost: &BTreeMap<String, usize>) {
if let Some(msg) = not_saved_message(lost) {
s.echo(msg);
}
}
/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks
/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and
/// the defaults and the current ones stay. Its insertion base is 0,0.
fn with_objects(src: &Drawing, ents: impl Iterator<Item = Entity>) -> Drawing {
let mut d = Drawing::new_imperial();
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
// The copied blocks keep their objects' handles, so the new ones start above them.
let kept: Vec<Handle> = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect();
for h in kept {
d.bump_handseed(h);
}
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
super::purge::purge_unused(&mut d);
d
}
fn moved(e: &Entity, m: &Mat3) -> Entity {
let mut e = e.clone();
e.kind.transform(m);
e
}
fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> {
erase_kept(s, hs)?;
s.set_selection(Vec::new());
Ok(())
}
/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with
/// .dwg added, as AutoCAD names a drawing.
fn output_path(typed: &str) -> String {
let t = typed.trim().trim_matches('"');
let lower = t.to_ascii_lowercase();
if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") }
}
/// Whether a file is there to replace; the web has no file system.
fn exists(path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
std::path::Path::new(path).exists()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
false
}
}
/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
File,
/// The file exists: "… already exists, do you want to replace it? [Yes/No] <N>".
Replace,
/// A DXF file's decimal places of accuracy.
Precision,
/// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter
/// for objects.
Block,
Base,
/// mOde at the base point prompt: asked, but the objects are erased whatever it says.
Mode,
Objects,
}
const SAME_NAME: &str = "= (block=output file)";
const WHOLE: &str = "* (whole drawing)";
#[derive(Default)]
struct DashWblockM {
ask: Ask,
path: String,
base: Vec2,
sel: SelectPhase,
}
impl Interactive for DashWblockM {
fn name(&self) -> &'static str {
"WBLOCK"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if cfg!(target_arch = "wasm32") {
s.echo(format!("-WBLOCK: {NO_FILES}."));
return Ok(Step::Cancel);
}
s.echo("Current settings: Object conversion=Delete");
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::File => Prompt::new("Enter name of output file", Accept::TEXT),
Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"),
Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"),
Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"),
Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]),
Ask::Mode => {
Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete")
}
Ask::Objects => self.sel.prompt(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::File => {
if let Input::Text(t) = &i
&& !t.trim().trim_matches('"').is_empty()
{
self.path = output_path(t);
self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) };
}
Ok(Step::Continue)
}
Ask::Replace => match &i {
// Yes goes on; No, the default, ends the command; anything else is asked again.
Input::Keyword(k) if k == "Yes" => {
self.ask = self.after_file(s);
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Enter => Ok(Step::Done),
_ => {
s.echo("Invalid option keyword.");
Ok(Step::Continue)
}
},
Ask::Precision => {
match &i {
Input::Enter => self.ask = Ask::Block,
// The file is written at full precision whatever is asked.
Input::Text(t) if t.trim().parse::<u8>().is_ok_and(|n| n <= 16) => self.ask = Ask::Block,
Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."),
Input::Keyword(_) => {}
_ => s.echo("Requires an integer between 0 and 16."),
}
Ok(Step::Continue)
}
Ask::Block => {
let name = match &i {
Input::Enter => {
self.ask = Ask::Base;
return Ok(Step::Continue);
}
Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole),
Input::Keyword(k) if k == SAME_NAME => stem(&self.path),
Input::Text(t) => t.trim().to_string(),
// A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found.
Input::Point(p) => format!("{},{}", p.x, p.y),
_ => return Ok(Step::Continue),
};
if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
return Ok(Step::Continue);
}
self.write(s, Source::Block(&name, None))
}
Ask::Base => {
match i {
Input::Keyword(_) => self.ask = Ask::Mode,
Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."),
Input::Point(p) => {
self.base = p;
self.sel = SelectPhase::begin(s);
if self.sel.done {
let picked = self.sel.picked.clone();
return self.write_objects(s, &picked);
}
self.ask = Ask::Objects;
}
_ => {}
}
Ok(Step::Continue)
}
Ask::Mode => {
// Any answer, Enter included, goes back to the base point.
self.ask = Ask::Base;
Ok(Step::Continue)
}
Ask::Objects => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
// Nothing selected: nothing is written.
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.write_objects(s, &hs),
},
}
}
}
impl DashWblockM {
/// The prompt after the file name: a DXF file's precision, else the block.
fn after_file(&self, s: &mut Session) -> Ask {
if self.path.to_ascii_lowercase().ends_with(".dxf") {
s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No");
Ask::Precision
} else {
Ask::Block
}
}
fn write(&self, s: &mut Session, what: Source) -> Result<Step> {
match write(s, "-wblock", &self.path, what) {
Ok((.., lost)) => echo_not_saved(s, &lost),
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
if hs.is_empty() {
return Ok(Step::Done);
}
match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) {
Ok((.., lost)) => {
echo_not_saved(s, &lost);
erase(s, hs)?;
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
}
+38 -1
View File
@@ -208,7 +208,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
let found = files.into_iter().find(|p| p.is_file());
let found = files.iter().find(|p| p.is_file()).cloned().or_else(|| files.iter().find_map(|p| case_insensitive(p)));
#[cfg(target_arch = "wasm32")]
let found: Option<PathBuf> = {
let _ = files;
@@ -217,6 +217,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
/// Upper bound on directory entries scanned per path component by [`case_insensitive`].
#[cfg(not(target_arch = "wasm32"))]
const MAX_CASE_SCAN: usize = 10_000;
/// The existing file `p` names when its letters' case is ignored, on file systems that don't
/// ignore it themselves. Drawings made on Windows often store a reference as `PLAN.DWG` for
/// `Plan.dwg`; Windows finds it, and so should Linux. Each missing component is matched against
/// its folder's entries; an exact match always wins, and an ambiguous one finds nothing.
#[cfg(not(target_arch = "wasm32"))]
fn case_insensitive(p: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
let Component::Normal(name) = c else {
out.push(c.as_os_str());
continue;
};
let exact = out.join(name);
if exact.exists() {
out = exact;
continue;
}
let want = name.to_str()?.to_lowercase();
let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out })
.ok()?
.take(MAX_CASE_SCAN)
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want));
let hit = hits.next()?;
if hits.next().is_some() {
return None;
}
out.push(hit.file_name());
}
out.is_file().then_some(out)
}
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
+1
View File
@@ -33,6 +33,7 @@ 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};
pub(crate) use text::justify;
use text::{MTextM, TextM, run_mtext, run_text};
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
+6 -8
View File
@@ -153,19 +153,17 @@ impl Interactive for HelixM {
}
match (self.center, self.rb, self.rt) {
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
(Some(c), None, _) => {
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c)
}
(Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c),
(Some(c), Some(rb), None) => {
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default(format!("{rb:.4}"))
.base(c)
}
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER)
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
.default("1.0000")
.base(c),
+2 -2
View File
@@ -119,7 +119,7 @@ impl Interactive for XlineM2 {
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
},
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]),
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
XMode::Bisect => match self.bis.len() {
@@ -129,7 +129,7 @@ impl Interactive for XlineM2 {
},
XMode::OffDist => {
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
"Through".into()
} else {
format!("{d:.4}")
+1 -7
View File
@@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
let mut hs = s.selection();
hs.retain(|h| !super::curves::is_locked(s, *h));
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
if d.remove_entity(h).is_some() {
n += 1;
}
}
let n = super::modify::erase_kept(s, &hs)?;
s.set_selection(Vec::new());
Ok(json!({ "erased": n }))
}
+171 -11
View File
@@ -117,14 +117,17 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
if !EXPORT_FORMATS.contains(&format.as_str()) {
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
}
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
match path {
let file = format!("export.{format}");
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
let mut r = match path {
Some(path) => {
write_file("export", path, &bytes)?;
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })
}
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
}
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
};
super::file::report_loss(s, &mut r, &file);
Ok(r)
}
// ---------- IMPORT ----------
@@ -330,17 +333,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
// A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493).
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot.
let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none();
let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() };
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
// A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF.
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none()));
let scale = given_scale.or(saved.scale);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
let px_per_paper_unit = width as f64 / sheet.size.x;
// Curves are flattened to about half a pixel.
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale);
let tol = 0.5 / (px_per_paper_unit * est);
let ropts = cadcraft_render::Options {
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
@@ -352,9 +361,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
let b = list.bounds;
let b = saved.window.unwrap_or(list.bounds);
// Paper point q shows drawing point (q - to) / k + from.
let (from, k, to) = if fit || space == Space::Model {
let (from, k, to) = if saved.window.is_some() {
// A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's.
let c = saved.center;
(if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min })
} else if fit || space == Space::Model {
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
} else {
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
@@ -372,7 +385,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
// A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it.
if fit || saved.window.is_some() || space == Space::Model {
// The view may scale and centre the sheet (the height cap), so map the printable corners
// (paper point q shows drawing point (q - to) / k + from) through it.
let screen = |q: Vec2| {
let (x, y) = view.to_screen((q - to) / k + from);
(f64::from(x), f64::from(y))
};
let (min, max) = (sheet.printable.min, sheet.printable.max);
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
}
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
let layout = space.tab_name();
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
@@ -738,6 +764,140 @@ mod tests {
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
}
/// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as
/// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493).
#[test]
fn preview_uses_the_layouts_saved_page_setup_by_default() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 836.0, 589.0];
page.scale_to_fit = true;
let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview");
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet");
}
/// With a saved plot window the preview follows the page setup's centring (fitted or not) and an
/// explicit `scale` beats a saved scale to fit, as in the PDF.
/// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it:
/// geometry in the margin leaves the preview unchanged.
#[test]
fn preview_clips_a_saved_window_to_the_printable_area() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 60.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away");
}
/// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the
/// printable clip must follow it, so ink in the margin is still blanked.
#[test]
fn preview_clip_follows_the_view_when_the_height_cap_applies() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.width_mm = 210.0;
page.height_mm = 1200.0;
page.landscape = false;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 100.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet");
}
/// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back
/// to the plain preview and never panic, and a layout with no saved setup previews exactly as
/// the plain 1:1 sheet does.
#[test]
fn preview_survives_hostile_saved_setups_and_default_is_unchanged() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let call = json!({ "layout": "Layout1", "width": 200 });
let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false }));
assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet");
let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| {
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = window;
page.scale = scale;
page.scale_to_fit = fit;
};
let good = [0.0, 0.0, 100.0, 60.0];
let (nan, inf) = (f64::NAN, f64::INFINITY);
for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] {
set(&mut s, window, 1.0, true);
assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing");
}
for scale in [0.0, -2.0, nan, inf] {
set(&mut s, good, scale, false);
let _ = png(&mut s, call.clone());
set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false);
assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored");
}
set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true);
let _ = png(&mut s, call.clone());
set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true);
let _ = png(&mut s, call.clone());
}
#[test]
fn preview_honours_saved_centering_and_an_explicit_scale() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 400.0, 200.0];
page.scale_to_fit = true;
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c;
set_center(&mut s, true);
let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
set_center(&mut s, false);
// The window is fitted by width, so only the other axis shows where it sits.
let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner");
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.center = true;
let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 }));
assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot");
assert_eq!(
by_scale,
png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })),
"scale decides, not the saved fit"
);
}
#[test]
fn pdfbook_collects_frames_into_one_pdf_per_row() {
our_hooks();
+127 -18
View File
@@ -85,12 +85,13 @@ pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
/// binary instead of ASCII DXF.
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveAs {
pub version: Option<Version>,
pub binary: bool,
pub portable_fonts: bool,
}
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
@@ -131,7 +132,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
};
Ok(SaveAs { version, binary })
let portable_fonts = match p.get("portableFonts") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
};
Ok(SaveAs { version, binary, portable_fonts })
}
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
@@ -148,7 +154,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
let d = copy.as_ref().unwrap_or(d);
let r = if opts.version.is_none() && !opts.binary {
(hooks.write)(d, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
@@ -178,11 +186,13 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
.menu(&["File", "Save As..."])
.key("Cmd+Shift+S")
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
.params(
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
)
.noundo(),
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
@@ -395,7 +405,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
}
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
save_as(s, "save", path, &opts)
}
@@ -677,6 +687,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
if is_drawing_file(&path) {
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
r["binary"] = json!(opts.binary);
r["portableFonts"] = json!(opts.portable_fonts);
}
Ok(r)
}
@@ -684,8 +695,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
fn drawing_version(s: &Session, path: &str) -> Version {
let v =
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
let v = s
.state()
.ok()
.and_then(|st| st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver))
.unwrap_or_default();
if is_dxf(path) { v } else { v.max(Version::R2004) }
}
@@ -714,6 +728,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if drops_unknown(format!("drawing.{fmt}")) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) {
report_sat_text_dropped(s, &mut r);
}
Ok(r)
}
@@ -731,15 +747,28 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
/// store section, which is written back with the drawing (`Foreign::data_store`).
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads
/// them from our DXF into entities of its own and writes them to DWG of every version we save,
/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the
/// bridge gets the type as stored, and acadrust knows only this spelling of it.
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted.
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`].
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
// A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be
// in the data store, and SAT text alone is lost (#2334).
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t))
&& (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags)))
{
continue;
}
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
@@ -755,20 +784,39 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
return None;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
Some(format!(
"Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}",
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> {
let version = drawing_version(s, path);
if is_dxf_file(path) && version >= Version::R2013 {
// The drawing a file holds: the host while the Block Editor is open.
return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
}
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()
}
/// Report what the file `path`, just written at the drawing's own version, left out.
pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) {
if is_dxf_file(path) {
if drawing_version(s, path) >= Version::R2013 {
report_sat_text_dropped(s, r);
}
} else {
let lost = loss_on_save(s, path);
report_not_saved(s, r, &lost);
}
}
/// 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 {
@@ -780,8 +828,31 @@ fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path).is_some_and(Format::is_drawing)
}
/// Whether `path` is a DXF file.
fn is_dxf_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path) == Some(Format::Dxf)
}
/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because
/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334).
fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
// The drawing a file holds: the host while the Block Editor is open.
let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
if lost.is_empty() {
return;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
s.echo(format!(
"Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}",
list.join(", ")
));
if let Some(o) = r.as_object_mut() {
o.insert("notSaved".into(), json!(lost));
}
}
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
let Some(msg) = not_saved_message(lost) else { return };
s.echo(msg);
if let Some(o) = r.as_object_mut() {
@@ -790,24 +861,62 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
}
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, asked: Option<Version>) -> Result<Value> {
// What the file will hold depends on the version it is saved as (the data store from 2013).
// QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for.
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let mut r = run(s, p)?;
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
report_sat_text_dropped(s, &mut r);
}
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
report_fonts(s, &mut r);
}
Ok(r)
}
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
/// result, and at the command line when the list changed since the last save. Styles from an xref
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
fn report_fonts(s: &mut Session, r: &mut Value) {
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
let fonts: Vec<(String, String)> = d
.text_styles
.iter()
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
.map(|t| (t.name.clone(), t.font.clone()))
.collect();
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
if fonts.is_empty() {
return;
}
if changed {
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
" SAVEAS with portableFonts writes Windows fonts for those that have one."
} else {
""
};
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
}
if let Some(o) = r.as_object_mut() {
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
}
}
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_save)
save_reporting(s, p, run_save, None)
}
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_saveas)
let asked = save_options("saveas", p)?.version;
save_reporting(s, p, run_saveas, asked)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+44 -3
View File
@@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
if !d.len().is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.distance = d.len();
s.last_point = a.xy();
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
@@ -285,6 +290,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
// Drawn as its faces' visible edges: the Z on the nearest one.
EntityKind::PolyfaceMesh(m) => m
.faces3d()
.iter()
.flat_map(cadcraft_doc::Face3d::visible_edges)
.map(|(a, b)| on_segment(a, b))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z),
_ => None,
}
}
@@ -422,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(what) = too_large(a, per) {
return Err(bad("area", format!("the {what} is too large")));
}
s.area = a;
s.perimeter = per;
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
}
@@ -508,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
total
}
EntityKind::Region(r) => (r.area(), r.perimeter()),
// Like an open polyline: the area closed by the chord, the perimeter its own length.
EntityKind::Spline(sp) if sp.is_valid() => {
let b = sp.bounds();
let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12));
// A length past f64's range is too large to report, as a polyline's is, not 0.
(cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY))
}
_ => return Err(bad("area", "object has no area")),
})
}
@@ -574,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
// What reading the file left out, in words (a damaged DWG's unreadable object records).
"readNotes": d.read_notes,
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
@@ -864,16 +888,25 @@ impl Interactive for DistM {
self.first_source = s.picked_viewport;
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => {
(Some(first), Input::Point(b)) => {
let (a, b) = match (self.first_source, s.picked_viewport) {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
_ => (first, b),
};
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
if !(b - a).len().is_finite() {
// The first point stays LASTPOINT, as after a measurement.
s.last_point = first;
return Err(bad("dist", "the distance is too large"));
}
let t = dist_text(s, a, b);
for l in t.lines() {
s.echo(l.to_string());
}
// As in AutoCAD, the second point doesn't become LASTPOINT: the first stays.
s.distance = (b - a).len();
s.last_point = first;
Ok(Step::Done)
}
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
@@ -901,7 +934,11 @@ impl DistM {
.as_deref()
.or(self.multi.as_deref())
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
if !d.is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.echo(format!("Distance = {}", fmt_d(s, d)));
s.distance = d;
Ok(Step::Done)
}
}
@@ -1115,10 +1152,14 @@ impl AreaM {
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
s.echo(area_text(s, a, per));
// AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's.
s.perimeter = per;
if self.mode == AreaMode::Single {
s.area = a;
return Step::Done;
}
self.total = total;
s.area = total;
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
Step::Continue
}
+1 -1
View File
@@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Op
}
fn valid_name(n: &str) -> bool {
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
+3 -3
View File
@@ -84,11 +84,11 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["File", "Print..."])
.key("Cmd+P")
.alias(&["print"])
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.noundo(),
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
.menu(&["File", "Export to PDF..."])
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.noundo(),
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
.alias(&["ms"])
@@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
let h = d.new_handle();
store.push(Entity { handle: h, ..Entity::clone(e) });
}
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });
Ok(json!({ "name": to }))
}
+2 -3
View File
@@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{_path}.cadcraft-tmp");
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes())
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
}
#[cfg(target_arch = "wasm32")]
+14
View File
@@ -293,3 +293,17 @@ fn mspace_edits_model_through_viewport() {
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
assert!(s.state().unwrap().mspace.is_none());
}
/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557).
#[test]
fn plot_help_names_the_device_and_the_built_in_devices() {
for id in ["plot", "exportpdf"] {
let help = crate::find_command(id).map(|c| c.params).unwrap_or_default();
assert!(help.contains("device?"), "{id}: {help}");
}
let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default();
for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] {
assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device");
assert!(help.contains(name), "plot help lacks {name}");
}
}
+4
View File
@@ -20,6 +20,7 @@ mod break_join;
mod explode;
mod fillet;
mod offset;
mod oops;
mod select_then;
mod stretch;
mod transform;
@@ -30,6 +31,8 @@ use explode::run_explode;
pub(crate) use explode::{explode_kind, explode_with};
use fillet::{FilletM, run_chamfer, run_fillet};
use offset::{OffsetM, run_offset};
use oops::run_oops;
pub(crate) use oops::{Erased, erase_kept};
use select_then::{DisplaceM, Op, SelectThen};
use stretch::{StretchM, run_stretch};
pub(crate) use transform::transform_entities;
@@ -43,6 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["e", "delete"])
.params("{handles?: [hex]} (default: selection)")
.interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))),
CommandSpec::new("oops", "Oops", run_oops).params("{} (brings back what the last ERASE, BLOCK or -WBLOCK removed)"),
CommandSpec::new("move", "Move", run_move)
.menu(&["Modify", "Move"])
.alias(&["m"])
+6
View File
@@ -151,6 +151,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
})
.collect(),
),
// A polyface mesh explodes into its 3D faces; one without any stays as it is.
EntityKind::PolyfaceMesh(m) => {
let faces = m.faces3d();
(!faces.is_empty())
.then(|| faces.into_iter().map(|f| Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Face3d(f) }).collect())
}
EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)),
EntityKind::MText(m) => Some(explode_mtext(e, m)),
// A region explodes into its boundary's lines and arcs.
+181
View File
@@ -0,0 +1,181 @@
//! OOPS: bring back the objects the last ERASE (or the Delete key) removed, or BLOCK deleting the
//! objects it makes a block of, or -WBLOCK the objects it writes, with their handles and each where
//! it was in the draw order, as AutoCAD 2024 does (accoreconsole, the drawing's `entnext` order read
//! back afterwards).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Drawing, Entity, EntityKind, EntityStore, Handle, Space};
use serde_json::{Value, json};
use crate::{Result, Session};
/// One erased object, with the objects that were directly under and above it.
#[derive(Clone, Debug)]
struct Taken {
space: Space,
entity: Arc<Entity>,
below: Option<Handle>,
above: Option<Handle>,
}
/// What the drawing's last erase removed, for OOPS.
#[derive(Clone, Debug, Default)]
pub(crate) struct Erased(Vec<Taken>);
impl Erased {
/// Never bring back the objects `drop` picks.
pub(crate) fn forget(&mut self, drop: impl Fn(Handle) -> bool) {
self.0.retain(|t| !drop(t.entity.handle));
}
}
/// Remove the objects `hs` and keep them for OOPS in place of what an earlier erase kept.
/// Returns how many went; when none did, OOPS keeps what it had.
pub(crate) fn erase_kept(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let d = s.doc_mut()?;
// Every neighbour is read before anything goes: objects erased together may be neighbours.
let at: Vec<(Handle, Space, Option<Handle>, Option<Handle>)> = hs
.iter()
.filter_map(|&h| {
let space = d.space_of(h)?;
let (below, above) = d.space(&space)?.neighbours(h)?;
Some((h, space, below, above))
})
.collect();
let mut taken = Vec::with_capacity(at.len());
for (h, space, below, above) in at {
if let Some(entity) = d.space_mut(&space).and_then(|st| st.remove(h)) {
taken.push(Taken { space, entity, below, above });
}
}
let n = taken.len();
// During REFEDIT, removing an object outside the working set gets the command undone
// (`refedit::after_command`), so what OOPS had stays.
let refused = taken.iter().any(|t| crate::cmd::blocks::refedit::locked(d, t.entity.handle));
if n > 0 && !refused {
s.state_mut()?.erased = Erased(taken);
}
Ok(n)
}
pub(super) fn run_oops(s: &mut Session, _p: &Value) -> Result<Value> {
let taken = s.state()?.erased.0.clone();
let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone());
let back: Vec<bool> = {
let d = s.doc()?;
let in_blocks = in_blocks(d, &taken, editing.as_deref());
taken.iter().map(|t| !in_blocks.contains(&t.entity.handle) && can_return(d, t)).collect()
};
// Nothing to bring back leaves the drawing as it is: no undo step.
if !back.contains(&true) {
return Ok(json!({ "restored": 0 }));
}
let n = restore(s.doc_mut()?, &taken, &back);
Ok(json!({ "restored": n }))
}
/// The handles of `taken` that a block's object has now (an undo can take the drawing back to
/// before an erased object was made, and a block's new object take its handle). The block open
/// in the Block Editor (`editing`) doesn't count: its objects there keep the handles they have in
/// it.
fn in_blocks(d: &Drawing, taken: &[Taken], editing: Option<&str>) -> HashSet<Handle> {
let hs: HashSet<Handle> = taken.iter().map(|t| t.entity.handle).collect();
let mut out = HashSet::new();
for b in d.blocks.values() {
if editing.is_some_and(|n| b.name.eq_ignore_ascii_case(n)) {
continue;
}
// Through whichever is smaller.
if b.entities.len() < hs.len() {
out.extend(b.entities.iter().map(|e| e.handle).filter(|h| hs.contains(h)));
} else {
out.extend(hs.iter().copied().filter(|h| b.entities.contains(*h)));
}
}
out
}
/// Whether `t` can come back: it is not in the drawing (OOPS again, or an undo brought it back),
/// its handle is one the drawing has given out, a reference being edited in place (REFEDIT)
/// doesn't keep it out, and its layout, layer and block are still there.
fn can_return(d: &Drawing, t: &Taken) -> bool {
let h = t.entity.handle;
h.0 < d.handseed
&& d.entity(h).is_none()
&& !crate::cmd::blocks::refedit::locked(d, h)
&& d.space(&t.space).is_some()
&& d.layer(&t.entity.common.layer).is_some()
&& match &t.entity.kind {
EntityKind::Insert(i) => d.block(&i.block).is_some(),
_ => true,
}
}
/// Put back the objects `back` marks. Each run of objects that were neighbours comes back from
/// its bottom one up, so each goes directly above the one that was under it.
fn restore(d: &mut Drawing, taken: &[Taken], back: &[bool]) -> usize {
let index: HashMap<Handle, usize> = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect();
let next: HashMap<Handle, usize> = taken.iter().enumerate().filter_map(|(i, t)| Some((t.below.filter(|b| index.contains_key(b))?, i))).collect();
let mut done = vec![false; taken.len()];
let mut n = 0;
for first in 0..taken.len() {
if taken[first].below.is_some_and(|b| index.contains_key(&b)) {
continue;
}
let mut at = Some(first);
while let Some(i) = at {
if std::mem::replace(&mut done[i], true) {
break;
}
if back[i] && put_back(d, taken, &index, i) {
n += 1;
}
at = next.get(&taken[i].entity.handle).copied();
}
}
n
}
/// Put `taken[i]` back directly above the nearest object that was under it and is here; when
/// those are gone, directly under the nearest one that was above it; else on top.
fn put_back(d: &mut Drawing, taken: &[Taken], index: &HashMap<Handle, usize>, i: usize) -> bool {
let t = &taken[i];
let Some(store) = d.space_mut(&t.space) else { return false };
let h = t.entity.handle;
store.push(Entity::clone(&t.entity));
match nearest(store, taken, index, t.below, |t| t.below) {
Some(Some(b)) => {
store.move_next_to(h, b, true);
}
Some(None) => {
store.send_to_back(h);
}
None => {
if let Some(Some(a)) = nearest(store, taken, index, t.above, |t| t.above) {
store.move_next_to(h, a, false);
}
}
}
true
}
/// From `h`, past objects erased with it that are not here, to the first object that is
/// (`Some(Some)`), the end of the draw order (`Some(None)`), or an object that is gone (`None`).
fn nearest(
store: &EntityStore,
taken: &[Taken],
index: &HashMap<Handle, usize>,
mut h: Option<Handle>,
step: fn(&Taken) -> Option<Handle>,
) -> Option<Option<Handle>> {
for _ in 0..=taken.len() {
let Some(x) = h else { return Some(None) };
if store.contains(x) {
return Some(Some(x));
}
h = step(taken.get(*index.get(&x)?)?);
}
None
}
+4 -9
View File
@@ -1,6 +1,6 @@
use cadcraft_geom::Vec3;
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, erase_kept, f64_req, json, point_param, point_req, targets};
// ---------------- core operations ----------------
@@ -54,14 +54,9 @@ fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut
}
pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);
if !locked && d.remove_entity(*h).is_some() {
n += 1;
}
}
let d = s.doc()?;
let free: Vec<Handle> = hs.iter().copied().filter(|h| !d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)).collect();
let n = erase_kept(s, &free)?;
s.set_selection(Vec::new());
Ok(n)
}
+1 -1
View File
@@ -81,7 +81,7 @@ impl Interactive for BlendM {
return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent");
}
match self.first {
None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]),
None => Prompt::new("Select first object", Accept::POINT).kw(&["CONtinuity"]),
Some(_) => Prompt::new("Select second point", Accept::POINT),
}
}
+4 -4
View File
@@ -194,14 +194,14 @@ impl Interactive for LengthenM {
fn prompt(&self, _s: &Session) -> Prompt {
match self.phase {
LPhase::Main => {
Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
Prompt::new("Select an object to measure", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
}
LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
LPhase::Delta => Prompt::new("Enter delta length", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"),
LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"),
LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
LPhase::Total => Prompt::new("Specify total length", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"),
LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]),
LPhase::Change => Prompt::new("Select an object to change", Accept::POINT).kw(&["Undo"]),
LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT),
}
}
+2 -2
View File
@@ -87,8 +87,8 @@ impl Interactive for NcopyM {
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.selecting_done, self.base) {
(false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]),
(true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
(false, _) => Prompt::new("Select nested objects to copy", Accept::POINT).kw(&["Settings"]),
(true, None) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
(true, Some(b)) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base(b),
}
}
+1 -1
View File
@@ -398,7 +398,7 @@ impl Interactive for PeditM {
fn prompt(&self, s: &Session) -> Prompt {
match self.phase {
PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(),
PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),
PPhase::Select => Prompt::new("Select polyline", Accept::POINT).kw(&["Multiple"]),
PPhase::Convert => {
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
}
+1
View File
@@ -103,6 +103,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
EntityKind::Hatch(h) => h.elevation = 0.0,
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z),
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
EntityKind::Region(_) => {}
EntityKind::Image(i) => {
z(&mut i.insert);
+104 -38
View File
@@ -146,20 +146,49 @@ impl Opts {
}
}
/// Round every number of a JSON value to a multiple of `q`.
fn quantize(v: &mut Value, q: f64) {
/// Take the measurements (floating-point numbers) out of a JSON value, in order, leaving a marker
/// in their place: two objects are equal when what is left matches, counts and flags included,
/// and each pair of measurements is within the tolerance.
fn split_numbers(v: &mut Value, nums: &mut Vec<f64>) {
match v {
Value::Number(n) => {
if let Some(x) = n.as_f64() {
*v = json!((x / q).round());
if n.is_f64()
&& let Some(x) = n.as_f64()
{
nums.push(x);
*v = Value::String(NUMBER.into());
}
}
Value::Array(a) => a.iter_mut().for_each(|x| quantize(x, q)),
Value::Object(o) => o.values_mut().for_each(|x| quantize(x, q)),
Value::Array(a) => a.iter_mut().for_each(|x| split_numbers(x, nums)),
Value::Object(o) => o.values_mut().for_each(|x| split_numbers(x, nums)),
_ => {}
}
}
/// What [`split_numbers`] leaves where a number was.
const NUMBER: &str = "\u{0}#";
/// Whether two lists of numbers match pairwise to within `tol`.
fn within(a: &[f64], b: &[f64], tol: f64) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
/// The numbers summed with weights in [1, 2) that no simple pattern shares (golden-ratio steps),
/// so points on a diagonal of a grid don't all get the same sum; and the sum of the weights.
fn weighted_sum(nums: &[f64]) -> (f64, f64) {
nums.iter().enumerate().fold((0.0, 0.0), |(sum, total), (j, x)| {
let w = 1.0 + (j as f64 * 0.618_033_988_749_895).fract();
(sum + w * x, total + w)
})
}
/// The cell of `x` on a grid of `step`. Values within one step of each other land in the same or
/// neighbouring cells; values too large for the grid share the outermost ones.
fn cell_of(x: f64, step: f64) -> i64 {
let k = (x / step).floor();
if k.is_finite() { k.clamp(-9e15, 9e15) as i64 } else { i64::MIN }
}
#[derive(Default)]
struct Report {
duplicates: usize,
@@ -234,33 +263,37 @@ fn overkill(s: &mut Session, hs: Option<Vec<Handle>>, o: &Opts) -> Result<Report
/// Equal objects (numbers compared to the tolerance): the first in draw order stays, unless a
/// later one is followed by an associative object.
///
/// Objects are hashed by their properties and shape (their JSON without the numbers), then by a
/// weighted sum of their numbers on a grid of the tolerance times the weights' sum: two equal
/// objects' sums are that close, so they land in the same or neighbouring cells, wherever the
/// numbers fall.
fn duplicates(cands: &[Cand], o: &Opts, gone: &mut [bool], rep: &mut Report) {
let mut seen: HashMap<(u32, String), usize> = HashMap::new();
for (i, c) in cands.iter().enumerate() {
// Keepers so far: (properties, shape) → cell of the sum → their numbers.
type Keepers = HashMap<i64, Vec<Vec<f64>>>;
let mut seen: HashMap<(u32, String), Keepers> = HashMap::new();
// Followed objects first, so a keeper is never displaced: everything deleted is within the
// tolerance of an object that stays.
let (followed, rest): (Vec<usize>, Vec<usize>) = (0..cands.len()).partition(|i| cands.get(*i).is_some_and(|c| c.protected));
for i in followed.into_iter().chain(rest) {
let Some(c) = cands.get(i) else { continue };
let Ok(mut v) = serde_json::to_value(&c.e.kind) else { continue };
if o.tol > 0.0 {
quantize(&mut v, o.q());
let mut nums = Vec::new();
split_numbers(&mut v, &mut nums);
let (sum, weights) = weighted_sum(&nums);
let cell = cell_of(sum, o.q() * weights.max(1.0));
let cells = seen.entry((c.prop, v.to_string())).or_default();
let found = [cell.saturating_sub(1), cell, cell.saturating_add(1)]
.into_iter()
.any(|k| cells.get(&k).is_some_and(|keepers| keepers.iter().any(|n| within(n, &nums, o.tol))));
if !found || c.protected {
// A followed object stays even when it matches another.
cells.entry(cell).or_default().push(nums);
continue;
}
let key = (c.prop, v.to_string());
match seen.get(&key).copied() {
None => {
seen.insert(key, i);
}
Some(k) => {
let keeper_protected = cands.get(k).is_some_and(|x| x.protected);
let victim = match (keeper_protected, c.protected) {
(_, false) => i,
(false, true) => {
seen.insert(key, i);
k
}
(true, true) => continue,
};
if let Some(g) = gone.get_mut(victim) {
*g = true;
rep.duplicates += 1;
}
}
if let Some(g) = gone.get_mut(i) {
*g = true;
rep.duplicates += 1;
}
}
}
@@ -421,8 +454,8 @@ fn collinear(members: &[&LineRec], cands: &[Cand], o: &Opts, gone: &mut [bool],
fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize, EntityKind>, rep: &mut Report) {
let q = o.q();
let g = q.max(1e-9);
let cell = |x: f64| (x / g).round() as i64;
let mut buckets: HashMap<(u32, i64, i64, i64, i64), Vec<usize>> = HashMap::new();
// (index, properties, centre and radius); cells of `g` hold them.
let mut recs: Vec<(usize, u32, [f64; 4])> = Vec::new();
for (i, c) in cands.iter().enumerate() {
if gone.get(i).copied().unwrap_or(true) || c.protected {
continue;
@@ -442,12 +475,45 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize
{
continue;
}
buckets.entry((c.prop, cell(center.x), cell(center.y), cell(r), cell(center.z))).or_default().push(i);
recs.push((i, c.prop, [center.x, center.y, r, center.z]));
}
let mut keys: Vec<_> = buckets.keys().copied().collect();
keys.sort_unstable();
for key in keys {
let Some(group) = buckets.get(&key) else { continue };
// Those within `g` of each other in centre and radius are on one circle. Each object joins the
// first group whose every member is within `g` of it, or starts one: matches don't chain, so
// nothing is deleted for matching only an object that itself matches the keeper. Members are
// looked for in the neighbouring cells too, so values on either side of a cell boundary meet.
let mut grid: HashMap<(u32, [i64; 4]), Vec<usize>> = HashMap::new();
let mut group_of: Vec<usize> = Vec::with_capacity(recs.len());
// Each group's records, in draw order; groups in the order of their first object.
let mut members: Vec<Vec<usize>> = Vec::new();
for (ri, (_, prop, v)) in recs.iter().enumerate() {
let cells = v.map(|x| cell_of(x, g));
let mut near_groups: Vec<usize> = Vec::new();
for n in 0..81_i64 {
let near = [n % 3 - 1, n / 3 % 3 - 1, n / 9 % 3 - 1, n / 27 % 3 - 1];
let mut key = cells;
for (k, d) in key.iter_mut().zip(near) {
*k = k.saturating_add(d);
}
near_groups.extend(grid.get(&(*prop, key)).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|rj| group_of.get(*rj)));
}
near_groups.sort_unstable();
near_groups.dedup();
let fits = |gi: &usize| members.get(*gi).is_some_and(|m| m.iter().all(|rk| recs.get(*rk).is_some_and(|(_, _, w)| within(v, w, g))));
let gi = match near_groups.into_iter().find(fits) {
Some(gi) => gi,
None => {
members.push(Vec::new());
members.len() - 1
}
};
if let Some(m) = members.get_mut(gi) {
m.push(ri);
}
group_of.push(gi);
grid.entry((*prop, cells)).or_default().push(ri);
}
let groups = members.iter().map(|m| m.iter().filter_map(|ri| recs.get(*ri).map(|r| r.0)).collect::<Vec<usize>>());
for group in groups {
if group.len() < 2 {
continue;
}
@@ -465,7 +531,7 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap<usize
}
continue;
}
same_circle(group, cands, o, gone, changed, rep);
same_circle(&group, cands, o, gone, changed, rep);
}
}
+1 -3
View File
@@ -101,9 +101,7 @@ impl PlotterStore {
write: |n, t| {
let p = file(n)?;
std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?;
let tmp = p.with_extension("json.cadcraft-tmp");
std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?;
std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}"))
crate::atomic_write::write_atomic(&p, t.as_bytes()).map_err(|e| format!("{n}: {e}"))
},
delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")),
}
+4 -1
View File
@@ -765,10 +765,13 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles.
/// Text style names used by entities (in model, paper spaces and blocks), dimension,
/// multileader and table styles (their cell styles), and the text of complex linetypes.
pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec<String> {
let mut out: Vec<String> = d.dim_styles.iter().map(|s| s.text_style.clone()).collect();
out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone()));
out.extend(d.table_styles.iter().flat_map(|s| s.cell_styles.iter().map(|c| c.text_style.clone())));
out.extend(d.linetypes.iter().flat_map(|lt| lt.pattern.iter().filter_map(|el| el.style.clone())));
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for st in stores {
for e in st.iter() {
+3 -10
View File
@@ -66,17 +66,10 @@ impl Units {
}
/// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, and in surveyor's
/// units N, E, S or W), measured from +X as the drawing measures angles.
/// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, `N`), measured
/// from +X as the drawing measures angles.
fn direction(&self, t: &str) -> Option<f64> {
let cardinal = match t.trim().to_ascii_lowercase().as_str() {
"e" if self.aunits == 4 => Some(0.0),
"n" if self.aunits == 4 => Some(90f64.to_radians()),
"w" if self.aunits == 4 => Some(180f64.to_radians()),
"s" if self.aunits == 4 => Some(270f64.to_radians()),
_ => None,
};
let a = cardinal.or_else(|| AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t))?;
let a = AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t)?;
let a = if self.clockwise { -a } else { a };
a.is_finite().then(|| a.rem_euclid(TAU))
}
+1 -1
View File
@@ -112,7 +112,7 @@ impl Interactive for BreakM {
fn prompt(&self, _s: &Session) -> Prompt {
match self.table {
None => Prompt::new("Select a table", Accept::SELECT),
Some(_) => Prompt::new("Enter maximum part height or", Accept::POINT_OR_NUMBER).kw(&["Off"]),
Some(_) => Prompt::new("Enter maximum part height", Accept::POINT_OR_NUMBER).kw(&["Off"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
+15
View File
@@ -35,6 +35,11 @@ pub struct DocState {
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
/// drawing itself waits in it ([`DocState::drawing`]).
pub block_edit: Option<blockedit::BlockEdit>,
/// What the last erase removed, for OOPS.
pub(crate) erased: crate::cmd::Erased,
/// The fonts missing on Windows that a save last named: the next save names them again only
/// when they change.
pub(crate) fonts_noted: String,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -61,6 +66,8 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
binary_dxf: false,
block_edit: None,
erased: Default::default(),
fonts_noted: String::new(),
}
}
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
@@ -68,6 +75,14 @@ impl DocState {
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
}
/// Show the drawing an undo or redo step holds, with nothing selected; returns the drawing as
/// it was, as the step back.
pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot {
let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) };
self.follow_layout(&cur.doc);
self.revision += 1;
cur
}
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
/// a name `before` did not have), else go to the Model tab.
+1
View File
@@ -226,6 +226,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
}
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to),
EntityKind::PolyfaceMesh(m) => set_xy(m.vertices.get_mut(index)?, to),
EntityKind::Region(_) => return Some(translated(kind, delta)),
EntityKind::Viewport(v) => {
let opposite = *grips.get((index + 2) % 4)?;
+9
View File
@@ -92,6 +92,12 @@ pub struct Session {
pub last_command: Option<String>,
/// LASTPOINT.
pub last_point: Vec2,
/// AREA and PERIMETER: the last area AREA or MEASUREGEOM measured (in their Add and Subtract
/// modes the running total) and that area's perimeter. Not saved, as in AutoCAD.
pub area: f64,
pub perimeter: f64,
/// DISTANCE: the last distance DIST or MEASUREGEOM measured (with Multiple points, the total).
pub distance: f64,
/// The last segment given at a point prompt (its base point and the point): polar angles
/// relative to the last segment (POLARMODE bit 1) are measured from it.
pub last_segment: Option<(Vec2, Vec2)>,
@@ -173,6 +179,9 @@ impl Session {
log: Vec::new(),
last_command: None,
last_point: Vec2::ZERO,
area: 0.0,
perimeter: 0.0,
distance: 0.0,
last_segment: None,
last_polar: None,
point_mods: Vec::new(),
+82 -3
View File
@@ -18,7 +18,7 @@ use std::sync::Arc;
use cadcraft_doc::{Drawing, Handle};
use crate::{Input, Prompt, Session};
use crate::{Input, Prompt, Session, undoctl};
use eval::{Ctx, Machine, Need, Outcome};
use value::{Sym, Value};
@@ -111,6 +111,8 @@ pub struct LispState {
run: Option<Box<Run>>,
/// A routine runs, from its start to its end (also while its machine is out of `run`).
active: bool,
/// The running routine's own undo steps (also while its machine is out of `run`).
steps: RoutineSteps,
/// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system.
files: HashMap<String, String>,
/// Where startup files are read from (see `startup.rs`).
@@ -123,6 +125,19 @@ pub struct LispState {
pub(crate) limits: Limits,
}
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
/// was before it, last last. The routine is still one undo step once it ends.
#[derive(Default)]
struct RoutineSteps {
/// The drawing the routine runs in.
uid: u64,
undo: std::collections::VecDeque<crate::Snapshot>,
redo: std::collections::VecDeque<crate::Snapshot>,
/// Where the routine's undo step starts, when U or REDO inside it went on to the drawing's
/// own steps; else where the routine started.
base: Option<(Arc<Drawing>, Vec<Handle>)>,
}
/// What a run may use (see [`value`]'s memory notes).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Limits {
@@ -247,6 +262,7 @@ impl Session {
let st = self.state()?;
let before = Some((st.doc.clone(), st.selection.clone()));
let uid = st.uid;
self.lisp.steps = RoutineSteps { uid, ..RoutineSteps::default() };
self.lisp.run = Some(Box::new(Run {
machine,
uid,
@@ -324,7 +340,7 @@ impl Session {
Ok(o) => o,
Err(p) => {
// A panic inside a built-in: put the drawing back as the routine found it.
if let Some((doc, sel)) = &run.before
if let Some((doc, sel)) = self.lisp.steps.base.as_ref().or(run.before.as_ref())
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
{
st.doc = doc.clone();
@@ -422,6 +438,7 @@ impl Session {
fn lisp_end(&mut self, result: std::result::Result<Value, String>) {
let Some(run) = self.lisp.run.take() else { return };
self.lisp.active = false;
let steps = std::mem::take(&mut self.lisp.steps);
let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default();
self.lisp_flush(&mut env, true);
self.lisp.envs.insert(run.uid, env);
@@ -431,7 +448,7 @@ impl Session {
self.echo(text);
}
}
if let Some((doc, sel)) = run.before
if let Some((doc, sel)) = steps.base.or(run.before)
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid)
&& !Arc::ptr_eq(&doc, &st.doc)
{
@@ -489,6 +506,7 @@ impl Session {
self.lisp_error(LispError::quit(msg));
} else if self.lisp.active {
self.lisp.active = false;
self.lisp.steps = RoutineSteps::default();
self.echo(format!("; error: {msg}"));
self.lisp_last = Some(Err(msg));
} else {
@@ -577,6 +595,67 @@ impl Session {
self.lisp.active
}
/// A command the running routine ran changed the drawing (`snap` holds it as it was): U inside
/// the routine takes it back, as AutoCAD's does, while the routine stays one undo step. UNDO
/// Control One keeps only the last, None none.
pub(crate) fn lisp_record_step(&mut self, snap: crate::Snapshot) {
let Some(uid) = self.routine_steps() else { return };
let mut control = undoctl::Control::All;
// A command ends what REDO could bring back, the drawing's own steps too.
if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) {
st.redo.clear();
control = st.undo_ctl.control;
}
let steps = &mut self.lisp.steps;
steps.redo.clear();
match control {
undoctl::Control::None => steps.undo.clear(),
undoctl::Control::One => {
steps.undo.clear();
steps.undo.push_back(snap);
}
// As many as the drawing keeps.
undoctl::Control::All => {
steps.undo.push_back(snap);
if steps.undo.len() > undoctl::MAX_UNDO {
steps.undo.pop_front();
}
}
}
}
/// U (or with `redo`, REDO) inside the running routine: take its last command back (or bring
/// back what U took). None when it has none left, and the drawing's own steps go on from there.
pub(crate) fn lisp_take_back(&mut self, redo: bool) -> Option<String> {
let uid = self.routine_steps()?;
let st = self.docs.iter_mut().find(|d| d.uid == uid)?;
let steps = &mut self.lisp.steps;
let (from, to) = if redo { (&mut steps.redo, &mut steps.undo) } else { (&mut steps.undo, &mut steps.redo) };
let cur = st.swap_in(from.pop_back()?);
let label = cur.label.clone();
to.push_back(cur);
Some(label)
}
/// U or REDO inside the running routine went on to the drawing's steps before it: the
/// routine's own undo step starts from the drawing as that left it, and REDO brings back that
/// step before what U took back of the routine's.
pub(crate) fn lisp_rebase(&mut self) {
if self.routine_steps().is_none() {
return;
}
if let Ok(st) = self.state() {
self.lisp.steps.base = Some((st.doc.clone(), st.selection.clone()));
self.lisp.steps.redo.clear();
}
}
/// The active drawing's uid when a routine runs in it.
fn routine_steps(&self) -> Option<u64> {
let uid = self.state().ok()?.uid;
(self.lisp.active && self.lisp.steps.uid == uid).then_some(uid)
}
/// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted.
pub fn lisp_commands(&self) -> Vec<String> {
let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() };
+57 -9
View File
@@ -26,6 +26,41 @@ impl Accept {
pub const SELECT: Accept = Accept { point: true, number: false, text: false, select: true, enter: true };
}
/// Prompts that ask for one of their keywords, which AutoCAD 2024 writes without "or" before them:
/// "Enter an option [?/Make/Set/…]:", "Enter layout option [Copy/Delete/…/?] <set>:", "New color
/// [Truecolor/COlorbook] <BYLAYER>:". A prompt asking for something else besides its keywords has
/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from
/// AutoCAD's command line over measured runs, where no prompt was written both ways.
const NO_OR: [&str; 27] = [
"Bring to front",
"Calculate rectangle dimensions based on",
"Choose mode for selected object conversion",
"Create annotative block",
"Enter a configuration option",
"Enter a plotting preference",
"Enter an annotation option",
"Enter an option",
"Enter an option to change",
"Enter attribute visibility setting",
"Enter justification",
"Enter layout option",
"Enter leader format option",
"Enter line spacing type",
"Enter object ordering option",
"Enter polyline linetype generation option",
"Enter property to change",
"Enter the type of array",
"Enter type of unused objects to purge",
"Enter viewport arrangement",
"Explode block",
"New color",
"Orient relative to sheet in paper space viewports",
"Select a content type",
"Select a leader type",
"Use landing",
"Viewport View Locking",
];
/// A prompt shown on the command line.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -148,15 +183,7 @@ impl Prompt {
}
let mut s = self.message.clone();
if !self.keywords.is_empty() {
// A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else
// offers options besides what it asks for ("Specify next point or [Undo]").
s.push_str(if self.message.is_empty() {
"["
} else if self.message.ends_with('?') {
" ["
} else {
" or ["
});
s.push_str(self.keyword_lead());
s.push_str(&self.keywords.join("/"));
s.push(']');
}
@@ -166,6 +193,20 @@ impl Prompt {
s.push(':');
s
}
/// What goes between the message and its keywords. A question offers its answers ("Fix any
/// errors detected? [Yes/No]"), and so does a prompt for an option ([`NO_OR`]): " [". Anything
/// else offers options besides what it asks for ("Specify next point or [Undo]"): " or [". With
/// no message, "[". The command line in the window draws the keywords itself and takes this
/// too.
pub fn keyword_lead(&self) -> &'static str {
if self.message.is_empty() {
"["
} else if self.message.ends_with('?') || NO_OR.contains(&self.message.as_str()) {
" ["
} else {
" or ["
}
}
/// Match typed text against keywords: full word or the capitalised shortcut, case-insensitive.
pub fn match_keyword(&self, text: &str) -> Option<String> {
let t = text.trim();
@@ -338,6 +379,13 @@ mod tests {
assert_eq!(p.display(), "Specify next point or [Undo/Close]:");
let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
assert_eq!(q.display(), "Erase polyline? [Yes/No] <No>:");
// A prompt for an option has no "or", as AutoCAD writes it.
let o = Prompt::new("Enter an option", Accept::TEXT).kw(&["Close", "Join"]);
assert_eq!(o.display(), "Enter an option [Close/Join]:");
let c = Prompt::new("New color", Accept::TEXT).kw(&["Truecolor", "COlorbook"]).default("BYLAYER");
assert_eq!(c.display(), "New color [Truecolor/COlorbook] <BYLAYER>:");
let b = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
assert_eq!(b.display(), "Enter block name or [?]:");
assert_eq!(p.match_keyword("u").as_deref(), Some("Undo"));
assert_eq!(p.match_keyword("CL").as_deref(), Some("Close"));
assert_eq!(p.match_keyword("x"), None);
+3
View File
@@ -32,6 +32,7 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
})
}
@@ -182,6 +183,7 @@ pub fn bracket() -> Drawing {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
);
add(
@@ -203,6 +205,7 @@ pub fn bracket() -> Drawing {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
);
+60 -6
View File
@@ -29,7 +29,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec<Vec<Vec2>> {
let list = cadcraft_render::build_entities(
d,
std::iter::once(e),
&cadcraft_render::Options { tolerance: tol, text: false, ..Default::default() },
&cadcraft_render::Options { tolerance: tol, text: false, linetypes: false, ..Default::default() },
);
v = list.prims.iter().map(|p| list.points(p).to_vec()).collect();
v.push(vec![c[0], c[1], c[2], c[3], c[0]]);
@@ -117,11 +117,15 @@ fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool {
// A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is
// the text there, not the text the dimension's own geometry would place.
EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) {
Some(blk) => blk.entities.iter().any(|be| match &be.kind {
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
_ => false,
}),
// The block draws translated by the insertion point (group 12): test there (#2634).
Some(blk) => {
let p = p - dm.insertion_point.xy();
blk.entities.iter().any(|be| match &be.kind {
EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p),
EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p),
_ => false,
})
}
None => {
let g = cadcraft_render::dimension_in(d, dm);
in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p)
@@ -502,6 +506,56 @@ mod tests {
// 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));
}
/// A block holding a diagonal dashed line, inserted at `scale`.
fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) {
let mut d = Drawing::new_imperial();
d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED"));
let mut line =
Entity::new(Handle(1), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(length, length, 0.0) }));
line.common.linetype = "DASHED".into();
let mut blk = cadcraft_doc::Block::new("B");
blk.entities.push(line);
d.blocks.insert("B".into(), std::sync::Arc::new(blk));
let insert = cadcraft_doc::Insert {
block: "B".into(),
insert: Vec3::new(0.0, 0.0, 0.0),
scale: Vec3::new(scale, scale, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
(d, h)
}
/// Every mouse move over a block hit-tests what it draws. Two blocks with a fine pattern on
/// long lines (inserted at 0.001) made 5.8 million dashes, and half a second, per move (#1285).
#[test]
fn hit_testing_a_block_does_not_lay_out_its_dashes() {
let (d, h) = dashed_block(20_000.0, 0.001);
let e = d.entity(h).unwrap();
let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() });
assert!(drawn.prims.len() > 10_000, "the pattern draws {} dashes", drawn.prims.len());
// The line, and the block's bounding rectangle.
assert_eq!(hit_polylines(&d, e, 0.01).len(), 2);
}
/// A dashed line in a block is hit across its gaps, as one outside a block is.
#[test]
fn a_dashed_line_in_a_block_is_picked_in_a_gap() {
let (d, h) = dashed_block(100.0, 1.0);
let e = d.entity(h).unwrap();
let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() });
let (a, b) = (drawn.prims.first().unwrap(), drawn.prims.get(1).unwrap());
let (end, start) = (*drawn.points(a).last().unwrap(), *drawn.points(b).first().unwrap());
assert!(end.dist(start) > 0.01, "a gap between the first two dashes");
let gap = end + (start - end) * 0.5;
assert_eq!(pick(&d, &Space::Model, gap, end.dist(start) / 8.0), Some(h));
}
}
#[cfg(test)]
+28 -15
View File
@@ -66,17 +66,25 @@ impl Session {
(r, _) => r,
};
let grouped = self.lisp_groups_undo();
let mut in_routine = None;
if spec.undoable
&& let Some((doc, sel, uid)) = before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&doc, &st.doc)
{
// Inside a LISP routine the routine's single undo step records it.
if !grouped {
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
let snap = Snapshot { label: spec.label.to_string(), doc, selection: sel };
// Inside a LISP routine the routine's single undo step records it, and the routine
// keeps it for U inside it.
if grouped {
in_routine = Some(snap);
} else {
st.push_undo(snap);
}
st.revision += 1;
}
if let Some(snap) = in_routine {
self.lisp_record_step(snap);
}
result
}
@@ -89,27 +97,32 @@ impl Session {
// ---------------- undo ----------------
/// Take the last step back. Inside a LISP routine, the routine's last command first; past
/// those, the drawing's step before the routine, which then starts from there.
pub fn undo(&mut self) -> Result<Option<String>> {
if let Some(label) = self.lisp_take_back(false) {
return Ok(Some(label));
}
let st = self.state_mut()?;
let Some(snap) = st.undo.pop() else { return Ok(None) };
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
st.doc = snap.doc;
st.follow_layout(&cur.doc);
st.selection = Vec::new();
let cur = st.swap_in(snap);
let label = cur.label.clone();
st.redo.push(cur);
st.revision += 1;
Ok(Some(snap.label))
self.lisp_rebase();
Ok(Some(label))
}
/// Bring back what the last undo took back; inside a LISP routine, what U inside it took first.
pub fn redo(&mut self) -> Result<Option<String>> {
if let Some(label) = self.lisp_take_back(true) {
return Ok(Some(label));
}
let st = self.state_mut()?;
let Some(snap) = st.redo.pop() else { return Ok(None) };
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
st.doc = snap.doc;
st.follow_layout(&cur.doc);
st.selection = Vec::new();
let cur = st.swap_in(snap);
let label = cur.label.clone();
st.undo.push(cur);
st.revision += 1;
Ok(Some(snap.label))
self.lisp_rebase();
Ok(Some(label))
}
}
+5 -1
View File
@@ -379,12 +379,16 @@ impl Session {
}
self.after_command(Some(&run.before), None, true);
// A transparent command's changes belong to the command it ran inside, and a command a
// LISP routine ran belongs to the routine's single undo step.
// LISP routine ran belongs to the routine's single undo step (the routine keeps it for U
// inside it).
if self.suspended.is_some() || self.lisp_groups_undo() {
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
st.revision += 1;
if self.suspended.is_none() {
self.lisp_record_step(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before });
}
}
return;
}
+16 -5
View File
@@ -67,6 +67,9 @@ const SESSION_VARS: &[&str] = &[
"ZOOMFACTOR",
"MEASUREINIT",
"LASTPOINT",
"AREA",
"PERIMETER",
"DISTANCE",
"FONTALT",
"FONTFALLBACK",
"SAVETIME",
@@ -145,6 +148,9 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"ZOOMFACTOR" => json!(st.zoomfactor),
"MEASUREINIT" => json!(i32::from(st.measureinit)),
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
"AREA" => json!(s.area),
"PERIMETER" => json!(s.perimeter),
"DISTANCE" => json!(s.distance),
// Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG.
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
"CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)),
@@ -249,6 +255,9 @@ const READ_ONLY: &[&str] = &[
"BLOCKEDITOR",
"CTAB",
"LASTPOINT",
"AREA",
"PERIMETER",
"DISTANCE",
"VIEWCTR",
"VIEWSIZE",
"EXTMIN",
@@ -464,11 +473,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
pub fn list(s: &Session) -> Value {
let mut m = serde_json::Map::new();
for n in SESSION_VARS {
if let Some(v) = get(s, n) {
m.insert((*n).to_string(), v);
}
}
if let Ok(d) = s.doc() {
for (k, v) in &d.header.vars {
if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") {
@@ -478,6 +482,13 @@ pub fn list(s: &Session) -> Value {
}
}
}
// The session's values last: a drawing's header can carry the same names (ORTHOMODE,
// LWDISPLAY, an AREA), and `get` reads the session's.
for n in SESSION_VARS {
if let Some(v) = get(s, n) {
m.insert((*n).to_string(), v);
}
}
table::list(s, &mut m);
Value::Object(m)
}
+28 -20
View File
@@ -277,7 +277,8 @@ impl AngleSettings {
}
/// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare
/// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`.
/// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`; a
/// surveyor's bearing (`N45dE`, or `N`, `E`, `S`, `W`) is its angle counterclockwise from east.
pub fn amount(&self, s: &str) -> Option<f64> {
parse_units(s, self.aunits).map(|(a, _)| a)
}
@@ -288,20 +289,19 @@ impl AngleSettings {
}
/// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in
/// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from
/// +X counterclockwise whatever ANGBASE and ANGDIR say: `<<a` in degrees, `<<<a` in AUNITS.
/// the ANGDIR sense, a surveyor's bearing too, as AutoCAD 2024 measures it (`N45dE` with
/// ANGBASE 90 and ANGDIR 1 is 45 degrees). The overrides measure from +X counterclockwise
/// whatever ANGBASE and ANGDIR say: `<<<a` in AUNITS or as a bearing, `<<a` in degrees only
/// (AutoCAD refuses `<<N45dE` and `<<N`).
pub fn direction(&self, s: &str) -> Option<f64> {
let t = s.trim();
if let Some(r) = t.strip_prefix("<<<") {
return parse_units(r, self.aunits).map(|(a, _)| a);
}
if let Some(r) = t.strip_prefix("<<") {
return parse_units(r, 0).map(|(a, _)| a);
}
let (a, absolute) = parse_units(t, self.aunits)?;
if absolute {
return Some(a);
return parse_units(r, 0).filter(|(_, bearing)| !bearing).map(|(a, _)| a);
}
let (a, _) = parse_units(t, self.aunits)?;
let v = self.angbase + if self.clockwise { -a } else { a };
v.is_finite().then_some(v)
}
@@ -328,7 +328,8 @@ pub fn parse_angle(s: &str) -> Option<f64> {
AngleSettings::default().direction(s)
}
/// A typed angle in radians and whether it is absolute (a surveyor's bearing).
/// A typed angle in radians and whether it is a surveyor's bearing (`N45dE`, or `N`, `E`, `S`,
/// `W`), whose angle is its direction counterclockwise from east.
fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
@@ -339,10 +340,7 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
(num(r)?, false)
} else if let Some(g) = t.strip_suffix('g') {
((num(g)? * 0.9).to_radians(), false)
} else if let Some(b) = bearing(&t) {
(b, true)
} else if let Some(b) = (aunits == 4).then(|| cardinal(&t)).flatten() {
// A due bearing as surveyor's units show it: one letter.
} else if let Some(b) = cardinal(&t).or_else(|| bearing(&t)) {
(b, true)
} else if let Some(v) = num(&t) {
let a = match aunits {
@@ -357,7 +355,8 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> {
v.0.is_finite().then_some(v)
}
/// A due bearing (`n`, `e`, `s`, `w`, lower case) as radians counterclockwise from +X.
/// A compass point (`n`, `e`, `s`, `w`, lower case), a due bearing as surveyor's units show it,
/// as radians counterclockwise from +X. AutoCAD 2024 takes one in any angle format.
fn cardinal(t: &str) -> Option<f64> {
match t {
"e" => Some(0.0),
@@ -567,14 +566,19 @@ mod tests {
#[test]
fn surveyors_units_read_a_due_bearing_typed_as_one_letter() {
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`.
let a = AngleSettings { aunits: 4, angbase: 1.0, clockwise: true };
for (t, want) in [("E", 0.0), ("n", PI / 2.0), ("W", PI), ("s", 1.5 * PI)] {
let got = a.direction(t).unwrap();
// What surveyor's units show for a due bearing can be typed back, e.g. `@10<E`. AutoCAD 2024
// reads the letter as an angle like any other, from ANGBASE in the ANGDIR sense: with
// ANGBASE 90 and ANGDIR 1, `@10<N` draws east.
let a = AngleSettings { aunits: 4, angbase: PI / 2.0, clockwise: true };
for (t, want) in [("E", PI / 2.0), ("n", 0.0), ("W", 1.5 * PI), ("s", PI)] {
let got = a.direction(t).unwrap().rem_euclid(2.0 * PI);
assert!((got - want).abs() < 1e-12, "{t}: {got}");
}
// Other angle units don't take a bare letter.
assert_eq!(AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }.direction("E"), None);
// And in any angle format: `@10<E` draws east at AUNITS 0, `@10<S` south at AUNITS 1.
for (aunits, t, want) in [(0, "E", 0.0), (1, "S", 1.5 * PI)] {
let got = AngleSettings { aunits, ..AngleSettings::default() }.direction(t);
assert!(got.is_some_and(|a| (a - want).abs() < 1e-12), "AUNITS {aunits} {t}: {got:?}");
}
}
#[test]
@@ -644,6 +648,10 @@ mod tests {
assert!((parse_angle("90").unwrap() - PI / 2.0).abs() < 1e-12);
assert!((parse_angle("45d30'").unwrap().to_degrees() - 45.5).abs() < 1e-9);
assert!((parse_angle("N45dE").unwrap().to_degrees() - 45.0).abs() < 1e-9);
// A compass point on its own, in either case.
for (t, deg) in [("N", 90.0), ("e", 0.0), ("W", 180.0), ("s", 270.0)] {
assert!((parse_angle(t).unwrap().to_degrees() - deg).abs() < 1e-9, "{t}");
}
assert!((parse_angle("1.5r").unwrap() - 1.5).abs() < 1e-12);
assert_eq!(format_angle(PI / 2.0, 0, 0), "90");
assert_eq!(format_angle(PI / 4.0, 4, 0), "N 45d E");
+1
View File
@@ -208,6 +208,7 @@ fn derived_dimension_fields_are_not_differences() {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
};
let mut s = Session::new();
s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim(30.0, 7.0))).unwrap();
+303
View File
@@ -0,0 +1,303 @@
//! -WBLOCK at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, the
//! drawing read back afterwards). It writes objects from a base point and erases them, whatever
//! its mOde option says; a block by name, `=` (the block named as the file) or `*` (the whole
//! drawing); asks before replacing a file; and writes nothing when nothing is selected.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::{Drawing, EntityKind, Handle};
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
/// One line per object written: a line's start (where the base point puts it), else its type.
fn write(d: &Drawing, _path: &str) -> Result<Vec<u8>, String> {
let mut out = String::new();
for e in d.model.iter() {
match &e.kind {
EntityKind::Line(l) => out.push_str(&format!("LINE {},{}\n", l.a.x, l.a.y)),
k => out.push_str(&format!("{}\n", k.dxf_name())),
}
}
Ok(out.into_bytes())
}
/// A session writing through [`write`], with the drawing the measured scripts start from: line A
/// (0,0)-(10,0), circle B at (5,5) radius 3, line C (0,20)-(10,20).
fn drawing() -> (Session, Handle, Handle, Handle) {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None });
let mut s = Session::new();
let handle = |v: serde_json::Value| Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap();
let a = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
let b = handle(s.execute("circle", &json!({ "center": [5, 5], "radius": 3 })).unwrap());
let c = handle(s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap());
(s, a, b, c)
}
/// A file in a test's own folder, with forward slashes for a LISP string.
fn file(dir: &TempDir, name: &str) -> String {
dir.path_str(name).replace('\\', "/")
}
fn lisp(s: &mut Session, expr: &str) {
s.cmdline(expr).unwrap();
}
fn ent(h: Handle) -> String {
format!("(handent \"{}\")", h.hex())
}
fn alive(s: &Session, h: Handle) -> bool {
s.doc().unwrap().entity(h).is_some()
}
fn read(path: &str) -> Vec<String> {
std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect()
}
#[test]
fn objects_are_written_from_the_base_point_and_erased() {
// WBLOCK-000: A and B from (5,5); AutoCAD erases both and keeps C.
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
let path = file(&dir, "WB00.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(5.0 5.0) {} {} \"\")", ent(a), ent(b)));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
assert!(!alive(&s, a) && !alive(&s, b) && alive(&s, c));
assert!(s.log.iter().any(|l| l == "Current settings: Object conversion=Delete"), "{:?}", s.log);
}
#[test]
fn the_prompts_are_autocads() {
let (mut s, a, _, _) = drawing();
let dir = TempDir::new("dash-wblock");
s.cmdline("-wblock").unwrap();
assert!(s.prompt_text().ends_with("Enter name of output file:"), "{}", s.prompt_text());
s.cmdline(&file(&dir, "P.dwg")).unwrap();
assert!(
s.prompt_text().ends_with("Enter name of existing block or [= (block=output file)/* (whole drawing)] <define new drawing>:"),
"{}",
s.prompt_text()
);
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text());
// WBLOCK-008: anything but a point or mOde there.
s.cmdline("").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Point or option keyword required."));
s.cmdline("_O").unwrap();
assert!(s.prompt_text().ends_with("[Convert to block/Retain/Delete] <Delete>:"), "{}", s.prompt_text());
s.cmdline("_R").unwrap();
assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().ends_with("Select objects:"), "{}", s.prompt_text());
lisp(&mut s, &ent(a));
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
#[test]
fn mode_is_asked_but_the_objects_are_erased_whatever_it_says() {
// WBM-01, WBM-02: Retain and Convert to block are taken, yet A is erased (no block reference
// takes its place), and the next -WBLOCK still says "Object conversion=Delete".
for mode in ["_R", "_C"] {
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
let first = file(&dir, "M01a.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{first}\" \"\" \"_O\" \"{mode}\" '(0.0 0.0) {} \"\")", ent(a)));
assert!(s.running.is_none(), "{mode}: {}", s.prompt_text());
assert_eq!(read(&first), ["LINE 0,0"]);
assert!(!alive(&s, a) && alive(&s, b) && alive(&s, c), "{mode}");
assert_eq!(s.doc().unwrap().model.len(), 2, "{mode}: nothing takes A's place");
let before = s.log.len();
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "M01b.dwg"), ent(b)));
assert!(s.log[before..].iter().any(|l| l == "Current settings: Object conversion=Delete"), "{mode}: {:?}", s.log);
assert!(!alive(&s, b) && alive(&s, c), "{mode}");
}
}
#[test]
fn nothing_selected_writes_nothing() {
// WBM-05: the command ends; no file, nothing erased.
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
let path = file(&dir, "M05.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(0.0 0.0) \"\")"));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(!std::path::Path::new(&path).exists());
assert!(alive(&s, a) && alive(&s, b) && alive(&s, c));
}
#[test]
fn an_existing_file_is_replaced_only_on_yes() {
// WBM-06: No (the default) ends the command; WBM-07: Yes goes on to the block prompt.
let (mut s, a, b, _) = drawing();
let dir = TempDir::new("dash-wblock");
let path = file(&dir, "M06.dwg");
std::fs::write(&path, "old").unwrap();
s.cmdline("-wblock").unwrap();
s.cmdline(&path).unwrap();
assert!(s.prompt_text().ends_with(&format!("{path} already exists, do you want to replace it? [Yes/No] <N>:")), "{}", s.prompt_text());
// Only Yes replaces it: other text is asked again.
s.cmdline("yodel").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
assert!(s.prompt_text().ends_with("[Yes/No] <N>:"), "{}", s.prompt_text());
s.cmdline("_N").unwrap();
assert!(s.running.is_none());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old");
// Enter is No too.
s.cmdline("-wblock").unwrap();
s.cmdline(&path).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"_Y\" \"\" '(0.0 0.0) {} \"\")", ent(b)));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(read(&path), ["CIRCLE"]);
assert!(alive(&s, a) && !alive(&s, b));
}
#[test]
fn a_block_is_written_by_name_by_equals_or_the_whole_drawing_by_star() {
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
s.execute("block", &json!({ "name": "WB01K", "base": [5, 5], "handles": [a.hex(), b.hex()], "keep": "delete" })).unwrap();
// WBLOCK-001: a block by name, from its base point; nothing is erased.
let path = file(&dir, "WB01.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"WB01K\")"));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
assert!(alive(&s, c));
// WBLOCK-002: "=" is the block named as the file.
let path = file(&dir, "WB01K.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"=\")"));
assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]);
// WBLOCK-003: "*" is the whole drawing.
let path = file(&dir, "WB03.dwg");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"*\")"));
assert_eq!(read(&path), ["LINE 0,20"]);
assert!(alive(&s, c));
// WBLOCK-005: a point given there is a name too.
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" '(0.0 0.0))", file(&dir, "WB05.dwg")));
assert!(s.log.iter().any(|l| l == "Block \"0,0\" not found."), "{:?}", s.log);
s.cancel();
// WBLOCK-008, WBLOCK-015: a name that isn't a block, typed or by "=", is refused and asked again.
for (answer, name) in [("WB08ZZ", "WB08ZZ"), ("=", "WB15")] {
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"{answer}\")", file(&dir, "WB15.dwg")));
assert!(s.log.iter().any(|l| *l == format!("Block \"{name}\" not found.")), "{answer}: {:?}", s.log);
assert!(s.prompt_text().contains("Enter name of existing block"), "{}", s.prompt_text());
s.cancel();
}
assert!(!std::path::Path::new(&file(&dir, "WB15.dwg")).exists());
}
#[test]
fn a_name_without_extension_is_a_dwg_and_a_dxf_asks_for_its_precision() {
// WBLOCK-004: no extension.
let (mut s, a, b, _) = drawing();
let dir = TempDir::new("dash-wblock");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "WB04"), ent(a)));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(read(&file(&dir, "WB04.dwg")), ["LINE 0,0"]);
// WBLOCK-010: a DXF file asks for its decimal places first; Enter takes 16.
let path = file(&dir, "WB10.dxf");
s.cmdline("-wblock").unwrap();
s.cmdline(&path).unwrap();
assert!(s.log.iter().any(|l| l.starts_with("Current DXF settings: Precision= 16 Format= ASCII")), "{:?}", s.log);
assert!(s.prompt_text().ends_with("Enter decimal places of accuracy (0 to 16) or [Binary/Preview] <16>:"), "{}", s.prompt_text());
s.cancel();
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" \"\" '(0.0 0.0) {} \"\")", ent(b)));
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(read(&path), ["CIRCLE"]);
assert!(!alive(&s, b));
}
#[test]
fn undo_brings_the_written_objects_back() {
// WBM-09: U after -WBLOCK.
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(5.0 5.0) {} {} \"\")", file(&dir, "M09.dwg"), ent(a), ent(b)));
assert!(!alive(&s, a) && !alive(&s, b));
s.cmdline("u").unwrap();
assert!(alive(&s, a) && alive(&s, b) && alive(&s, c));
}
#[test]
fn the_json_form_of_dash_wblock_erases_what_it_writes() {
let (mut s, a, b, _) = drawing();
let dir = TempDir::new("dash-wblock");
s.execute("wblock", &json!({ "path": file(&dir, "keep.dwg"), "handles": [a.hex()] })).unwrap();
assert!(alive(&s, a), "WBLOCK's JSON form keeps them");
s.execute("-wblock", &json!({ "path": file(&dir, "erase.dwg"), "handles": [b.hex()], "base": [5, 5] })).unwrap();
assert_eq!(read(&file(&dir, "erase.dwg")), ["CIRCLE"]);
assert!(!alive(&s, b));
// Nothing selected writes no file, as at the command line.
let none = file(&dir, "none.dwg");
assert!(s.execute("-wblock", &json!({ "path": none, "handles": [] })).is_err());
assert!(s.execute("-wblock", &json!({ "path": none, "handles": [b.hex()] })).is_err(), "erased already");
assert!(s.execute("-wblock", &json!({ "path": none })).is_err());
assert!(!std::path::Path::new(&none).exists());
}
#[test]
fn oops_brings_back_what_it_erased_where_it_was() {
// OOPSW-001 to OOPSW-005 (the drawing's `entnext` order read back): OOPS brings back what
// -WBLOCK erased, each where it was, as after ERASE. Only the last erase counts, and "*" (the
// whole drawing) erases nothing.
const OOPS: &str = "(command \"_.OOPS\")";
// (script with @A to @D for the objects and @F for a new file, the order afterwards)
let cases: [(&[&str], &str); 5] = [
(&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B @C \"\")", OOPS], "A B C D"),
(&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", "(command \"_.ERASE\" @D \"\")", OOPS], "A C D"),
(&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", OOPS], "A B C"),
(&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"*\")", OOPS], "A B C D"),
(&["(command \"_.-WBLOCK\" @F \"\" \"_O\" \"_R\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"),
];
let dir = TempDir::new("dash-wblock");
for (i, (script, want)) in cases.into_iter().enumerate() {
let (mut s, a, b, c) = drawing();
let v = s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
let d = Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap();
let hs = [a, b, c, d];
for line in script {
let mut line = line.to_string();
for (n, h) in ["@A", "@B", "@C", "@D"].into_iter().zip(hs) {
line = line.replace(n, &ent(h));
}
let line = line.replace("@F", &format!("\"{}\"", file(&dir, &format!("OOPSW{i}.dwg"))));
lisp(&mut s, &line);
assert!(s.running.is_none(), "{line}: {}", s.prompt_text());
}
let order: Vec<&str> =
s.doc().unwrap().model.iter().map(|e| hs.iter().position(|h| *h == e.handle).map_or("E", |k| ["A", "B", "C", "D"][k])).collect();
assert_eq!(order.join(" "), want, "{script:?}");
}
// The JSON form too.
let (mut s, a, b, c) = drawing();
s.execute("-wblock", &json!({ "path": file(&dir, "json.dwg"), "handles": [b.hex()] })).unwrap();
assert!(!alive(&s, b));
s.execute("oops", &json!({})).unwrap();
let order: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
assert_eq!(order, [a, b, c]);
}
#[test]
fn a_dash_wblock_refused_during_refedit_leaves_oops_as_it_was() {
let (mut s, a, b, c) = drawing();
let dir = TempDir::new("dash-wblock");
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [a.hex()], "keep": "convert" })).unwrap();
let ins = Handle::parse_hex(s.execute("entities", &json!({ "type": "INSERT" })).unwrap()["entities"][0]["handle"].as_str().unwrap()).unwrap();
s.execute("erase", &json!({ "handles": [c.hex()] })).unwrap();
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
// B isn't in the working set: -WBLOCK is refused and the drawing put back.
assert!(s.execute("-wblock", &json!({ "path": file(&dir, "out.dwg"), "handles": [b.hex()] })).is_err());
assert!(alive(&s, b));
s.execute("refclose", &json!({ "save": false })).unwrap();
// OOPS still has C, the last erase that stood.
s.execute("oops", &json!({})).unwrap();
assert!(alive(&s, c));
}
+107
View File
@@ -0,0 +1,107 @@
//! `INSERT name=file` keeps the field of an attribute definition: the definition's extension
//! dictionary and the objects in it come along under new handles, so the attributes the block's
//! references get can carry the field when the drawing is saved.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::foreign::{ForeignExtra, ForeignObject};
use cadcraft_engine::doc::{Attrib, Drawing, Entity, EntityKind, Handle, RawTag, Text};
use cadcraft_engine::geom::Vec3;
use serde_json::json;
#[path = "support/temp_dir.rs"]
mod temp_dir;
use temp_dir::TempDir;
fn tag(code: i32, value: &str) -> RawTag {
RawTag { code, value: value.into() }
}
fn object(handle: u64, kind: &str, tags: Vec<RawTag>) -> ForeignObject {
ForeignObject { handle: Handle(handle), kind: kind.into(), tags }
}
/// A drawing file in a test format: model space holds a definition NUMBER (handle 0x50) whose
/// extension dictionary 0x60 owns the ACAD_FIELD dictionary 0x61, which owns the field 0x62 with
/// a child field 0x63. Handles 0x61..0x63 stand for the file's own; a second, unrelated object
/// 0x70 is not part of that tree. The bytes `plain` give the same definition without any of it.
fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
let mut d = Drawing::new_imperial();
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.0,
value: "TEMPLATE".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: Default::default(),
valign: Default::default(),
};
let def = Attrib {
tag: "NUMBER".into(),
text,
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
d.model.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
if bytes == b"plain" {
return Ok(d);
}
let f = Arc::make_mut(&mut d.foreign);
f.entities.insert(Handle(0x50), ForeignExtra { xdict: Some(Handle(0x60)), ..Default::default() });
f.objects.push(object(0x60, "DICTIONARY", vec![tag(5, "60"), tag(330, "50"), tag(3, "ACAD_FIELD"), tag(360, "61")]));
f.objects.push(object(0x61, "DICTIONARY", vec![tag(5, "61"), tag(330, "60"), tag(3, "TEXT"), tag(360, "62")]));
f.objects.push(object(0x62, "FIELD", vec![tag(5, "62"), tag(330, "61"), tag(1, "_text"), tag(360, "63")]));
f.objects.push(object(0x63, "FIELD", vec![tag(5, "63"), tag(330, "62"), tag(1, "AcVar")]));
f.objects.push(object(0x70, "XRECORD", vec![tag(5, "70"), tag(330, "0")]));
f.classes.push(vec![tag(1, "FIELD"), tag(2, "AcDbField")]);
Ok(d)
}
fn hooks() {
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
}
#[test]
fn the_definition_of_an_inserted_file_keeps_its_field() {
hooks();
let dir = TempDir::new("insert-file-field");
let path = dir.file("border.dxf", "");
let mut s = Session::new();
s.execute("insert", &json!({ "name": "BORDER", "file": path, "at": [0, 0], "attribs": { "NUMBER": "A-1" } })).unwrap();
let d = s.doc().unwrap();
let def = d.block("BORDER").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))).expect("the definition");
assert_ne!(def.handle, Handle(0x50), "the definition has a handle of this drawing");
let root = d.foreign.entities.get(&def.handle).and_then(|x| x.xdict).expect("the definition has an extension dictionary");
let tree = d.foreign.tree(root);
assert_eq!(tree.len(), 4, "the dictionaries and both fields, not the unrelated object");
assert!(tree.iter().all(|h| ![0x60, 0x61, 0x62, 0x63, 0x70].contains(&h.0)), "the copies have new handles");
// The root's owner is the definition, the others point at each other.
let owner =
|h: Handle| d.foreign.objects.iter().find(|o| o.handle == h).and_then(|o| o.tags.iter().find(|t| t.code == 330)).map(|t| t.value.clone());
assert_eq!(owner(tree[0]), Some(def.handle.hex()));
assert_eq!(owner(tree[1]), Some(tree[0].hex()));
assert_eq!(owner(tree[3]), Some(tree[2].hex()));
assert!(d.foreign.classes.iter().any(|c| c.iter().any(|t| t.code == 1 && t.value == "FIELD")), "the field class comes along");
}
#[test]
fn a_definition_without_a_field_gets_none() {
hooks();
let dir = TempDir::new("insert-file-nofield");
let path = dir.file("plain.dxf", "plain");
let mut s = Session::new();
s.execute("insert", &json!({ "name": "PLAIN", "file": path, "at": [0, 0] })).unwrap();
let d = s.doc().unwrap();
assert!(d.block("PLAIN").is_some());
assert!(d.foreign.is_empty(), "nothing is kept for a drawing without fields");
}
+72 -2
View File
@@ -4,6 +4,7 @@
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::lisp::value::Value;
use cadcraft_engine::units::AngleSettings;
use serde_json::json;
@@ -40,8 +41,77 @@ fn polar_angles_measure_from_angbase_in_angdir() {
assert_near(line_end(&mut s, "@10<<90"), 0.0, 10.0, "@10<<90");
setvar(&mut s, "AUNITS", json!(3));
assert_near(line_end(&mut s, "@10<<<3.141592653589793"), -10.0, 0.0, "@10<<<pi");
// A surveyor's bearing is absolute.
assert_near(line_end(&mut s, "@10<N90dE"), 10.0, 0.0, "@10<N90dE");
// A surveyor's bearing is an angle like any other, from ANGBASE in the ANGDIR sense: AutoCAD
// 2024 draws `@10<N45dE` at 45 degrees here, and `@10<N` east.
setvar(&mut s, "AUNITS", json!(0));
let h = 10.0 / 2f64.sqrt();
assert_near(line_end(&mut s, "@10<N45dE"), h, h, "@10<N45dE");
assert_near(line_end(&mut s, "@10<N"), 10.0, 0.0, "@10<N");
}
#[test]
fn bearings_and_compass_points_turn_with_angbase_and_angdir() {
// AutoCAD 2024 (accoreconsole), LINE 0,0 to the point: (input, AUNITS, ANGBASE in degrees,
// ANGDIR, the direction of the line in degrees counterclockwise from east). A bearing or a
// compass point is an angle like any other, in any AUNITS; `<<<` measures it from east.
let cases = [
("@10<N", 0, 0.0, 0, 90.0),
("@10<N", 4, 0.0, 0, 90.0),
("@10<E", 0, 0.0, 0, 0.0),
("@10<W", 4, 0.0, 0, 180.0),
("@10<S", 1, 0.0, 0, 270.0),
("@10<N45dE", 0, 0.0, 0, 45.0),
("@10<N", 4, 90.0, 1, 0.0),
("@10<N45dE", 0, 90.0, 1, 45.0),
("@10<N45dE", 0, 0.0, 1, 315.0),
("@10<N45dE", 0, 90.0, 0, 135.0),
("@10<N", 0, 90.0, 0, 180.0),
("@10<E", 0, 0.0, 1, 0.0),
("@10<N", 0, 0.0, 1, 270.0),
// ANGBASE 0.5 radians: AutoCAD drew it at 253.64789 degrees.
("@10<S45dW", 0, 0.5f64.to_degrees(), 0, 225.0 + 0.5f64.to_degrees()),
("@10<<<N45dE", 4, 90.0, 1, 45.0),
("@10<<<N45dE", 0, 90.0, 1, 45.0),
("@10<<<N", 4, 90.0, 1, 90.0),
("@10<<<N", 0, 90.0, 1, 90.0),
];
for (to, aunits, angbase, angdir, deg) in cases {
let mut s = Session::new();
setvar(&mut s, "AUNITS", json!(aunits));
setvar(&mut s, "ANGBASE", json!(angbase));
setvar(&mut s, "ANGDIR", json!(angdir));
let a = f64::to_radians(deg);
assert_near(line_end(&mut s, to), 10.0 * a.cos(), 10.0 * a.sin(), &format!("{to} AUNITS {aunits} ANGBASE {angbase} ANGDIR {angdir}"));
}
// `<<` is in degrees only: AutoCAD refuses a bearing or a compass point there.
let settings = AngleSettings { aunits: 0, angbase: std::f64::consts::FRAC_PI_2, clockwise: true };
assert_eq!(settings.direction("<<N45dE"), None);
assert_eq!(settings.direction("<<N"), None);
assert!(settings.direction("<<45").is_some_and(|a| (a - std::f64::consts::FRAC_PI_4).abs() < 1e-12));
// A compass point as an amount turns as ANGDIR says: ROTATE by N with ANGDIR 1 turns a line
// along +X to -Y in AutoCAD 2024.
let mut s = Session::new();
setvar(&mut s, "ANGDIR", json!(1));
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
for t in ["rotate", "all", "", "0,0", "N"] {
s.cmdline(t).unwrap();
}
match &s.doc().unwrap().model.last().unwrap().kind {
EntityKind::Line(l) => assert_near(l.b.xy(), 0.0, -10.0, "ROTATE by N with ANGDIR 1"),
k => panic!("expected a line, got {k:?}"),
}
// ANGTOF reads them the same way: AutoCAD 2024 returns 0 for "N" with ANGBASE 90, ANGDIR 1
// and AUNITS 3, and 4.18879020478639 for "S 30d W".
let mut s = Session::new();
setvar(&mut s, "AUNITS", json!(3));
setvar(&mut s, "ANGBASE", json!(90));
setvar(&mut s, "ANGDIR", json!(1));
let n = s.lisp_eval("(angtof \"N\")", false, true, Vec::new()).unwrap();
assert!(matches!(n, Some(Ok(Value::Real(a))) if a.abs() < 1e-12), "{n:?}");
let mut s = Session::new();
setvar(&mut s, "AUNITS", json!(4));
let sw = s.lisp_eval("(angtof \"S 30d W\")", false, true, Vec::new()).unwrap();
assert!(matches!(sw, Some(Ok(Value::Real(a))) if (a - 4.18879020478639).abs() < 1e-12), "{sw:?}");
}
#[test]
+17
View File
@@ -212,3 +212,20 @@ fn arrays_over_maxarray_are_refused_not_shrunk() {
assert!(s.current_prompt().is_none(), "{}", prompt(&s));
assert_eq!(circle_centers(&s).len(), 2000);
}
#[test]
fn json_arraypolar_rejects_bad_fill_angles() {
let mut s = Session::new();
let h = s.execute("circle", &json!({"center": [5, 0], "radius": 1})).unwrap()["handle"].clone();
let before = s.doc().unwrap().model.iter().count();
for bad in [0.0, 720.0, -400.0, 360.5] {
let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": bad, "associative": false});
let e = s.execute("arraypolar", &p).unwrap_err();
assert!(e.to_string().contains("Requires a fill angle between -360 and 360 degrees"), "{bad}: {e}");
assert_eq!(s.doc().unwrap().model.iter().count(), before, "{bad}");
}
for ok in [360.0, -360.0] {
let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": ok, "associative": false});
s.execute("arraypolar", &p).unwrap();
}
}
+1 -1
View File
@@ -336,7 +336,7 @@ fn typed_arrayedit() {
let a = rect_array(&mut s);
typed(&mut s, &["arrayedit", "l"]);
let p = s.prompt_text();
assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] <eXit>"), "{p}");
assert!(p.contains("Enter an option [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] <eXit>"), "{p}");
typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]);
assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text());
assert_eq!(centres(&s, &a).len(), 8);
+186
View File
@@ -0,0 +1,186 @@
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts with
//! TEXTSIZE 2.5 and AFLAGS 16, the drawing and AFLAGS read back afterwards).
use cadcraft_engine::doc::{Attrib, EntityKind, HAlign, VAlign};
use cadcraft_engine::{Session, sysvars};
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.execute("setvar", &json!({ "name": "TEXTSIZE", "value": 2.5 })).unwrap();
s.execute("setvar", &json!({ "name": "AFLAGS", "value": 16 })).unwrap();
s
}
fn attdefs(s: &Session) -> Vec<Attrib> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).collect()
}
fn aflags(s: &Session) -> i64 {
sysvars::get(s, "AFLAGS").and_then(|v| v.as_f64()).unwrap() as i64
}
fn run(script: &str) -> Session {
let mut s = session();
s.cmdline(script).unwrap();
assert!(s.running.is_none(), "{script}: {}", s.prompt_text());
s
}
#[test]
fn modes_are_toggled_kept_in_aflags_and_stored_on_the_definition() {
// (script, AFLAGS after, invisible, constant, verify, preset, lock position, tag, prompt, value)
type Case = (&'static str, i64, [bool; 5], &'static str, &'static str, &'static str);
let cases: [Case; 5] = [
// ATTDEF-000: the modes as they are.
(
"(command \"_.-ATTDEF\" \"\" \"T1\" \"Nhap ten\" \"MAC\" '(5.0 10.0) 2.5 0)",
16,
[false, false, false, false, true],
"T1",
"Nhap ten",
"MAC",
),
// ATTDEF-001: Invisible and Verify.
(
"(command \"_.-ATTDEF\" \"_I\" \"_V\" \"\" \"T2\" \"Hoi hai\" \"D2\" '(5.0 10.0) 3.0 30)",
21,
[true, false, true, false, true],
"T2",
"Hoi hai",
"D2",
),
// ATTDEF-002: Constant asks for the value, not a prompt and a default.
("(command \"_.-ATTDEF\" \"_C\" \"\" \"T3\" \"GIATRI\" '(5.0 10.0) 2.5 0)", 18, [false, true, false, false, true], "T3", "", "GIATRI"),
// ATTDEF-003: Preset.
(
"(command \"_.-ATTDEF\" \"_P\" \"\" \"T4\" \"Hoi bon\" \"D4\" '(612350.678 2398775.432) 2.5 0)",
24,
[false, false, false, true, true],
"T4",
"Hoi bon",
"D4",
),
// ATTDEF-004: Lock position off.
(
"(command \"_.-ATTDEF\" \"_L\" \"\" \"T5\" \"Hoi nam\" \"D5\" '(5.0 10.0) 2.5 0)",
0,
[false, false, false, false, false],
"T5",
"Hoi nam",
"D5",
),
];
for (script, flags, [inv, con, ver, pre, lock], tag, prompt, value) in cases {
let s = run(script);
assert_eq!(aflags(&s), flags, "{script}");
let a = attdefs(&s).remove(0);
assert_eq!((a.invisible, a.constant, a.modes.verify, a.modes.preset, a.modes.lock_position), (inv, con, ver, pre, lock), "{script}");
assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), (tag, prompt, value), "{script}");
}
// ATTDEF-001's height 3 and 30 degrees.
let a = attdefs(&run(cases[1].0)).remove(0);
assert!((a.text.height - 3.0).abs() < 1e-12 && (a.text.rotation - 30f64.to_radians()).abs() < 1e-12, "{a:?}");
}
#[test]
fn tags_justification_and_style_as_autocad_takes_them() {
// ATTDEF-021, ATTDEF-022: a tag is upper-cased, spaces and all.
for (typed, tag) in [("maso", "MASO"), ("ten ban ve", "TEN BAN VE")] {
let s = run(&format!("(command \"_.-ATTDEF\" \"\" \"{typed}\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)"));
assert_eq!(attdefs(&s)[0].tag, tag);
}
// ATTDEF-023: an empty prompt stays empty.
let s = run("(command \"_.-ATTDEF\" \"\" \"T10\" \"\" \"D10\" '(612350.678 2398775.432) 2.5 0)");
assert_eq!(attdefs(&s)[0].prompt, "");
// ATTDEF-025: an empty tag is refused and asked again.
let s = run("(command \"_.-ATTDEF\" \"\" \"\" \"T11\" \"Hoi muoi mot\" \"D11\" '(5.0 10.0) 2.5 0)");
assert!(s.log.iter().any(|l| l == "Tag cannot be null"), "{:?}", s.log);
assert_eq!(attdefs(&s)[0].tag, "T11");
// ATTDEF-005: Justify MC at the middle point.
let s = run("(command \"_.-ATTDEF\" \"\" \"T6\" \"Hoi sau\" \"D6\" \"_J\" \"_MC\" '(5.0 10.0) 2.5 0)");
let a = attdefs(&s).remove(0);
assert_eq!((a.text.halign, a.text.valign), (HAlign::Center, VAlign::Middle));
assert!(a.text.align_pt.is_some_and(|p| p.x == 5.0 && p.y == 10.0), "{a:?}");
// ATTDEF-006: Style, then the point.
let s = run("(command \"_.-ATTDEF\" \"\" \"T7\" \"Hoi bay\" \"D7\" \"_S\" \"Standard\" '(0.505 0.26) 2.5 0)");
let a = attdefs(&s).remove(0);
assert!(a.text.style == "Standard" && a.text.insert.x == 0.505 && a.text.insert.y == 0.26, "{a:?}");
}
#[test]
fn the_prompts_are_autocads() {
let mut s = session();
s.cmdline("-attdef").unwrap();
assert!(s.log.iter().any(|l| l == "Current attribute modes:"), "{:?}", s.log);
assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=N Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N"));
assert!(s.prompt_text().contains("[Invisible/Constant/Verify/Preset/Lock position/Annotative/Multiple lines] <done>:"), "{}", s.prompt_text());
s.cmdline("X").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
s.cmdline("_C").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=Y Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N"));
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Enter attribute tag name:"), "{}", s.prompt_text());
s.cmdline("T3").unwrap();
assert!(s.prompt_text().ends_with("Enter attribute value:"), "{}", s.prompt_text());
s.cmdline("GIATRI").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Current text style: \"Standard\" Text height: 2.5000 Justify: Left"));
assert!(s.prompt_text().ends_with("Specify start point of text or [Justify/Style]:"), "{}", s.prompt_text());
s.cmdline("_J").unwrap();
// A wrong answer, or a justification not available here, is asked again.
for typed in ["X", "_A"] {
s.cmdline(typed).unwrap();
assert!(s.prompt_text().contains("[Left/Center/Right/Align/Middle/Fit/TL/"), "{typed}: {}", s.prompt_text());
}
s.cmdline("_MC").unwrap();
assert!(s.prompt_text().ends_with("Specify middle point of text:"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().ends_with("Specify height <2.5000>:"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Specify rotation angle of text <0>:"), "{}", s.prompt_text());
s.cancel();
}
#[test]
fn a_constant_attribute_stays_in_the_block() {
// INSERTATT-011: the constant TC is no attribute of the reference; the other one, TN, is.
let mut s = session();
s.execute("line", &json!({ "points": [[612345.678, 2398765.432], [612355.678, 2398765.432]] })).unwrap();
s.cmdline("(setq hl_A (entlast))").unwrap();
s.cmdline("(progn (command \"_.-ATTDEF\" \"_C\" \"\" \"TC\" \"HANG\" '(612350.678 2398775.432) 2.5 0) (setq hl_a1 (entlast)))").unwrap();
s.cmdline("(setvar \"AFLAGS\" 16)").unwrap();
s.cmdline("(progn (command \"_.-ATTDEF\" \"\" \"TN\" \"Hoi N\" \"MN\" '(612350.678 2398785.432) 2.5 0) (setq hl_a2 (entlast)))").unwrap();
s.cmdline("(command \"_.-BLOCK\" \"B11\" '(612345.678 2398765.432) hl_a1 hl_a2 hl_A \"\")").unwrap();
s.cmdline("(command \"_.-INSERT\" \"B11\" '(612445.678 2398815.432) 1 1 0 \"XN\")").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let ins = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).unwrap();
let tags: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect();
assert_eq!(tags, [("TN".to_string(), "XN".to_string())]);
}
#[test]
fn a_typed_height_is_a_distance() {
let mut s = session();
for typed in ["-attdef", "", "T1", "P", "V", "5,10", "x"] {
s.cmdline(typed).unwrap();
}
assert_eq!(s.log.last().map(String::as_str), Some("Requires a positive height."));
// Feet and inches, as TEXT takes them.
for typed in ["1'6\"", "0"] {
s.cmdline(typed).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(attdefs(&s)[0].text.height, 18.0);
}
#[test]
fn the_modes_start_from_aflags() {
// AFLAGS 0, not the default 16: Invisible toggled on gives 1, and nothing else.
let mut s = session();
s.execute("setvar", &json!({ "name": "AFLAGS", "value": 0 })).unwrap();
s.cmdline("(command \"_.-ATTDEF\" \"_I\" \"\" \"T1\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(aflags(&s), 1);
let a = attdefs(&s).remove(0);
assert_eq!((a.invisible, a.modes.lock_position), (true, false));
}
+4
View File
@@ -314,6 +314,7 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition(
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
};
let blocks = |dim_end: f64, first: u64| {
let mut part = Block::new("PART");
@@ -400,6 +401,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
};
let mut part = Block::new("PART");
part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim)));
@@ -514,6 +516,7 @@ fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
};
let mut s = Session::new();
let mut drawn = Block::new("*D1");
@@ -833,6 +836,7 @@ fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() {
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
};
for k in 1..=MAX_BLOCK_DEPTH {
let mut b = Block::new(&format!("B{k}"));
+9 -56
View File
@@ -39,7 +39,7 @@ fn run(s: &mut Session, answers: &str) {
#[test]
fn the_measured_scripts_set_the_units() {
let cases: [(Units, &str, Units); 21] = [
let cases: [(Units, &str, Units); 22] = [
(DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)),
(DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)),
(DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)),
@@ -61,6 +61,7 @@ fn the_measured_scripts_set_the_units() {
(DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)),
((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)),
((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)),
(DEFAULT, "\"\" \"\" \"\" \"\" \"N\" \"\"", (2, 4, 0, 0, FRAC_PI_2, 0)),
];
for (from, answers, to) in cases {
let mut s = session(from);
@@ -112,15 +113,7 @@ fn prompts_and_listings_show_the_drawings_units() {
// Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it.
let mut s = session((2, 4, 0, 0, 0.0, 1));
s.script(
"-units
5
",
)
.unwrap();
s.script("-units\n\n\n5\n\n").unwrap();
let directions = [
"Direction for angle E:",
" East 3 o'clock = E",
@@ -133,15 +126,7 @@ fn prompts_and_listings_show_the_drawings_units() {
// The listing shows directions to the precision just given.
let mut s = session(DEFAULT);
s.script(
"-units
2
2
",
)
.unwrap();
s.script("-units\n\n\n2\n2\n").unwrap();
let directions = [
"Direction for angle 0d0':",
" East 3 o'clock = 0d0'",
@@ -166,27 +151,11 @@ fn a_rejected_answer_is_asked_again() {
s.cmdline("3").unwrap();
assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log);
assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:");
s.script(
"
9
",
)
.unwrap();
s.script("\n\n9\n").unwrap();
assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log);
s.script(
"
abc
",
)
.unwrap();
s.script("\nabc\n").unwrap();
assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log);
s.script(
"
",
)
.unwrap();
s.script("\n\n").unwrap();
assert!(s.running.is_none());
assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s));
}
@@ -196,28 +165,12 @@ fn each_answer_is_kept_as_it_is_given() {
// Esc after the angle format: the answers before it are kept, and the new angle format's
// precision is reset to 4.
let mut s = session(DEFAULT);
s.script(
"-units
4
16
2
",
)
.unwrap();
s.script("-units\n4\n16\n2\n").unwrap();
s.cancel();
assert!(s.running.is_none());
assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s));
let mut s = session(DEFAULT);
s.script(
"-units
3
2
3
5
90
",
)
.unwrap();
s.script("-units\n3\n2\n3\n5\n90\n").unwrap();
s.cancel();
assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s));
}
+271
View File
@@ -0,0 +1,271 @@
//! AREA, PERIMETER and DISTANCE as AutoCAD 2024 leaves them after AREA, DIST and MEASUREGEOM
//! (measured with accoreconsole, running these scripts on these drawings): AREA is the area just
//! measured, or the running total in Add and Subtract mode, PERIMETER the perimeter of the last
//! area; DISTANCE the distance measured, or the total of DIST's Multiple points. After DIST's two
//! points LASTPOINT is still the first; after Multiple points it is the last.
use cadcraft_engine::doc::{Common, EntityKind, Handle};
use cadcraft_engine::geom::{Spline, Vec2};
use cadcraft_engine::{Input, Session, sysvars};
use serde_json::{Value, json};
fn var(s: &Session, name: &str) -> f64 {
sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap_or_else(|| panic!("{name} not a number"))
}
fn lastpoint(s: &Session) -> (f64, f64) {
let v = sysvars::get(s, "LASTPOINT").unwrap();
(v[0].as_f64().unwrap(), v[1].as_f64().unwrap())
}
fn close(got: f64, want: f64) -> bool {
(got - want).abs() <= 1e-9 * (1.0 + want.abs())
}
fn handle(v: &Value) -> Handle {
Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap()
}
fn lisp(s: &mut Session, expr: &str) {
s.cmdline(expr).unwrap();
assert!(s.running.is_none(), "{expr}: {}", s.prompt_text());
}
/// The closed polyline with an arc and the circle the Add-mode scripts pick.
fn p_and_c(s: &mut Session) -> (Handle, Handle) {
let p = handle(&s.execute("pline", &json!({ "vertices": [[0, 0], { "p": [40, 0], "bulge": 0.5 }, [40, 30], [0, 30]], "closed": true })).unwrap());
let c = handle(&s.execute("circle", &json!({ "center": [150, 15], "radius": 10 })).unwrap());
(p, c)
}
#[test]
fn area_of_points_sets_area_and_perimeter() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")");
assert!(close(var(&s, "AREA"), 1200.0) && close(var(&s, "PERIMETER"), 140.0));
}
#[test]
fn add_mode_totals_the_area_and_keeps_the_last_perimeter() {
// Points, then the circle as an object.
let mut s = Session::new();
let (_, c) = p_and_c(&mut s);
lisp(
&mut s,
&format!(
"(command \"_.AREA\" \"_A\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\" \"_O\" (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")",
c.hex()
),
);
assert!(close(var(&s, "AREA"), 1514.159265358979), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER"));
// Both as objects.
let mut s = Session::new();
let (p, c) = p_and_c(&mut s);
lisp(
&mut s,
&format!(
"(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(0.0 15.0)) (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")",
p.hex(),
c.hex()
),
);
assert!(close(var(&s, "AREA"), 1671.411490437671), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER"));
}
#[test]
fn subtract_mode_takes_the_area_off_the_total() {
// At large coordinates: the polyline added, the circle subtracted.
let mut s = Session::new();
let p = handle(
&s.execute(
"pline",
&json!({ "vertices": [[-35000, 81000], { "p": [-33000, 81000], "bulge": 0.5 }, [-33000, 82500], [-35000, 82500]], "closed": true }),
)
.unwrap(),
);
let c = handle(&s.execute("circle", &json!({ "center": [-27000, 81750], "radius": 500 })).unwrap());
lisp(
&mut s,
&format!(
"(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(-35000.0 81750.0)) \"\" \"_S\" \"_O\" (list (handent \"{}\") '(-27000.0 81750.0)) \"\" \"\")",
p.hex(),
c.hex()
),
);
assert!(close(var(&s, "AREA"), 2607732.399299283), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 3141.592653589793), "{}", var(&s, "PERIMETER"));
}
#[test]
fn area_of_an_open_spline_closes_it_by_its_chord() {
let control = [(0.8, 0.25), (0.82, 0.24), (0.84, 0.25), (0.84, 0.28), (0.82, 0.29), (0.8, 0.28), (0.8, 0.25)];
let spline = Spline {
degree: 3,
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 4.0, 4.0],
control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(),
weights: Vec::new(),
fit: Vec::new(),
closed: false,
fit_opts: Default::default(),
};
let mut s = Session::new();
let space = s.space();
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.8 0.265)))", h.hex()));
assert!(close(var(&s, "AREA"), 0.001308333333333334), "{}", var(&s, "AREA"));
// The spline's length, 0.13046952991870 by an independent integration. AutoCAD reported
// 0.1304733129441191, 2.9e-5 longer than that: its own approximation of the length.
assert!(close(var(&s, "PERIMETER"), 0.1304695299187), "{}", var(&s, "PERIMETER"));
}
#[test]
fn area_of_a_spline_counts_its_narrow_knot_spans() {
// A degree-1 spline is its control polygon, here the triangle (0,0) (1,0) (0,1): area 0.5,
// perimeter 2 + sqrt(2), though two of its three knot spans are a billionth of the domain.
let control = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)];
let spline = Spline {
degree: 1,
knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0],
control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(),
..Spline::default()
};
let mut s = Session::new();
let space = s.space();
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.5 0.0)))", h.hex()));
assert!(close(var(&s, "AREA"), 0.5), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 2.0 + 2f64.sqrt()), "{}", var(&s, "PERIMETER"));
}
#[test]
fn area_of_a_spline_with_more_spans_than_samples_counts_each() {
// The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: 5000 spans along the bottom,
// then two a millionth wide up the right side and across the top. Area 10000, perimeter (the
// open spline's length) 300.
let mut control: Vec<Vec2> = (0..=5000).map(|i| Vec2::new(i as f64 / 50.0, 0.0)).collect();
control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]);
let mut knots: Vec<f64> = (0..=5000).map(f64::from).collect();
knots.insert(0, 0.0);
knots.extend([5000.0 + 1e-6, 5000.0 + 2e-6, 5000.0 + 2e-6]);
let spline = Spline { degree: 1, knots, control, ..Spline::default() };
let mut s = Session::new();
let space = s.space();
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(50.0 0.0)))", h.hex()));
assert!(close(var(&s, "AREA"), 1e4), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 300.0), "{}", var(&s, "PERIMETER"));
}
#[test]
fn a_spline_too_long_to_measure_is_refused_and_not_kept() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")");
// Finite ends 2e308 apart: its chord closes no area, but its length is past f64's range. That is
// too large to report, as a polyline's is, not a perimeter of 0.
let spline = Spline::from_line(Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0), 1);
let space = s.space();
let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap();
let err = s.execute("area", &json!({ "handle": h.hex() })).unwrap_err();
assert!(err.to_string().contains("the perimeter is too large"), "{err}");
lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.0 0.0)))", h.hex()));
assert!(s.log.iter().any(|l| l.contains("The perimeter is too large.")), "{:?}", s.log);
assert!(close(var(&s, "AREA"), 50.0), "{}", var(&s, "AREA"));
assert!(close(var(&s, "PERIMETER"), 20.0 + 200f64.sqrt()), "{}", var(&s, "PERIMETER"));
}
#[test]
fn dist_sets_the_distance_and_leaves_lastpoint_at_the_first_point() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))");
assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE"));
let (x, y) = lastpoint(&s);
assert!(close(x, 0.5) && close(y, 0.25), "{x}, {y}");
}
#[test]
fn dist_with_multiple_points_sets_the_total_and_lastpoint_at_the_last() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")");
assert!(close(var(&s, "DISTANCE"), 110.0), "{}", var(&s, "DISTANCE"));
let (x, y) = lastpoint(&s);
assert!(close(x, 0.0) && close(y, 30.0), "{x}, {y}");
}
#[test]
fn measuregeom_sets_them_as_dist_and_area_do() {
let mut s = Session::new();
// (AutoCAD's MEASUREGEOM asks for the next option after each measurement until eXit; here it
// ends after one.)
lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_D\" '(0.0 0.0) '(30.0 40.0))");
assert!(close(var(&s, "DISTANCE"), 50.0), "{}", var(&s, "DISTANCE"));
lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_AR\" '(0.5 0.25) '(0.54 0.25) '(0.54 0.28) '(0.5 0.28) \"\")");
assert!(close(var(&s, "AREA"), 0.0012) && close(var(&s, "PERIMETER"), 0.14), "{} {}", var(&s, "AREA"), var(&s, "PERIMETER"));
}
#[test]
fn the_json_forms_set_them_too_and_they_are_read_only() {
let mut s = Session::new();
s.execute("area", &json!({ "points": [[0, 0], [40, 0], [40, 30], [0, 30]] })).unwrap();
s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap();
assert_eq!((var(&s, "AREA"), var(&s, "PERIMETER"), var(&s, "DISTANCE")), (1200.0, 140.0, 50.0));
for name in ["AREA", "PERIMETER", "DISTANCE"] {
assert!(s.execute("setvar", &json!({ "name": name, "value": 1 })).is_err(), "{name}");
}
}
#[test]
fn lastpoint_after_multiple_points_is_the_last_point_entered() {
// A length typed with Length extends the path, but LASTPOINT stays at the last point picked;
// after Undo it stays at the point undone (AutoCAD 2024 leaves LASTPOINT at 10,0 and 10,10).
let mut s = Session::new();
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) \"_L\" 5.0 \"\")");
assert!(close(var(&s, "DISTANCE"), 15.0), "{}", var(&s, "DISTANCE"));
let (x, y) = lastpoint(&s);
assert!(close(x, 10.0) && close(y, 0.0), "{x}, {y}");
let mut s = Session::new();
lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) '(10.0 10.0) \"_U\" \"_T\")");
assert!(close(var(&s, "DISTANCE"), 10.0), "{}", var(&s, "DISTANCE"));
let (x, y) = lastpoint(&s);
assert!(close(x, 10.0) && close(y, 10.0), "{x}, {y}");
}
#[test]
fn a_distance_too_large_to_measure_is_refused_and_not_kept() {
let mut s = Session::new();
lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))");
assert!(close(var(&s, "DISTANCE"), 0.05));
// Finite points whose distance overflows: the JSON form, two picked points, Multiple points.
assert!(s.execute("dist", &json!({ "p1": [-1e308, 0], "p2": [1e308, 0] })).is_err());
s.cmdline("dist").unwrap();
s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap();
assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last());
// LASTPOINT stays at the first point, as after a measurement.
assert_eq!(lastpoint(&s), (-1e308, 0.0));
s.cancel();
s.cmdline("dist").unwrap();
s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap();
s.cmdline("m").unwrap();
s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last());
s.cancel();
assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE"));
}
#[test]
fn the_variable_list_shows_the_sessions_values() {
// A drawing's header can carry these names; GETVAR reads the session's, and so does the list.
let mut s = Session::new();
s.doc_mut().unwrap().header.set_f64("AREA", 99.0);
// Every DXF header carries ORTHOMODE; the session's ortho is off.
s.doc_mut().unwrap().header.set_i64("ORTHOMODE", 1);
lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")");
let list = s.execute("sysvars", &json!({})).unwrap();
assert!(close(var(&s, "AREA"), 50.0));
assert_eq!(list["AREA"].as_f64(), Some(var(&s, "AREA")), "{}", list["AREA"]);
assert_eq!(list["ORTHOMODE"], sysvars::get(&s, "ORTHOMODE").unwrap());
assert_eq!(list["ORTHOMODE"], 0);
}
+11 -1
View File
@@ -3,7 +3,7 @@
//! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point};
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point, PolyfaceMesh};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
use serde_json::json;
@@ -56,6 +56,16 @@ fn id_reports_the_z_of_what_is_at_the_point() {
assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::<Vec<_>>());
}
#[test]
fn id_reports_the_z_on_a_polyface_mesh() {
let mut s = Session::new();
// A sloped TIN as two triangles: z = 1000 + x / 10 over (0..100, 0..100).
let vertices = vec![Vec3::new(0.0, 0.0, 1000.0), Vec3::new(100.0, 0.0, 1010.0), Vec3::new(100.0, 100.0, 1010.0), Vec3::new(0.0, 100.0, 1000.0)];
add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]] }));
s.cmdline("zoom e").ok();
assert!((z(&mut s, [50.0, 0.0]) - 1005.0).abs() < 1e-9, "on the mesh's edge");
}
fn z_given(s: &mut Session) -> f64 {
s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap()
}
+1 -1
View File
@@ -17,7 +17,7 @@ fn typed_layout_options_prompt_for_names() {
s.cmdline("LAYOUT").unwrap();
let p = s.current_prompt().expect("LAYOUT prompts").display();
assert_eq!(p, "Enter layout option or [Copy/Delete/New/Template/Rename/SAveas/Set/?] <set>:");
assert_eq!(p, "Enter layout option [Copy/Delete/New/Template/Rename/SAveas/Set/?] <set>:");
s.cmdline("n").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter name of new layout <Layout3>:");
s.cmdline("Sheet9").unwrap();
+340
View File
@@ -0,0 +1,340 @@
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, with
//! DIMTAD 1, DIMGAP 0.625, DIMASZ 2.5, DIMSCALE 1 and DIMTXT 2.5 unless a case sets another, and
//! the drawing read back afterwards).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Insert, Leader, MText};
use serde_json::json;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
}
/// A drawing as the measured scripts start from: the dimension settings, and a text "XYZ" 2.5
/// high at (100,40) that the Copy cases copy (`(entlast)` after it).
fn session(setup: &str) -> Session {
let mut s = Session::new();
for (name, v) in [("DIMTAD", 1.0), ("DIMGAP", 0.625), ("DIMASZ", 2.5), ("DIMSCALE", 1.0), ("DIMTXT", 2.5)] {
s.execute("setvar", &json!({ "name": name, "value": v })).unwrap();
}
s.execute("text", &json!({ "at": [100, 40], "text": "XYZ", "height": 2.5 })).unwrap();
s.cmdline("(setq hl_T (entlast))").unwrap();
if !setup.is_empty() {
s.cmdline(setup).unwrap();
}
s
}
fn run(setup: &str, script: &str) -> Session {
let mut s = session(setup);
s.cmdline(script).unwrap();
assert!(s.running.is_none(), "{script}: {}", s.prompt_text());
s
}
fn leaders(s: &Session) -> Vec<Leader> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Leader(l) = &e.kind { Some(l.clone()) } else { None }).collect()
}
fn mtexts(s: &Session) -> Vec<MText> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.clone()) } else { None }).collect()
}
fn inserts(s: &Session) -> Vec<Insert> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect()
}
fn vertices(l: &Leader) -> Vec<[f64; 2]> {
l.vertices.iter().map(|v| [v.x, v.y]).collect()
}
fn same_points(got: &[[f64; 2]], want: &[[f64; 2]]) -> bool {
got.len() == want.len() && got.iter().zip(want).all(|(g, w)| near(g[0], w[0]) && near(g[1], w[1]))
}
/// (case, setup, script, leader vertices, text insertion point, attachment, height, contents)
type Case = (&'static str, &'static str, &'static str, &'static [[f64; 2]], [f64; 2], u8, f64, &'static str);
#[test]
fn leaders_and_their_text_are_where_autocad_puts_them() {
let cases: [Case; 17] = [
// A horizontal last segment: no hook line; the text DIMGAP right of and above the end.
(
"000",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
[40.625, 15.625],
7,
2.5,
"ABC",
),
// More than 15° off horizontal: a hook line DIMASZ long into the end.
(
"001",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]],
[35.625, 30.625],
7,
2.5,
"ABC",
),
(
"002",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"AB\" \"CD\" \"\")",
&[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]],
[20.625, 15.625],
7,
2.5,
"AB\\PCD",
),
// To the left: the text right-attached, DIMGAP left of the end.
(
"004",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [-17.5, 15.0], [-20.0, 15.0]],
[-20.625, 15.625],
9,
2.5,
"ABC",
),
// A vertical last segment hooks to the right.
(
"007-VN",
"",
"(command \"_.LEADER\" '(612345.678 2398765.432) '(612345.678 2398795.432) \"\" \"ABC\" \"\")",
&[[612345.678, 2398765.432], [612343.178, 2398795.432], [612345.678, 2398795.432]],
[612346.303, 2398796.057],
7,
2.5,
"ABC",
),
// 14.9° and 15° keep the end; 15.1° hooks.
(
"100",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.32158853384029) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 20.32158853384029]],
[40.625, 20.94658853384029],
7,
2.5,
"ABC",
),
(
"101",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.358983848622454) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 20.358983848622454]],
[40.625, 20.983983848622454],
7,
2.5,
"ABC",
),
(
"102",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.39641415635812) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [37.5, 20.39641415635812], [40.0, 20.39641415635812]],
[40.625, 21.02141415635812],
7,
2.5,
"ABC",
),
// DIMTAD 0: centred on the end, left- or right-attached.
(
"020",
"(setvar \"DIMTAD\" 0)",
"(command \"_.LEADER\" '(0.5 0.25) '(0.52 0.265) '(0.54 0.265) \"\" \"ABC\" \"\")",
&[[0.5, 0.25], [0.52, 0.265], [0.54, 0.265]],
[1.165, 0.265],
4,
2.5,
"ABC",
),
(
"103-VN",
"(setvar \"DIMTAD\" 0)",
"(command \"_.LEADER\" '(612345.678 2398765.432) '(612325.678 2398780.432) '(612305.678 2398780.432) \"\" \"ABC\" \"\")",
&[[612345.678, 2398765.432], [612325.678, 2398780.432], [612305.678, 2398780.432]],
[612305.053, 2398780.432],
6,
2.5,
"ABC",
),
// DIMGAP, DIMSCALE (hook, gap and height) and DIMTXT.
(
"021",
"(setvar \"DIMGAP\" 2.0)",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
[42.0, 17.0],
7,
2.5,
"ABC",
),
(
"022",
"(setvar \"DIMSCALE\" 2.0)",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [30.0, 30.0], [35.0, 30.0]],
[36.25, 31.25],
7,
5.0,
"ABC",
),
(
"024",
"(setvar \"DIMTXT\" 5.0)",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"AB\" \"CD\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
[40.625, 15.625],
7,
5.0,
"AB\\PCD",
),
// A spline takes no hook line.
(
"026",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" '(35.0 30.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [35.0, 30.0]],
[35.625, 30.625],
7,
2.5,
"ABC",
),
// Undo takes the last point back.
(
"014",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"_U\" '(40.0 15.0) \"\" \"ABC\" \"\")",
&[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]],
[40.625, 15.625],
7,
2.5,
"ABC",
),
// Annotation options: Mtext, at the command line, comes back to the first line.
(
"106",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_M\" \"ABC\" \"\")",
&[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]],
[20.625, 15.625],
7,
2.5,
"ABC",
),
// Copy: centred on its line; with DIMTAD on the leader ends half the text height lower.
(
"016",
"",
"(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" hl_T)",
&[[0.0, 0.0], [17.5, 13.75], [20.0, 13.75]],
[20.625, 15.625],
4,
2.5,
"XYZ",
),
];
for (case, setup, script, want_vertices, at, attach, height, contents) in cases {
let s = run(setup, script);
let ls = leaders(&s);
assert_eq!(ls.len(), 1, "{case}");
assert!(same_points(&vertices(&ls[0]), want_vertices), "{case}: {:?}", vertices(&ls[0]));
let ms = mtexts(&s);
assert_eq!(ms.len(), 1, "{case}");
let m = &ms[0];
assert!(near(m.insert.x, at[0]) && near(m.insert.y, at[1]), "{case}: {:?}", m.insert);
assert_eq!((m.attach, m.contents.as_str()), (attach, contents), "{case}");
assert!(near(m.height, height) && m.width == 0.0, "{case}: {} {}", m.height, m.width);
}
}
#[test]
fn format_sets_the_path_and_the_arrow() {
// LEADER-011 Format None; LEADER-012 None then Arrow; LEADER-013 Spline then STraight.
for (script, spline, arrow) in [
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, false),
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" \"_F\" \"_A\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" \"_F\" \"_ST\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true),
] {
let l = leaders(&run("", script)).remove(0);
assert_eq!((l.spline, l.arrow), (spline, arrow), "{script}");
}
}
#[test]
fn no_annotation_a_block_or_a_copied_multiline_text() {
// LEADER-015: None leaves the leader alone, without a hook line.
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_N\")");
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]]));
assert!(mtexts(&s).is_empty());
// LEADER-107: a block, scale 2 (Y as X), 30°, where it is placed; no hook line.
let mut s = session("");
s.execute("line", &json!({ "points": [[100, 0], [110, 0]] })).unwrap();
s.cmdline("(command \"_.-BLOCK\" \"LB107\" '(100.0 0.0) (entlast) \"\")").unwrap();
s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_B\" \"LB107\" '(50.0 20.0) 2 \"\" 30)").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]]));
let i = inserts(&s).remove(0);
assert!(i.block == "LB107" && near(i.insert.x, 50.0) && near(i.insert.y, 20.0), "{i:?}");
assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, 30f64.to_radians()), "{i:?}");
// LEADER-105: a copied multiline text keeps its width; LEADER-137 to the left.
let mut s = session("");
s.cmdline("(command \"_.MTEXT\" '(60.0 40.0) '(90.0 30.0) \"MT\" \"\")").unwrap();
s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" (entlast))").unwrap();
let copy = mtexts(&s).into_iter().find(|m| near(m.insert.x, 20.625)).unwrap();
assert!(near(copy.width, 30.0) && copy.attach == 4 && copy.contents == "MT", "{copy:?}");
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"\" \"_C\" hl_T)");
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [-17.5, 13.75], [-20.0, 13.75]]));
let m = mtexts(&s).into_iter().next().unwrap();
assert!(near(m.insert.x, -20.625) && near(m.insert.y, 15.625) && m.attach == 6, "{m:?}");
}
#[test]
fn the_json_form_places_its_text_as_the_command_line_does() {
let mut s = session("");
let v = s.execute("leader", &json!({ "points": [[0, 0], [20, 15], [35, 30]], "text": "ABC" })).unwrap();
assert_eq!(v["handles"].as_array().map(Vec::len), Some(2));
assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]]));
let m = mtexts(&s).remove(0);
assert!(near(m.insert.x, 35.625) && near(m.insert.y, 30.625) && m.attach == 7 && near(m.height, 2.5), "{m:?}");
}
#[test]
fn the_prompts_are_autocads_and_one_point_draws_nothing() {
let mut s = session("");
s.cmdline("leader").unwrap();
assert!(s.prompt_text().ends_with("Specify leader start point:"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().ends_with("Specify next point:"), "{}", s.prompt_text());
s.cmdline("20,15").unwrap();
assert!(s.prompt_text().ends_with("Specify next point or [Annotation/Format/Undo] <Annotation>:"), "{}", s.prompt_text());
s.cmdline("_F").unwrap();
assert!(s.prompt_text().ends_with("[Spline/STraight/Arrow/None] <Exit>:"), "{}", s.prompt_text());
s.cmdline("").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("Enter first line of annotation text or <options>:"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.prompt_text().ends_with("[Tolerance/Copy/Block/None/Mtext] <Mtext>:"), "{}", s.prompt_text());
// LEADER-019: Tolerance, at the command line, comes back to the first line.
s.cmdline("_T").unwrap();
assert!(s.prompt_text().ends_with("Enter first line of annotation text or <options>:"), "{}", s.prompt_text());
// An answer that isn't an option is refused; `?` at the block name lists the blocks.
s.cmdline("").unwrap();
s.cmdline("X").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword."));
s.cmdline("_B").unwrap();
assert!(s.prompt_text().ends_with("Enter block name or [?]:"), "{}", s.prompt_text());
s.cmdline("?").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Defined blocks: (none)"));
s.cancel();
// LEADER-139: one point and Enter.
let s = run("", "(command \"_.LEADER\" '(0.0 0.0) \"\")");
assert!(leaders(&s).is_empty() && mtexts(&s).is_empty());
}
@@ -0,0 +1,154 @@
//! U, UNDO and REDO inside a LISP routine, as AutoCAD 2024 runs them (accoreconsole: a line A, a
//! line B, a circle C and a line D, then one routine, then the drawing's `entnext` order read back).
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::Handle;
use serde_json::json;
/// A session with A, B, C and D in draw order, each its own undo step, and their handles.
fn drawing() -> (Session, Vec<Handle>) {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap();
s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
let hs = handles(&s);
(s, hs)
}
fn handles(s: &Session) -> Vec<Handle> {
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
}
/// The drawing's objects in draw order: A to D by their handles, E for any other.
fn order(s: &Session, hs: &[Handle]) -> String {
let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char);
handles(s).iter().map(|h| name(h).to_string()).collect::<Vec<_>>().join(" ")
}
/// Run `parts` as ONE routine, `@A`…`@D` standing for the objects.
fn routine(s: &mut Session, hs: &[Handle], parts: &[&str]) {
let mut code = format!("(progn {})", parts.join(" "));
for (i, h) in hs.iter().enumerate() {
code = code.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex()));
}
s.cmdline(&code).unwrap();
assert!(s.running.is_none(), "{code}: {}", s.prompt_text());
}
const EB: &str = "(command \"_.ERASE\" @B \"\")";
const EC: &str = "(command \"_.ERASE\" @C \"\")";
const U: &str = "(command \"_.U\")";
const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")";
const MAKE: &str = "(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 60.0 0.0) (11 10.0 60.0 0.0)))";
#[test]
fn u_in_a_routine_takes_back_the_routines_last_command() {
// (case, the routine, the drawing's order afterwards)
let cases: [(&str, &[&str], &str); 9] = [
("U-001: ERASE, U", &[EB, U], "A B C D"),
("U-002: two erases, U", &[EB, EC, U], "A C D"),
("U-003: two erases, U, U", &[EB, EC, U, U], "A B C D"),
("U-005: entdel, ERASE, U", &["(entdel @B)", EC, U], "A C D"),
("U-006: ERASE, entdel, U", &[EB, "(entdel @C)", U], "A B C D"),
("U-007: LINE, U, REDO", &[LINE, U, "(command \"_.REDO\")"], "A B C D E"),
("U-008: two erases, UNDO 2", &[EB, EC, "(command \"_.UNDO\" \"2\")"], "A B C D"),
("U-012: ERASE, entmake, U", &[EB, MAKE, U], "A B C D"),
// OOPS-011 had an OOPS after the U, which brought B back: B was still erased.
("OOPS-011: ERASE, LINE, U", &[EB, LINE, U], "A C D"),
];
for (case, parts, want) in cases {
let (mut s, hs) = drawing();
routine(&mut s, &hs, parts);
assert_eq!(order(&s, &hs), want, "{case}");
}
}
#[test]
fn the_routine_is_still_one_undo_step_after_it() {
let (mut s, hs) = drawing();
let depth = s.state().unwrap().undo.len();
routine(&mut s, &hs, &[EB, EC, LINE, U, "(entdel @A)"]);
assert_eq!(order(&s, &hs), "D");
assert_eq!(s.state().unwrap().undo.len(), depth + 1);
s.cmdline("U").unwrap();
assert_eq!(order(&s, &hs), "A B C D");
s.cmdline("REDO").unwrap();
assert_eq!(order(&s, &hs), "D");
}
#[test]
fn u_with_nothing_of_the_routine_left_takes_back_the_step_before_it() {
let (mut s, hs) = drawing();
let depth = s.state().unwrap().undo.len();
routine(&mut s, &hs, &[EB, U, U]);
// The second U takes back D's line, drawn before the routine.
assert_eq!(order(&s, &hs), "A B C");
assert_eq!(s.state().unwrap().undo.len(), depth - 1, "the routine changed nothing more of its own");
// A command after that makes the routine's step, from where the U took the drawing.
routine(&mut s, &hs, &[EB, U, U, "(command \"_.ERASE\" @A \"\")"]);
assert_eq!(order(&s, &hs), "B");
s.cmdline("U").unwrap();
assert_eq!(order(&s, &hs), "A B");
}
#[test]
fn undo_off_still_refuses_inside_a_routine() {
let (mut s, hs) = drawing();
routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_N\")", EB, U]);
assert_eq!(order(&s, &hs), "A C D");
}
#[test]
fn redo_inside_a_routine_brings_back_the_last_u_first() {
// U past the routine's own command, then REDO: the drawing's step comes back, not the erase.
let (mut s, hs) = drawing();
let depth = s.state().unwrap().undo.len();
routine(&mut s, &hs, &[EB, U, U, "(command \"_.REDO\")"]);
assert_eq!(order(&s, &hs), "A B C D");
assert_eq!(s.state().unwrap().undo.len(), depth, "the routine left nothing of its own to undo");
// A command inside the routine ends what REDO could bring back from before it.
let (mut s, hs) = drawing();
s.cmdline("U").unwrap();
assert_eq!(order(&s, &hs), "A B C");
routine(&mut s, &hs, &[EB, "(command \"_.REDO\")"]);
assert_eq!(order(&s, &hs), "A C");
}
#[test]
fn a_command_without_prompts_is_a_step_of_the_routine_too() {
// HATCHTOBACK runs as its JSON form from a routine; U takes it back, not the hatch.
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
let rect = handles(&s)[0];
s.execute("hatch", &json!({ "handles": [rect.hex()], "pattern": "SOLID" })).unwrap();
let both = handles(&s);
assert_eq!(both.len(), 2);
s.cmdline("(progn (command \"_.HATCHTOBACK\") (command \"_.U\"))").unwrap();
assert_eq!(handles(&s), both);
}
#[test]
fn undo_control_one_keeps_only_the_routines_last_command() {
// As UNDO Control One keeps only the drawing's last step: the second U goes on to the step
// before the routine (D's line), the drawing as it was then.
let (mut s, hs) = drawing();
routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_O\")", EB, EC, U, U]);
assert_eq!(order(&s, &hs), "A B C");
}
#[test]
fn a_routine_keeps_as_many_steps_as_the_drawing() {
use cadcraft_engine::undoctl::MAX_UNDO;
let (mut s, hs) = drawing();
// Two lines more than the routine keeps steps for, then a U for each kept step and one more,
// which goes on to the drawing's step before the routine (D's line): the drawing as it was
// then, without the two lines whose steps went.
let code =
format!("(progn (repeat {} (command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")) (repeat {} (command \"_.U\")))", MAX_UNDO + 2, MAX_UNDO + 1);
s.cmdline(&code).unwrap();
assert!(s.running.is_none());
assert_eq!(order(&s, &hs), "A B C");
}
+7
View File
@@ -5,6 +5,7 @@ mod annotative;
mod arc_ctrl_direction;
mod array_commands;
mod assoc_array;
mod attdef_command;
mod attdisp_attsync;
mod attedit_prompts;
mod attrib_modes;
@@ -59,6 +60,7 @@ mod hatch_numeric_validation;
mod hatch_options;
mod helix;
mod inquiry_keywords;
mod inquiry_variables;
mod inquiry_z;
mod insert_attribute_prompts;
mod insert_prompts;
@@ -69,8 +71,10 @@ mod layer_cmdline;
mod layer_merge_delete;
mod layoff_repeat;
mod layout_prompts;
mod leader_command;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod lisp_corpus;
mod lisp_undo_in_routine;
mod malformed_handles;
mod matchprop;
mod measure_distance_snapping;
@@ -90,6 +94,7 @@ mod non_finite_edits;
mod offset_closed_spline;
mod offset_ellipse_side;
mod offset_move;
mod oops_command;
mod options_profile;
mod overkill;
mod parallel_display_list;
@@ -100,8 +105,10 @@ mod pickdrag;
mod pline_arc_xline_options;
mod plot_styles;
mod point_modifiers;
mod polyface_explode;
mod post_command_passes;
mod prompt_keywords;
mod prompt_or_once;
mod props_bad_lineweight;
mod props_insert_scale_attribs;
mod purge_mlinestyles;
+260
View File
@@ -0,0 +1,260 @@
//! OOPS, as AutoCAD 2024 runs it (accoreconsole: a line A, a line B, a circle C and a line D, these
//! scripts, then the drawing's `entnext` order read back).
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::{Common, EntityKind, Handle, Line, Space};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
/// A session that `entmake` works in.
fn session() -> Session {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
Session::new()
}
/// A session with A, B, C and D in draw order, and their handles.
fn drawing() -> (Session, Vec<Handle>) {
let mut s = session();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap();
s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
let hs = handles(&s);
(s, hs)
}
fn handles(s: &Session) -> Vec<Handle> {
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
}
/// The drawing's objects in draw order: A to D by their handles, E for any other.
fn order(s: &Session, hs: &[Handle]) -> String {
let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char);
handles(s).iter().map(|h| name(h).to_string()).collect::<Vec<_>>().join(" ")
}
/// Run `script` with `@A`…`@D` standing for the objects.
fn run(s: &mut Session, hs: &[Handle], script: &[&str]) {
for line in script {
let mut line = line.to_string();
for (i, h) in hs.iter().enumerate() {
line = line.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex()));
}
s.cmdline(&line).unwrap();
assert!(s.running.is_none(), "{line}: {}", s.prompt_text());
}
}
fn oops(s: &mut Session) -> Value {
s.execute("oops", &json!({})).unwrap()
}
#[test]
fn the_last_erase_comes_back_where_it_was() {
const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")";
const OOPS: &str = "(command \"_.OOPS\")";
// (case, script, the drawing's order afterwards: E is the line drawn after the erase)
let cases: [(&str, &[&str], &str); 14] = [
("OOPS-000: nothing erased yet", &[OOPS], "A B C D"),
("OOPS-001: two in the middle", &["(command \"_.ERASE\" @B @C \"\")", OOPS], "A B C D"),
("OOPS-002: picked in reverse", &["(command \"_.ERASE\" @C @B \"\")", OOPS], "A B C D"),
("OOPS-003: a new object after the erase", &["(command \"_.ERASE\" @B \"\")", LINE, OOPS], "A B C D E"),
("OOPS-004: the bottom object", &["(command \"_.ERASE\" @A \"\")", LINE, OOPS], "A B C D E"),
("OOPS-005: twice", &["(command \"_.ERASE\" @B \"\")", OOPS, OOPS], "A B C D"),
("OOPS-006: after U", &["(command \"_.ERASE\" @B \"\")", "U", OOPS], "A B C D"),
("OOPS-007: the last erase only", &["(command \"_.ERASE\" @B \"\")", "(command \"_.ERASE\" @C \"\")", OOPS], "A C D"),
("OOPS-008: U after OOPS", &["(command \"_.ERASE\" @B \"\")", OOPS, "U"], "A C D"),
("OOPS-009: entdel", &["(entdel @B)", OOPS], "A C D"),
("OOPS-010: -BLOCK", &["(command \"_.-BLOCK\" \"BKO\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"),
("OOPS-011: U of another command", &["(command \"_.ERASE\" @B \"\")", LINE, "U", OOPS], "A B C D"),
("OOPS-012: the object under it erased since", &["(command \"_.ERASE\" @B \"\")", "(entdel @A)", OOPS], "B C D"),
("OOPS-013: everything", &["(command \"_.ERASE\" \"_ALL\" \"\")", OOPS], "A B C D"),
];
for (case, script, want) in cases {
let (mut s, hs) = drawing();
run(&mut s, &hs, script);
assert_eq!(order(&s, &hs), want, "{case}");
}
}
#[test]
fn oops_says_nothing_and_counts_what_came_back() {
let (mut s, hs) = drawing();
s.cmdline("oops").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
s.execute("erase", &json!({ "handles": [hs[1].hex(), hs[3].hex()] })).unwrap();
s.cmdline("oops").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
assert_eq!(order(&s, &hs), "A B C D");
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
s.execute("u", &json!({})).unwrap();
assert_eq!(order(&s, &hs), "A C");
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
// They come back as they were.
let d = s.doc().unwrap();
assert!(matches!(d.entity(hs[3]).map(|e| &e.kind), Some(EntityKind::Line(l)) if l.a.y == 40.0 && l.b.x == 10.0));
}
#[test]
fn the_delete_key_and_block_feed_oops_too() {
// Edit > Clear, the Delete key.
let (mut s, hs) = drawing();
s.set_selection(vec![hs[2]]);
s.execute("erase.selection", &json!({})).unwrap();
assert_eq!(order(&s, &hs), "A B D");
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "A B C D");
// BLOCK deleting its objects.
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[3].hex()], "keep": "delete" })).unwrap();
assert_eq!(order(&s, &hs), "B C");
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn an_object_an_undo_took_away_stays_away() {
// U past the line's creation: its handle is free again.
let mut s = session();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
let x = handles(&s)[0];
s.execute("erase", &json!({ "handles": [x.hex()] })).unwrap();
s.execute("u", &json!({})).unwrap();
s.execute("u", &json!({})).unwrap();
assert!(handles(&s).is_empty());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert!(handles(&s).is_empty());
// A new object takes the handle.
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
assert_eq!(handles(&s), [x]);
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert!(matches!(s.doc().unwrap().entity(x).map(|e| &e.kind), Some(EntityKind::Circle(_))));
// A block's object takes the handle: one erased object of two, one of a block's two.
for (erased, in_block) in [(2, 1), (1, 2)] {
let mut s = session();
for y in 0..erased {
s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap();
}
let hs = handles(&s);
s.execute("erase", &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })).unwrap();
for _ in 0..=erased {
s.execute("u", &json!({})).unwrap();
}
assert!(handles(&s).is_empty());
s.cmdline("(entmake '((0 . \"BLOCK\") (2 . \"BX\") (10 0.0 0.0 0.0) (70 . 0)))").unwrap();
for _ in 0..in_block {
s.cmdline("(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))").unwrap();
}
s.cmdline("(entmake '((0 . \"ENDBLK\")))").unwrap();
assert!(s.doc().unwrap().block("BX").is_some_and(|b| b.entities.contains(hs[0])), "{erased} {in_block}");
assert_eq!(oops(&mut s), json!({ "restored": 0 }), "{erased} {in_block}");
assert!(handles(&s).is_empty());
}
}
#[test]
fn an_object_whose_layer_or_block_is_gone_stays_away() {
// Its layer purged.
let (mut s, hs) = drawing();
run(&mut s, &hs, &["(command \"_.-LAYER\" \"_M\" \"L1\" \"\")"]);
s.execute("line", &json!({ "points": [[0, 60], [10, 60]] })).unwrap();
let on_l1 = *handles(&s).last().unwrap();
run(&mut s, &hs, &["(setvar \"CLAYER\" \"0\")"]);
s.execute("erase", &json!({ "handles": [on_l1.hex()] })).unwrap();
s.execute("purge", &json!({ "type": "layers", "names": "L1" })).unwrap();
assert!(s.doc().unwrap().layer("L1").is_none());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
// Its block purged.
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
s.execute("insert", &json!({ "name": "BK", "at": [50, 0] })).unwrap();
let ins = *handles(&s).last().unwrap();
s.execute("erase", &json!({ "handles": [ins.hex()] })).unwrap();
s.execute("purge", &json!({ "type": "blocks", "names": "BK" })).unwrap();
assert!(s.doc().unwrap().block("BK").is_none());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn the_block_editor_has_an_oops_of_its_own() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
s.execute("bedit", &json!({ "name": "BK" })).unwrap();
// The drawing's erased line doesn't come back into the block.
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
s.execute("bclose", &json!({ "save": false })).unwrap();
// Back in the drawing, it does.
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn a_layouts_objects_come_back_to_it() {
let mut s = session();
let paper = Space::Paper("Layout1".into());
let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) });
let hs: Vec<Handle> = (0..3).map(|y| s.doc_mut().unwrap().add(&paper, Common::default(), line(f64::from(y))).unwrap()).collect();
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
let d = s.doc().unwrap();
assert!(d.model.is_empty());
assert_eq!(d.space(&paper).unwrap().handles(), hs);
}
#[test]
fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "convert" })).unwrap();
let ins = *handles(&s).last().unwrap();
s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap();
let r = s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
// The drawing's line stays out while the reference is edited...
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
// ...and a working copy comes back.
let copy = r["working"][0].as_str().unwrap().to_string();
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
// Once the edit is over, its copies don't come back into the drawing.
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
s.execute("refclose", &json!({ "save": true })).unwrap();
assert_eq!(s.doc().unwrap().block("BK").unwrap().entities.len(), 1);
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert_eq!(order(&s, &hs), "C E");
// The drawing's own erased line does, after an edit that ends.
s.execute("erase", &json!({ "handles": [hs[2].hex()] })).unwrap();
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
s.execute("refclose", &json!({ "save": false })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "C E");
}
#[test]
fn a_block_refused_during_refedit_leaves_oops_as_it_was() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "convert" })).unwrap();
let ins = *handles(&s).last().unwrap();
s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap();
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
// B isn't in the working set: the block is refused and the drawing put back.
let r = s.execute("block", &json!({ "name": "BK2", "base": [0, 0], "handles": [hs[1].hex()], "keep": "delete" }));
assert!(r.is_err(), "{r:?}");
s.execute("refclose", &json!({ "save": false })).unwrap();
// OOPS still has D, the last erase that stood.
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert!(s.doc().unwrap().entity(hs[3]).is_some());
}
#[test]
fn an_erase_in_the_block_editor_comes_back_there() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "retain" })).unwrap();
s.execute("bedit", &json!({ "name": "BK" })).unwrap();
let inside = handles(&s);
assert_eq!(inside.len(), 2);
s.execute("erase", &json!({ "handles": [inside[0].hex()] })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(handles(&s), inside);
}
+86
View File
@@ -66,6 +66,48 @@ fn duplicates_tolerance_and_ignored_properties() {
assert!(r["message"].as_str().unwrap().contains("duplicate(s) deleted"));
}
#[test]
fn objects_within_the_tolerance_are_duplicates_on_either_side_of_a_rounding_step() {
// Each pair is closer than the tolerance, but its numbers round to different multiples of it
// (50.004 → 5000, 50.006 → 5001 at 0.01).
let pairs = [
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50.004, 50], "radius": 3 }), json!({ "center": [50.006, 50], "radius": 3 })),
(json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50, 50], "radius": 3.004 }), json!({ "center": [50, 50], "radius": 3.006 })),
(json!({}), "circle", json!({ "center": [0.0000014, 0], "radius": 3 }), json!({ "center": [0.0000016, 0], "radius": 3 })),
(json!({ "tolerance": 0.01 }), "point", json!({ "at": [10.004, 0] }), json!({ "at": [10.006, 0] })),
(
json!({ "tolerance": 0.01 }),
"text",
json!({ "at": [0.004, 0], "text": "A", "height": 2.5 }),
json!({ "at": [0.006, 0], "text": "A", "height": 2.5 }),
),
];
for (opts, cmd, a, b) in pairs {
let mut s = Session::new();
s.execute(cmd, &a).unwrap();
s.execute(cmd, &b).unwrap();
let r = ok(&mut s, opts);
assert_eq!(r["deleted"], 1, "{cmd} {a} {b}: {r}");
assert_eq!(count(&s), 1, "{cmd} {a} {b}");
}
// Arcs on (about) one circle are combined the same way.
let mut s = Session::new();
s.execute("arc", &json!({ "center": [0.004, 0], "radius": 5, "start": 0, "end": 90 })).unwrap();
s.execute("arc", &json!({ "center": [0.006, 0], "radius": 5, "start": 45, "end": 180 })).unwrap();
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["overlapping"], 1, "{r}");
assert_eq!(count(&s), 1);
// Farther apart than the tolerance, they stay.
let mut s = Session::new();
s.execute("circle", &json!({ "center": [50.004, 50], "radius": 3 })).unwrap();
s.execute("circle", &json!({ "center": [50.016, 50], "radius": 3 })).unwrap();
s.execute("point", &json!({ "at": [10.004, 0] })).unwrap();
s.execute("point", &json!({ "at": [10.016, 0] })).unwrap();
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["deleted"], 0, "{r}");
assert_eq!(count(&s), 4);
}
#[test]
fn collinear_lines_overlap_and_end_to_end() {
let mut s = Session::new();
@@ -230,3 +272,47 @@ fn many_objects_stay_fast() {
assert_eq!(count(&s), 4001);
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
}
#[test]
fn within_the_tolerance_does_not_chain() {
// At 0.01, the arc is within the tolerance of both circles, but the circles are 0.016 apart:
// the arc goes, both circles stay, whichever comes first in draw order.
let circle = |s: &mut Session, x: f64| s.execute("circle", &json!({ "center": [x, 0], "radius": 3 })).unwrap();
let arc_at = |s: &mut Session, x: f64, start: f64, end: f64| {
s.execute("arc", &json!({ "center": [x, 0], "radius": 3, "start": start, "end": end })).unwrap()
};
let kinds = |s: &Session| s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
let mut s = Session::new();
circle(&mut s, 50.0);
arc_at(&mut s, 50.008, 0.0, 90.0);
circle(&mut s, 50.016);
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!((r["duplicates"].as_u64(), r["overlapping"].as_u64()), (Some(0), Some(1)), "{r}");
assert_eq!(kinds(&s), ["Circle", "Circle"]);
let mut s = Session::new();
arc_at(&mut s, 50.008, 0.0, 90.0);
circle(&mut s, 50.0);
circle(&mut s, 50.016);
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["deleted"], 1, "{r}");
assert_eq!(kinds(&s), ["Circle", "Circle"]);
// Arcs alone: the third is 0.016 from the first, so it is not combined with it.
let mut s = Session::new();
arc_at(&mut s, 0.0, 0.0, 90.0);
arc_at(&mut s, 0.008, 45.0, 180.0);
arc_at(&mut s, 0.016, 100.0, 200.0);
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["overlapping"], 1, "{r}");
assert_eq!(kinds(&s), ["Arc", "Arc"]);
// A followed line displaces the first one, which a third line matched: the third is 0.016 from
// the followed line, so it stays.
let mut s = Session::new();
let first = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let third = line(&mut s, [0.0, 0.008], [10.0, 0.008]);
let followed = line(&mut s, [0.0, -0.008], [10.0, -0.008]);
s.execute("dimlinear", &json!({ "object": followed.hex(), "at": [5, 5] })).unwrap();
let r = ok(&mut s, json!({ "tolerance": 0.01 }));
assert_eq!(r["deleted"], 1, "{r}");
let d = s.doc().unwrap();
assert!(d.entity(first).is_none() && d.entity(third).is_some() && d.entity(followed).is_some());
}
+27
View File
@@ -104,3 +104,30 @@ fn stylesmanager_creates_edits_saves_and_loads_tables() {
// Undo takes a table edit back.
s.execute("undo", &json!({})).unwrap();
}
/// Issue #1928: saving a plot style table to a file goes through the atomic writer: a symlink
/// planted at the old fixed temporary name is left alone, and the file keeps its permissions.
#[cfg(unix)]
#[test]
fn plot_style_save_is_atomic_and_keeps_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-pstyle-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("office.stb.json");
std::fs::write(&path, "old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let victim = dir.join("victim");
std::fs::write(&victim, "keep").unwrap();
std::os::unix::fs::symlink(&victim, dir.join("office.stb.json.cadcraft-tmp")).unwrap();
let mut s = Session::new();
s.execute("stylesmanager", &json!({"action": "new", "name": "office.stb"})).unwrap();
let p = path.to_string_lossy().to_string();
s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap();
assert_ne!(std::fs::read_to_string(&path).unwrap(), "old");
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,32 @@
//! EXPLODE turns a polyface mesh into its 3D faces, as AutoCAD does; hidden edges stay hidden.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, EntityKind, PolyfaceMesh, Space};
use cadcraft_geom::Vec3;
use serde_json::json;
#[test]
fn exploding_a_polyface_mesh_gives_its_3d_faces() {
let mut s = Session::new();
let mesh = PolyfaceMesh {
vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)],
faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]],
};
let h = s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::PolyfaceMesh(mesh)).unwrap();
s.execute("explode", &json!({"handles": [h.hex()]})).unwrap();
let faces: Vec<_> = s
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Face3d(f) => Some(f.clone()),
_ => None,
})
.collect();
assert_eq!(faces.len(), 2);
assert!(s.doc().unwrap().model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_))));
// The diagonal starts at the third corner of the first triangle: edge 3 (and the closing 4th) hidden.
assert_eq!(faces[0].corners[2], Vec3::new(10.0, 10.0, 2.0));
assert_eq!(faces[0].hidden_edges, 4 | 8);
}
+77
View File
@@ -0,0 +1,77 @@
//! Prompts that offer options say "or" once, as AutoCAD 2024's do: "Enter angle of xline (0) or
//! [Reference]:". The prompt adds "or" before its options, so a message that ended in "or" read
//! "or or".
use cadcraft_engine::Session;
use serde_json::json;
fn shows(s: &Session, text: &str) {
let p = s.prompt_text();
assert!(p.ends_with(text), "{p}");
assert!(!p.contains(" or or "), "{p}");
}
#[test]
fn xline_angle_and_offset() {
let mut s = Session::new();
s.cmdline("xline").unwrap();
s.cmdline("a").unwrap();
shows(&s, "Enter angle of xline (0) or [Reference]:");
s.cancel();
s.cmdline("xline").unwrap();
s.cmdline("o").unwrap();
shows(&s, "Specify offset distance or [Through] <1.0000>:");
}
#[test]
fn helix_radii_and_height() {
let mut s = Session::new();
s.script("helix\n0,0\n").unwrap();
shows(&s, "Specify base radius or [Diameter] <1.0000>:");
s.cmdline("5").unwrap();
shows(&s, "Specify top radius or [Diameter] <5.0000>:");
s.cmdline("").unwrap();
shows(&s, "Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <1.0000>:");
}
#[test]
fn blend_first_object() {
let mut s = Session::new();
s.cmdline("blend").unwrap();
shows(&s, "Select first object or [CONtinuity]:");
}
#[test]
fn lengthen_options() {
let mut s = Session::new();
s.cmdline("lengthen").unwrap();
shows(&s, "Select an object to measure or [DElta/Percent/Total/DYnamic] <DYnamic>:");
s.cmdline("de").unwrap();
shows(&s, "Enter delta length or [Angle] <0.0000>:");
s.cmdline("2").unwrap();
shows(&s, "Select an object to change or [Undo]:");
s.cancel();
s.cmdline("lengthen").unwrap();
s.cmdline("t").unwrap();
shows(&s, "Specify total length or [Angle] <1.0000>:");
}
#[test]
fn ncopy_pedit_and_table_break() {
let mut s = Session::new();
s.script("line 25,-5 25,5\n\nline 0,0 10,0\n\n-block B 0,0 L\n\n").unwrap();
s.execute("insert", &json!({ "name": "B", "at": [20, 0] })).unwrap();
s.cmdline("zoom e").unwrap();
s.cmdline("ncopy").unwrap();
shows(&s, "Select nested objects to copy or [Settings]:");
s.script("22,0\n\n").unwrap();
shows(&s, "Specify base point or [Displacement/Multiple] <Displacement>:");
s.cancel();
s.cmdline("pedit").unwrap();
shows(&s, "Select polyline or [Multiple]:");
s.cancel();
s.execute("table", &json!({ "at": [0, 20], "cells": [["1", ""], ["2", ""]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap();
s.cmdline("table.break").unwrap();
s.cmdline("1,19.5").unwrap();
shows(&s, "Enter maximum part height or [Off]:");
}
+12 -1
View File
@@ -5,7 +5,7 @@
//! has no thickness while a polyline's keeps it.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point};
use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point, PolyfaceMesh};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
use serde_json::json;
@@ -182,3 +182,14 @@ fn offset_keeps_a_polylines_elevation_and_thickness() {
let first = k.vertices.first().unwrap().p;
assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}");
}
#[test]
fn scale_scales_a_polyface_meshs_vertex_heights() {
let mut s = Session::new();
let vertices = vec![Vec3::new(0.0, 0.0, 2.0), Vec3::new(10.0, 0.0, 2.0), Vec3::new(0.0, 10.0, 6.0)];
let m = add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, 3, 0]] }), 0.0);
scale(&mut s, m, "0.0 0.0");
let EntityKind::PolyfaceMesh(k) = get(&s, m).0 else { panic!() };
let want = [Vec3::new(0.0, 0.0, 3.4), Vec3::new(17.0, 0.0, 3.4), Vec3::new(0.0, 17.0, 10.2)];
assert!(k.vertices.iter().zip(want).all(|(a, b)| near3(*a, b)), "{:?}", k.vertices);
}
+2 -2
View File
@@ -33,9 +33,9 @@ fn typed_vplayer_changes_layers_in_chosen_viewports() {
// Freeze in the current viewport (the default inside a viewport).
s.cmdline("VPLAYER").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:");
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:");
s.script("F\n0\n").unwrap();
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [All/Select/Current/Except current] <Current>:");
assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [All/Select/Current/Except current] <Current>:");
s.script("\n").unwrap();
assert_eq!(frozen(&s, &a), ["0"]);
assert!(frozen(&s, &b).is_empty());
@@ -0,0 +1,14 @@
//! `layer.new` refuses control characters in a layer name (the DXF writer would turn them into
//! spaces and the name would change on reopen).
use cadcraft_engine::Session;
use serde_json::json;
#[test]
fn layer_new_rejects_control_characters() {
let mut s = Session::new();
for name in ["A\nB", "A\tB", "A\u{0}B"] {
assert!(s.execute("layer.new", &json!({"name": name})).is_err(), "{name:?} was accepted");
}
assert!(s.execute("layer.new", &json!({"name": "A B"})).is_ok());
}
@@ -0,0 +1,54 @@
//! Text styles with Linux fonts AutoCAD on Windows doesn't have: a save names them, and SAVEAS
//! with portableFonts writes Windows fonts in that file while the drawing keeps its own.
use cadcraft_doc::{Drawing, TextStyle};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use serde_json::json;
fn dxf(d: &Drawing, _: &str) -> Result<Vec<u8>, String> {
Ok(cadcraft_io::write_dxf(d).into_bytes())
}
fn session() -> Session {
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: dxf, plot: None });
let mut d = Drawing::new_metric();
for (name, font) in [("Notes", "DejaVuSans.ttf"), ("Plain", "arial.ttf"), ("Tight", "Liberation Sans Narrow")] {
d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() });
}
let mut s = Session::empty();
s.open_drawing(d, "fonts.dxf", None);
s
}
#[test]
fn portable_fonts_writes_windows_fonts_in_that_file() {
let mut s = session();
let dir = std::env::temp_dir().join(format!("cadcraft-portable-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
let file = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
let both = json!([{ "style": "Notes", "font": "DejaVuSans.ttf" }, { "style": "Tight", "font": "Liberation Sans Narrow" }]);
// portableFonts writes Verdana for DejaVu Sans; the narrow style has no Windows font.
let r = s.execute("saveas", &json!({ "path": path("portable.dxf"), "portableFonts": true })).unwrap();
assert_eq!(r["nonPortableFonts"], json!([{ "style": "Tight", "font": "Liberation Sans Narrow" }]));
assert_eq!(s.log.last().map(String::as_str), Some("Fonts AutoCAD on Windows doesn't have: Tight (Liberation Sans Narrow)."));
assert!(file("portable.dxf").contains("verdana.ttf"));
assert!(s.state().unwrap().drawing().text_styles.iter().any(|t| t.font == "DejaVuSans.ttf"), "the drawing keeps its fonts");
// A later save without it writes the drawing's fonts again, and says so.
let r = s.execute("qsave", &json!({})).unwrap();
assert_eq!(r["nonPortableFonts"], both);
assert!(s.log.last().is_some_and(|l| l.ends_with("SAVEAS with portableFonts writes Windows fonts for those that have one.")), "{:?}", s.log);
assert!(!file("portable.dxf").contains("verdana.ttf"));
// The same list isn't said again on every save.
let lines = s.log.len();
let r = s.execute("qsave", &json!({})).unwrap();
assert_eq!(r["nonPortableFonts"], both);
assert!(s.log.iter().skip(lines).all(|l| !l.starts_with("Fonts AutoCAD")), "{:?}", s.log);
assert!(s.execute("saveas", &json!({ "path": path("bad.dxf"), "portableFonts": "yes" })).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+161 -2
View File
@@ -3,7 +3,7 @@
//! writes them back as read: nothing to report.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::cmd::file::{IoHooks, SaveAs, set_io, set_write_as};
use cadcraft_engine::doc::{Block, Common, Drawing, EntityKind, RawTag, Space, Unknown};
use serde_json::json;
@@ -29,7 +29,7 @@ fn save_lists_the_entities_the_file_does_not_keep() {
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 }));
let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 3DSOLID, 2 MLINE, 1 REGION";
let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): 1 3DSOLID, 2 MLINE, 1 REGION";
assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log);
// DXF keeps them.
@@ -52,6 +52,38 @@ fn save_lists_the_entities_the_file_does_not_keep() {
assert!(r.get("notSaved").is_none() && s.log.len() == n);
}
#[test]
fn export_and_wblock_say_what_a_dwg_leaves_out() {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
let mut d = Drawing::new_imperial();
let h = d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET")).unwrap().hex();
let mut s = Session::empty();
s.open_drawing(d, "foreign.dxf", None);
let file = |ext: &str| std::env::temp_dir().join(format!("cadcraft-leaves-out-{}.{ext}", std::process::id())).to_string_lossy().into_owned();
let said = |l: &String| l.starts_with("Not saved in the DWG file") && l.ends_with(": 1 ACME_WIDGET");
let (dwg, dxf) = (file("dwg"), file("dxf"));
for (cmd, p) in [("export", json!({ "path": dwg })), ("wblock", json!({ "path": dwg, "handles": [h] }))] {
s.log.clear();
let r = s.execute(cmd, &p).unwrap();
assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }), "{cmd}");
assert!(s.log.iter().any(said), "{cmd}: {:?}", s.log);
}
// -WBLOCK takes the objects out of the drawing, so last.
let r = s.execute("-wblock", &json!({ "path": dwg, "handles": [h] })).unwrap();
assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }));
let _ = std::fs::remove_file(&dwg);
// DXF keeps them.
s.execute("undo", &json!({})).unwrap();
for (cmd, p) in [("export", json!({ "path": dxf })), ("wblock", json!({ "path": dxf, "handles": [h] }))] {
s.log.clear();
assert!(s.execute(cmd, &p).unwrap().get("notSaved").is_none(), "{cmd}");
assert!(!s.log.iter().any(said), "{cmd}: {:?}", s.log);
}
let _ = std::fs::remove_file(&dxf);
}
#[test]
fn templates_report_what_dwg_does_not_keep() {
use cadcraft_engine::cmd::file::drops_unknown;
@@ -82,3 +114,130 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() {
// Before 2013 a file has no data store: every solid loses its geometry.
assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect());
}
/// A DXF of 2013 or later can't hold a solid's SAT-text geometry (it lives in the data store
/// there), so the save leaves it out and says so; 2010 and earlier keep it (#2334).
#[test]
fn saving_sat_text_solids_as_dxf_2013_reports_them() {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec()));
let mut d = Drawing::new_imperial();
let sat = EntityKind::Unknown(Unknown {
dxf_type: "REGION".into(),
tags: vec![
RawTag { code: 8, value: "0".into() },
RawTag { code: 1, value: "400 1 1 0".into() },
RawTag { code: 3, value: "End-of-ACIS-data".into() },
],
});
d.add(&Space::Model, Common::default(), sat).unwrap();
let mut s = Session::empty();
s.open_drawing(d, "region.dxf", None);
let path = std::env::temp_dir().join(format!("cadcraft-sat-note-{}.dxf", std::process::id()));
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap();
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file") && l.contains("1 REGION")), "{:?}", s.log);
// QSAVE takes no version: a `version` passed to it doesn't change what it writes (the
// drawing's own 2018), so the note still comes.
let r = s.execute("qsave", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap();
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
let n = s.log.len();
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap();
let _ = std::fs::remove_file(&path);
assert!(r.get("notSaved").is_none() && s.log.len() == n);
}
/// The interactive save asks with the same list, and EXPORT reports it, so overwriting a 2013+
/// DXF never drops solids on a command-line note alone (#2334).
#[test]
fn a_dxf_2013_loss_is_known_before_saving() {
use cadcraft_engine::cmd::file::loss_on_save;
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
let sat = |d: &mut Drawing| {
let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }];
d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap();
};
let mut modern = Drawing::new_imperial();
modern.header.set_str("ACADVER", "AC1032");
sat(&mut modern);
let mut s = Session::empty();
s.open_drawing(modern, "region.dxf", None);
assert_eq!(loss_on_save(&s, "region.dxf"), [("REGION".to_string(), 1)].into_iter().collect());
let path = std::env::temp_dir().join(format!("cadcraft-sat-export-{}.dxf", std::process::id()));
let r = s.execute("export", &json!({ "path": path.to_string_lossy() })).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file")), "{:?}", s.log);
let mut old = Drawing::new_imperial();
old.header.set_str("ACADVER", "AC1024");
sat(&mut old);
s.open_drawing(old, "region.dxf", None);
assert!(loss_on_save(&s, "region.dxf").is_empty(), "2010 keeps SAT text");
}
/// In the Block Editor the file still holds the host drawing: its solids and version decide the
/// note, not the edited block's (#2334).
#[test]
fn the_note_counts_the_host_drawing_while_a_block_is_edited() {
set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None });
set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec()));
let mut d = Drawing::new_imperial();
let sat = EntityKind::Unknown(Unknown {
dxf_type: "REGION".into(),
tags: vec![
RawTag { code: 8, value: "0".into() },
RawTag { code: 1, value: "400 1 1 0".into() },
RawTag { code: 3, value: "End-of-ACIS-data".into() },
],
});
d.add(&Space::Model, Common::default(), sat).unwrap();
d.blocks.insert("Door".into(), std::sync::Arc::new(Block::new("Door")));
let mut s = Session::empty();
s.open_drawing(d, "region.dxf", None);
s.execute("bedit", &json!({ "name": "Door" })).unwrap();
let path = std::env::temp_dir().join(format!("cadcraft-sat-edit-{}.dxf", std::process::id()));
let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["notSaved"], json!({ "REGION": 1 }));
}
/// A DWG before 2013 writes a solid's SAT text as it is, so a save there doesn't list a solid that
/// holds it; from 2013 the geometry must be in the data store and SAT text alone is lost, so the
/// save lists it. A solid with neither is always listed (#2334).
#[test]
fn a_dwg_keeps_sat_text_solids_only_before_2013() {
use cadcraft_engine::cmd::file::{Version, not_saved_as};
let tag = |code: i32, value: &str| RawTag { code, value: value.into() };
let solid = |tags: Vec<RawTag>| EntityKind::Unknown(Unknown { dxf_type: "3DSOLID".into(), tags });
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(1, "400 1 1 0")])).unwrap();
d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(70, "1")])).unwrap();
assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 1)].into_iter().collect(), "2010 keeps the SAT-text solid");
assert_eq!(not_saved_as(&d, Version::R2018), [("3DSOLID".to_string(), 2)].into_iter().collect(), "2018 loses it");
}
/// The DWG bridge writes OLE objects (`ole2frame_roundtrip` in the io tests), so saving one as
/// DWG reports nothing for them, at any version, while other unmodelled types are still listed.
/// The type is matched as stored: the bridge gets it as stored too, and acadrust reads only the
/// upper-case spelling into its OLE entity, so any other spelling is still reported.
#[test]
fn ole_objects_are_not_reported() {
use cadcraft_engine::cmd::file::{Version, not_saved_as};
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), unknown("OLE2FRAME")).unwrap();
d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap();
d.add(&Space::Model, Common::default(), unknown("ole2frame")).unwrap();
let mut b = Block::new("Logo");
let h = d.new_handle();
b.entities.push(cadcraft_engine::doc::Entity { handle: h, common: Common::default(), kind: unknown("OLE2FRAME") });
d.blocks.insert("Logo".into(), std::sync::Arc::new(b));
for v in [Version::R2000, Version::R2010, Version::R2018] {
assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1), ("ole2frame".to_string(), 1)].into_iter().collect(), "{v:?}");
}
}

Some files were not shown because too many files have changed in this diff Show More